evolcore 0.0.1 → 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 (379) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION-0.5.0.md +378 -0
  4. package/README.md +318 -14
  5. package/ROLE_ACCESS_CONTROL.md +174 -0
  6. package/assets/.env.template +4 -0
  7. package/bin/ec-safe-output.js +161 -0
  8. package/bin/ec.js +29 -0
  9. package/dist/agents/baseagent.js +163 -0
  10. package/dist/agents/claude-runner.js +2565 -0
  11. package/dist/agents/codex-app-server-client.js +448 -0
  12. package/dist/agents/codex-runner.js +2682 -0
  13. package/dist/agents/gemini-runner.js +666 -0
  14. package/dist/agents/runner-types.js +75 -0
  15. package/dist/aun/aid/agentmd.js +216 -0
  16. package/dist/aun/aid/client.js +132 -0
  17. package/dist/aun/aid/control-aid.js +91 -0
  18. package/dist/aun/aid/identity.js +518 -0
  19. package/dist/aun/aid/index.js +4 -0
  20. package/dist/aun/aid/store.js +74 -0
  21. package/dist/aun/aid/types.js +1 -0
  22. package/dist/aun/aid/validation.js +21 -0
  23. package/dist/aun/group-identity.js +10 -0
  24. package/dist/aun/msg/group-index.js +6 -0
  25. package/dist/aun/msg/group.js +1231 -0
  26. package/dist/aun/msg/history.js +123 -0
  27. package/dist/aun/msg/index.js +5 -0
  28. package/dist/aun/msg/p2p.js +393 -0
  29. package/dist/aun/msg/payload-type.js +27 -0
  30. package/dist/aun/msg/upload.js +137 -0
  31. package/dist/aun/outbox.js +168 -0
  32. package/dist/aun/rpc/caller.js +42 -0
  33. package/dist/aun/rpc/connection.js +25 -0
  34. package/dist/aun/rpc/index.js +2 -0
  35. package/dist/aun/service-proxy.js +225 -0
  36. package/dist/aun/storage/download.js +29 -0
  37. package/dist/aun/storage/index.js +3 -0
  38. package/dist/aun/storage/manage.js +10 -0
  39. package/dist/aun/storage/upload.js +68 -0
  40. package/dist/channels/aun.js +4164 -0
  41. package/dist/channels/contact-bind-code.js +134 -0
  42. package/dist/channels/daemon.js +422 -0
  43. package/dist/channels/dingtalk.js +1479 -0
  44. package/dist/channels/feishu.js +1865 -0
  45. package/dist/channels/qqbot.js +409 -0
  46. package/dist/channels/wechat.js +817 -0
  47. package/dist/channels/wecom-card.js +101 -0
  48. package/dist/channels/wecom-onboarding.js +82 -0
  49. package/dist/channels/wecom-state.js +191 -0
  50. package/dist/channels/wecom.js +1157 -0
  51. package/dist/cli/agent-command.js +642 -0
  52. package/dist/cli/agent.js +1059 -0
  53. package/dist/cli/aun-commands.js +2003 -0
  54. package/dist/cli/bench.js +1228 -0
  55. package/dist/cli/cli-argv.js +66 -0
  56. package/dist/cli/code-stats.js +329 -0
  57. package/dist/cli/command-log.js +82 -0
  58. package/dist/cli/config-selector.js +69 -0
  59. package/dist/cli/config.js +261 -0
  60. package/dist/cli/contact.js +71 -0
  61. package/dist/cli/ctl-command.js +62 -0
  62. package/dist/cli/daemon-commands.js +2750 -0
  63. package/dist/cli/fs-command.js +1447 -0
  64. package/dist/cli/handoff-command.js +302 -0
  65. package/dist/cli/help.js +35 -0
  66. package/dist/cli/index.js +374 -0
  67. package/dist/cli/init-channel.js +1372 -0
  68. package/dist/cli/init.js +590 -0
  69. package/dist/cli/link-rules.js +240 -0
  70. package/dist/cli/model.js +591 -0
  71. package/dist/cli/net-check.js +723 -0
  72. package/dist/cli/queue-command.js +150 -0
  73. package/dist/cli/raw-key-input.js +25 -0
  74. package/dist/cli/response.js +344 -0
  75. package/dist/cli/restart-monitor.js +480 -0
  76. package/dist/cli/stats.js +609 -0
  77. package/dist/cli/task-context.js +80 -0
  78. package/dist/cli/trigger-command.js +545 -0
  79. package/dist/cli/version.js +93 -0
  80. package/dist/cli/watch-logs.js +33 -0
  81. package/dist/cli/watch-msg.js +673 -0
  82. package/dist/config/boot-log.js +266 -0
  83. package/dist/config/builtin-role-templates.js +42 -0
  84. package/dist/config/builtin-roles.js +91 -0
  85. package/dist/config/config-batch-get.js +11 -0
  86. package/dist/config/config-field-policy.js +261 -0
  87. package/dist/config/config-manager.js +1120 -0
  88. package/dist/config/config-operation-service.js +384 -0
  89. package/dist/config/contact-alias.js +68 -0
  90. package/dist/config/contact-book-store.js +454 -0
  91. package/dist/config/contact-book-v2-startup.js +35 -0
  92. package/dist/config/contact-book.js +224 -0
  93. package/dist/config/contact-operation-service.js +110 -0
  94. package/dist/config/gateway-config.js +858 -0
  95. package/dist/config/lifecycle.js +17 -0
  96. package/dist/config/mention-mode.js +27 -0
  97. package/dist/config/merge.js +161 -0
  98. package/dist/config/owner-policy.js +4 -0
  99. package/dist/config/peer-role-resolver.js +218 -0
  100. package/dist/config/resolved-config-op.js +483 -0
  101. package/dist/config/role-config-v4-startup.js +32 -0
  102. package/dist/config/role-config-v5-startup.js +27 -0
  103. package/dist/config/role-ranks.js +18 -0
  104. package/dist/config/role-schema.js +105 -0
  105. package/dist/config/role-service.js +156 -0
  106. package/dist/config/role-store.js +215 -0
  107. package/dist/config/roles.js +64 -0
  108. package/dist/config/schema-registry.js +154 -0
  109. package/dist/config/snapshot.js +598 -0
  110. package/dist/config-store.js +501 -0
  111. package/dist/core/auth/agent-delegation.js +111 -0
  112. package/dist/core/auth/auth-gateway.js +166 -0
  113. package/dist/core/auth/authenticated-actor.js +6 -0
  114. package/dist/core/auth/authorization-audit.js +119 -0
  115. package/dist/core/auth/operation-authorizer.js +720 -0
  116. package/dist/core/auth/operation-catalog.js +731 -0
  117. package/dist/core/baseagent-loader.js +54 -0
  118. package/dist/core/bootstrap-service.js +175 -0
  119. package/dist/core/capability/capability-manager.js +316 -0
  120. package/dist/core/capability/providers/claude-capability-provider.js +176 -0
  121. package/dist/core/capability/providers/codex-capability-provider.js +148 -0
  122. package/dist/core/capability/providers/gemini-capability-provider.js +10 -0
  123. package/dist/core/capability/types.js +27 -0
  124. package/dist/core/causation/audit.js +103 -0
  125. package/dist/core/causation/aun-association.js +111 -0
  126. package/dist/core/causation/context.js +93 -0
  127. package/dist/core/causation/index.js +4 -0
  128. package/dist/core/causation/types.js +2 -0
  129. package/dist/core/channel-loader.js +277 -0
  130. package/dist/core/command/agent-control.js +616 -0
  131. package/dist/core/command/cli-intent-parser.js +225 -0
  132. package/dist/core/command/command-handler.js +1733 -0
  133. package/dist/core/command/connect-menu.js +374 -0
  134. package/dist/core/command/menu-handler.js +3452 -0
  135. package/dist/core/command/menu-protocol.js +247 -0
  136. package/dist/core/command/role-menu.js +1623 -0
  137. package/dist/core/command/slash-gate.js +148 -0
  138. package/dist/core/command/slash-handler.js +3066 -0
  139. package/dist/core/daemon-file-cache.js +222 -0
  140. package/dist/core/event-bus.js +32 -0
  141. package/dist/core/event-catalog.js +810 -0
  142. package/dist/core/evolagent-registry.js +545 -0
  143. package/dist/core/evolagent.js +342 -0
  144. package/dist/core/handoff/dispatcher.js +229 -0
  145. package/dist/core/handoff/mutex.js +46 -0
  146. package/dist/core/handoff/runtime.js +324 -0
  147. package/dist/core/handoff/store.js +537 -0
  148. package/dist/core/handoff/types.js +12 -0
  149. package/dist/core/inference/text-inference.js +173 -0
  150. package/dist/core/interaction-registration.js +10 -0
  151. package/dist/core/interaction-router.js +278 -0
  152. package/dist/core/message/create-status.js +67 -0
  153. package/dist/core/message/im-renderer.js +659 -0
  154. package/dist/core/message/items-formatter.js +76 -0
  155. package/dist/core/message/logical-queue-bridge.js +123 -0
  156. package/dist/core/message/message-bridge.js +874 -0
  157. package/dist/core/message/message-cache.js +56 -0
  158. package/dist/core/message/message-log.js +339 -0
  159. package/dist/core/message/message-processor.js +4 -0
  160. package/dist/core/message/message-queue.js +1446 -0
  161. package/dist/core/message/message-utils.js +76 -0
  162. package/dist/core/message/peer-mode.js +105 -0
  163. package/dist/core/message/pending-hints.js +232 -0
  164. package/dist/core/message/response-depth.js +33 -0
  165. package/dist/core/message/response-engine.js +4020 -0
  166. package/dist/core/message/response-snapshot.js +83 -0
  167. package/dist/core/message/send-receipt.js +24 -0
  168. package/dist/core/message/stream-debouncer.js +139 -0
  169. package/dist/core/message/stream-idle-monitor.js +124 -0
  170. package/dist/core/model/config-scope.js +162 -0
  171. package/dist/core/model/field-scope.js +78 -0
  172. package/dist/core/model/model-catalog.js +227 -0
  173. package/dist/core/model/model-diagnostics.js +182 -0
  174. package/dist/core/model/model-permission.js +90 -0
  175. package/dist/core/permission/approval-gateway.js +1017 -0
  176. package/dist/core/permission/ec-command-parser.js +347 -0
  177. package/dist/core/permission/execution-sandbox.js +16 -0
  178. package/dist/core/permission/index.js +6 -0
  179. package/dist/core/permission/mode.js +24 -0
  180. package/dist/core/permission/sandbox-runtime.js +265 -0
  181. package/dist/core/permission/tool-policy.js +1019 -0
  182. package/dist/core/permission/unix-socket-policy.js +99 -0
  183. package/dist/core/protected-paths.js +332 -0
  184. package/dist/core/relation/peer-identity.js +222 -0
  185. package/dist/core/relation/peer-key.js +1 -0
  186. package/dist/core/role/runtime-policy.js +141 -0
  187. package/dist/core/session/adapters/claude-session-file-adapter.js +218 -0
  188. package/dist/core/session/adapters/codex-session-file-adapter.js +333 -0
  189. package/dist/core/session/adapters/gemini-session-file-adapter.js +181 -0
  190. package/dist/core/session/session-file-adapter.js +7 -0
  191. package/dist/core/session/session-file-health.js +45 -0
  192. package/dist/core/session/session-fs-store.js +273 -0
  193. package/dist/core/session/session-key.js +24 -0
  194. package/dist/core/session/session-manager.js +1643 -0
  195. package/dist/core/session/session-mapper.js +100 -0
  196. package/dist/core/session/session-renew.js +314 -0
  197. package/dist/core/session/session-title.js +128 -0
  198. package/dist/core/session/session-turn-coordinator.js +205 -0
  199. package/dist/core/session/session-turns.js +67 -0
  200. package/dist/core/system-channels.js +29 -0
  201. package/dist/eck/baseagent-caps.js +18 -0
  202. package/dist/eck/detect.js +47 -0
  203. package/dist/eck/group-rules-sync.js +345 -0
  204. package/dist/eck/init.js +77 -0
  205. package/dist/eck/kit-renderer.js +359 -0
  206. package/dist/eck/manifest-engine.js +446 -0
  207. package/dist/eck/message-renderer.js +199 -0
  208. package/dist/eck/rules-loader.js +28 -0
  209. package/dist/index.js +2926 -4
  210. package/dist/ipc.js +777 -0
  211. package/dist/paths.js +262 -0
  212. package/dist/product.js +18 -0
  213. package/dist/response-system/context-builder.js +71 -0
  214. package/dist/response-system/coordinator.js +117 -0
  215. package/dist/response-system/decision-executor.js +86 -0
  216. package/dist/response-system/engines/v1/index.js +21 -0
  217. package/dist/response-system/engines/v1/interactive-flow.js +27 -0
  218. package/dist/response-system/engines/v1/proactive-flow.js +137 -0
  219. package/dist/response-system/engines/v1/types.js +1 -0
  220. package/dist/response-system/extensions.js +41 -0
  221. package/dist/response-system/index.js +6 -0
  222. package/dist/response-system/modes/index.js +7 -0
  223. package/dist/response-system/modes/single-session/index.js +72 -0
  224. package/dist/response-system/queues/fifo-queue.js +44 -0
  225. package/dist/response-system/queues/index.js +6 -0
  226. package/dist/response-system/queues/lifo-queue.js +42 -0
  227. package/dist/response-system/queues/priority-queue.js +63 -0
  228. package/dist/response-system/registry.js +97 -0
  229. package/dist/response-system/resolver.js +37 -0
  230. package/dist/response-system/selector.js +23 -0
  231. package/dist/response-system/types.js +7 -0
  232. package/dist/stats/billing.js +163 -0
  233. package/dist/stats/budget.js +93 -0
  234. package/dist/stats/db.js +403 -0
  235. package/dist/stats/eck-vars.js +89 -0
  236. package/dist/stats/index.js +11 -0
  237. package/dist/stats/normalizer.js +80 -0
  238. package/dist/stats/price-resolver.js +138 -0
  239. package/dist/stats/query.js +763 -0
  240. package/dist/stats/role-budget.js +168 -0
  241. package/dist/stats/writer.js +151 -0
  242. package/dist/trigger/anomaly-store.js +258 -0
  243. package/dist/trigger/audit.js +152 -0
  244. package/dist/trigger/event-source.js +119 -0
  245. package/dist/trigger/feedback.js +685 -0
  246. package/dist/trigger/history.js +290 -0
  247. package/dist/trigger/manager.js +294 -0
  248. package/dist/trigger/parser.js +595 -0
  249. package/dist/trigger/patch.js +155 -0
  250. package/dist/trigger/scheduler.js +1602 -0
  251. package/dist/trigger/script-executor.js +155 -0
  252. package/dist/trigger/state.js +145 -0
  253. package/dist/trigger/types.js +1 -0
  254. package/dist/trigger/validation.js +634 -0
  255. package/dist/types.js +12 -0
  256. package/dist/utils/aid-bind.js +313 -0
  257. package/dist/utils/atomic-write.js +95 -0
  258. package/dist/utils/avatar-upload.js +123 -0
  259. package/dist/utils/cross-platform.js +297 -0
  260. package/dist/utils/ecweb-utils.js +73 -0
  261. package/dist/utils/error-dict.json +153 -0
  262. package/dist/utils/error-utils.js +349 -0
  263. package/dist/utils/instance-registry.js +444 -0
  264. package/dist/utils/locale.js +21 -0
  265. package/dist/utils/log-writer.js +270 -0
  266. package/dist/utils/logger.js +89 -0
  267. package/dist/utils/markdown-to-plain-text.js +20 -0
  268. package/dist/utils/media-cache.js +274 -0
  269. package/dist/utils/model-prices.jsonl +20 -0
  270. package/dist/utils/npm-ops.js +210 -0
  271. package/dist/utils/process-introspect.js +133 -0
  272. package/dist/utils/process-tree-stats.js +271 -0
  273. package/dist/utils/project-path.js +74 -0
  274. package/dist/utils/restart-safety.js +31 -0
  275. package/dist/utils/stats.js +410 -0
  276. package/dist/utils/system-memory.js +62 -0
  277. package/dist/utils/tool-summary.js +284 -0
  278. package/dist/utils/welcome.js +268 -0
  279. package/kits/docs/GUIDE.md +20 -0
  280. package/kits/docs/INDEX.md +66 -0
  281. package/kits/docs/aun/CHEATSHEET.md +19 -0
  282. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  283. package/kits/docs/channels/aun.md +65 -0
  284. package/kits/docs/channels/feishu.md +56 -0
  285. package/kits/docs/context-assembly.md +366 -0
  286. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  287. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  288. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  289. package/kits/docs/eck_templates/runtime.template.md +19 -0
  290. package/kits/docs/evolcore/INDEX.md +68 -0
  291. package/kits/docs/evolcore/agent.md +77 -0
  292. package/kits/docs/evolcore/aid.md +52 -0
  293. package/kits/docs/evolcore/config.md +149 -0
  294. package/kits/docs/evolcore/contact.md +57 -0
  295. package/kits/docs/evolcore/ctl.md +46 -0
  296. package/kits/docs/evolcore/event.md +216 -0
  297. package/kits/docs/evolcore/fs-architecture.md +1215 -0
  298. package/kits/docs/evolcore/fs.md +110 -0
  299. package/kits/docs/evolcore/group-fs.md +17 -0
  300. package/kits/docs/evolcore/group-rules.md +226 -0
  301. package/kits/docs/evolcore/group.md +150 -0
  302. package/kits/docs/evolcore/model.md +50 -0
  303. package/kits/docs/evolcore/msg.md +136 -0
  304. package/kits/docs/evolcore/response.md +75 -0
  305. package/kits/docs/evolcore/rpc.md +37 -0
  306. package/kits/docs/evolcore/self-summary.md +29 -0
  307. package/kits/docs/evolcore/stats.md +83 -0
  308. package/kits/docs/evolcore/storage.md +50 -0
  309. package/kits/docs/evolcore/trigger.md +539 -0
  310. package/kits/docs/identity/AID_PROFILE_SPEC.md +26 -0
  311. package/kits/docs/identity/PATH_OPS.md +16 -0
  312. package/kits/docs/identity/ROLE_DETAIL.md +23 -0
  313. package/kits/docs/identity/identity-tools.md +26 -0
  314. package/kits/docs/path-registry.md +43 -0
  315. package/kits/docs/prompt-loading-architecture.md +266 -0
  316. package/kits/docs/venues/aun-group.md +45 -0
  317. package/kits/docs/venues/aun-private.md +10 -0
  318. package/kits/docs/venues/client-desktop.md +10 -0
  319. package/kits/docs/venues/client-mobile.md +10 -0
  320. package/kits/docs/venues/feishu-group.md +13 -0
  321. package/kits/docs/venues/feishu-private.md +9 -0
  322. package/kits/docs/venues/group.md +25 -0
  323. package/kits/docs/venues/private.md +10 -0
  324. package/kits/eck_manifest.auxiliary.json +43 -0
  325. package/kits/eck_manifest.json +203 -0
  326. package/kits/eck_message_manifest.json +63 -0
  327. package/kits/migrations/README-role-config-v4.md +32 -0
  328. package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
  329. package/kits/migrations/migrate-role-config-v4.mjs +623 -0
  330. package/kits/migrations/migrate-role-config-v5.mjs +346 -0
  331. package/kits/migrations/rename-config-file.mjs +99 -0
  332. package/kits/rules/01-overview.md +142 -0
  333. package/kits/rules/02-navigation.md +76 -0
  334. package/kits/rules/03-identity.md +34 -0
  335. package/kits/rules/04-relation.md +59 -0
  336. package/kits/rules/05-venue.md +44 -0
  337. package/kits/rules/06-channel.md +59 -0
  338. package/kits/schemas/_meta.json +32 -0
  339. package/kits/schemas/agent-config.schema.1.json +177 -0
  340. package/kits/schemas/agent-config.schema.2.json +239 -0
  341. package/kits/schemas/agent-config.schema.3.json +119 -0
  342. package/kits/schemas/agent-config.schema.4.json +208 -0
  343. package/kits/schemas/agent-config.schema.5.json +326 -0
  344. package/kits/schemas/agent-config.schema.6.json +322 -0
  345. package/kits/schemas/contact-book.schema.1.json +36 -0
  346. package/kits/schemas/contact-book.schema.2.json +43 -0
  347. package/kits/schemas/daemon.schema.1.json +90 -0
  348. package/kits/schemas/defaults.schema.1.json +81 -0
  349. package/kits/schemas/menu-exec-schema-commands.md +208 -0
  350. package/kits/schemas/migrations/README.md +28 -0
  351. package/kits/schemas/relation-config.schema.1.json +158 -0
  352. package/kits/schemas/relation-config.schema.2.json +73 -0
  353. package/kits/schemas/relation-config.schema.3.json +50 -0
  354. package/kits/schemas/relation-config.schema.4.json +47 -0
  355. package/kits/schemas/relation-config.schema.5.json +47 -0
  356. package/kits/schemas/role-config.schema.1.json +201 -0
  357. package/kits/schemas/role-registry.schema.1.json +35 -0
  358. package/kits/schemas/single-session.schema.1.json +31 -0
  359. package/kits/templates/bootstrap-welcome.md +15 -0
  360. package/kits/templates/message-fragments/handoff-request-to-target.md +13 -0
  361. package/kits/templates/message-fragments/handoff-response-to-origin.md +10 -0
  362. package/kits/templates/message-fragments/inject-default.md +2 -0
  363. package/kits/templates/message-fragments/item.md +2 -0
  364. package/kits/templates/roles/admin.json +9 -0
  365. package/kits/templates/roles/member.json +41 -0
  366. package/kits/templates/roles/owner.json +9 -0
  367. package/kits/templates/roles/visitor.json +40 -0
  368. package/kits/templates/system-fragments/baseagent.md +14 -0
  369. package/kits/templates/system-fragments/bootstrap.md +16 -0
  370. package/kits/templates/system-fragments/channel.md +48 -0
  371. package/kits/templates/system-fragments/commands.md +28 -0
  372. package/kits/templates/system-fragments/identity.md +11 -0
  373. package/kits/templates/system-fragments/relation.md +19 -0
  374. package/kits/templates/system-fragments/session.md +53 -0
  375. package/kits/templates/system-fragments/venue.md +31 -0
  376. package/package.json +50 -15
  377. package/dist/index.d.ts +0 -7
  378. package/dist/index.d.ts.map +0 -1
  379. package/dist/index.js.map +0 -1
