@qwen-code/channel-dingtalk 0.21.0-preview.0

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