evolcore 0.0.2 → 0.0.4

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