evolcore 0.0.2 → 0.0.3

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.
Files changed (124) hide show
  1. package/CHANGELOG.md +44 -793
  2. package/dist/agents/claude-runner.js +197 -17
  3. package/dist/agents/codex-runner.js +46 -3
  4. package/dist/aun/outbox.js +8 -0
  5. package/dist/channels/aun.js +21 -4
  6. package/dist/channels/contact-bind-code.js +134 -0
  7. package/dist/channels/dingtalk.js +979 -149
  8. package/dist/channels/feishu.js +130 -54
  9. package/dist/channels/wecom-card.js +101 -0
  10. package/dist/channels/wecom-onboarding.js +82 -0
  11. package/dist/channels/wecom-state.js +191 -0
  12. package/dist/channels/wecom.js +755 -163
  13. package/dist/cli/agent-command.js +2 -1
  14. package/dist/cli/aun-commands.js +88 -33
  15. package/dist/cli/bench.js +2 -2
  16. package/dist/cli/contact.js +71 -0
  17. package/dist/cli/ctl-command.js +2 -2
  18. package/dist/cli/daemon-commands.js +77 -214
  19. package/dist/cli/handoff-command.js +2 -2
  20. package/dist/cli/help.js +9 -5
  21. package/dist/cli/index.js +132 -116
  22. package/dist/cli/init-channel.js +92 -97
  23. package/dist/cli/init.js +63 -26
  24. package/dist/cli/model.js +2 -1
  25. package/dist/cli/net-check.js +2 -2
  26. package/dist/cli/queue-command.js +30 -6
  27. package/dist/cli/raw-key-input.js +25 -0
  28. package/dist/cli/response.js +5 -6
  29. package/dist/cli/restart-monitor.js +25 -1
  30. package/dist/cli/stats.js +6 -4
  31. package/dist/cli/trigger-command.js +55 -15
  32. package/dist/cli/version.js +6 -1
  33. package/dist/config/builtin-role-templates.js +22 -10
  34. package/dist/config/builtin-roles.js +7 -1
  35. package/dist/config/config-manager.js +221 -17
  36. package/dist/config/contact-alias.js +68 -0
  37. package/dist/config/contact-book-store.js +454 -0
  38. package/dist/config/contact-book-v2-startup.js +35 -0
  39. package/dist/config/contact-book.js +156 -303
  40. package/dist/config/contact-operation-service.js +110 -0
  41. package/dist/config/peer-role-resolver.js +133 -54
  42. package/dist/config/role-ranks.js +18 -0
  43. package/dist/config/role-service.js +16 -19
  44. package/dist/config/role-store.js +16 -5
  45. package/dist/config/roles.js +10 -1
  46. package/dist/config-store.js +0 -2
  47. package/dist/core/auth/authorization-audit.js +10 -1
  48. package/dist/core/auth/operation-authorizer.js +2 -0
  49. package/dist/core/auth/operation-catalog.js +56 -0
  50. package/dist/core/command/command-handler.js +105 -10
  51. package/dist/core/command/connect-menu.js +374 -0
  52. package/dist/core/command/menu-handler.js +114 -16
  53. package/dist/core/command/role-menu.js +128 -29
  54. package/dist/core/command/slash-handler.js +28 -18
  55. package/dist/core/daemon-file-cache.js +12 -6
  56. package/dist/core/event-catalog.js +70 -0
  57. package/dist/core/evolagent-registry.js +0 -1
  58. package/dist/core/evolagent.js +20 -9
  59. package/dist/core/message/im-renderer.js +2 -0
  60. package/dist/core/message/message-bridge.js +79 -8
  61. package/dist/core/message/message-queue.js +10 -0
  62. package/dist/core/message/message-utils.js +8 -2
  63. package/dist/core/message/response-engine.js +269 -25
  64. package/dist/core/message/send-receipt.js +24 -0
  65. package/dist/core/message/stream-debouncer.js +11 -2
  66. package/dist/core/permission/tool-policy.js +47 -15
  67. package/dist/core/protected-paths.js +2 -0
  68. package/dist/core/session/session-fs-store.js +44 -3
  69. package/dist/core/session/session-manager.js +61 -5
  70. package/dist/index.js +62 -6
  71. package/dist/ipc.js +34 -5
  72. package/dist/stats/billing.js +20 -8
  73. package/dist/trigger/manager.js +3 -0
  74. package/dist/trigger/parser.js +77 -2
  75. package/dist/trigger/patch.js +8 -1
  76. package/dist/trigger/scheduler.js +3 -1
  77. package/dist/trigger/validation.js +13 -0
  78. package/dist/utils/aid-bind.js +43 -29
  79. package/dist/utils/instance-registry.js +14 -7
  80. package/dist/utils/log-writer.js +46 -0
  81. package/dist/utils/media-cache.js +4 -1
  82. package/dist/utils/model-prices.jsonl +6 -3
  83. package/dist/utils/restart-safety.js +31 -0
  84. package/dist/utils/system-memory.js +62 -0
  85. package/kits/docs/INDEX.md +2 -1
  86. package/kits/docs/evolcore/INDEX.md +5 -3
  87. package/kits/docs/evolcore/agent.md +9 -1
  88. package/kits/docs/evolcore/aid.md +5 -2
  89. package/kits/docs/evolcore/contact.md +57 -0
  90. package/kits/docs/evolcore/fs.md +9 -0
  91. package/kits/docs/evolcore/group.md +12 -3
  92. package/kits/docs/evolcore/model.md +4 -1
  93. package/kits/docs/evolcore/msg.md +9 -3
  94. package/kits/docs/evolcore/response.md +16 -21
  95. package/kits/docs/evolcore/rpc.md +2 -0
  96. package/kits/docs/evolcore/stats.md +15 -2
  97. package/kits/docs/evolcore/storage.md +1 -0
  98. package/kits/docs/evolcore/trigger.md +17 -2
  99. package/kits/eck_manifest.json +12 -0
  100. package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
  101. package/kits/schemas/_meta.json +7 -4
  102. package/kits/schemas/agent-config.schema.6.json +322 -0
  103. package/kits/schemas/contact-book.schema.2.json +43 -0
  104. package/kits/schemas/relation-config.schema.5.json +47 -0
  105. package/kits/schemas/role-config.schema.1.json +1 -0
  106. package/kits/schemas/role-registry.schema.1.json +2 -2
  107. package/kits/templates/roles/admin.json +1 -0
  108. package/kits/templates/roles/member.json +1 -0
  109. package/kits/templates/roles/owner.json +1 -0
  110. package/kits/templates/roles/visitor.json +1 -0
  111. package/kits/templates/system-fragments/commands.md +3 -1
  112. package/package.json +4 -4
  113. package/assets/brand/evolcore/README.md +0 -19
  114. package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
  115. package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
  116. package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
  117. package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
  118. package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
  119. package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
  120. package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
  121. package/assets/brand/evolcore/evolcore-logo.png +0 -0
  122. package/assets/brand/evolcore/evolcore-logo.svg +0 -14
  123. package/assets/brand/evolcore/evolcore-mark.png +0 -0
  124. package/assets/brand/evolcore/evolcore-mark.svg +0 -10
@@ -1,11 +1,130 @@
1
- import { randomInt } from 'crypto';
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createHash, randomInt } from 'crypto';
2
4
  import { logger } from '../utils/logger.js';
3
5
  import { requireOptional } from '../utils/npm-ops.js';
4
6
  import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
5
7
  import { formatItemsAsText } from '../core/message/items-formatter.js';
8
+ import { sentReceipt, suppressedReceipt } from '../core/message/send-receipt.js';
6
9
  import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
7
10
  import { isValidAid } from '../aun/aid/validation.js';
8
11
  import { bindContactAlias } from '../config/contact-book.js';