@@ -0,0 +1,1479 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createHash, randomInt } from 'crypto';
4
+ import { logger } from '../utils/logger.js';
5
+ import { requireOptional } from '../utils/npm-ops.js';
6
+ import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
7
+ import { formatItemsAsText } from '../core/message/items-formatter.js';
8
+ import { sentReceipt, suppressedReceipt } from '../core/message/send-receipt.js';
9
+ import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
10
+ import { isValidAid } from '../aun/aid/validation.js';
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
+ }
128
+ export const DINGTALK_BIND_CODE_TTL_MS = 10 * 60 * 1000;
129
+ export const DINGTALK_BIND_MAX_FAILED_ATTEMPTS = 5;
130
+ const pendingContactBinds = new Map();
131
+ export function registerPendingDingtalkContactBind(req) {
132
+ const selfAid = String(req.selfAid || '').trim();
133
+ const channelName = String(req.channelName || '').trim();
134
+ const primaryId = String(req.primaryId || '').trim();
135
+ const now = req.now ?? Date.now();
136
+ const code = req.code ? String(req.code).trim() : generateDingtalkBindCode();
137
+ if (!isValidAid(selfAid))
138
+ return { ok: false, error: `invalid selfAid: ${req.selfAid}` };
139
+ if (!channelName)
140
+ return { ok: false, error: 'missing channelName' };
141
+ if (!isValidAid(primaryId))
142
+ return { ok: false, error: `invalid primaryId: ${req.primaryId}` };
143
+ if (!/^\d{6}$/.test(code))
144
+ return { ok: false, error: 'binding code must be 6 digits' };
145
+ const key = pendingBindKey(selfAid, channelName);
146
+ const replaced = pendingContactBinds.has(key);
147
+ const item = {
148
+ selfAid,
149
+ channelName,
150
+ primaryId,
151
+ code,
152
+ createdAt: now,
153
+ expiresAt: now + DINGTALK_BIND_CODE_TTL_MS,
154
+ failedAttempts: 0,
155
+ maxFailedAttempts: DINGTALK_BIND_MAX_FAILED_ATTEMPTS,
156
+ };
157
+ pendingContactBinds.set(key, item);
158
+ return { ok: true, code, expiresAt: item.expiresAt, replaced };
159
+ }
160
+ export function handlePendingDingtalkContactBindMessage(ctx) {
161
+ if (ctx.channelType !== 'dingtalk')
162
+ return { handled: false };
163
+ const selfAid = String(ctx.selfAid || '').trim();
164
+ const channelName = String(ctx.channelName || '').trim();
165
+ if (!selfAid || !channelName)
166
+ return { handled: false };
167
+ const key = pendingBindKey(selfAid, channelName);
168
+ const item = pendingContactBinds.get(key);
169
+ if (!item)
170
+ return { handled: false };
171
+ const now = ctx.now ?? Date.now();
172
+ if (now >= item.expiresAt) {
173
+ pendingContactBinds.delete(key);
174
+ return {
175
+ handled: true,
176
+ status: 'expired',
177
+ remainingAttempts: 0,
178
+ reply: '钉钉身份绑定码已超时,本次绑定失败。请重新执行 ec init dingtalk 并再次扫码绑定。',
179
+ };
180
+ }
181
+ if (ctx.chatType !== 'private')
182
+ return { handled: false };
183
+ const input = String(ctx.content ?? '').trim();
184
+ if (!/^\d+$/.test(input) || !/^\d{6}$/.test(input)) {
185
+ return {
186
+ handled: true,
187
+ status: 'format',
188
+ reply: '请直接发送 6 位数字绑定码。',
189
+ remainingAttempts: item.maxFailedAttempts - item.failedAttempts,
190
+ };
191
+ }
192
+ if (input !== item.code) {
193
+ item.failedAttempts += 1;
194
+ const remaining = Math.max(0, item.maxFailedAttempts - item.failedAttempts);
195
+ if (remaining === 0) {
196
+ pendingContactBinds.delete(key);
197
+ return {
198
+ handled: true,
199
+ status: 'failed',
200
+ remainingAttempts: 0,
201
+ reply: '绑定码无效,本次钉钉身份绑定失败。请重新执行 ec init dingtalk 并再次扫码绑定。',
202
+ };
203
+ }
204
+ return {
205
+ handled: true,
206
+ status: 'wrong-code',
207
+ remainingAttempts: remaining,
208
+ reply: '绑定码错误,请重新发送 6 位数字绑定码。',
209
+ };
210
+ }
211
+ const actorId = String(ctx.actorId || '').trim();
212
+ if (!actorId) {
213
+ return {
214
+ handled: true,
215
+ status: 'missing-actor',
216
+ remainingAttempts: item.maxFailedAttempts - item.failedAttempts,
217
+ reply: '无法识别当前钉钉发送者身份,未建立绑定。请重新发送绑定码或重新执行绑定流程。',
218
+ };
219
+ }
220
+ try {
221
+ bindContactAlias(item.selfAid, item.primaryId, 'dingtalk', actorId, item.channelName);
222
+ pendingContactBinds.delete(key);
223
+ return {
224
+ handled: true,
225
+ status: 'bound',
226
+ reply: `钉钉身份绑定成功:${item.channelName}:${encodeURIComponent(actorId)} -> ${item.primaryId}`,
227
+ };
228
+ }
229
+ catch (error) {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ return {
232
+ handled: true,
233
+ status: 'write-failed',
234
+ remainingAttempts: item.maxFailedAttempts - item.failedAttempts,
235
+ reply: `钉钉身份绑定写入失败:${message}`,
236
+ };
237
+ }
238
+ }
239
+ export function getPendingDingtalkContactBind(selfAid, channelName) {
240
+ const item = pendingContactBinds.get(pendingBindKey(selfAid, channelName));
241
+ return item ? { ...item } : null;
242
+ }
243
+ export function clearPendingDingtalkContactBinds() {
244
+ pendingContactBinds.clear();
245
+ }
246
+ function generateDingtalkBindCode() {
247
+ return String(randomInt(0, 1_000_000)).padStart(6, '0');
248
+ }
249
+ function pendingBindKey(selfAid, channelName) {
250
+ return `${String(selfAid || '').trim()}\u0000${String(channelName || '').trim()}`;
251
+ }
252
+ // ── Webhook SSRF validation ────────────────────────────────────────────────────
253
+ const WEBHOOK_RE = /^https:\/\/(api|oapi)\.dingtalk\.com\//;
254
+ // ── DingtalkChannel ────────────────────────────────────────────────────────────
255
+ export class DingtalkChannel {
256
+ agentAid;
257
+ channelName;
258
+ config;
259
+ client = null;
260
+ connected = false;
261
+ messageHandler = null;
262
+ recallHandler;
263
+ webhookCache = new Map();
264
+ conversationIdCache = new Map();
265
+ senderStaffIdCache = new Map();
266
+ messageContextCache = new Map();
267
+ routes = new Map();
268
+ queuedReactions = new Map();
269
+ thinkingReactions = new Map();
270
+ taskReactionMessages = new Map();
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();
279
+ cleanupInterval = null;
280
+ projectPathProvider = null;
281
+ // Welcome message manager
282
+ welcomeManager;
283
+ constructor(config, agentAid, channelName) {
284
+ this.agentAid = agentAid;
285
+ this.channelName = channelName;
286
+ this.config = config;
287
+ // 初始化 welcomeManager(使用共享帮助函数)
288
+ if (agentAid && channelName) {
289
+ this.welcomeManager = initWelcomeManager('dingtalk', agentAid, channelName);
290
+ }
291
+ }
292
+ // ── Public helpers (testable) ──────────────────────────────────────────────
293
+ isValidWebhook(url) {
294
+ if (!url)
295
+ return false;
296
+ return WEBHOOK_RE.test(url);
297
+ }
298
+ isDuplicate(msgId, chatId = '') {
299
+ if (this.seenMessages.has(msgId))
300
+ return true;
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
+ }
312
+ return false;
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
+ }
403
+ resolveChatId(conversationType, conversationId, senderId) {
404
+ return conversationType === '2' ? conversationId : senderId;
405
+ }
406
+ shouldProcessGroupMessage(conversationId, isInAtList) {
407
+ if (this.config.requireMention === false)
408
+ return true;
409
+ if (this.config.freeResponseChats?.includes(conversationId))
410
+ return true;
411
+ return isInAtList;
412
+ }
413
+ extractText(content) {
414
+ if (!content)
415
+ return '';
416
+ const text = content.text;
417
+ if (typeof text === 'string')
418
+ return text.trim();
419
+ if (text && typeof text === 'object' && typeof text.content === 'string')
420
+ return text.content.trim();
421
+ return '';
422
+ }
423
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
424
+ async connect() {
425
+ const { clientId, clientSecret } = this.config;
426
+ if (!clientId || !clientSecret || clientId.includes('your-') || clientSecret.includes('your-')) {
427
+ throw new Error('DingTalk clientId/clientSecret not configured');
428
+ }
429
+ this.loadSeenMessages();
430
+ this.loadRoutes();
431
+ const { DWClient, TOPIC_ROBOT, TOPIC_CARD } = await requireOptional('dingtalk-stream');
432
+ this.client = new DWClient({ clientId, clientSecret });
433
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (msg) => {
434
+ await this.handleIncoming(msg);
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
+ }
445
+ await this.client.connect();
446
+ this.connected = true;
447
+ // Hourly cleanup of old dedup entries
448
+ this.cleanupInterval = setInterval(() => {
449
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
450
+ for (const [id, ts] of this.seenMessages) {
451
+ if (ts < cutoff)
452
+ this.seenMessages.delete(id);
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();
463
+ }, 60 * 60 * 1000);
464
+ logger.info('[DingTalk] Connected via Stream Mode');
465
+ }
466
+ async disconnect() {
467
+ this.connected = false;
468
+ if (this.cleanupInterval) {
469
+ clearInterval(this.cleanupInterval);
470
+ this.cleanupInterval = null;
471
+ }
472
+ if (this.client) {
473
+ try {
474
+ this.client.disconnect();
475
+ }
476
+ catch { /* ignore */ }
477
+ this.client = null;
478
+ }
479
+ logger.info('[DingTalk] Disconnected');
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
+ }
495
+ onMessage(handler) {
496
+ this.messageHandler = handler;
497
+ }
498
+ onRecall(handler) {
499
+ this.recallHandler = handler;
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
+ }
520
+ // ── Inbound message handling ───────────────────────────────────────────────
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);
525
+ try {
526
+ const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
527
+ const msgId = data.msgId;
528
+ const conversationType = data.conversationType;
529
+ const conversationId = data.conversationId;
530
+ const senderId = data.senderStaffId || data.senderId;
531
+ const senderNick = data.senderNick;
532
+ const sessionWebhook = data.sessionWebhook;
533
+ const msgtype = data.msgtype;
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)) {
539
+ logger.debug(`[DingTalk] Duplicate message skipped: ${msgId}`);
540
+ return;
541
+ }
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
+ };
568
+ // Group gate
569
+ if (conversationType === '2') {
570
+ if (!this.shouldProcessGroupMessage(conversationId, isMentioned)) {
571
+ logger.debug(`[DingTalk] Group message ignored (not mentioned): ${msgId}`);
572
+ return;
573
+ }
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
+ }
584
+ // Webhook cache (SSRF validated)
585
+ if (sessionWebhook && this.isValidWebhook(sessionWebhook)) {
586
+ this.webhookCache.set(chatId, sessionWebhook);
587
+ }
588
+ // Dispatch by msgtype
589
+ if (!this.messageHandler)
590
+ return;
591
+ // 首次交互欢迎消息(使用共享帮助函数)
592
+ await sendWelcomeIfNeeded(this.welcomeManager, senderId, chatId, async (id, text) => { await this.sendMessage(id, text); }, 'DingTalk');
593
+ if (msgtype === 'text' || !msgtype) {
594
+ const text = this.extractText(data);
595
+ if (!text)
596
+ return;
597
+ await this.messageHandler({
598
+ channelId: chatId, content: text, chatType,
599
+ peerId: senderId || '', peerName: senderNick, messageId: msgId,
600
+ ...eventMetadata,
601
+ });
602
+ }
603
+ else if (msgtype === 'picture' || msgtype === 'image') {
604
+ await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
605
+ }
606
+ else if (msgtype === 'file') {
607
+ await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, eventMetadata);
608
+ }
609
+ else if (msgtype === 'richText') {
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);
627
+ }
628
+ else {
629
+ await this.messageHandler({
630
+ channelId: chatId,
631
+ content: `[不支持的消息类型: ${msgtype}]`,
632
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
633
+ ...eventMetadata,
634
+ });
635
+ }
636
+ }
637
+ catch (error) {
638
+ logger.error('[DingTalk] Failed to process incoming message:', error);
639
+ }
640
+ }
641
+ // ── Inbound media handling ─────────────────────────────────────────────────
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 = {}) {
680
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
681
+ const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
682
+ if (!downloadRef) {
683
+ logger.warn('[DingTalk] Image message without downloadUrl');
684
+ await this.messageHandler({
685
+ channelId: chatId, content: '[图片下载失败:缺少下载链接]',
686
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
687
+ ...metadata,
688
+ });
689
+ return;
690
+ }
691
+ try {
692
+ const { validateImage } = await import('../utils/media-cache.js');
693
+ const buffer = await this.downloadMessageMedia(downloadRef);
694
+ const result = await validateImage(buffer);
695
+ if (result.mime) {
696
+ await this.messageHandler({
697
+ channelId: chatId,
698
+ content: '用户发送了一张图片,请分析这张图片的内容。',
699
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
700
+ images: [{ data: buffer.toString('base64'), mimeType: result.mime }],
701
+ ...metadata,
702
+ });
703
+ }
704
+ else {
705
+ logger.warn(`[DingTalk] Image validation failed: ${!result.mime && 'reason' in result ? result.reason : 'unknown'}`);
706
+ await this.messageHandler({
707
+ channelId: chatId, content: '[图片验证失败]',
708
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
709
+ ...metadata,
710
+ });
711
+ }
712
+ }
713
+ catch (error) {
714
+ logger.error('[DingTalk] Failed to download image:', error);
715
+ await this.messageHandler({
716
+ channelId: chatId, content: '[图片下载失败]',
717
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
718
+ ...metadata,
719
+ });
720
+ }
721
+ }
722
+ async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
723
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
724
+ const downloadRef = content.downloadUrl || content.downloadCode || data.downloadUrl || data.downloadCode;
725
+ const fileName = content.fileName || data.fileName || 'unknown';
726
+ if (!downloadRef) {
727
+ logger.warn('[DingTalk] File message without downloadUrl');
728
+ await this.messageHandler({
729
+ channelId: chatId, content: `[文件下载失败:缺少下载链接] ${fileName}`,
730
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
731
+ ...metadata,
732
+ });
733
+ return;
734
+ }
735
+ try {
736
+ const { saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
737
+ const projectPath = this.projectPathProvider
738
+ ? await this.projectPathProvider(chatId)
739
+ : process.cwd();
740
+ const buffer = await this.downloadMessageMedia(downloadRef);
741
+ const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
742
+ await this.messageHandler({
743
+ channelId: chatId,
744
+ content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
745
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
746
+ ...metadata,
747
+ });
748
+ }
749
+ catch (error) {
750
+ logger.error('[DingTalk] Failed to download file:', error);
751
+ await this.messageHandler({
752
+ channelId: chatId, content: `[文件下载失败] ${fileName}`,
753
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
754
+ ...metadata,
755
+ });
756
+ }
757
+ }
758
+ async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId, metadata = {}) {
759
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
760
+ const richText = content.richText;
761
+ if (!Array.isArray(richText)) {
762
+ await this.messageHandler({
763
+ channelId: chatId, content: '[不支持的富文本格式]',
764
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
765
+ ...metadata,
766
+ });
767
+ return;
768
+ }
769
+ let text = '';
770
+ const images = [];
771
+ for (const item of richText) {
772
+ if (item.type === 'text' && item.text) {
773
+ text += item.text;
774
+ }
775
+ else if (item.type === 'picture' && (item.downloadUrl || item.downloadCode)) {
776
+ try {
777
+ const { validateImage } = await import('../utils/media-cache.js');
778
+ const buffer = await this.downloadMessageMedia(item.downloadUrl || item.downloadCode);
779
+ const result = await validateImage(buffer);
780
+ if (result.mime) {
781
+ images.push({ data: buffer.toString('base64'), mimeType: result.mime });
782
+ }
783
+ }
784
+ catch (error) {
785
+ logger.warn('[DingTalk] Failed to download richText image:', error);
786
+ }
787
+ }
788
+ }
789
+ const prompt = text.trim() || (images.length > 0 ? '用户发送了一张图片,请分析这张图片的内容。' : '[空消息]');
790
+ await this.messageHandler({
791
+ channelId: chatId, content: prompt, chatType,
792
+ peerId: senderId || '', peerName: senderNick, messageId: msgId,
793
+ images: images.length > 0 ? images : undefined,
794
+ ...metadata,
795
+ });
796
+ }
797
+ // ── Outbound: OpenAPI sends return processQueryKey message receipts ───────
798
+ async sendMessage(chatId, content) {
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)
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;
878
+ }
879
+ try {
880
+ const token = await this.client?.getAccessToken();
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'}`, {
886
+ method: 'POST',
887
+ headers: {
888
+ 'Content-Type': 'application/json',
889
+ 'x-acs-dingtalk-access-token': token,
890
+ },
891
+ body: JSON.stringify({
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,
900
+ }),
901
+ signal: AbortSignal.timeout(15_000),
902
+ });
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;
908
+ }
909
+ return true;
910
+ }
911
+ catch (error) {
912
+ logger.debug(`[DingTalk] ${recall ? 'recall' : 'reply'} emotion ${emotionName} failed for ${messageId}: ${error?.message || error}`);
913
+ return false;
914
+ }
915
+ }
916
+ // ── Outbound: image via Open API ───────────────────────────────────────────
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}`);
926
+ }
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)}`);
938
+ }
939
+ // Step 2: Send via robot API
940
+ return [await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }))];
941
+ }
942
+ // ── Outbound: file via Open API ────────────────────────────────────────────
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;
948
+ try {
949
+ bytesRead = fs.readSync(fd, header, 0, header.length, 0);
950
+ }
951
+ finally {
952
+ fs.closeSync(fd);
953
+ }
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);
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 }))];
978
+ }
979
+ // ── Robot message send helper (group vs DM) ────────────────────────────────
980
+ async sendRobotMessage(chatId, token, msgKey, msgParam) {
981
+ const headers = { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' };
982
+ const { clientId } = this.config;
983
+ // Group chatId = conversationId, DM chatId = senderId
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'))) {
989
+ // Group: chatId is the conversationId
990
+ res = await fetch('https://api.dingtalk.com/v1.0/robot/groupMessages/send', {
991
+ method: 'POST', headers,
992
+ body: JSON.stringify({ msgKey, msgParam, openConversationId: chatId, robotCode: clientId }),
993
+ signal: AbortSignal.timeout(15_000),
994
+ });
995
+ }
996
+ else if (staffId || (!route && chatId)) {
997
+ // DM: use senderStaffId
998
+ const targetStaffId = staffId || chatId;
999
+ res = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
1000
+ method: 'POST', headers,
1001
+ body: JSON.stringify({ msgKey, msgParam, userIds: [targetStaffId], robotCode: clientId }),
1002
+ signal: AbortSignal.timeout(15_000),
1003
+ });
1004
+ }
1005
+ else {
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('操作失败,请重试'));
1354
+ }
1355
+ }
1356
+ }
1357
+ // ── Plugin ─────────────────────────────────────────────────────────────────────
1358
+ function isValidCredential(value) {
1359
+ return !!value && !value.includes('your-') && !value.includes('placeholder');
1360
+ }
1361
+ export class DingtalkChannelPlugin {
1362
+ name = 'dingtalk';
1363
+ async createInstance(inst, ctx) {
1364
+ if (inst.enabled === false)
1365
+ return null;
1366
+ if (!isValidCredential(inst.clientId) || !isValidCredential(inst.clientSecret))
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
+ }
1373
+ const channel = new DingtalkChannel({
1374
+ clientId: inst.clientId,
1375
+ clientSecret: inst.clientSecret,
1376
+ requireMention: inst.requireMention,
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`),
1381
+ }, ctx.agentName, inst.name);
1382
+ const mode = resolveShowActivities(inst);
1383
+ const adapter = {
1384
+ channelName: inst.name,
1385
+ channelKey: inst.name,
1386
+ capabilities: { file: true, image: true, interaction: !!inst.cardTemplateId, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
1387
+ send: async (envelope, payload) => {
1388
+ const channelId = envelope.channelId;
1389
+ switch (payload.kind) {
1390
+ case 'result.text':
1391
+ case 'command.result':
1392
+ case 'command.error':
1393
+ case 'system.notice':
1394
+ case 'system.error':
1395
+ case 'result.error':
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));
1399
+ case 'activity.batch': {
1400
+ const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
1401
+ const text = formatItemsAsText(filtered);
1402
+ if (text)
1403
+ return sentReceipt(envelope, await channel.sendMessage(channelId, text));
1404
+ return suppressedReceipt(envelope, 'empty_activity');
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');
1417
+ case 'status.requires_action':
1418
+ return sentReceipt(envelope, await channel.sendMessage(channelId, '等待 owner 审批'));
1419
+ case 'status.error':
1420
+ await channel.completeAckReaction(envelope.taskId, false);
1421
+ if (payload.metadata?.message)
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
+ }
1433
+ if (payload.fallbackText)
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');
1438
+ }
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),
1445
+ };
1446
+ const policy = {
1447
+ canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
1448
+ canListProjects: (_, identity) => identity === 'owner' || identity === 'admin',
1449
+ canCreateSession: () => true,
1450
+ canDeleteSession: () => true,
1451
+ canImportCliSession: (_, identity) => identity === 'owner' || identity === 'admin',
1452
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
1453
+ showMiddleResult: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
1454
+ middleOutputMode: (chatType, identity) => middleOutputModePolicy(mode, chatType, identity),
1455
+ showIdleMonitor: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
1456
+ accumulateErrors: () => true,
1457
+ };
1458
+ return {
1459
+ channelType: 'dingtalk', adapter, channel,
1460
+ policy,
1461
+ options: { fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g, supportsImages: true, flushDelay: inst.flushDelay },
1462
+ connect: () => channel.connect(),
1463
+ disconnect: () => channel.disconnect(),
1464
+ onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
1465
+ registerBridge(bridge, channelType) {
1466
+ bridge.register(adapter.channelName, (handler) => channel.onMessage(async (event) => {
1467
+ await handler({
1468
+ channel: adapter.channelName, channelType, channelId: event.channelId,
1469
+ selfAID: ctx.agentName, content: event.content, images: event.images,
1470
+ chatType: event.chatType || 'private', peerId: event.peerId || '',
1471
+ peerName: event.peerName, messageId: event.messageId,
1472
+ mentions: event.mentions, isMentioned: event.isMentioned,
1473
+ topicName: event.topicName, source: event.source,
1474
+ });
1475
+ }), async (channelId, text) => { await channel.sendMessage(channelId, text); }, adapter, channelType);
1476
+ },
1477
+ };
1478
+ }
1479
+ }