@qwen-code/channel-dingtalk 0.21.0-nightly.20260729.0c0ca5fed

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1078 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { Buffer } from 'node:buffer';
6
+ import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs';
7
+ import { ChannelBase, isTerminalTaskLifecycleType, sanitizeLogText, sanitizeSenderName, } from '@qwen-code/channel-base';
8
+ import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js';
9
+ import { downloadMedia } from './media.js';
10
+ import { DingTalkMediaUploadError, findImageMarkers, readValidatedImage, replaceImageMarkers, uploadDingTalkImage, } from './outbound-image.js';
11
+ import { DingtalkConnectionManager, } from './DingtalkConnectionManager.js';
12
+ /** Track seen msgIds to deduplicate retried callbacks. */
13
+ const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
14
+ const ACK_REACTION_NAME = '👀';
15
+ const ACK_EMOTION_ID = '2659900';
16
+ const ACK_EMOTION_BG_ID = 'im_bg_1';
17
+ const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion';
18
+ const EMOTION_MAX_ATTEMPTS = 3;
19
+ const EMOTION_RETRY_BASE_DELAY_MS = 250;
20
+ const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
21
+ const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
22
+ const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
23
+ const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
24
+ const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
25
+ const TEXT_MESSAGE_LIMIT = 3800;
26
+ const mentionTarget = Symbol('mentionTarget');
27
+ const IMAGE_INSTRUCTIONS = [
28
+ '',
29
+ 'If you created an image file (screenshot, chart, etc.), you can send it to the user by writing:',
30
+ '`[IMAGE: /absolute/path/to/file.png]` (without the backticks)',
31
+ '',
32
+ 'The marker is stripped from text and the image is uploaded automatically.',
33
+ '',
34
+ 'Only use a real image file inside the workspace or system temporary directory.',
35
+ ].join('\n');
36
+ function withNonBotMentionContext(data, text) {
37
+ if (!Array.isArray(data.atUsers) || typeof data.chatbotUserId !== 'string') {
38
+ return text;
39
+ }
40
+ const mentions = new Set();
41
+ for (const user of data.atUsers) {
42
+ if (!user)
43
+ continue;
44
+ const dingtalkId = typeof user.dingtalkId === 'string' ? user.dingtalkId : undefined;
45
+ // DingTalk Stream always sets dingtalkId for the bot entry; staffId-only bot entries are not expected.
46
+ if (dingtalkId === data.chatbotUserId)
47
+ continue;
48
+ const staffId = typeof user.staffId === 'string' ? user.staffId : undefined;
49
+ const stableId = dingtalkId || staffId;
50
+ if (stableId)
51
+ mentions.add(stableId);
52
+ }
53
+ if (mentions.size === 0)
54
+ return text;
55
+ const memberLabel = mentions.size === 1 ? 'member' : 'members';
56
+ const context = `[Mentioned ${mentions.size} other group ${memberLabel}]`;
57
+ return text ? `${context}\n${text}` : context;
58
+ }
59
+ function splitTextChunks(text, firstChunkLimit) {
60
+ if (!text)
61
+ return [text];
62
+ const chunks = [];
63
+ let offset = 0;
64
+ let chunkLimit = firstChunkLimit;
65
+ while (offset < text.length) {
66
+ chunks.push(text.slice(offset, offset + chunkLimit));
67
+ offset += chunkLimit;
68
+ chunkLimit = TEXT_MESSAGE_LIMIT;
69
+ }
70
+ return chunks;
71
+ }
72
+ export class DingtalkChannel extends ChannelBase {
73
+ client;
74
+ atSender;
75
+ connectionManager;
76
+ seenMessages = new Map();
77
+ mentionTargets = new Map();
78
+ sessionMentionTargets = new Map();
79
+ textReplySessions = new Set();
80
+ bufferedMentionTargets = new Set();
81
+ bufferedMentionTargetsBySession = new Map();
82
+ dedupTimer;
83
+ /** Map conversationId → latest sessionWebhook URL for sending replies. */
84
+ webhooks = new Map();
85
+ activeReactionKeys = new Set();
86
+ /** sessionId → reaction keys, so a dead session's reactions can be recalled. */
87
+ sessionReactionKeys = new Map();
88
+ /**
89
+ * Real inbound message ids (insertion-ordered, size-capped). Unlike the
90
+ * TTL-swept seenMessages dedup map, entries survive long queue waits, so a
91
+ * turn that starts minutes after its message arrived still gets a reaction.
92
+ */
93
+ inboundMessageIds = new Set();
94
+ /**
95
+ * Token cache for proactive sends. The stream SDK only refreshes its token
96
+ * on (re)connect, so a long-lived socket serves a stale one after ~2h.
97
+ */
98
+ proactiveToken;
99
+ constructor(name, config, bridge, options) {
100
+ super(name, config, bridge, options);
101
+ this.atSender =
102
+ config['atSender'] === true;
103
+ if (!this.config.instructions) {
104
+ this.config.instructions = [
105
+ '## DingTalk Channel',
106
+ '',
107
+ 'You are responding through DingTalk.',
108
+ IMAGE_INSTRUCTIONS,
109
+ ].join('\n');
110
+ }
111
+ else if (!this.config.instructions.includes('[IMAGE:')) {
112
+ this.config.instructions += IMAGE_INSTRUCTIONS;
113
+ }
114
+ if (!config.clientId || !config.clientSecret) {
115
+ throw new Error(`Channel "${name}" requires clientId and clientSecret for DingTalk.`);
116
+ }
117
+ const rawUseConnectionManager = config
118
+ .useConnectionManager;
119
+ if (rawUseConnectionManager !== undefined &&
120
+ typeof rawUseConnectionManager !== 'boolean') {
121
+ throw new Error(`Channel "${name}" useConnectionManager must be a boolean.`);
122
+ }
123
+ const useConnectionManager = rawUseConnectionManager ?? true;
124
+ this.client = this.createClient(useConnectionManager);
125
+ if (useConnectionManager) {
126
+ this.connectionManager = new DingtalkConnectionManager({
127
+ initialClient: this.client,
128
+ createClient: () => this.createClient(true),
129
+ getSocket: (client) => client.socket,
130
+ onClientChanged: (client) => {
131
+ this.client = client;
132
+ },
133
+ log: (message) => {
134
+ process.stderr.write(`[DingTalk:${this.name}] ${sanitizeLogText(message, 200)}\n`);
135
+ },
136
+ });
137
+ }
138
+ }
139
+ createClient(useConnectionManager) {
140
+ const client = new DWClient({
141
+ clientId: this.config.clientId,
142
+ clientSecret: this.config.clientSecret,
143
+ keepAlive: !useConnectionManager,
144
+ });
145
+ client.config.autoReconnect = !useConnectionManager;
146
+ this.installStructuredDownstreamHandler(client);
147
+ this.registerMessageHandler(client);
148
+ return client;
149
+ }
150
+ installStructuredDownstreamHandler(streamClient) {
151
+ const client = streamClient;
152
+ client.debug = false;
153
+ // Keep raw SDK downstream frames off stdout; this switch mirrors the SDK
154
+ // dispatch table and should be checked when upgrading the DingTalk SDK.
155
+ client.onDownStream = (raw) => {
156
+ this.onDownStream(raw, client);
157
+ };
158
+ }
159
+ registerMessageHandler(client) {
160
+ client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
161
+ client.send(msg.headers.messageId, {
162
+ status: EventAck.SUCCESS,
163
+ message: 'ok',
164
+ });
165
+ this.onMessage(msg);
166
+ });
167
+ }
168
+ onDownStream(raw, client) {
169
+ this.connectionManager?.noteActivity(client);
170
+ const decoded = this.decodeDownStream(raw);
171
+ let msg;
172
+ try {
173
+ const parsed = JSON.parse(decoded.text);
174
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
175
+ process.stderr.write(`[DingTalk:${this.name}] downstream parsed to non-object, ignoring.\n`);
176
+ return;
177
+ }
178
+ msg = parsed;
179
+ }
180
+ catch (err) {
181
+ process.stderr.write(`[DingTalk:${this.name}] Failed to parse downstream: ${sanitizeLogText(String(err), 200)}\n`);
182
+ return;
183
+ }
184
+ const headers = msg.headers && typeof msg.headers === 'object' ? msg.headers : {};
185
+ const type = typeof msg.type === 'string' ? msg.type : '';
186
+ const topic = typeof headers['topic'] === 'string' ? headers['topic'] : '';
187
+ const messageId = typeof headers['messageId'] === 'string' ? headers['messageId'] : '';
188
+ process.stderr.write(`[DingTalk:${this.name}] downstream type=${sanitizeLogText(type, 40)} topic=${sanitizeLogText(topic, 80)} messageId=${sanitizeLogText(messageId, 80)} bytes=${decoded.bytes}\n`);
189
+ if ((type === 'CALLBACK' || type === 'EVENT') && (!topic || !messageId)) {
190
+ process.stderr.write(`[DingTalk:${this.name}] Ignoring downstream with invalid routing headers.\n`);
191
+ return;
192
+ }
193
+ const normalizedMsg = {
194
+ ...msg,
195
+ headers: { ...headers, topic, messageId },
196
+ };
197
+ switch (type) {
198
+ case 'SYSTEM':
199
+ this.callDownStreamHandler(client, 'onSystem', normalizedMsg);
200
+ if (topic === 'disconnect') {
201
+ this.connectionManager?.requestReconnect(client, 'SYSTEM disconnect');
202
+ }
203
+ break;
204
+ case 'EVENT':
205
+ this.callDownStreamHandler(client, 'onEvent', normalizedMsg);
206
+ break;
207
+ case 'CALLBACK':
208
+ this.callDownStreamHandler(client, 'onCallback', normalizedMsg);
209
+ break;
210
+ default:
211
+ process.stderr.write(`[DingTalk:${this.name}] Ignoring downstream type ${sanitizeLogText(type || 'unknown', 40)}.\n`);
212
+ }
213
+ }
214
+ callDownStreamHandler(client, method, msg) {
215
+ try {
216
+ client[method](msg);
217
+ }
218
+ catch (err) {
219
+ process.stderr.write(`[DingTalk:${this.name}] ${method} failed: ${sanitizeLogText(String(err), 200)}\n`);
220
+ }
221
+ }
222
+ decodeDownStream(raw) {
223
+ if (typeof raw === 'string') {
224
+ return { text: raw, bytes: Buffer.byteLength(raw) };
225
+ }
226
+ if (Buffer.isBuffer(raw)) {
227
+ return { text: raw.toString('utf8'), bytes: raw.length };
228
+ }
229
+ if (raw instanceof Uint8Array) {
230
+ return { text: Buffer.from(raw).toString('utf8'), bytes: raw.byteLength };
231
+ }
232
+ if (raw instanceof ArrayBuffer) {
233
+ return {
234
+ text: Buffer.from(raw).toString('utf8'),
235
+ bytes: raw.byteLength,
236
+ };
237
+ }
238
+ return { text: String(raw), bytes: Buffer.byteLength(String(raw)) };
239
+ }
240
+ async connect() {
241
+ if (this.connectionManager) {
242
+ await this.connectionManager.start();
243
+ }
244
+ else {
245
+ await this.client.connect();
246
+ }
247
+ // Periodically clean up dedup map
248
+ this.dedupTimer = setInterval(() => {
249
+ const now = Date.now();
250
+ for (const [id, ts] of this.seenMessages) {
251
+ if (now - ts > DEDUP_TTL_MS) {
252
+ this.seenMessages.delete(id);
253
+ }
254
+ }
255
+ }, 60_000);
256
+ process.stderr.write(`[DingTalk:${this.name}] Connected via stream.\n`);
257
+ }
258
+ /**
259
+ * A group message with no conversationId can't be routed to a stable shared
260
+ * session (chatId would fall back to the expiring sessionWebhook), so it is
261
+ * dropped on ingestion. Exposed for testing the drop rule.
262
+ */
263
+ static isUnroutableGroupMessage(isGroup, conversationId) {
264
+ return isGroup && !conversationId;
265
+ }
266
+ async prepareOutgoingText(text) {
267
+ const markers = findImageMarkers(text);
268
+ if (markers.length === 0)
269
+ return text;
270
+ const replacements = [];
271
+ for (const marker of markers) {
272
+ const fileName = basename(marker.path)
273
+ .replace(/[\r\n[\]]+/g, '_')
274
+ .slice(0, 100) || 'image';
275
+ try {
276
+ const image = readValidatedImage(marker.path, {
277
+ workspaceDir: this.config.cwd,
278
+ });
279
+ let mediaId;
280
+ for (let attempt = 0; attempt < 2; attempt++) {
281
+ const token = await this.getProactiveToken();
282
+ try {
283
+ mediaId = await uploadDingTalkImage(image, token);
284
+ break;
285
+ }
286
+ catch (error) {
287
+ if (error instanceof DingTalkMediaUploadError &&
288
+ error.authFailure &&
289
+ attempt === 0) {
290
+ this.proactiveToken = undefined;
291
+ continue;
292
+ }
293
+ throw error;
294
+ }
295
+ }
296
+ if (!mediaId) {
297
+ throw new Error('DingTalk media upload returned no MediaID');
298
+ }
299
+ replacements.push(`![image](${mediaId})`);
300
+ }
301
+ catch (error) {
302
+ process.stderr.write(`[DingTalk:${this.name}] outbound image upload failed (${sanitizeLogText(fileName, 100)}): ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
303
+ replacements.push(`[Image delivery failed: ${fileName}]`);
304
+ }
305
+ }
306
+ return replaceImageMarkers(text, markers, replacements);
307
+ }
308
+ async sendReply(chatId, text, atUserId) {
309
+ // chatId is a conversationId — resolve to the latest sessionWebhook
310
+ const webhook = this.webhooks.get(chatId);
311
+ if (!webhook) {
312
+ process.stderr.write(`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`);
313
+ return;
314
+ }
315
+ const outgoingText = await this.prepareOutgoingText(text);
316
+ const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
317
+ const chunks = normalizeDingTalkMarkdown(mentionPrefix + outgoingText);
318
+ const title = extractTitle(outgoingText);
319
+ for (let i = 0; i < chunks.length; i++) {
320
+ const chunk = chunks[i];
321
+ const isMention = i === 0 && atUserId !== undefined;
322
+ const body = {
323
+ msgtype: 'markdown',
324
+ markdown: {
325
+ title: i === 0 ? title : `${title} (cont.)`,
326
+ text: chunk,
327
+ },
328
+ ...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
329
+ };
330
+ const resp = await fetch(webhook, {
331
+ method: 'POST',
332
+ headers: { 'Content-Type': 'application/json' },
333
+ body: JSON.stringify(body),
334
+ });
335
+ if (!resp.ok) {
336
+ const detail = await resp.text().catch(() => '');
337
+ process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`);
338
+ }
339
+ }
340
+ }
341
+ async sendTextReply(chatId, text, atUserId) {
342
+ const webhook = this.webhooks.get(chatId);
343
+ if (!webhook)
344
+ return;
345
+ const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
346
+ const chunks = splitTextChunks(text, TEXT_MESSAGE_LIMIT - mentionPrefix.length);
347
+ for (let i = 0; i < chunks.length; i++) {
348
+ const isMention = i === 0 && atUserId !== undefined;
349
+ const resp = await fetch(webhook, {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({
353
+ msgtype: 'text',
354
+ text: {
355
+ content: isMention ? `${mentionPrefix}${chunks[i]}` : chunks[i],
356
+ },
357
+ ...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
358
+ }),
359
+ });
360
+ if (isMention && process.env['QWEN_CHANNEL_DEBUG_MENTIONS'] === '1') {
361
+ const payload = (await resp
362
+ .clone()
363
+ .json()
364
+ .catch(() => undefined));
365
+ const response = payload && typeof payload === 'object'
366
+ ? payload
367
+ : {};
368
+ const value = response['errcode'] ?? response['code'];
369
+ const code = typeof value === 'number' || typeof value === 'string'
370
+ ? String(value)
371
+ : 'unknown';
372
+ process.stderr.write(`[DingTalk:${this.name}] mention delivery status=${resp.status} code=${code}\n`);
373
+ }
374
+ if (!resp.ok) {
375
+ const detail = await resp.text().catch(() => '');
376
+ process.stderr.write(`[DingTalk:${this.name}] sendTextReply failed: HTTP ${resp.status} ${detail}\n`);
377
+ }
378
+ }
379
+ }
380
+ async sendMessage(chatId, text) {
381
+ await this.sendReply(chatId, text);
382
+ }
383
+ supportsProactiveSend() {
384
+ return true;
385
+ }
386
+ // Regular proactive paths accept only group targets; webhook tasks may use
387
+ // DMs through the one-to-one API.
388
+ supportsProactiveTarget(target) {
389
+ return (target.isGroup === true &&
390
+ target.threadId === undefined &&
391
+ this.isStableTargetId(target.chatId));
392
+ }
393
+ supportsProactiveDeliveryTarget(target) {
394
+ return (typeof target.isGroup === 'boolean' &&
395
+ target.threadId === undefined &&
396
+ this.isStableTargetId(target.chatId));
397
+ }
398
+ supportsProactiveWebhookTarget(target) {
399
+ return (typeof target.isGroup === 'boolean' &&
400
+ target.threadId === undefined &&
401
+ this.isStableTargetId(target.chatId));
402
+ }
403
+ /**
404
+ * Single-shot cold send: a failed chunk aborts the remainder (already-sent
405
+ * chunks are not recalled) and the error surfaces in the loop's lastError.
406
+ */
407
+ async pushProactive(target, text) {
408
+ if (!text.trim())
409
+ return;
410
+ const outgoingText = await this.prepareOutgoingText(text);
411
+ const chunks = normalizeDingTalkMarkdown(outgoingText);
412
+ const title = extractTitle(outgoingText);
413
+ for (let i = 0; i < chunks.length; i++) {
414
+ await this.sendProactiveChunk(target, i === 0 ? title : `${title} (cont.)`, chunks[i], `chunk ${i + 1}/${chunks.length}`);
415
+ }
416
+ }
417
+ async getProactiveToken() {
418
+ const cached = this.proactiveToken;
419
+ if (cached && Date.now() < cached.expiresAt)
420
+ return cached.token;
421
+ const url = `${TOKEN_API}?appkey=${encodeURIComponent(this.config.clientId)}&appsecret=${encodeURIComponent(this.config.clientSecret)}`;
422
+ let data;
423
+ try {
424
+ const resp = await fetch(url, {
425
+ signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
426
+ });
427
+ data = (await resp.json());
428
+ }
429
+ catch {
430
+ process.stderr.write(`[DingTalk:${this.name}] access token fetch failed.\n`);
431
+ throw new Error('DingTalk access token fetch failed');
432
+ }
433
+ if (!data.access_token) {
434
+ const errmsg = sanitizeLogText(String(data.errmsg ?? ''), 200);
435
+ process.stderr.write(`[DingTalk:${this.name}] access token request failed: gettoken errcode=${data.errcode} ${errmsg}\n`);
436
+ throw new Error(`DingTalk access token request failed: gettoken errcode=${data.errcode}${errmsg ? ` ${errmsg}` : ''}`);
437
+ }
438
+ this.proactiveToken = {
439
+ token: data.access_token,
440
+ // Refresh a minute early so a fire mid-expiry doesn't race the TTL.
441
+ expiresAt: Date.now() + Math.max(60, (data.expires_in ?? 7200) - 60) * 1000,
442
+ };
443
+ return data.access_token;
444
+ }
445
+ async sendProactiveChunk(target, title, text, chunkLabel) {
446
+ const targetKind = target.isGroup === true ? 'group' : 'dm';
447
+ for (let attempt = 0;; attempt++) {
448
+ const token = await this.getProactiveToken();
449
+ let resp;
450
+ try {
451
+ const targetBody = target.isGroup === true
452
+ ? { openConversationId: target.chatId }
453
+ : { userIds: [target.chatId] };
454
+ resp = await fetch(target.isGroup === true ? GROUP_MSG_API : DIRECT_MSG_API, {
455
+ method: 'POST',
456
+ headers: {
457
+ 'x-acs-dingtalk-access-token': token,
458
+ 'Content-Type': 'application/json',
459
+ },
460
+ body: JSON.stringify({
461
+ robotCode: this.config.clientId,
462
+ ...targetBody,
463
+ msgKey: PROACTIVE_MSG_KEY,
464
+ msgParam: JSON.stringify({ title, text }),
465
+ }),
466
+ signal: AbortSignal.timeout(PROACTIVE_FETCH_TIMEOUT_MS),
467
+ });
468
+ }
469
+ catch (err) {
470
+ const cause = err.cause;
471
+ process.stderr.write(`[DingTalk:${this.name}] proactive send error (${targetKind}, ${chunkLabel}): ${err}${cause ? ` (${cause})` : ''}\n`);
472
+ throw new Error(`DingTalk proactive send failed: ${err instanceof Error ? err.message : String(err)}`);
473
+ }
474
+ if (resp.status === 401 && attempt === 0) {
475
+ // Stale or revoked token — refresh once and retry this chunk.
476
+ this.proactiveToken = undefined;
477
+ await resp.body?.cancel();
478
+ continue;
479
+ }
480
+ if (!resp.ok) {
481
+ const detail = sanitizeLogText(await resp.text().catch(() => ''), 300);
482
+ process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): HTTP ${resp.status} ${detail}\n`);
483
+ throw new Error(`DingTalk proactive send failed: HTTP ${resp.status}${detail ? ` ${detail}` : ''}`);
484
+ }
485
+ if (target.isGroup === false) {
486
+ let data;
487
+ try {
488
+ data = (await resp.json());
489
+ }
490
+ catch {
491
+ process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid JSON response\n`);
492
+ throw new Error('DingTalk proactive send failed: invalid JSON response');
493
+ }
494
+ if (data.invalidStaffIdList?.includes(target.chatId)) {
495
+ process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): invalid direct recipient\n`);
496
+ throw new Error('DingTalk proactive send failed: invalid direct recipient');
497
+ }
498
+ if (data.flowControlledStaffIdList?.includes(target.chatId)) {
499
+ process.stderr.write(`[DingTalk:${this.name}] proactive send failed (${targetKind}, ${chunkLabel}): direct recipient rate limited\n`);
500
+ throw new Error('DingTalk proactive send failed: direct recipient rate limited');
501
+ }
502
+ return;
503
+ }
504
+ await resp.body?.cancel();
505
+ return;
506
+ }
507
+ }
508
+ getAccessToken() {
509
+ return this.client.getConfig().access_token;
510
+ }
511
+ async emotionApi(endpoint, msgId, conversationId) {
512
+ const robotCode = this.config.clientId;
513
+ if (!robotCode || !msgId || !conversationId)
514
+ return;
515
+ try {
516
+ const token = this.config.clientSecret
517
+ ? await this.getProactiveToken()
518
+ : this.getAccessToken();
519
+ if (!token)
520
+ return;
521
+ for (let attempt = 0; attempt < EMOTION_MAX_ATTEMPTS; attempt++) {
522
+ const resp = await fetch(`${EMOTION_API}/${endpoint}`, {
523
+ method: 'POST',
524
+ headers: {
525
+ 'x-acs-dingtalk-access-token': token,
526
+ 'Content-Type': 'application/json',
527
+ },
528
+ body: JSON.stringify({
529
+ robotCode,
530
+ openMsgId: msgId,
531
+ openConversationId: conversationId,
532
+ emotionType: 2,
533
+ emotionName: ACK_REACTION_NAME,
534
+ textEmotion: {
535
+ emotionId: ACK_EMOTION_ID,
536
+ emotionName: ACK_REACTION_NAME,
537
+ text: ACK_REACTION_NAME,
538
+ backgroundId: ACK_EMOTION_BG_ID,
539
+ },
540
+ }),
541
+ });
542
+ if (resp.ok)
543
+ return;
544
+ const isTransient = resp.status === 429 || resp.status >= 500;
545
+ if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
546
+ await resp.body?.cancel();
547
+ await new Promise((resolve) => setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt));
548
+ continue;
549
+ }
550
+ const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
551
+ process.stderr.write(`[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`);
552
+ return;
553
+ }
554
+ }
555
+ catch {
556
+ // best-effort, don't break message flow
557
+ }
558
+ }
559
+ async attachReaction(msgId, conversationId) {
560
+ await this.emotionApi('reply', msgId, conversationId);
561
+ }
562
+ async recallReaction(msgId, conversationId) {
563
+ await this.emotionApi('recall', msgId, conversationId);
564
+ }
565
+ disconnect() {
566
+ if (this.dedupTimer) {
567
+ clearInterval(this.dedupTimer);
568
+ }
569
+ this.activeReactionKeys.clear();
570
+ this.sessionReactionKeys.clear();
571
+ if (this.connectionManager) {
572
+ this.connectionManager.stop();
573
+ }
574
+ else {
575
+ this.client.disconnect();
576
+ }
577
+ process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`);
578
+ }
579
+ /** Stable API targets are conversation or user IDs, never webhook URLs. */
580
+ isStableTargetId(chatId) {
581
+ return !!chatId && !/^https?:\/\//i.test(chatId);
582
+ }
583
+ reactionKey(messageId, conversationId) {
584
+ return `${conversationId}:${messageId}`;
585
+ }
586
+ rememberInboundMessageId(msgId) {
587
+ this.inboundMessageIds.delete(msgId);
588
+ this.inboundMessageIds.add(msgId);
589
+ if (this.inboundMessageIds.size > 1000) {
590
+ const oldest = this.inboundMessageIds.values().next().value;
591
+ if (oldest !== undefined)
592
+ this.inboundMessageIds.delete(oldest);
593
+ }
594
+ }
595
+ logReactionFailure(action, err) {
596
+ process.stderr.write(`[DingTalk:${this.name}] ${action} failed: ${err instanceof Error ? err.message : err}\n`);
597
+ }
598
+ startReaction(chatId, messageId, sessionId) {
599
+ if (!messageId || !this.isStableTargetId(chatId))
600
+ return;
601
+ // Loop lifecycle events carry the internal job id as messageId; the
602
+ // emotion API only accepts ids of real inbound messages, so skip anything
603
+ // we never saw arrive.
604
+ if (!this.inboundMessageIds.has(messageId))
605
+ return;
606
+ const key = this.reactionKey(messageId, chatId);
607
+ if (this.activeReactionKeys.has(key))
608
+ return;
609
+ this.activeReactionKeys.add(key);
610
+ if (sessionId) {
611
+ let keys = this.sessionReactionKeys.get(sessionId);
612
+ if (!keys) {
613
+ keys = new Map();
614
+ this.sessionReactionKeys.set(sessionId, keys);
615
+ }
616
+ keys.set(key, { messageId, chatId });
617
+ }
618
+ this.attachReaction(messageId, chatId)
619
+ .then(() => {
620
+ if (!this.activeReactionKeys.has(key)) {
621
+ void this.recallReaction(messageId, chatId).catch((err) => {
622
+ this.logReactionFailure('late reaction recall', err);
623
+ });
624
+ }
625
+ })
626
+ .catch((err) => {
627
+ this.activeReactionKeys.delete(key);
628
+ this.logReactionFailure('reaction attach', err);
629
+ });
630
+ }
631
+ stopReaction(chatId, messageId, sessionId) {
632
+ if (!messageId || !this.isStableTargetId(chatId))
633
+ return;
634
+ const key = this.reactionKey(messageId, chatId);
635
+ if (sessionId) {
636
+ const keys = this.sessionReactionKeys.get(sessionId);
637
+ if (keys) {
638
+ keys.delete(key);
639
+ if (keys.size === 0)
640
+ this.sessionReactionKeys.delete(sessionId);
641
+ }
642
+ }
643
+ if (!this.activeReactionKeys.delete(key))
644
+ return;
645
+ this.recallReaction(messageId, chatId).catch((err) => {
646
+ this.logReactionFailure('reaction recall', err);
647
+ });
648
+ }
649
+ /** Recall reactions left behind when a session dies without terminal lifecycle events. */
650
+ onSessionDied(sessionId) {
651
+ const bufferedTargets = this.bufferedMentionTargetsBySession.get(sessionId);
652
+ if (bufferedTargets) {
653
+ this.bufferedMentionTargetsBySession.delete(sessionId);
654
+ for (const messageId of bufferedTargets) {
655
+ this.bufferedMentionTargets.delete(messageId);
656
+ this.mentionTargets.delete(messageId);
657
+ }
658
+ }
659
+ this.sessionMentionTargets.delete(sessionId);
660
+ this.textReplySessions.delete(sessionId);
661
+ const keys = this.sessionReactionKeys.get(sessionId);
662
+ if (keys) {
663
+ this.sessionReactionKeys.delete(sessionId);
664
+ for (const [key, { messageId, chatId }] of keys) {
665
+ if (this.activeReactionKeys.delete(key)) {
666
+ void this.recallReaction(messageId, chatId).catch((err) => {
667
+ this.logReactionFailure('session-death reaction recall', err);
668
+ });
669
+ }
670
+ }
671
+ }
672
+ super.onSessionDied(sessionId);
673
+ }
674
+ onTaskLifecycle(event) {
675
+ if (event.type === 'started') {
676
+ this.startReaction(event.chatId, event.messageId, event.sessionId);
677
+ return;
678
+ }
679
+ if (isTerminalTaskLifecycleType(event.type)) {
680
+ if (event.messageId)
681
+ this.mentionTargets.delete(event.messageId);
682
+ this.stopReaction(event.chatId, event.messageId, event.sessionId);
683
+ }
684
+ }
685
+ onPromptBufferDropped(_chatId, sessionId, messageIds) {
686
+ for (const messageId of messageIds) {
687
+ this.bufferedMentionTargets.delete(messageId);
688
+ this.mentionTargets.delete(messageId);
689
+ this.untrackBufferedMentionTarget(sessionId, messageId);
690
+ }
691
+ }
692
+ onPromptBufferDrained(_chatId, sessionId, messageIds) {
693
+ for (const messageId of messageIds) {
694
+ this.bufferedMentionTargets.delete(messageId);
695
+ this.untrackBufferedMentionTarget(sessionId, messageId);
696
+ }
697
+ for (const messageId of messageIds.slice(0, -1)) {
698
+ this.mentionTargets.delete(messageId);
699
+ }
700
+ }
701
+ onPromptBuffered(_chatId, sessionId, messageId) {
702
+ if (messageId && this.mentionTargets.has(messageId)) {
703
+ this.bufferedMentionTargets.add(messageId);
704
+ let targets = this.bufferedMentionTargetsBySession.get(sessionId);
705
+ if (!targets) {
706
+ targets = new Set();
707
+ this.bufferedMentionTargetsBySession.set(sessionId, targets);
708
+ }
709
+ targets.add(messageId);
710
+ }
711
+ }
712
+ onPromptStart(chatId, sessionId, messageId) {
713
+ if (messageId) {
714
+ this.bufferedMentionTargets.delete(messageId);
715
+ this.untrackBufferedMentionTarget(sessionId, messageId);
716
+ const atUserId = this.mentionTargets.get(messageId);
717
+ this.mentionTargets.delete(messageId);
718
+ if (this.atSender && atUserId) {
719
+ this.sessionMentionTargets.set(sessionId, atUserId);
720
+ this.textReplySessions.add(sessionId);
721
+ }
722
+ }
723
+ this.startReaction(chatId, messageId, sessionId);
724
+ }
725
+ async handleInbound(envelope) {
726
+ if (!(await this.preflightInbound(envelope)))
727
+ return;
728
+ const messageId = envelope.messageId;
729
+ const atUserId = envelope[mentionTarget];
730
+ if (this.atSender && messageId && atUserId) {
731
+ this.mentionTargets.set(messageId, atUserId);
732
+ }
733
+ await this.processInbound(envelope);
734
+ }
735
+ async processInbound(envelope) {
736
+ const messageId = envelope.messageId;
737
+ try {
738
+ await super.processInbound(envelope);
739
+ }
740
+ finally {
741
+ if (messageId && !this.bufferedMentionTargets.has(messageId)) {
742
+ this.mentionTargets.delete(messageId);
743
+ }
744
+ }
745
+ }
746
+ untrackBufferedMentionTarget(sessionId, messageId) {
747
+ const targets = this.bufferedMentionTargetsBySession.get(sessionId);
748
+ if (!targets)
749
+ return;
750
+ targets.delete(messageId);
751
+ if (targets.size === 0)
752
+ this.bufferedMentionTargetsBySession.delete(sessionId);
753
+ }
754
+ onPromptEnd(chatId, sessionId, messageId) {
755
+ this.sessionMentionTargets.delete(sessionId);
756
+ this.textReplySessions.delete(sessionId);
757
+ this.stopReaction(chatId, messageId, sessionId);
758
+ }
759
+ async sendResponseMessage(chatId, text, sessionId) {
760
+ const atUserId = this.atSender
761
+ ? this.sessionMentionTargets.get(sessionId)
762
+ : undefined;
763
+ if (atUserId)
764
+ this.sessionMentionTargets.delete(sessionId);
765
+ if (this.textReplySessions.has(sessionId)) {
766
+ if (findImageMarkers(text).length > 0) {
767
+ await this.sendReply(chatId, text, atUserId);
768
+ return;
769
+ }
770
+ await this.sendTextReply(chatId, text, atUserId);
771
+ return;
772
+ }
773
+ await this.sendReply(chatId, text);
774
+ }
775
+ /**
776
+ * Extract quoted/referenced message context from a reply.
777
+ * DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy).
778
+ */
779
+ extractQuotedContext(data) {
780
+ // Newer format: text.repliedMsg
781
+ if (data.text?.isReplyMsg && data.text.repliedMsg) {
782
+ const replied = data.text.repliedMsg;
783
+ const isReplyToBot = !!data.chatbotUserId && replied.senderId === data.chatbotUserId;
784
+ // Note: DingTalk doesn't include content for interactiveCard replies
785
+ // (bot responses sent via webhook). Only user message quotes have text.
786
+ const text = this.summarizeRepliedContent(replied);
787
+ return { referencedText: text || undefined, isReplyToBot };
788
+ }
789
+ // Legacy format: quoteMessage
790
+ if (data.quoteMessage) {
791
+ const quote = data.quoteMessage;
792
+ const isReplyToBot = !!data.chatbotUserId && quote.senderId === data.chatbotUserId;
793
+ const text = quote.text?.content?.trim();
794
+ return { referencedText: text || undefined, isReplyToBot };
795
+ }
796
+ return { isReplyToBot: false };
797
+ }
798
+ /**
799
+ * Build a text summary from a repliedMsg, handling text, richText, and
800
+ * media message types with placeholders.
801
+ */
802
+ summarizeRepliedContent(replied) {
803
+ const msgType = replied.msgType;
804
+ const content = replied.content;
805
+ // Direct text content
806
+ if (content?.text?.trim()) {
807
+ return content.text.trim();
808
+ }
809
+ // RichText: concatenate text parts, placeholder for images
810
+ if (content?.richText && Array.isArray(content.richText)) {
811
+ const parts = [];
812
+ for (const part of content.richText) {
813
+ const partType = part.type || 'text';
814
+ if (partType === 'text' && part.text) {
815
+ parts.push(part.text);
816
+ }
817
+ else if (partType === 'picture') {
818
+ parts.push('[image]');
819
+ }
820
+ else if (partType === 'at' && part.atName) {
821
+ parts.push(`@${part.atName}`);
822
+ }
823
+ }
824
+ const summary = parts.join('').trim();
825
+ if (summary)
826
+ return summary;
827
+ }
828
+ // Media type placeholders
829
+ switch (msgType) {
830
+ case 'picture':
831
+ return '[image]';
832
+ case 'file':
833
+ return `[file: ${content?.fileName || 'file'}]`;
834
+ case 'audio':
835
+ return '[audio]';
836
+ case 'video':
837
+ return '[video]';
838
+ default:
839
+ break;
840
+ }
841
+ return '';
842
+ }
843
+ /**
844
+ * Extract text and media download codes from an incoming DingTalk message.
845
+ * Handles text, richText, picture, file, audio, and video message types.
846
+ */
847
+ extractContent(data) {
848
+ const msgtype = data.msgtype || 'text';
849
+ if (msgtype === 'richText') {
850
+ const richText = data.content?.richText;
851
+ if (!Array.isArray(richText)) {
852
+ return { text: '', downloadCodes: [] };
853
+ }
854
+ let text = '';
855
+ const codes = [];
856
+ for (const part of richText) {
857
+ const partType = part.type || 'text';
858
+ if (partType === 'text' && part.text) {
859
+ text += part.text;
860
+ }
861
+ else if (partType === 'picture' && part.downloadCode) {
862
+ codes.push(part.downloadCode);
863
+ }
864
+ }
865
+ return {
866
+ text: text.trim() || (codes.length > 0 ? '(image)' : ''),
867
+ downloadCodes: codes,
868
+ mediaType: codes.length > 0 ? 'image' : undefined,
869
+ };
870
+ }
871
+ if (msgtype === 'picture') {
872
+ const code = data.content?.downloadCode;
873
+ return {
874
+ text: '(image)',
875
+ downloadCodes: code ? [code] : [],
876
+ mediaType: 'image',
877
+ };
878
+ }
879
+ if (msgtype === 'file') {
880
+ const code = data.content?.downloadCode;
881
+ const fileName = data.content?.fileName || undefined;
882
+ return {
883
+ text: `(file: ${fileName || 'file'})`,
884
+ downloadCodes: code ? [code] : [],
885
+ mediaType: 'file',
886
+ fileName,
887
+ };
888
+ }
889
+ if (msgtype === 'audio') {
890
+ const code = data.content?.downloadCode;
891
+ const recognition = data.content?.recognition;
892
+ return {
893
+ text: recognition || '(audio)',
894
+ downloadCodes: code ? [code] : [],
895
+ mediaType: 'audio',
896
+ };
897
+ }
898
+ if (msgtype === 'video') {
899
+ const code = data.content?.downloadCode;
900
+ return {
901
+ text: '(video)',
902
+ downloadCodes: code ? [code] : [],
903
+ mediaType: 'video',
904
+ };
905
+ }
906
+ // Default: text message
907
+ return { text: data.text?.content?.trim() || '', downloadCodes: [] };
908
+ }
909
+ /**
910
+ * Download a media file and attach it to the envelope.
911
+ * Images → base64 in envelope; files → saved to temp dir with path in text.
912
+ */
913
+ async attachMedia(envelope, downloadCode, mediaType, fileName) {
914
+ let token;
915
+ try {
916
+ token = await this.getProactiveToken();
917
+ }
918
+ catch {
919
+ process.stderr.write(`[DingTalk:${this.name}] Cannot download media: access token refresh failed.\n`);
920
+ return;
921
+ }
922
+ const robotCode = this.config.clientId;
923
+ if (!robotCode) {
924
+ process.stderr.write(`[DingTalk:${this.name}] Cannot download media: missing robotCode.\n`);
925
+ return;
926
+ }
927
+ const media = await downloadMedia(downloadCode, robotCode, token);
928
+ if (!media)
929
+ return;
930
+ if (mediaType === 'image') {
931
+ const mimeType = media.mimeType.startsWith('image/')
932
+ ? media.mimeType
933
+ : 'image/jpeg';
934
+ envelope.attachments = [
935
+ ...(envelope.attachments || []),
936
+ {
937
+ type: 'image',
938
+ data: media.buffer.toString('base64'),
939
+ mimeType,
940
+ },
941
+ ];
942
+ }
943
+ else {
944
+ // Save non-image files to temp dir so the agent can read them
945
+ const dir = join(tmpdir(), 'channel-files', randomUUID());
946
+ mkdirSync(dir, { recursive: true });
947
+ const safeName = basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`;
948
+ const filePath = join(dir, safeName);
949
+ writeFileSync(filePath, media.buffer);
950
+ // Clean up placeholder text like "(audio)", "(video)", "(file: name)"
951
+ if (envelope.text === `(file: ${fileName || 'file'})` ||
952
+ envelope.text === '(audio)' ||
953
+ envelope.text === '(video)') {
954
+ envelope.text = '';
955
+ }
956
+ envelope.attachments = [
957
+ ...(envelope.attachments || []),
958
+ {
959
+ type: mediaType,
960
+ filePath,
961
+ mimeType: media.mimeType,
962
+ fileName: safeName,
963
+ },
964
+ ];
965
+ }
966
+ }
967
+ onMessage(downstream) {
968
+ try {
969
+ const data = typeof downstream.data === 'string'
970
+ ? JSON.parse(downstream.data)
971
+ : downstream.data;
972
+ this.logDebugPayload('DingTalk', data);
973
+ const dataMsgId = typeof data.msgId === 'string' ? data.msgId : undefined;
974
+ const headerMsgId = typeof downstream.headers.messageId === 'string'
975
+ ? downstream.headers.messageId
976
+ : undefined;
977
+ const msgId = dataMsgId || headerMsgId;
978
+ // Dedup: DingTalk retries unACKed messages
979
+ if (msgId && this.seenMessages.has(msgId)) {
980
+ return;
981
+ }
982
+ if (msgId) {
983
+ this.seenMessages.set(msgId, Date.now());
984
+ this.rememberInboundMessageId(msgId);
985
+ }
986
+ const isGroup = data.conversationType === '2';
987
+ const sessionWebhook = typeof data.sessionWebhook === 'string'
988
+ ? data.sessionWebhook
989
+ : undefined;
990
+ const conversationId = typeof data.conversationId === 'string'
991
+ ? data.conversationId
992
+ : undefined;
993
+ const conversationTitle = typeof data.conversationTitle === 'string'
994
+ ? data.conversationTitle
995
+ : undefined;
996
+ const isMentioned = Boolean(data.isInAtList);
997
+ const senderNick = typeof data.senderNick === 'string' ? data.senderNick : undefined;
998
+ const senderStaffId = typeof data.senderStaffId === 'string' ? data.senderStaffId : undefined;
999
+ const senderIdValue = typeof data.senderId === 'string' ? data.senderId : undefined;
1000
+ if (!sessionWebhook) {
1001
+ process.stderr.write(`[DingTalk:${this.name}] No sessionWebhook in message, skipping.\n`);
1002
+ return;
1003
+ }
1004
+ // A group message with no conversationId can't be routed to a stable
1005
+ // session — chatId would fall back to the expiring sessionWebhook and the
1006
+ // shared-session key would churn. Drop it rather than fragment the group.
1007
+ if (DingtalkChannel.isUnroutableGroupMessage(isGroup, conversationId)) {
1008
+ // Include identifying context so an operator can tell whether one sender
1009
+ // or every group message is affected if DingTalk starts omitting
1010
+ // conversationId (API regression / edge-case message type).
1011
+ process.stderr.write(`[DingTalk:${this.name}] Group message has no conversationId, skipping (msgId=${msgId || 'unknown'}, sender=${sanitizeSenderName(senderNick || senderStaffId || 'unknown')})\n`);
1012
+ return;
1013
+ }
1014
+ // Cache webhook by conversationId so sendMessage can look it up
1015
+ if (conversationId) {
1016
+ this.webhooks.set(conversationId, sessionWebhook);
1017
+ }
1018
+ process.stderr.write(`[DingTalk:${this.name}] message msgId=${sanitizeLogText(msgId || 'unknown', 80)} conversationId=${sanitizeLogText(conversationId || '', 120)} isGroup=${isGroup} isMentioned=${isMentioned} senderNick=${sanitizeLogText(senderNick || '', 80)} senderStaffId=${sanitizeLogText(senderStaffId || '', 80)} senderId=${sanitizeLogText(senderIdValue || '', 80)}\n`);
1019
+ // Extract text and media info from message
1020
+ const content = this.extractContent(data);
1021
+ let cleanText = content.text;
1022
+ // Strip first @mention (the bot) from text, keep other @mentions intact.
1023
+ // Anchor to start-of-string so @ symbols inside URLs or emails
1024
+ // (e.g. git@host:path) are not accidentally stripped (#7402).
1025
+ if (isMentioned) {
1026
+ cleanText = cleanText.replace(/^\s*@[^\s\p{Cf}]+/u, '').trim();
1027
+ }
1028
+ // Extract quoted message context
1029
+ const quoted = this.extractQuotedContext(data);
1030
+ const chatId = conversationId || sessionWebhook;
1031
+ // After stripping the bot @mention, cleanText may legitimately be empty
1032
+ // (user pinged the bot with no other text). Don't fall back to the
1033
+ // original text in that case — it would re-introduce the @mention.
1034
+ const messageText = isMentioned ? cleanText : cleanText || content.text;
1035
+ const envelopeText = isGroup
1036
+ ? withNonBotMentionContext(data, messageText)
1037
+ : messageText;
1038
+ const senderId = senderStaffId || senderIdValue || '';
1039
+ const senderName = senderNick || senderId || 'Unknown';
1040
+ const envelope = {
1041
+ channelName: this.name,
1042
+ senderId,
1043
+ senderName,
1044
+ chatId,
1045
+ ...(isGroup && conversationTitle
1046
+ ? { chatName: conversationTitle }
1047
+ : {}),
1048
+ text: envelopeText,
1049
+ isGroup,
1050
+ isMentioned,
1051
+ isReplyToBot: quoted.isReplyToBot,
1052
+ referencedText: quoted.referencedText,
1053
+ };
1054
+ // Reactions are resolved later via the chatId passed to
1055
+ // onPromptStart/onPromptEnd — no extra bookkeeping needed.
1056
+ envelope.messageId = msgId;
1057
+ if (this.atSender && isGroup && senderStaffId) {
1058
+ envelope[mentionTarget] = senderStaffId;
1059
+ }
1060
+ const processMessage = async () => {
1061
+ // Download media if present (first downloadCode only for images)
1062
+ if (content.downloadCodes.length > 0 && content.mediaType) {
1063
+ await this.attachMedia(envelope, content.downloadCodes[0], content.mediaType, content.fileName);
1064
+ }
1065
+ await this.handleInbound(envelope);
1066
+ };
1067
+ // Don't await — stream callback should return quickly
1068
+ processMessage().catch((err) => {
1069
+ process.stderr.write(`[DingTalk:${this.name}] Error handling message: ${err}\n`);
1070
+ this.sendMessage(chatId, 'Sorry, something went wrong processing your message.').catch(() => { });
1071
+ });
1072
+ }
1073
+ catch (err) {
1074
+ process.stderr.write(`[DingTalk:${this.name}] Failed to parse message: ${err}\n`);
1075
+ }
1076
+ }
1077
+ }
1078
+ //# sourceMappingURL=DingtalkAdapter.js.map