12
+ import { resolvePaths } from '../paths.js';
13
+ const DINGTALK_QUEUED_EMOTION = 'Pin';
14
+ const DINGTALK_THINKING_EMOTION = 'BusinessTrip';
15
+ const DINGTALK_DONE_EMOTION = 'Done';
16
+ const DINGTALK_WRONG_EMOTION = 'Wrong';
17
+ const DINGTALK_CARD_CALLBACK_TOPIC = '/v1.0/card/instances/callback';
18
+ const DINGTALK_CARD_MAX_BUTTONS = 6;
19
+ const DINGTALK_CARD_MAX_CHECKERS = 8;
20
+ const DINGTALK_MAX_MESSAGE_CHARS = 18_000;
21
+ const DINGTALK_CARD_PENDING_TTL_MS = 24 * 60 * 60 * 1000;
22
+ const DINGTALK_CARD_SETTLED_RETENTION_MS = 24 * 60 * 60 * 1000;
23
+ const DINGTALK_MEDIA_HOST_SUFFIXES = [
24
+ '.dingtalk.com',
25
+ '.aliyuncs.com',
26
+ '.alicdn.com',
27
+ '.aliyun.com',
28
+ ];
29
+ function asRecord(value) {
30
+ if (!value || typeof value !== 'object' || Array.isArray(value))
31
+ return {};
32
+ return value;
33
+ }
34
+ function parseJsonRecord(value) {
35
+ if (typeof value === 'string') {
36
+ try {
37
+ return asRecord(JSON.parse(value));
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ return asRecord(value);
44
+ }
45
+ function splitDingtalkMessage(content) {
46
+ const parts = [];
47
+ let remaining = content;
48
+ while (remaining.length > DINGTALK_MAX_MESSAGE_CHARS) {
49
+ let splitAt = remaining.lastIndexOf('\n\n', DINGTALK_MAX_MESSAGE_CHARS);
50
+ if (splitAt <= 0)
51
+ splitAt = remaining.lastIndexOf('\n', DINGTALK_MAX_MESSAGE_CHARS);
52
+ if (splitAt <= 0)
53
+ splitAt = DINGTALK_MAX_MESSAGE_CHARS;
54
+ parts.push(remaining.slice(0, splitAt).trimEnd());
55
+ remaining = remaining.slice(splitAt).trimStart();
56
+ }
57
+ if (remaining)
58
+ parts.push(remaining);
59
+ return parts;
60
+ }
61
+ function isTrustedDingtalkMediaUrl(value) {
62
+ try {
63
+ const url = new URL(value);
64
+ if (url.protocol !== 'https:' || url.username || url.password)
65
+ return false;
66
+ const host = url.hostname.toLowerCase();
67
+ return DINGTALK_MEDIA_HOST_SUFFIXES.some(suffix => (host === suffix.slice(1) || host.endsWith(suffix)));
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ export function dingtalkCardTrackId(channelName, interactionId) {
74
+ return `ec_${createHash('sha256').update(`${channelName || 'dingtalk'}:${interactionId}`).digest('base64url')}`;
75
+ }
76
+ export function buildDingtalkCardParamMap(interaction, state, status = '') {
77
+ const map = {
78
+ interactionId: interaction.id,
79
+ title: interaction.kind.title,
80
+ body: interaction.kind.body || '',
81
+ state,
82
+ status,
83
+ buttonCount: String(Math.min(interaction.kind.buttons.length, DINGTALK_CARD_MAX_BUTTONS)),
84
+ checkerCount: interaction.kind.kind === 'action'
85
+ ? String(Math.min(interaction.kind.checkers?.length || 0, DINGTALK_CARD_MAX_CHECKERS))
86
+ : '0',
87
+ allowCustomInput: interaction.kind.kind === 'action' && interaction.kind.allowCustomInput ? 'true' : 'false',
88
+ };
89
+ for (let i = 0; i < DINGTALK_CARD_MAX_BUTTONS; i++) {
90
+ const button = interaction.kind.buttons[i];
91
+ map[`button_${i}_text`] = button?.label || '';
92
+ map[`button_${i}_style`] = button?.style || 'default';
93
+ map[`button_${i}_visible`] = button ? 'true' : 'false';
94
+ map[`button_${i}_disabled`] = state !== 'pending' || !!(button && 'disabled' in button && button.disabled)
95
+ ? 'true'
96
+ : 'false';
97
+ }
98
+ const checkers = interaction.kind.kind === 'action' ? interaction.kind.checkers || [] : [];
99
+ for (let i = 0; i < DINGTALK_CARD_MAX_CHECKERS; i++) {
100
+ const checker = checkers[i];
101
+ map[`checker_${i}_label`] = checker?.label || '';
102
+ map[`checker_${i}_description`] = checker?.description || '';
103
+ map[`checker_${i}_visible`] = checker ? 'true' : 'false';
104
+ }
105
+ return map;
106
+ }
107
+ export function parseDingtalkCardCallback(data) {
108
+ const root = parseJsonRecord(data);
109
+ const content = parseJsonRecord(root.content);
110
+ const privateData = parseJsonRecord(content.cardPrivateData ?? root.cardPrivateData);
111
+ const actionIds = Array.isArray(privateData.actionIds)
112
+ ? privateData.actionIds
113
+ : Array.isArray(content.actionIds)
114
+ ? content.actionIds
115
+ : [];
116
+ const params = parseJsonRecord(privateData.params ?? content.params);
117
+ return {
118
+ outTrackId: root.outTrackId ?? content.outTrackId,
119
+ operatorId: root.userId ?? root.operatorId ?? content.userId,
120
+ actionId: typeof actionIds[0] === 'string'
121
+ ? actionIds[0]
122
+ : typeof root.actionId === 'string'
123
+ ? root.actionId
124
+ : undefined,
125
+ values: params,
126
+ };
127
+ }
9
128
  export const DINGTALK_BIND_CODE_TTL_MS = 10 * 60 * 1000;
10
129
  export const DINGTALK_BIND_MAX_FAILED_ATTEMPTS = 5;
11
130
  const pendingContactBinds = new Map();
@@ -66,7 +185,7 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
66
185
  return {
67
186
  handled: true,
68
187
  status: 'format',
69
- reply: '请直接发送 6 位数字绑定码。格式错误不计入错误次数。',
188
+ reply: '请直接发送 6 位数字绑定码。',
70
189
  remainingAttempts: item.maxFailedAttempts - item.failedAttempts,
71
190
  };
72
191
  }
@@ -79,14 +198,14 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
79
198
  handled: true,
80
199
  status: 'failed',
81
200
  remainingAttempts: 0,
82
- reply: '绑定码错误次数已达上限,本次钉钉身份绑定失败。请重新执行 ec init dingtalk 并再次扫码绑定。',
201
+ reply: '绑定码无效,本次钉钉身份绑定失败。请重新执行 ec init dingtalk 并再次扫码绑定。',
83
202
  };
84
203
  }
85
204
  return {
86
205
  handled: true,
87
206
  status: 'wrong-code',
88
207
  remainingAttempts: remaining,
89
- reply: `绑定码错误,请重新发送 6 位数字绑定码。剩余 ${remaining} 次机会。`,
208
+ reply: '绑定码错误,请重新发送 6 位数字绑定码。',
90
209
  };
91
210
  }
92
211
  const actorId = String(ctx.actorId || '').trim();
@@ -104,7 +223,7 @@ export function handlePendingDingtalkContactBindMessage(ctx) {
104
223
  return {
105
224
  handled: true,
106
225
  status: 'bound',
107
- reply: `钉钉身份绑定成功:dingtalk:${actorId} -> ${item.primaryId}`,
226
+ reply: `钉钉身份绑定成功:${item.channelName}:${encodeURIComponent(actorId)} -> ${item.primaryId}`,
108
227
  };
109
228
  }
110
229
  catch (error) {
@@ -144,7 +263,19 @@ export class DingtalkChannel {
144
263
  webhookCache = new Map();
145
264
  conversationIdCache = new Map();
146
265
  senderStaffIdCache = new Map();
266
+ messageContextCache = new Map();
267
+ routes = new Map();
268
+ queuedReactions = new Map();
269
+ thinkingReactions = new Map();
270
+ taskReactionMessages = new Map();
147
271
  seenMessages = new Map();
272
+ interactionCallback;
273
+ interactionInvalidationCallback;
274
+ cardsByTrackId = new Map();
275
+ cardTrackIdByInteraction = new Map();
276
+ pendingCardsByChat = new Map();
277
+ cardActionsInFlight = new Set();
278
+ interactionSendTails = new Map();
148
279
  cleanupInterval = null;
149
280
  projectPathProvider = null;
150
281
  // Welcome message manager
@@ -164,12 +295,111 @@ export class DingtalkChannel {
164
295
  return false;
165
296
  return WEBHOOK_RE.test(url);
166
297
  }
167
- isDuplicate(msgId) {
298
+ isDuplicate(msgId, chatId = '') {
168
299
  if (this.seenMessages.has(msgId))
169
300
  return true;
170
- this.seenMessages.set(msgId, Date.now());
301
+ const ts = Date.now();
302
+ this.seenMessages.set(msgId, ts);
303
+ if (this.config.seenMsgFile) {
304
+ try {
305
+ fs.mkdirSync(path.dirname(this.config.seenMsgFile), { recursive: true });
306
+ fs.appendFileSync(this.config.seenMsgFile, JSON.stringify({ id: msgId, ts, chatId }) + '\n');
307
+ }
308
+ catch (error) {
309
+ logger.debug('[DingTalk] Failed to persist seen message:', error);
310
+ }
311
+ }
171
312
  return false;
172
313
  }
314
+ loadSeenMessages() {
315
+ const file = this.config.seenMsgFile;
316
+ if (!file)
317
+ return;
318
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
319
+ try {
320
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
321
+ if (!line)
322
+ continue;
323
+ const record = JSON.parse(line);
324
+ if (typeof record.id === 'string' && typeof record.ts === 'number' && record.ts > cutoff) {
325
+ this.seenMessages.set(record.id, record.ts);
326
+ }
327
+ }
328
+ }
329
+ catch (error) {
330
+ if (error?.code !== 'ENOENT')
331
+ logger.warn('[DingTalk] Failed to load seen messages:', error);
332
+ }
333
+ }
334
+ rewriteSeenMessages() {
335
+ const file = this.config.seenMsgFile;
336
+ if (!file)
337
+ return;
338
+ try {
339
+ fs.mkdirSync(path.dirname(file), { recursive: true });
340
+ const records = [...this.seenMessages.entries()].map(([id, ts]) => JSON.stringify({ id, ts }));
341
+ if (records.length === 0)
342
+ fs.rmSync(file, { force: true });
343
+ else {
344
+ const tempFile = `${file}.${process.pid}.tmp`;
345
+ fs.writeFileSync(tempFile, records.join('\n') + '\n');
346
+ fs.renameSync(tempFile, file);
347
+ }
348
+ }
349
+ catch (error) {
350
+ logger.debug('[DingTalk] Failed to compact seen messages:', error);
351
+ }
352
+ }
353
+ rememberRoute(chatId, route) {
354
+ this.routes.set(chatId, route);
355
+ if (route.staffId)
356
+ this.senderStaffIdCache.set(chatId, route.staffId);
357
+ this.conversationIdCache.set(chatId, route.conversationId);
358
+ const file = this.config.routeFile;
359
+ if (!file)
360
+ return;
361
+ try {
362
+ fs.mkdirSync(path.dirname(file), { recursive: true });
363
+ const serializable = Object.fromEntries(this.routes);
364
+ const tempFile = `${file}.${process.pid}.tmp`;
365
+ fs.writeFileSync(tempFile, JSON.stringify(serializable, null, 2) + '\n');
366
+ fs.renameSync(tempFile, file);
367
+ }
368
+ catch (error) {
369
+ logger.debug('[DingTalk] Failed to persist routes:', error);
370
+ }
371
+ }
372
+ loadRoutes() {
373
+ const file = this.config.routeFile;
374
+ if (!file)
375
+ return;
376
+ try {
377
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
378
+ for (const [chatId, raw] of Object.entries(parseJsonRecord(parsed))) {
379
+ const route = raw;
380
+ if ((route.chatType === 'private' || route.chatType === 'group') && typeof route.conversationId === 'string') {
381
+ this.routes.set(chatId, route);
382
+ if (route.staffId)
383
+ this.senderStaffIdCache.set(chatId, route.staffId);
384
+ this.conversationIdCache.set(chatId, route.conversationId);
385
+ }
386
+ }
387
+ }
388
+ catch (error) {
389
+ if (error?.code !== 'ENOENT')
390
+ logger.warn('[DingTalk] Failed to load routes:', error);
391
+ }
392
+ }
393
+ acknowledgeStreamMessage(msg, response = { status: 'OK' }) {
394
+ if (!this.client || !msg?.headers?.messageId)
395
+ return;
396
+ try {
397
+ this.client.socketCallBackResponse(msg.headers.messageId, { response: JSON.stringify(response) });
398
+ }
399
+ catch (error) {
400
+ logger.warn('[DingTalk] Stream ACK failed:', error);
401
+ }
402
+ }
173
403
  resolveChatId(conversationType, conversationId, senderId) {
174
404
  return conversationType === '2' ? conversationId : senderId;
175
405
  }
@@ -196,11 +426,22 @@ export class DingtalkChannel {
196
426
  if (!clientId || !clientSecret || clientId.includes('your-') || clientSecret.includes('your-')) {
197
427
  throw new Error('DingTalk clientId/clientSecret not configured');
198
428
  }
199
- const { DWClient, TOPIC_ROBOT } = await requireOptional('dingtalk-stream');
429
+ this.loadSeenMessages();
430
+ this.loadRoutes();
431
+ const { DWClient, TOPIC_ROBOT, TOPIC_CARD } = await requireOptional('dingtalk-stream');
200
432
  this.client = new DWClient({ clientId, clientSecret });
201
433
  this.client.registerCallbackListener(TOPIC_ROBOT, async (msg) => {
202
434
  await this.handleIncoming(msg);
203
435
  });
436
+ this.client.registerCallbackListener(TOPIC_CARD || DINGTALK_CARD_CALLBACK_TOPIC, async (msg) => {
437
+ await this.handleCardCallbackMessage(msg);
438
+ });
439
+ if (typeof this.client.registerAllEventListener === 'function') {
440
+ this.client.registerAllEventListener((event) => {
441
+ this.handlePlatformEvent(event);
442
+ return { status: 'SUCCESS' };
443
+ });
444
+ }
204
445
  await this.client.connect();
205
446
  this.connected = true;
206
447
  // Hourly cleanup of old dedup entries
@@ -210,6 +451,15 @@ export class DingtalkChannel {
210
451
  if (ts < cutoff)
211
452
  this.seenMessages.delete(id);
212
453
  }
454
+ this.rewriteSeenMessages();
455
+ for (const [id, context] of this.messageContextCache) {
456
+ if (context.createdAt < cutoff) {
457
+ this.messageContextCache.delete(id);
458
+ this.queuedReactions.delete(id);
459
+ this.thinkingReactions.delete(id);
460
+ }
461
+ }
462
+ this.cleanupCardState();
213
463
  }, 60 * 60 * 1000);
214
464
  logger.info('[DingTalk] Connected via Stream Mode');
215
465
  }
@@ -228,14 +478,50 @@ export class DingtalkChannel {
228
478
  }
229
479
  logger.info('[DingTalk] Disconnected');
230
480
  }
481
+ getStatus() {
482
+ return { connected: this.connected };
483
+ }
484
+ async reconnect() {
485
+ if (this.connected)
486
+ await this.disconnect();
487
+ try {
488
+ await this.connect();
489
+ return '重连成功';
490
+ }
491
+ catch (error) {
492
+ return `重连失败: ${error instanceof Error ? error.message : String(error)}`;
493
+ }
494
+ }
231
495
  onMessage(handler) {
232
496
  this.messageHandler = handler;
233
497
  }
234
498
  onRecall(handler) {
235
499
  this.recallHandler = handler;
236
500
  }
501
+ handlePlatformEvent(event) {
502
+ const data = parseJsonRecord(event?.data);
503
+ const bizData = parseJsonRecord(data.biz_data ?? data.bizData);
504
+ const eventType = String(data.biz_type ?? data.bizType ?? event?.headers?.eventType ?? '');
505
+ if (eventType === '260') {
506
+ logger.debug('[DingTalk] Robot message read event:', bizData);
507
+ }
508
+ else if (eventType === '261') {
509
+ // This event describes an outbound robot message being recalled. It is
510
+ // not the inbound user-message recall signal consumed by MessageBridge.
511
+ logger.debug('[DingTalk] Robot outbound message recalled:', bizData);
512
+ }
513
+ else if (eventType === '262') {
514
+ logger.debug('[DingTalk] Robot message reaction event:', bizData);
515
+ }
516
+ else {
517
+ logger.debug(`[DingTalk] Ignored platform event type=${eventType || 'unknown'}`);
518
+ }
519
+ }
237
520
  // ── Inbound message handling ───────────────────────────────────────────────
238
521
  async handleIncoming(msg) {
522
+ // Stream callbacks must be acknowledged even when the payload is a
523
+ // duplicate, stale, malformed, or intentionally ignored by mention policy.
524
+ this.acknowledgeStreamMessage(msg);
239
525
  try {
240
526
  const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
241
527
  const msgId = data.msgId;
@@ -245,44 +531,65 @@ export class DingtalkChannel {
245
531
  const senderNick = data.senderNick;
246
532
  const sessionWebhook = data.sessionWebhook;
247
533
  const msgtype = data.msgtype;
248
- // Dedup
249
- if (msgId && this.isDuplicate(msgId)) {
534
+ const chatId = this.resolveChatId(conversationType, conversationId, senderId);
535
+ const chatType = conversationType === '2' ? 'group' : 'private';
536
+ // Dedup is persisted before any policy rejection so a restart cannot
537
+ // re-execute or repeatedly re-evaluate the same transport message.
538
+ if (msgId && this.isDuplicate(msgId, chatId)) {
250
539
  logger.debug(`[DingTalk] Duplicate message skipped: ${msgId}`);
251
540
  return;
252
541
  }
253
- const chatId = this.resolveChatId(conversationType, conversationId, senderId);
254
- const chatType = conversationType === '2' ? 'group' : 'private';
255
- // Cache sender info for Open API sends
256
- if (senderId)
257
- this.senderStaffIdCache.set(chatId, senderId);
258
- if (conversationId)
259
- this.conversationIdCache.set(chatId, conversationId);
542
+ if (conversationId) {
543
+ this.rememberRoute(chatId, {
544
+ chatType,
545
+ conversationId,
546
+ staffId: senderId || undefined,
547
+ updatedAt: Date.now(),
548
+ });
549
+ }
550
+ const createdAt = Number(data.createAt ?? 0);
551
+ if (createdAt > 0 && Date.now() - createdAt > 5 * 60 * 1000) {
552
+ logger.warn(`[DingTalk] Dropping stale message: id=${msgId} age=${Math.round((Date.now() - createdAt) / 1000)}s`);
553
+ return;
554
+ }
555
+ const isMentioned = !!(data.isInAtList || (data.atUsers && data.atUsers.length > 0));
556
+ const mentions = Array.isArray(data.atUsers)
557
+ ? data.atUsers.map((item, index) => ({
558
+ userId: item.staffId || item.dingtalkId || '',
559
+ name: item.name,
560
+ key: `at_${index}`,
561
+ })).filter((item) => item.userId)
562
+ : [];
563
+ const eventMetadata = {
564
+ mentions: mentions.length > 0 ? mentions : undefined,
565
+ isMentioned,
566
+ topicName: typeof data.conversationTitle === 'string' ? data.conversationTitle : undefined,
567
+ };
260
568
  // Group gate
261
569
  if (conversationType === '2') {
262
- const isInAtList = !!(data.isInAtList || (data.atUsers && data.atUsers.length > 0));
263
- if (!this.shouldProcessGroupMessage(conversationId, isInAtList)) {
570
+ if (!this.shouldProcessGroupMessage(conversationId, isMentioned)) {
264
571
  logger.debug(`[DingTalk] Group message ignored (not mentioned): ${msgId}`);
265
572
  return;
266
573
  }
267
574
  }
575
+ // Emotion APIs require both the inbound message ID and openConversationId.
576
+ // Keep the association at the channel boundary so core only handles the
577
+ // channel-neutral messageId.
578
+ if (msgId && conversationId) {
579
+ this.messageContextCache.set(msgId, {
580
+ openConversationId: conversationId,
581
+ createdAt: Date.now(),
582
+ });
583
+ }
268
584
  // Webhook cache (SSRF validated)
269
585
  if (sessionWebhook && this.isValidWebhook(sessionWebhook)) {
270
586
  this.webhookCache.set(chatId, sessionWebhook);
271
587
  }
272
- // ACK to prevent 60s retry
273
- if (this.client && msg.headers?.messageId) {
274
- try {
275
- this.client.socketCallBackResponse(msg.headers.messageId, { response: JSON.stringify({ status: 'OK' }) });
276
- }
277
- catch (e) {
278
- logger.warn('[DingTalk] ACK failed:', e);
279
- }
280
- }
281
588
  // Dispatch by msgtype
282
589
  if (!this.messageHandler)
283
590
  return;
284
591
  // 首次交互欢迎消息(使用共享帮助函数)
285
- await sendWelcomeIfNeeded(this.welcomeManager, senderId, chatId, (id, text) => this.sendMessage(id, text), 'DingTalk');
592
+ await sendWelcomeIfNeeded(this.welcomeManager, senderId, chatId, async (id, text) => { await this.sendMessage(id, text); }, 'DingTalk');
286
593
  if (msgtype === 'text' || !msgtype) {
287
594
  const text = this.extractText(data);
288
595
  if (!text)
@@ -290,22 +597,40 @@ export class DingtalkChannel {
290
597
  await this.messageHandler({
291
598
  channelId: chatId, content: text, chatType,
292
599
  peerId: senderId || '', peerName: senderNick, messageId: msgId,
600
+ ...eventMetadata,
293
601
  });
294
602
  }
295
603
  else if (msgtype === 'picture' || msgtype === 'image') {
296
- await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId);
604
+ await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
297
605
  }
298
606
  else if (msgtype === 'file') {
299
- await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId);
607
+ await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
300
608
  }
301
609
  else if (msgtype === 'richText') {
302
- await this.handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId);
610
+ await this.handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
611
+ }
612
+ else if (msgtype === 'audio') {
613
+ const recognition = data.recognition || data.content?.recognition;
614
+ await this.messageHandler({
615
+ channelId: chatId,
616
+ content: recognition ? `用户发送了语音:${recognition}` : '[语音消息:未提供语音识别文本]',
617
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
618
+ ...eventMetadata,
619
+ });
620
+ }
621
+ else if (msgtype === 'video') {
622
+ const videoContent = parseJsonRecord(data.content);
623
+ await this.handleFileMessage({
624
+ ...data,
625
+ content: { ...videoContent, fileName: videoContent.fileName || data.fileName || 'video.mp4' },
626
+ }, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
303
627
  }
304
628
  else {
305
629
  await this.messageHandler({
306
630
  channelId: chatId,
307
631
  content: `[不支持的消息类型: ${msgtype}]`,
308
632
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
633
+ ...eventMetadata,
309
634
  });
310
635
  }
311
636
  }
@@ -314,20 +639,58 @@ export class DingtalkChannel {
314
639
  }
315
640
  }
316
641
  // ── Inbound media handling ─────────────────────────────────────────────────
317
- async handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId) {
642
+ async resolveMessageDownloadUrl(downloadUrlOrCode) {
643
+ if (/^https?:\/\//i.test(downloadUrlOrCode)) {
644
+ if (!isTrustedDingtalkMediaUrl(downloadUrlOrCode)) {
645
+ throw new Error('DingTalk media URL rejected: untrusted host');
646
+ }
647
+ return downloadUrlOrCode;
648
+ }
649
+ const token = await this.client?.getAccessToken();
650
+ if (!token)
651
+ throw new Error('DingTalk access token unavailable for media download');
652
+ const response = await fetch('https://api.dingtalk.com/v1.0/robot/messageFiles/download', {
653
+ method: 'POST',
654
+ headers: {
655
+ 'Content-Type': 'application/json',
656
+ 'x-acs-dingtalk-access-token': token,
657
+ },
658
+ body: JSON.stringify({ downloadCode: downloadUrlOrCode, robotCode: this.config.clientId }),
659
+ signal: AbortSignal.timeout(15_000),
660
+ });
661
+ const body = await response.json().catch(() => undefined);
662
+ if (!response.ok || !body?.downloadUrl) {
663
+ throw new Error(`DingTalk media URL resolution failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
664
+ }
665
+ if (!isTrustedDingtalkMediaUrl(body.downloadUrl)) {
666
+ throw new Error('DingTalk media URL resolution returned an untrusted host');
667
+ }
668
+ return body.downloadUrl;
669
+ }
670
+ async downloadMessageMedia(downloadUrlOrCode) {
671
+ const { safeFetch } = await import('../utils/media-cache.js');
672
+ const downloadUrl = await this.resolveMessageDownloadUrl(downloadUrlOrCode);
673
+ const host = new URL(downloadUrl).hostname;
674
+ return safeFetch(downloadUrl, {
675
+ allowedHosts: new Set([host]),
676
+ rejectRedirects: true,
677
+ });
678
+ }
679
+ async handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
318
680
  const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
319
- const downloadUrl = content.downloadUrl || content.downloadCode;
320
- if (!downloadUrl) {
681
+ const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
682
+ if (!downloadRef) {
321
683
  logger.warn('[DingTalk] Image message without downloadUrl');
322
684
  await this.messageHandler({
323
685
  channelId: chatId, content: '[图片下载失败:缺少下载链接]',
324
686
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
687
+ ...metadata,
325
688
  });
326
689
  return;
327
690
  }
328
691
  try {
329
- const { safeFetch, validateImage } = await import('../utils/media-cache.js');
330
- const buffer = await safeFetch(downloadUrl, { skipSsrfCheck: true });
692
+ const { validateImage } = await import('../utils/media-cache.js');
693
+ const buffer = await this.downloadMessageMedia(downloadRef);
331
694
  const result = await validateImage(buffer);
332
695
  if (result.mime) {
333
696
  await this.messageHandler({
@@ -335,6 +698,7 @@ export class DingtalkChannel {
335
698
  content: '用户发送了一张图片,请分析这张图片的内容。',
336
699
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
337
700
  images: [{ data: buffer.toString('base64'), mimeType: result.mime }],
701
+ ...metadata,
338
702
  });
339
703
  }
340
704
  else {
@@ -342,6 +706,7 @@ export class DingtalkChannel {
342
706
  await this.messageHandler({
343
707
  channelId: chatId, content: '[图片验证失败]',
344
708
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
709
+ ...metadata,
345
710
  });
346
711
  }
347
712
  }
@@ -350,32 +715,35 @@ export class DingtalkChannel {
350
715
  await this.messageHandler({
351
716
  channelId: chatId, content: '[图片下载失败]',
352
717
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
718
+ ...metadata,
353
719
  });
354
720
  }
355
721
  }
356
- async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId) {
722
+ async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
357
723
  const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
358
- const downloadUrl = content.downloadUrl || content.downloadCode;
359
- const fileName = content.fileName || 'unknown';
360
- if (!downloadUrl) {
724
+ const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
725
+ const fileName = content.fileName || data.fileName || 'unknown';
726
+ if (!downloadRef) {
361
727
  logger.warn('[DingTalk] File message without downloadUrl');
362
728
  await this.messageHandler({
363
729
  channelId: chatId, content: `[文件下载失败:缺少下载链接] ${fileName}`,
364
730
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
731
+ ...metadata,
365
732
  });
366
733
  return;
367
734
  }
368
735
  try {
369
- const { safeFetch, saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
736
+ const { saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
370
737
  const projectPath = this.projectPathProvider
371
738
  ? await this.projectPathProvider(chatId)
372
739
  : process.cwd();
373
- const buffer = await safeFetch(downloadUrl, { skipSsrfCheck: true });
740
+ const buffer = await this.downloadMessageMedia(downloadRef);
374
741
  const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
375
742
  await this.messageHandler({
376
743
  channelId: chatId,
377
744
  content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
378
745
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
746
+ ...metadata,
379
747
  });
380
748
  }
381
749
  catch (error) {
@@ -383,16 +751,18 @@ export class DingtalkChannel {
383
751
  await this.messageHandler({
384
752
  channelId: chatId, content: `[文件下载失败] ${fileName}`,
385
753
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
754
+ ...metadata,
386
755
  });
387
756
  }
388
757
  }
389
- async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId) {
758
+ async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
390
759
  const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
391
760
  const richText = content.richText;
392
761
  if (!Array.isArray(richText)) {
393
762
  await this.messageHandler({
394
763
  channelId: chatId, content: '[不支持的富文本格式]',
395
764
  chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
765
+ ...metadata,
396
766
  });
397
767
  return;
398
768
  }
@@ -402,10 +772,10 @@ export class DingtalkChannel {
402
772
  if (item.type === 'text' && item.text) {
403
773
  text += item.text;
404
774
  }
405
- else if (item.type === 'picture' && item.downloadUrl) {
775
+ else if (item.type === 'picture' && (item.downloadUrl || item.downloadCode)) {
406
776
  try {
407
- const { safeFetch, validateImage } = await import('../utils/media-cache.js');
408
- const buffer = await safeFetch(item.downloadUrl, { skipSsrfCheck: true });
777
+ const { validateImage } = await import('../utils/media-cache.js');
778
+ const buffer = await this.downloadMessageMedia(item.downloadUrl || item.downloadCode);
409
779
  const result = await validateImage(buffer);
410
780
  if (result.mime) {
411
781
  images.push({ data: buffer.toString('base64'), mimeType: result.mime });
@@ -421,136 +791,566 @@ export class DingtalkChannel {
421
791
  channelId: chatId, content: prompt, chatType,
422
792
  peerId: senderId || '', peerName: senderNick, messageId: msgId,
423
793
  images: images.length > 0 ? images : undefined,
794
+ ...metadata,
424
795
  });
425
796
  }
426
- // ── Outbound: text via sessionWebhook ──────────────────────────────────────
797
+ // ── Outbound: OpenAPI sends return processQueryKey message receipts ───────
427
798
  async sendMessage(chatId, content) {
428
- const webhook = this.webhookCache.get(chatId);
429
- if (!webhook) {
430
- logger.warn(`[DingTalk] No webhook cached for chatId: ${chatId}, message dropped`);
799
+ if (!content.trim())
800
+ return [];
801
+ const token = await this.client?.getAccessToken();
802
+ if (!token)
803
+ throw new Error('DingTalk access token unavailable for sendMessage');
804
+ const messageIds = [];
805
+ for (const part of splitDingtalkMessage(content)) {
806
+ messageIds.push(await this.sendRobotMessage(chatId, token, 'sampleMarkdown', JSON.stringify({ title: 'EvolCore', text: part })));
807
+ }
808
+ return messageIds;
809
+ }
810
+ // ── Outbound: processing-state emoji reactions ────────────────────────────────
811
+ /** Mark a message as queued. Core only calls this while another task is running. */
812
+ async acknowledgeMessage(messageId) {
813
+ await this.ensureReaction(messageId, DINGTALK_QUEUED_EMOTION, this.queuedReactions);
814
+ }
815
+ /** Upgrade Queued to Thinking without leaving a visible state gap. */
816
+ async promoteAckReaction(messageId, taskId, sourceMessageIds) {
817
+ const messageIds = [...new Set([...(sourceMessageIds ?? []), messageId].filter(Boolean))];
818
+ if (taskId)
819
+ this.taskReactionMessages.set(taskId, messageIds);
820
+ // Register every in-flight reaction before yielding. A very short task may
821
+ // otherwise finalize while later messages in a merged batch are still
822
+ // untracked, leaving a late BusinessTrip behind.
823
+ const thinking = messageIds.map(id => (this.ensureReaction(id, DINGTALK_THINKING_EMOTION, this.thinkingReactions)));
824
+ await Promise.all(thinking);
825
+ await Promise.all(messageIds.map(id => (this.clearReaction(id, DINGTALK_QUEUED_EMOTION, this.queuedReactions))));
826
+ }
827
+ /** Clear processing reactions and replace them with Done or Wrong. */
828
+ async completeAckReaction(taskId, completed) {
829
+ const messageIds = this.taskReactionMessages.get(taskId);
830
+ if (!messageIds)
431
831
  return;
832
+ this.taskReactionMessages.delete(taskId);
833
+ for (const messageId of messageIds) {
834
+ // Add the terminal state first so users never see an empty reaction gap.
835
+ let terminalAdded = await this.sendEmotion(messageId, completed ? DINGTALK_DONE_EMOTION : DINGTALK_WRONG_EMOTION, false);
836
+ if (!terminalAdded) {
837
+ terminalAdded = await this.sendEmotion(messageId, completed ? DINGTALK_DONE_EMOTION : DINGTALK_WRONG_EMOTION, false);
838
+ }
839
+ if (!terminalAdded) {
840
+ logger.warn(`[DingTalk] Failed to add terminal reaction for ${messageId}; preserving processing reaction`);
841
+ continue;
842
+ }
843
+ await this.clearReaction(messageId, DINGTALK_QUEUED_EMOTION, this.queuedReactions);
844
+ await this.clearReaction(messageId, DINGTALK_THINKING_EMOTION, this.thinkingReactions);
845
+ }
846
+ }
847
+ async ensureReaction(messageId, emotionName, reactions) {
848
+ const existing = reactions.get(messageId);
849
+ if (existing)
850
+ return existing;
851
+ const pending = this.sendEmotion(messageId, emotionName, false)
852
+ .then((ok) => {
853
+ if (!ok)
854
+ reactions.delete(messageId);
855
+ return ok;
856
+ })
857
+ .catch(() => {
858
+ reactions.delete(messageId);
859
+ return false;
860
+ });
861
+ reactions.set(messageId, pending);
862
+ return pending;
863
+ }
864
+ async clearReaction(messageId, emotionName, reactions) {
865
+ const pending = reactions.get(messageId);
866
+ if (!pending)
867
+ return;
868
+ const added = await pending.catch(() => false);
869
+ if (added)
870
+ await this.sendEmotion(messageId, emotionName, true);
871
+ reactions.delete(messageId);
872
+ }
873
+ async sendEmotion(messageId, emotionName, recall) {
874
+ const context = this.messageContextCache.get(messageId);
875
+ if (!context) {
876
+ logger.debug(`[DingTalk] Cannot ${recall ? 'recall' : 'add'} emotion: no context for message ${messageId}`);
877
+ return false;
432
878
  }
433
879
  try {
434
880
  const token = await this.client?.getAccessToken();
435
- const response = await fetch(webhook, {
881
+ if (!token) {
882
+ logger.debug('[DingTalk] Cannot send emotion: no access token');
883
+ return false;
884
+ }
885
+ const response = await fetch(`https://api.dingtalk.com/v1.0/robot/emotion/${recall ? 'recall' : 'reply'}`, {
436
886
  method: 'POST',
437
887
  headers: {
438
888
  'Content-Type': 'application/json',
439
- 'x-acs-dingtalk-access-token': token || '',
889
+ 'x-acs-dingtalk-access-token': token,
440
890
  },
441
891
  body: JSON.stringify({
442
- msgtype: 'markdown',
443
- markdown: { title: 'Bot', text: content },
892
+ robotCode: this.config.clientId,
893
+ openMsgId: messageId,
894
+ openConversationId: context.openConversationId,
895
+ // Pin / BusinessTrip / Done / Wrong are DingTalk built-in
896
+ // reactions. emotionType=2 is for custom text reactions and
897
+ // causes BusinessTrip to fail with system.error.
898
+ emotionType: 1,
899
+ emotionName,
444
900
  }),
445
901
  signal: AbortSignal.timeout(15_000),
446
902
  });
447
- if (!response.ok) {
448
- const body = await response.text().catch(() => '');
449
- logger.error(`[DingTalk] sendMessage failed for ${chatId}: ${response.status} ${body}`);
903
+ const body = await response.json().catch(() => undefined);
904
+ if (!response.ok || body?.success === false) {
905
+ logger.warn(`[DingTalk] ${recall ? 'recall' : 'reply'} emotion ${emotionName} failed for ${messageId}: `
906
+ + `${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
907
+ return false;
450
908
  }
909
+ return true;
451
910
  }
452
911
  catch (error) {
453
- logger.error(`[DingTalk] sendMessage failed for ${chatId}:`, error.message);
912
+ logger.debug(`[DingTalk] ${recall ? 'recall' : 'reply'} emotion ${emotionName} failed for ${messageId}: ${error?.message || error}`);
913
+ return false;
454
914
  }
455
915
  }
456
916
  // ── Outbound: image via Open API ───────────────────────────────────────────
457
- async sendImage(chatId, png) {
458
- try {
459
- const token = await this.client?.getAccessToken();
460
- if (!token) {
461
- logger.warn('[DingTalk] No access token for sendImage');
462
- return;
463
- }
464
- // Step 1: Upload media
465
- const FormData = (await requireOptional('form-data')).default;
466
- const form = new FormData();
467
- form.append('type', 'image');
468
- form.append('media', png, { filename: 'image.png', contentType: 'image/png' });
469
- const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(30_000) });
470
- const uploadData = await uploadRes.json();
471
- const mediaId = uploadData?.media_id;
472
- if (!mediaId) {
473
- logger.error('[DingTalk] Media upload failed:', uploadData);
474
- return;
475
- }
476
- // Step 2: Send via robot API
477
- await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }));
917
+ async sendImage(chatId, image) {
918
+ const token = await this.client?.getAccessToken();
919
+ if (!token)
920
+ throw new Error('DingTalk access token unavailable for sendImage');
921
+ const { validateImage } = await import('../utils/media-cache.js');
922
+ const imageInfo = await validateImage(image);
923
+ if (!imageInfo.mime) {
924
+ const reason = 'reason' in imageInfo ? imageInfo.reason : 'unsupported image';
925
+ throw new Error(`DingTalk image validation failed: ${reason}`);
478
926
  }
479
- catch (error) {
480
- logger.error(`[DingTalk] sendImage failed for ${chatId}:`, error.message);
927
+ const extension = imageInfo.mime === 'image/jpeg' ? 'jpg' : imageInfo.mime.slice('image/'.length);
928
+ // Step 1: Upload media
929
+ const FormData = (await requireOptional('form-data')).default;
930
+ const form = new FormData();
931
+ form.append('type', 'image');
932
+ form.append('media', image, { filename: `image.${extension}`, contentType: imageInfo.mime });
933
+ const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(30_000) });
934
+ const uploadData = await uploadRes.json();
935
+ const mediaId = uploadData?.media_id;
936
+ if (!uploadRes.ok || uploadData?.errcode || !mediaId) {
937
+ throw new Error(`DingTalk media upload failed: ${uploadRes.status} ${JSON.stringify(uploadData)}`);
481
938
  }
939
+ // Step 2: Send via robot API
940
+ return [await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }))];
482
941
  }
483
942
  // ── Outbound: file via Open API ────────────────────────────────────────────
484
943
  async sendFile(chatId, filePath) {
944
+ // Detect image files → route to sendImage (same pattern as Feishu)
945
+ const header = Buffer.alloc(4_100);
946
+ const fd = fs.openSync(filePath, 'r');
947
+ let bytesRead = 0;
485
948
  try {
486
- // Detect image files route to sendImage (same pattern as Feishu)
487
- const fs = await import('fs');
488
- const path = await import('path');
489
- const header = Buffer.alloc(12);
490
- const fd = fs.openSync(filePath, 'r');
491
- fs.readSync(fd, header, 0, 12, 0);
949
+ bytesRead = fs.readSync(fd, header, 0, header.length, 0);
950
+ }
951
+ finally {
492
952
  fs.closeSync(fd);
493
- const { fileTypeFromBuffer } = await import('file-type');
494
- const ftype = await fileTypeFromBuffer(header);
495
- if (ftype && ftype.mime.startsWith('image/')) {
496
- const buf = fs.readFileSync(filePath);
497
- return this.sendImage(chatId, buf);
498
- }
499
- const token = await this.client?.getAccessToken();
500
- if (!token) {
501
- logger.warn('[DingTalk] No access token for sendFile');
502
- return;
503
- }
504
- // Step 1: Upload media
505
- const FormData = (await requireOptional('form-data')).default;
506
- const form = new FormData();
507
- form.append('type', 'file');
508
- form.append('media', fs.createReadStream(filePath), { filename: path.basename(filePath) });
509
- const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
510
- const uploadData = await uploadRes.json();
511
- const mediaId = uploadData?.media_id;
512
- if (!mediaId) {
513
- logger.error('[DingTalk] File upload failed:', uploadData);
514
- return;
515
- }
516
- // Step 2: Send via robot API
517
- const fileName = path.basename(filePath);
518
- const fileType = path.extname(filePath).replace('.', '') || 'file';
519
- await this.sendRobotMessage(chatId, token, 'sampleFile', JSON.stringify({ mediaId: `@${mediaId}`, fileName, fileType }));
520
953
  }
521
- catch (error) {
522
- logger.error(`[DingTalk] sendFile failed for ${chatId}:`, error.message);
954
+ const { default: imageType } = await import('image-type');
955
+ const ftype = await imageType(header.subarray(0, bytesRead)).catch(() => undefined);
956
+ if (ftype && ftype.mime.startsWith('image/')) {
957
+ const buf = fs.readFileSync(filePath);
958
+ return this.sendImage(chatId, buf);
523
959
  }
960
+ const token = await this.client?.getAccessToken();
961
+ if (!token)
962
+ throw new Error('DingTalk access token unavailable for sendFile');
963
+ // Step 1: Upload media
964
+ const FormData = (await requireOptional('form-data')).default;
965
+ const form = new FormData();
966
+ form.append('type', 'file');
967
+ form.append('media', fs.createReadStream(filePath), { filename: path.basename(filePath) });
968
+ const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
969
+ const uploadData = await uploadRes.json();
970
+ const mediaId = uploadData?.media_id;
971
+ if (!uploadRes.ok || uploadData?.errcode || !mediaId) {
972
+ throw new Error(`DingTalk file upload failed: ${uploadRes.status} ${JSON.stringify(uploadData)}`);
973
+ }
974
+ // Step 2: Send via robot API
975
+ const fileName = path.basename(filePath);
976
+ const fileType = path.extname(filePath).replace('.', '') || 'file';
977
+ return [await this.sendRobotMessage(chatId, token, 'sampleFile', JSON.stringify({ mediaId: `@${mediaId}`, fileName, fileType }))];
524
978
  }
525
979
  // ── Robot message send helper (group vs DM) ────────────────────────────────
526
980
  async sendRobotMessage(chatId, token, msgKey, msgParam) {
527
981
  const headers = { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' };
528
982
  const { clientId } = this.config;
529
983
  // Group chatId = conversationId, DM chatId = senderId
530
- const cachedConvId = this.conversationIdCache.get(chatId);
531
- const staffId = this.senderStaffIdCache.get(chatId);
532
- if (cachedConvId === chatId) {
984
+ const route = this.routes.get(chatId);
985
+ const cachedConvId = route?.conversationId ?? this.conversationIdCache.get(chatId);
986
+ const staffId = route?.staffId ?? this.senderStaffIdCache.get(chatId) ?? (route?.chatType === 'private' ? chatId : undefined);
987
+ let res;
988
+ if (route?.chatType === 'group' || cachedConvId === chatId || (!route && chatId.startsWith('cid'))) {
533
989
  // Group: chatId is the conversationId
534
- const res = await fetch('https://api.dingtalk.com/v1.0/robot/groupMessages/send', {
990
+ res = await fetch('https://api.dingtalk.com/v1.0/robot/groupMessages/send', {
535
991
  method: 'POST', headers,
536
992
  body: JSON.stringify({ msgKey, msgParam, openConversationId: chatId, robotCode: clientId }),
537
993
  signal: AbortSignal.timeout(15_000),
538
994
  });
539
- if (!res.ok)
540
- logger.error(`[DingTalk] Group robot send failed: ${res.status}`);
541
995
  }
542
- else if (staffId) {
996
+ else if (staffId || (!route && chatId)) {
543
997
  // DM: use senderStaffId
544
- const res = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
998
+ const targetStaffId = staffId || chatId;
999
+ res = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
545
1000
  method: 'POST', headers,
546
- body: JSON.stringify({ msgKey, msgParam, userIds: [staffId], robotCode: clientId }),
1001
+ body: JSON.stringify({ msgKey, msgParam, userIds: [targetStaffId], robotCode: clientId }),
547
1002
  signal: AbortSignal.timeout(15_000),
548
1003
  });
549
- if (!res.ok)
550
- logger.error(`[DingTalk] DM robot send failed: ${res.status}`);
551
1004
  }
552
1005
  else {
553
- logger.warn(`[DingTalk] Cannot send robot message: no conversation/staff ID cached for ${chatId}`);
1006
+ throw new Error(`DingTalk route unavailable for chatId=${chatId}`);
1007
+ }
1008
+ const body = await res.json().catch(() => undefined);
1009
+ if (!res.ok || body?.success === false || body?.code || body?.errcode) {
1010
+ throw new Error(`DingTalk robot send failed: ${res.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
1011
+ }
1012
+ if (Array.isArray(body?.invalidStaffIdList) && body.invalidStaffIdList.length > 0) {
1013
+ throw new Error(`DingTalk robot send rejected recipients: ${body.invalidStaffIdList.join(',')}`);
1014
+ }
1015
+ if (Array.isArray(body?.flowControlledStaffIdList) && body.flowControlledStaffIdList.length > 0) {
1016
+ throw new Error(`DingTalk robot send rate limited recipients: ${body.flowControlledStaffIdList.join(',')}`);
1017
+ }
1018
+ const messageId = body?.processQueryKey ?? body?.result?.processQueryKey;
1019
+ if (!messageId)
1020
+ throw new Error(`DingTalk robot send succeeded without processQueryKey: ${JSON.stringify(body)}`);
1021
+ return messageId;
1022
+ }
1023
+ onInteraction(callback) {
1024
+ this.interactionCallback = callback;
1025
+ }
1026
+ onInteractionInvalidated(callback) {
1027
+ this.interactionInvalidationCallback = callback;
1028
+ }
1029
+ async withInteractionSendLock(chatId, run) {
1030
+ const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
1031
+ let release;
1032
+ const current = new Promise(resolve => { release = resolve; });
1033
+ this.interactionSendTails.set(chatId, current);
1034
+ await previous.catch(() => undefined);
1035
+ try {
1036
+ return await run();
1037
+ }
1038
+ finally {
1039
+ release();
1040
+ if (this.interactionSendTails.get(chatId) === current)
1041
+ this.interactionSendTails.delete(chatId);
1042
+ }
1043
+ }
1044
+ cardActionMap(interaction) {
1045
+ const result = new Map();
1046
+ interaction.kind.buttons.slice(0, DINGTALK_CARD_MAX_BUTTONS).forEach((button, index) => {
1047
+ const action = interaction.kind.kind === 'command-card'
1048
+ ? button.command
1049
+ : button.key;
1050
+ result.set(`button_${index}`, action);
1051
+ result.set(`btn_${index}`, action);
1052
+ });
1053
+ if (interaction.kind.kind === 'action' && interaction.kind.allowCustomInput) {
1054
+ result.set('custom_submit', '_custom_input');
1055
+ result.set('btn_submit_custom', '_custom_input');
1056
+ }
1057
+ return result;
1058
+ }
1059
+ trackPendingCard(entry) {
1060
+ this.cardsByTrackId.set(entry.outTrackId, entry);
1061
+ this.cardTrackIdByInteraction.set(entry.interaction.id, entry.outTrackId);
1062
+ let pending = this.pendingCardsByChat.get(entry.chatId);
1063
+ if (!pending) {
1064
+ pending = new Set();
1065
+ this.pendingCardsByChat.set(entry.chatId, pending);
1066
+ }
1067
+ pending.add(entry.outTrackId);
1068
+ }
1069
+ untrackPendingCard(entry) {
1070
+ const pending = this.pendingCardsByChat.get(entry.chatId);
1071
+ pending?.delete(entry.outTrackId);
1072
+ if (pending?.size === 0)
1073
+ this.pendingCardsByChat.delete(entry.chatId);
1074
+ }
1075
+ deleteCardEntry(entry) {
1076
+ this.untrackPendingCard(entry);
1077
+ this.cardsByTrackId.delete(entry.outTrackId);
1078
+ if (this.cardTrackIdByInteraction.get(entry.interaction.id) === entry.outTrackId) {
1079
+ this.cardTrackIdByInteraction.delete(entry.interaction.id);
1080
+ }
1081
+ this.cardActionsInFlight.delete(entry.outTrackId);
1082
+ }
1083
+ /** Bound in-memory card metadata even for interactions without expiresAt. */
1084
+ cleanupCardState(now = Date.now()) {
1085
+ for (const entry of [...this.cardsByTrackId.values()]) {
1086
+ if (entry.settledAt) {
1087
+ if (entry.settledAt <= now - DINGTALK_CARD_SETTLED_RETENTION_MS) {
1088
+ this.deleteCardEntry(entry);
1089
+ }
1090
+ continue;
1091
+ }
1092
+ const expired = (entry.interaction.expiresAt != null && entry.interaction.expiresAt <= now)
1093
+ || entry.createdAt <= now - DINGTALK_CARD_PENDING_TTL_MS;
1094
+ if (!expired)
1095
+ continue;
1096
+ entry.invalidated = true;
1097
+ entry.invalidatedReason = 'expired';
1098
+ entry.settledAt = now;
1099
+ this.untrackPendingCard(entry);
1100
+ if (!this.cardActionsInFlight.has(entry.outTrackId)) {
1101
+ void this.updateCard(entry, 'invalid', '审批已超时').catch(error => {
1102
+ logger.warn(`[DingTalk] Failed to expire stale card ${entry.outTrackId}:`, error);
1103
+ });
1104
+ }
1105
+ }
1106
+ }
1107
+ cardResponse(entry, state, status) {
1108
+ return {
1109
+ cardData: { cardParamMap: buildDingtalkCardParamMap(entry.interaction, state, status) },
1110
+ cardUpdateOptions: { updateCardDataByKey: false, updatePrivateDataByKey: true },
1111
+ };
1112
+ }
1113
+ privateCardFeedback(message) {
1114
+ return {
1115
+ userPrivateData: { cardParamMap: { feedback: message } },
1116
+ cardUpdateOptions: { updatePrivateDataByKey: true },
1117
+ };
1118
+ }
1119
+ async updateCard(entry, state, status) {
1120
+ const token = await this.client?.getAccessToken();
1121
+ if (!token)
1122
+ throw new Error('DingTalk access token unavailable for card update');
1123
+ const response = await fetch('https://api.dingtalk.com/v1.0/card/instances', {
1124
+ method: 'PUT',
1125
+ headers: {
1126
+ 'Content-Type': 'application/json',
1127
+ 'x-acs-dingtalk-access-token': token,
1128
+ },
1129
+ body: JSON.stringify({
1130
+ outTrackId: entry.outTrackId,
1131
+ cardData: { cardParamMap: buildDingtalkCardParamMap(entry.interaction, state, status) },
1132
+ cardUpdateOptions: { updateCardDataByKey: false, updatePrivateDataByKey: false },
1133
+ }),
1134
+ signal: AbortSignal.timeout(15_000),
1135
+ });
1136
+ const body = await response.json().catch(() => undefined);
1137
+ if (!response.ok || body?.success === false || body?.code || body?.errcode) {
1138
+ throw new Error(`DingTalk card update failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
1139
+ }
1140
+ }
1141
+ async invalidateInteraction(interactionId, reason = 'cancelled') {
1142
+ const trackId = this.cardTrackIdByInteraction.get(interactionId);
1143
+ const entry = trackId ? this.cardsByTrackId.get(trackId) : undefined;
1144
+ if (!entry || entry.resolved || entry.invalidated)
1145
+ return;
1146
+ entry.invalidated = true;
1147
+ entry.invalidatedReason = reason;
1148
+ entry.settledAt = Date.now();
1149
+ this.untrackPendingCard(entry);
1150
+ // Card callbacks must respond with cardData; DingTalk explicitly forbids
1151
+ // calling the update API while the callback is still being handled.
1152
+ if (this.cardActionsInFlight.has(entry.outTrackId))
1153
+ return;
1154
+ const status = reason === 'expired' ? '审批已超时' : reason === 'superseded' ? '已有新的交互请求' : '卡片已失效';
1155
+ await this.updateCard(entry, 'invalid', status).catch(error => {
1156
+ logger.warn(`[DingTalk] Failed to invalidate card ${entry.outTrackId}:`, error);
1157
+ });
1158
+ }
1159
+ async invalidatePendingCards(chatId, trackIds) {
1160
+ const pending = trackIds ?? [...(this.pendingCardsByChat.get(chatId) ?? [])];
1161
+ for (const trackId of pending) {
1162
+ const entry = this.cardsByTrackId.get(trackId);
1163
+ if (!entry || entry.resolved || entry.invalidated)
1164
+ continue;
1165
+ if (this.interactionInvalidationCallback) {
1166
+ try {
1167
+ await this.interactionInvalidationCallback(entry.interaction.id, 'superseded');
1168
+ }
1169
+ catch (error) {
1170
+ logger.warn('[DingTalk] Failed to cancel superseded interaction:', error);
1171
+ }
1172
+ }
1173
+ await this.invalidateInteraction(entry.interaction.id, 'superseded');
1174
+ }
1175
+ }
1176
+ async sendInteraction(chatId, interaction) {
1177
+ return this.withInteractionSendLock(chatId, async () => {
1178
+ if (!this.config.cardTemplateId)
1179
+ return false;
1180
+ if (interaction.kind.buttons.length > DINGTALK_CARD_MAX_BUTTONS) {
1181
+ logger.warn(`[DingTalk] Interaction ${interaction.id} has too many buttons for the configured card template`);
1182
+ return false;
1183
+ }
1184
+ if (interaction.kind.kind === 'action' && (interaction.kind.checkers?.length || 0) > DINGTALK_CARD_MAX_CHECKERS) {
1185
+ logger.warn(`[DingTalk] Interaction ${interaction.id} has too many checkers for the configured card template`);
1186
+ return false;
1187
+ }
1188
+ const previousPendingCards = [...(this.pendingCardsByChat.get(chatId) ?? [])];
1189
+ const route = this.routes.get(chatId);
1190
+ const isGroup = route?.chatType === 'group' || (!route && chatId.startsWith('cid'));
1191
+ const staffId = route?.staffId || (!isGroup ? chatId : undefined);
1192
+ if (!isGroup && !staffId)
1193
+ return false;
1194
+ const token = await this.client?.getAccessToken();
1195
+ if (!token)
1196
+ throw new Error('DingTalk access token unavailable for card send');
1197
+ const outTrackId = dingtalkCardTrackId(this.channelName, interaction.id);
1198
+ const conversationId = route?.conversationId || chatId;
1199
+ const openSpaceId = isGroup
1200
+ ? `dtv1.card//IM_GROUP.${conversationId}`
1201
+ : `dtv1.card//IM_ROBOT.${staffId}`;
1202
+ const payload = {
1203
+ cardTemplateId: this.config.cardTemplateId,
1204
+ outTrackId,
1205
+ callbackType: 'STREAM',
1206
+ openSpaceId,
1207
+ cardData: { cardParamMap: buildDingtalkCardParamMap(interaction, 'pending') },
1208
+ };
1209
+ if (isGroup)
1210
+ payload.imGroupOpenDeliverModel = { robotCode: this.config.clientId };
1211
+ else
1212
+ payload.imRobotOpenDeliverModel = { robotCode: this.config.clientId };
1213
+ const response = await fetch('https://api.dingtalk.com/v1.0/card/instances/createAndDeliver', {
1214
+ method: 'POST',
1215
+ headers: {
1216
+ 'Content-Type': 'application/json',
1217
+ 'x-acs-dingtalk-access-token': token,
1218
+ },
1219
+ body: JSON.stringify(payload),
1220
+ signal: AbortSignal.timeout(15_000),
1221
+ });
1222
+ const body = await response.json().catch(() => undefined);
1223
+ if (!response.ok || body?.success === false || body?.code || body?.errcode) {
1224
+ throw new Error(`DingTalk card send failed: ${response.status}${body ? ` ${JSON.stringify(body)}` : ''}`);
1225
+ }
1226
+ const failedDelivery = Array.isArray(body?.result?.deliverResults)
1227
+ ? body.result.deliverResults.find((result) => result?.success === false)
1228
+ : undefined;
1229
+ if (failedDelivery) {
1230
+ throw new Error(`DingTalk card delivery failed: ${failedDelivery.errorMsg || JSON.stringify(failedDelivery)}`);
1231
+ }
1232
+ const deliveredTrackId = body?.result?.outTrackId;
1233
+ if (typeof deliveredTrackId !== 'string' || !deliveredTrackId) {
1234
+ throw new Error(`DingTalk card send succeeded without result.outTrackId: ${JSON.stringify(body)}`);
1235
+ }
1236
+ if (deliveredTrackId !== outTrackId) {
1237
+ throw new Error(`DingTalk card send returned an unexpected outTrackId: ${deliveredTrackId}`);
1238
+ }
1239
+ this.trackPendingCard({
1240
+ interaction,
1241
+ chatId,
1242
+ outTrackId,
1243
+ messageId: outTrackId,
1244
+ actionMap: this.cardActionMap(interaction),
1245
+ createdAt: Date.now(),
1246
+ });
1247
+ // Only supersede older cards after the replacement was delivered and
1248
+ // tracked. A transient create failure must not destroy the last usable
1249
+ // approval/menu card.
1250
+ await this.invalidatePendingCards(chatId, previousPendingCards);
1251
+ return outTrackId;
1252
+ });
1253
+ }
1254
+ async processCardCallback(callback, streamMessageId) {
1255
+ if (!callback.outTrackId)
1256
+ return this.privateCardFeedback('无法识别卡片');
1257
+ const entry = this.cardsByTrackId.get(callback.outTrackId);
1258
+ if (!entry || entry.invalidated || entry.resolved)
1259
+ return this.privateCardFeedback('卡片已失效,请重新发起');
1260
+ if (entry.interaction.expiresAt && entry.interaction.expiresAt <= Date.now()) {
1261
+ entry.invalidated = true;
1262
+ entry.invalidatedReason = 'expired';
1263
+ entry.settledAt = Date.now();
1264
+ this.untrackPendingCard(entry);
1265
+ return this.cardResponse(entry, 'invalid', '审批已超时');
1266
+ }
1267
+ if (entry.interaction.initiatorId && callback.operatorId !== entry.interaction.initiatorId) {
1268
+ return this.privateCardFeedback('仅卡片发起者可操作');
1269
+ }
1270
+ if (this.cardActionsInFlight.has(callback.outTrackId))
1271
+ return this.privateCardFeedback('操作正在处理中');
1272
+ const action = callback.actionId ? entry.actionMap.get(callback.actionId) : undefined;
1273
+ if (!action)
1274
+ return this.privateCardFeedback('无法识别所选操作');
1275
+ this.cardActionsInFlight.add(callback.outTrackId);
1276
+ try {
1277
+ if (entry.interaction.kind.kind === 'command-card') {
1278
+ if (!this.messageHandler)
1279
+ return this.privateCardFeedback('处理器暂不可用');
1280
+ const route = this.routes.get(entry.chatId);
1281
+ await this.messageHandler({
1282
+ channelId: entry.chatId,
1283
+ content: action,
1284
+ chatType: route?.chatType || 'private',
1285
+ peerId: callback.operatorId || '',
1286
+ messageId: `card-trigger-${streamMessageId}`,
1287
+ source: 'card-trigger',
1288
+ });
1289
+ }
1290
+ else {
1291
+ if (!this.interactionCallback)
1292
+ return this.privateCardFeedback('处理器暂不可用');
1293
+ const response = {
1294
+ type: 'interaction.response',
1295
+ id: entry.interaction.id,
1296
+ action,
1297
+ values: callback.values,
1298
+ operatorId: callback.operatorId,
1299
+ };
1300
+ const accepted = (await this.interactionCallback(response)) !== false;
1301
+ if (!accepted || entry.invalidated) {
1302
+ entry.invalidated = true;
1303
+ entry.invalidatedReason ||= 'backend_rejected';
1304
+ entry.settledAt = Date.now();
1305
+ this.untrackPendingCard(entry);
1306
+ return this.cardResponse(entry, 'invalid', '操作未被接受');
1307
+ }
1308
+ }
1309
+ entry.resolved = true;
1310
+ entry.settledAt = Date.now();
1311
+ this.untrackPendingCard(entry);
1312
+ const selected = entry.interaction.kind.buttons.find(button => (entry.interaction.kind.kind === 'command-card' ? button.command : button.key) === action);
1313
+ return this.cardResponse(entry, 'resolved', selected?.label || '已处理');
1314
+ }
1315
+ finally {
1316
+ this.cardActionsInFlight.delete(callback.outTrackId);
1317
+ }
1318
+ }
1319
+ async handleCardCallbackMessage(msg) {
1320
+ const data = typeof msg.data === 'string' ? parseJsonRecord(msg.data) : parseJsonRecord(msg.data);
1321
+ const callback = parseDingtalkCardCallback(data);
1322
+ const streamMessageId = msg?.headers?.messageId || `unknown-${Date.now()}`;
1323
+ const work = this.processCardCallback(callback, streamMessageId);
1324
+ let timedOut = false;
1325
+ let timeoutId;
1326
+ const timeout = new Promise(resolve => {
1327
+ timeoutId = setTimeout(() => {
1328
+ timedOut = true;
1329
+ resolve(this.privateCardFeedback('操作已提交,正在处理'));
1330
+ }, 1_500);
1331
+ });
1332
+ try {
1333
+ const response = await Promise.race([work, timeout]);
1334
+ if (!timedOut && timeoutId)
1335
+ clearTimeout(timeoutId);
1336
+ this.acknowledgeStreamMessage(msg, response);
1337
+ if (timedOut) {
1338
+ void work.then(async (finalResponse) => {
1339
+ const entry = callback.outTrackId ? this.cardsByTrackId.get(callback.outTrackId) : undefined;
1340
+ const cardData = finalResponse?.cardData;
1341
+ if (entry && cardData) {
1342
+ const state = entry.invalidated ? 'invalid' : 'resolved';
1343
+ const status = cardData?.cardParamMap?.status || '已处理';
1344
+ await this.updateCard(entry, state, status).catch(error => logger.warn('[DingTalk] Delayed card update failed:', error));
1345
+ }
1346
+ }).catch(error => logger.error('[DingTalk] Delayed card callback failed:', error));
1347
+ }
1348
+ }
1349
+ catch (error) {
1350
+ if (timeoutId)
1351
+ clearTimeout(timeoutId);
1352
+ logger.error('[DingTalk] Card callback failed:', error);
1353
+ this.acknowledgeStreamMessage(msg, this.privateCardFeedback('操作失败,请重试'));
554
1354
  }
555
1355
  }
556
1356
  }
@@ -565,17 +1365,25 @@ export class DingtalkChannelPlugin {
565
1365
  return null;
566
1366
  if (!isValidCredential(inst.clientId) || !isValidCredential(inst.clientSecret))
567
1367
  return null;
1368
+ const stateKey = createHash('sha256').update(`${ctx.agentName}:${inst.name}`).digest('hex').slice(0, 16);
1369
+ const dataDir = resolvePaths().dataDir;
1370
+ if (inst.cardCallbackRouteKey) {
1371
+ logger.warn(`[DingTalk] cardCallbackRouteKey is ignored for ${inst.name}; interactive cards use Stream callbacks`);
1372
+ }
568
1373
  const channel = new DingtalkChannel({
569
1374
  clientId: inst.clientId,
570
1375
  clientSecret: inst.clientSecret,
571
1376
  requireMention: inst.requireMention,
572
1377
  freeResponseChats: inst.freeResponseChats,
1378
+ cardTemplateId: inst.cardTemplateId,
1379
+ seenMsgFile: path.join(dataDir, `dingtalk-seen-${stateKey}.jsonl`),
1380
+ routeFile: path.join(dataDir, `dingtalk-routes-${stateKey}.json`),
573
1381
  }, ctx.agentName, inst.name);
574
1382
  const mode = resolveShowActivities(inst);
575
1383
  const adapter = {
576
1384
  channelName: inst.name,
577
1385
  channelKey: inst.name,
578
- capabilities: { file: true, image: true, interaction: false, markdown: true, thought: false, status: false, thread: false, authenticatedApproval: true },
1386
+ capabilities: { file: true, image: true, interaction: !!inst.cardTemplateId, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
579
1387
  send: async (envelope, payload) => {
580
1388
  const channelId = envelope.channelId;
581
1389
  switch (payload.kind) {
@@ -585,35 +1393,55 @@ export class DingtalkChannelPlugin {
585
1393
  case 'system.notice':
586
1394
  case 'system.error':
587
1395
  case 'result.error':
588
- await channel.sendMessage(channelId, payload.text);
589
- return;
590
- case 'result.file':
591
- await channel.sendFile(channelId, payload.filePath);
592
- return;
593
- case 'result.image':
594
- await channel.sendImage(channelId, payload.data);
595
- return;
1396
+ return sentReceipt(envelope, await channel.sendMessage(channelId, payload.text));
1397
+ case 'result.file': return sentReceipt(envelope, await channel.sendFile(channelId, payload.filePath));
1398
+ case 'result.image': return sentReceipt(envelope, await channel.sendImage(channelId, payload.data));
596
1399
  case 'activity.batch': {
597
1400
  const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
598
1401
  const text = formatItemsAsText(filtered);
599
1402
  if (text)
600
- await channel.sendMessage(channelId, text);
601
- return;
1403
+ return sentReceipt(envelope, await channel.sendMessage(channelId, text));
1404
+ return suppressedReceipt(envelope, 'empty_activity');
602
1405
  }
1406
+ case 'status.started':
1407
+ case 'status.queued':
1408
+ case 'status.progress':
1409
+ return suppressedReceipt(envelope, 'reaction_lifecycle');
1410
+ case 'status.completed':
1411
+ await channel.completeAckReaction(envelope.taskId, true);
1412
+ return suppressedReceipt(envelope, 'reaction_lifecycle');
1413
+ case 'status.interrupted':
1414
+ case 'status.timeout':
1415
+ await channel.completeAckReaction(envelope.taskId, false);
1416
+ return suppressedReceipt(envelope, 'reaction_lifecycle');
603
1417
  case 'status.requires_action':
604
- await channel.sendMessage(channelId, '等待 owner 审批');
605
- return;
1418
+ return sentReceipt(envelope, await channel.sendMessage(channelId, '等待 owner 审批'));
606
1419
  case 'status.error':
1420
+ await channel.completeAckReaction(envelope.taskId, false);
607
1421
  if (payload.metadata?.message)
608
- await channel.sendMessage(channelId, payload.metadata.message);
609
- return;
610
- case 'interaction':
1422
+ return sentReceipt(envelope, await channel.sendMessage(channelId, payload.metadata.message));
1423
+ return suppressedReceipt(envelope, 'reaction_lifecycle');
1424
+ case 'interaction': {
1425
+ try {
1426
+ const messageId = await channel.sendInteraction(channelId, payload.interaction);
1427
+ if (messageId)
1428
+ return sentReceipt(envelope, [messageId]);
1429
+ }
1430
+ catch (error) {
1431
+ logger.warn(`[DingTalk] Interactive card delivery failed, using text fallback: ${error instanceof Error ? error.message : String(error)}`);
1432
+ }
611
1433
  if (payload.fallbackText)
612
- await channel.sendMessage(channelId, payload.fallbackText);
613
- return;
614
- default: return;
1434
+ return sentReceipt(envelope, await channel.sendMessage(channelId, payload.fallbackText));
1435
+ throw new Error('DingTalk interaction delivery failed and no fallback text was provided');
1436
+ }
1437
+ default: return suppressedReceipt(envelope, 'unhandled_payload');
615
1438
  }
616
1439
  },
1440
+ acknowledge: (messageId) => channel.acknowledgeMessage(messageId),
1441
+ promoteAck: (messageId, context) => (channel.promoteAckReaction(messageId, context?.taskId, context?.messageIds)),
1442
+ onInteraction: (callback) => channel.onInteraction(callback),
1443
+ onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
1444
+ invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
617
1445
  };
618
1446
  const policy = {
619
1447
  canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
@@ -641,8 +1469,10 @@ export class DingtalkChannelPlugin {
641
1469
  selfAID: ctx.agentName, content: event.content, images: event.images,
642
1470
  chatType: event.chatType || 'private', peerId: event.peerId || '',
643
1471
  peerName: event.peerName, messageId: event.messageId,
1472
+ mentions: event.mentions, isMentioned: event.isMentioned,
1473
+ topicName: event.topicName, source: event.source,
644
1474
  });
645
- }), (channelId, text) => channel.sendMessage(channelId, text), adapter, channelType);
1475
+ }), async (channelId, text) => { await channel.sendMessage(channelId, text); }, adapter, channelType);
646
1476
  },
647
1477
  };
648
1478
  }