cli-claw-kit 0.0.1

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 (295) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/config/default-groups.json +1 -0
  4. package/config/global-agents-md.template.md +37 -0
  5. package/config/mount-allowlist.json +11 -0
  6. package/container/Dockerfile +160 -0
  7. package/container/agent-runner/dist/.tsbuildinfo +1 -0
  8. package/container/agent-runner/dist/agent-definitions.js +22 -0
  9. package/container/agent-runner/dist/channel-prefixes.js +16 -0
  10. package/container/agent-runner/dist/codex-config.js +29 -0
  11. package/container/agent-runner/dist/image-detector.js +96 -0
  12. package/container/agent-runner/dist/index.js +2587 -0
  13. package/container/agent-runner/dist/mcp-tools.js +1076 -0
  14. package/container/agent-runner/dist/stream-event.types.js +5 -0
  15. package/container/agent-runner/dist/stream-processor.js +867 -0
  16. package/container/agent-runner/dist/types.js +6 -0
  17. package/container/agent-runner/dist/utils.js +115 -0
  18. package/container/agent-runner/package.json +36 -0
  19. package/container/agent-runner/prompts/security-rules.md +31 -0
  20. package/container/agent-runner/src/agent-definitions.ts +27 -0
  21. package/container/agent-runner/src/channel-prefixes.ts +16 -0
  22. package/container/agent-runner/src/codex-config.ts +40 -0
  23. package/container/agent-runner/src/image-detector.ts +116 -0
  24. package/container/agent-runner/src/index.ts +3107 -0
  25. package/container/agent-runner/src/mcp-tools.ts +1295 -0
  26. package/container/agent-runner/src/stream-event.types.ts +10 -0
  27. package/container/agent-runner/src/stream-processor.ts +932 -0
  28. package/container/agent-runner/src/types.ts +75 -0
  29. package/container/agent-runner/src/utils.ts +114 -0
  30. package/container/agent-runner/tsconfig.json +17 -0
  31. package/container/build.sh +28 -0
  32. package/container/entrypoint.sh +64 -0
  33. package/container/skills/agent-browser/SKILL.md +159 -0
  34. package/container/skills/install-skill/SKILL.md +64 -0
  35. package/container/skills/post-test-cleanup/SKILL.md +121 -0
  36. package/dist/.tsbuildinfo +1 -0
  37. package/dist/agent-output-parser.js +459 -0
  38. package/dist/app-root.js +52 -0
  39. package/dist/assistant-meta-footer.js +1 -0
  40. package/dist/auth.js +91 -0
  41. package/dist/billing.js +694 -0
  42. package/dist/channel-prefixes.js +16 -0
  43. package/dist/cli.js +86 -0
  44. package/dist/commands.js +79 -0
  45. package/dist/config.js +120 -0
  46. package/dist/container-runner.js +981 -0
  47. package/dist/daily-summary.js +210 -0
  48. package/dist/db.js +3683 -0
  49. package/dist/dingtalk.js +1347 -0
  50. package/dist/feishu-markdown-style.js +97 -0
  51. package/dist/feishu-streaming-card.js +1875 -0
  52. package/dist/feishu.js +1628 -0
  53. package/dist/file-manager.js +270 -0
  54. package/dist/group-queue.js +1070 -0
  55. package/dist/group-runtime.js +35 -0
  56. package/dist/host-workspace-cwd.js +85 -0
  57. package/dist/im-channel.js +384 -0
  58. package/dist/im-command-utils.js +142 -0
  59. package/dist/im-downloader.js +45 -0
  60. package/dist/im-manager.js +527 -0
  61. package/dist/im-utils.js +53 -0
  62. package/dist/image-detector.js +96 -0
  63. package/dist/index.js +5828 -0
  64. package/dist/logger.js +22 -0
  65. package/dist/mcp-utils.js +66 -0
  66. package/dist/message-attachments.js +69 -0
  67. package/dist/message-notifier.js +36 -0
  68. package/dist/middleware/auth.js +85 -0
  69. package/dist/mount-security.js +315 -0
  70. package/dist/permissions.js +67 -0
  71. package/dist/project-memory.js +6 -0
  72. package/dist/provider-pool.js +189 -0
  73. package/dist/qq.js +826 -0
  74. package/dist/reset-admin.js +42 -0
  75. package/dist/routes/admin.js +543 -0
  76. package/dist/routes/agent-definitions.js +241 -0
  77. package/dist/routes/agents.js +533 -0
  78. package/dist/routes/auth.js +675 -0
  79. package/dist/routes/billing.js +490 -0
  80. package/dist/routes/browse.js +210 -0
  81. package/dist/routes/bug-report.js +387 -0
  82. package/dist/routes/config.js +1868 -0
  83. package/dist/routes/files.js +671 -0
  84. package/dist/routes/groups.js +1367 -0
  85. package/dist/routes/mcp-servers.js +320 -0
  86. package/dist/routes/memory.js +523 -0
  87. package/dist/routes/monitor.js +307 -0
  88. package/dist/routes/skills.js +777 -0
  89. package/dist/routes/tasks.js +509 -0
  90. package/dist/routes/usage.js +64 -0
  91. package/dist/routes/workspace-config.js +458 -0
  92. package/dist/runtime-build.js +112 -0
  93. package/dist/runtime-command-handler.js +189 -0
  94. package/dist/runtime-command-registry.js +1 -0
  95. package/dist/runtime-config.js +1777 -0
  96. package/dist/runtime-identity.js +52 -0
  97. package/dist/schemas.js +590 -0
  98. package/dist/script-runner.js +64 -0
  99. package/dist/sdk-query.js +82 -0
  100. package/dist/skill-utils.js +145 -0
  101. package/dist/sqlite-compat.js +19 -0
  102. package/dist/stream-event.types.js +5 -0
  103. package/dist/streaming-runtime-meta.js +29 -0
  104. package/dist/task-scheduler.js +695 -0
  105. package/dist/task-utils.js +13 -0
  106. package/dist/telegram-pairing.js +59 -0
  107. package/dist/telegram.js +897 -0
  108. package/dist/terminal-manager.js +307 -0
  109. package/dist/tool-step-display.js +1 -0
  110. package/dist/types.js +1 -0
  111. package/dist/utils.js +85 -0
  112. package/dist/web-context.js +161 -0
  113. package/dist/web.js +1377 -0
  114. package/dist/wechat-crypto.js +182 -0
  115. package/dist/wechat.js +589 -0
  116. package/dist/workspace-runtime-reset.js +35 -0
  117. package/package.json +107 -0
  118. package/shared/assistant-meta-footer.ts +127 -0
  119. package/shared/channel-prefixes.ts +16 -0
  120. package/shared/dist/assistant-meta-footer.d.ts +29 -0
  121. package/shared/dist/assistant-meta-footer.js +85 -0
  122. package/shared/dist/channel-prefixes.d.ts +4 -0
  123. package/shared/dist/channel-prefixes.js +16 -0
  124. package/shared/dist/image-detector.d.ts +20 -0
  125. package/shared/dist/image-detector.js +96 -0
  126. package/shared/dist/runtime-command-registry.d.ts +38 -0
  127. package/shared/dist/runtime-command-registry.js +185 -0
  128. package/shared/dist/stream-event.d.ts +65 -0
  129. package/shared/dist/stream-event.js +8 -0
  130. package/shared/dist/tool-step-display.d.ts +4 -0
  131. package/shared/dist/tool-step-display.js +11 -0
  132. package/shared/image-detector.ts +116 -0
  133. package/shared/runtime-command-registry.ts +252 -0
  134. package/shared/stream-event.ts +67 -0
  135. package/shared/tool-step-display.ts +21 -0
  136. package/shared/tsconfig.json +24 -0
  137. package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
  138. package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
  139. package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
  140. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  141. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  142. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  143. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  144. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  145. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  146. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  147. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  148. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  149. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  150. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  151. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  152. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  153. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  154. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  155. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  156. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  157. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  158. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  159. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  160. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  161. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  162. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  163. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  164. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  165. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  166. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  167. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  168. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  169. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  170. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  171. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  172. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  173. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  174. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  175. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  176. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  177. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  178. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  179. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  180. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  181. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  182. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  183. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  184. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  185. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  186. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  187. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  188. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  189. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  190. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  191. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  192. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  193. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  194. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  195. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  196. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  197. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  198. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  199. package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
  200. package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
  201. package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
  202. package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
  203. package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
  204. package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
  205. package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
  206. package/web/dist/assets/band-CquvqAHh.js +1 -0
  207. package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
  208. package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
  209. package/web/dist/assets/channel-BOVj73LR.js +1 -0
  210. package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
  211. package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
  212. package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
  213. package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
  214. package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
  215. package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
  216. package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
  217. package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
  218. package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
  219. package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
  220. package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
  221. package/web/dist/assets/clone-BmaCesfa.js +1 -0
  222. package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
  223. package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  224. package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
  225. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  226. package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
  227. package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
  228. package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
  229. package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
  230. package/web/dist/assets/error-CGD5mp5f.js +1 -0
  231. package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
  232. package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
  233. package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
  234. package/web/dist/assets/graph-CeAEckur.js +1 -0
  235. package/web/dist/assets/index-CPnL1_qC.js +768 -0
  236. package/web/dist/assets/index-DVevCbcO.css +10 -0
  237. package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
  238. package/web/dist/assets/init-Dmth1JHB.js +1 -0
  239. package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
  240. package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
  241. package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
  242. package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
  243. package/web/dist/assets/linear-DiaJloY5.js +1 -0
  244. package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
  245. package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
  246. package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
  247. package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
  248. package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
  249. package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
  250. package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
  251. package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
  252. package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
  253. package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
  254. package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
  255. package/web/dist/assets/square-0CqMX1Q3.js +11 -0
  256. package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
  257. package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
  258. package/web/dist/assets/step-D51IIHGA.js +1 -0
  259. package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
  260. package/web/dist/assets/time-O8zIGux3.js +1 -0
  261. package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
  262. package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
  263. package/web/dist/assets/utils-KGAn0XTg.js +11 -0
  264. package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
  265. package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
  266. package/web/dist/assets/zap-_hKJYy7J.js +6 -0
  267. package/web/dist/favicon.svg +332 -0
  268. package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
  269. package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
  270. package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
  271. package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
  272. package/web/dist/fonts/DMSans-latin.woff2 +0 -0
  273. package/web/dist/icons/README.md +20 -0
  274. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  275. package/web/dist/icons/icon-128.png +0 -0
  276. package/web/dist/icons/icon-144.png +0 -0
  277. package/web/dist/icons/icon-152.png +0 -0
  278. package/web/dist/icons/icon-192.png +0 -0
  279. package/web/dist/icons/icon-192.svg +332 -0
  280. package/web/dist/icons/icon-384.png +0 -0
  281. package/web/dist/icons/icon-48.png +0 -0
  282. package/web/dist/icons/icon-512-maskable.png +0 -0
  283. package/web/dist/icons/icon-512.png +0 -0
  284. package/web/dist/icons/icon-512.svg +332 -0
  285. package/web/dist/icons/icon-72.png +0 -0
  286. package/web/dist/icons/icon-96.png +0 -0
  287. package/web/dist/icons/loading-logo.svg +332 -0
  288. package/web/dist/icons/logo-1024.png +0 -0
  289. package/web/dist/icons/logo-icon.svg +332 -0
  290. package/web/dist/icons/logo-text.svg +332 -0
  291. package/web/dist/index.html +30 -0
  292. package/web/dist/manifest.webmanifest +1 -0
  293. package/web/dist/registerSW.js +1 -0
  294. package/web/dist/sw.js +1 -0
  295. package/web/dist/workbox-08d6266a.js +1 -0
@@ -0,0 +1,1347 @@
1
+ /**
2
+ * DingTalk Bot Stream Connection Factory
3
+ *
4
+ * Implements DingTalk bot connection using official Stream mode SDK:
5
+ * - WebSocket connection for receiving events
6
+ * - Message deduplication (LRU 1000 / 30min TTL)
7
+ * - Group mention filtering
8
+ * - REST API for sending messages
9
+ *
10
+ * Reference: https://open.dingtalk.com/document/orgapp/the-streaming-mode-is-connected-to-the-robot-receiving-message
11
+ */
12
+ import crypto from 'crypto';
13
+ import fs from 'node:fs/promises';
14
+ import http from 'node:http';
15
+ import https from 'node:https';
16
+ import { DWClient, TOPIC_ROBOT, } from 'dingtalk-stream';
17
+ import { storeChatMetadata, storeMessageDirect, updateChatName } from './db.js';
18
+ import { notifyNewImMessage } from './message-notifier.js';
19
+ import { broadcastNewMessage } from './web.js';
20
+ import { logger } from './logger.js';
21
+ import { saveDownloadedFile, MAX_FILE_SIZE } from './im-downloader.js';
22
+ import { detectImageMimeType } from './image-detector.js';
23
+ import { markdownToPlainText, splitTextChunks } from './im-utils.js';
24
+ // ─── Constants ──────────────────────────────────────────────────
25
+ const DINGTALK_API_BASE = 'https://api.dingtalk.com';
26
+ const MSG_DEDUP_MAX = 1000;
27
+ const MSG_DEDUP_TTL = 30 * 60 * 1000; // 30min
28
+ const MSG_SPLIT_LIMIT = 4000; // DingTalk markdown card limit
29
+ // Same 5MB threshold as WeChat — only inline base64 for small images
30
+ const IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024;
31
+ // Minimum valid image size (bytes) — discard responses that are too small to be real images
32
+ const MIN_IMAGE_SIZE = 500;
33
+ // ─── Helpers ────────────────────────────────────────────────────
34
+ // markdownToPlainText imported from ./im-utils.js
35
+ /**
36
+ * Convert standard Markdown to DingTalk markdown format.
37
+ * DingTalk supports: headers (#/#/###), bold (**text**), italic (*text*),
38
+ * unordered lists (- item), links [text](url), blockquotes (> text), inline code (`code`).
39
+ * Strips: code blocks, strikethrough, images.
40
+ */
41
+ function convertToDingTalkMarkdown(md) {
42
+ let text = md;
43
+ // Code blocks → code block marker (DingTalk supports ``` fence)
44
+ // Keep them as-is since DingTalk markdown supports fenced code
45
+ // Images: ![alt](url) → alt (DingTalk doesn't render inline images in markdown)
46
+ text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1');
47
+ // Links: keep as [text](url) since DingTalk markdown supports them
48
+ // Strikethrough: ~~text~~ → text (not supported)
49
+ text = text.replace(/~~(.+?)~~/g, '$1');
50
+ // Headings: keep as-is (# to ######)
51
+ // Bold: keep as-is **text**
52
+ // Italic: keep as-is *text*
53
+ // Unordered lists: keep as-is - item
54
+ // Blockquotes: keep as-is > text
55
+ // Inline code: keep as-is `code`
56
+ return text;
57
+ }
58
+ // splitTextChunks imported from ./im-utils.js
59
+ /**
60
+ * Parse JID to determine chat type and extract conversation ID / staff ID.
61
+ * dingtalk:c2c:{senderStaffId} → { type: 'c2c', conversationId: senderStaffId }
62
+ * dingtalk:group:{openConversationId} → { type: 'group', conversationId: openConversationId }
63
+ * c2c:{senderStaffId} → { type: 'c2c', conversationId: senderStaffId } (legacy without prefix)
64
+ */
65
+ function parseDingTalkChatId(chatId) {
66
+ if (chatId.startsWith('dingtalk:c2c:')) {
67
+ // Format: dingtalk:c2c:{senderStaffId}, extract senderStaffId
68
+ return { type: 'c2c', conversationId: chatId.slice(13) };
69
+ }
70
+ if (chatId.startsWith('dingtalk:group:')) {
71
+ return { type: 'group', conversationId: chatId.slice(15) };
72
+ }
73
+ // Legacy format without prefix
74
+ if (chatId.startsWith('c2c:')) {
75
+ return { type: 'c2c', conversationId: chatId.slice(4) };
76
+ }
77
+ if (chatId.startsWith('group:')) {
78
+ return { type: 'group', conversationId: chatId.slice(6) };
79
+ }
80
+ // Legacy format: direct conversationId (assume group)
81
+ if (chatId.startsWith('cid')) {
82
+ return { type: 'group', conversationId: chatId };
83
+ }
84
+ return null;
85
+ }
86
+ // ─── Factory Function ───────────────────────────────────────────
87
+ export function createDingTalkConnection(config) {
88
+ // SDK client state
89
+ let client = null;
90
+ let stopping = false;
91
+ let readyFired = false;
92
+ let reconnectCheckInterval = null;
93
+ // Token state for REST API
94
+ let tokenInfo = null;
95
+ // Message deduplication
96
+ const msgCache = new Map();
97
+ // Last message ID per chat (for reply context)
98
+ const lastMessageIds = new Map();
99
+ // Session webhook per chat (for sending replies)
100
+ const lastSessionWebhooks = new Map();
101
+ // Session webhook expiry per chat
102
+ const sessionWebhookExpiry = new Map();
103
+ const SESSION_WEBHOOK_TTL = 5 * 60 * 1000; // 5 minutes
104
+ // Sender ID per chat (for sending files back to user)
105
+ const lastSenderIds = new Map();
106
+ // Sender staff ID per chat (enterprise staff ID for batchSend API)
107
+ const lastSenderStaffIds = new Map();
108
+ function isDuplicate(msgId) {
109
+ const now = Date.now();
110
+ // Map preserves insertion order; stop at first non-expired entry
111
+ for (const [id, ts] of msgCache.entries()) {
112
+ if (now - ts > MSG_DEDUP_TTL) {
113
+ msgCache.delete(id);
114
+ }
115
+ else {
116
+ break;
117
+ }
118
+ }
119
+ if (msgCache.size >= MSG_DEDUP_MAX) {
120
+ const firstKey = msgCache.keys().next().value;
121
+ if (firstKey)
122
+ msgCache.delete(firstKey);
123
+ }
124
+ return msgCache.has(msgId);
125
+ }
126
+ function markSeen(msgId) {
127
+ // delete + set to refresh insertion order (move to end)
128
+ msgCache.delete(msgId);
129
+ msgCache.set(msgId, Date.now());
130
+ }
131
+ // ─── Token Management ──────────────────────────────────────
132
+ async function getAccessToken() {
133
+ // Check cached token
134
+ if (tokenInfo && Date.now() < tokenInfo.expiresAt - 300000) {
135
+ return tokenInfo.token;
136
+ }
137
+ // Fetch new token using GET method (钉钉 API 支持 GET 和 POST)
138
+ return new Promise((resolve, reject) => {
139
+ const url = new URL('https://oapi.dingtalk.com/gettoken');
140
+ url.searchParams.set('appkey', config.clientId);
141
+ url.searchParams.set('appsecret', config.clientSecret);
142
+ const req = https.request({
143
+ hostname: url.hostname,
144
+ path: url.pathname + url.search,
145
+ method: 'GET',
146
+ }, (res) => {
147
+ const chunks = [];
148
+ res.on('data', (chunk) => chunks.push(chunk));
149
+ res.on('end', () => {
150
+ try {
151
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
152
+ if (data.errcode !== 0) {
153
+ reject(new Error(`DingTalk token error: ${data.errmsg}`));
154
+ return;
155
+ }
156
+ const expiresIn = Number(data.expires_in) || 7200;
157
+ tokenInfo = {
158
+ token: data.access_token,
159
+ expiresAt: Date.now() + expiresIn * 1000,
160
+ };
161
+ logger.info({ expiresIn }, 'DingTalk access token refreshed');
162
+ resolve(data.access_token);
163
+ }
164
+ catch (err) {
165
+ reject(err);
166
+ }
167
+ });
168
+ res.on('error', reject);
169
+ });
170
+ req.on('error', reject);
171
+ req.end();
172
+ });
173
+ }
174
+ // ─── REST API ──────────────────────────────────────────────
175
+ async function apiRequest(method, path, body) {
176
+ const token = await getAccessToken();
177
+ const url = new URL(path, DINGTALK_API_BASE);
178
+ const bodyStr = body ? JSON.stringify(body) : undefined;
179
+ return new Promise((resolve, reject) => {
180
+ const req = https.request({
181
+ hostname: url.hostname,
182
+ path: url.pathname + url.search,
183
+ method,
184
+ headers: {
185
+ 'x-acs-dingtalk-access-token': token,
186
+ 'Content-Type': 'application/json',
187
+ ...(bodyStr
188
+ ? { 'Content-Length': String(Buffer.byteLength(bodyStr)) }
189
+ : {}),
190
+ },
191
+ }, (res) => {
192
+ const chunks = [];
193
+ res.on('data', (chunk) => chunks.push(chunk));
194
+ res.on('end', () => {
195
+ const text = Buffer.concat(chunks).toString('utf-8');
196
+ try {
197
+ const data = JSON.parse(text);
198
+ if (res.statusCode && res.statusCode >= 400) {
199
+ const errMsg = data.message || data.msg || text;
200
+ reject(new Error(`DingTalk API ${method} ${path} failed (${res.statusCode}): ${errMsg}`));
201
+ return;
202
+ }
203
+ resolve(data);
204
+ }
205
+ catch {
206
+ if (res.statusCode && res.statusCode >= 400) {
207
+ reject(new Error(`DingTalk API ${method} ${path} failed (${res.statusCode}): ${text}`));
208
+ }
209
+ else {
210
+ resolve({});
211
+ }
212
+ }
213
+ });
214
+ res.on('error', reject);
215
+ });
216
+ req.on('error', reject);
217
+ if (bodyStr)
218
+ req.write(bodyStr);
219
+ req.end();
220
+ });
221
+ }
222
+ // ─── Message Sending ──────────────────────────────────────
223
+ /**
224
+ * Send message via sessionWebhook (from incoming message)
225
+ * This is the standard DingTalk robot reply mechanism
226
+ */
227
+ async function sendViaSessionWebhook(sessionWebhook, content, useMarkdown = false) {
228
+ const token = await getAccessToken();
229
+ const body = useMarkdown
230
+ ? {
231
+ msgtype: 'markdown',
232
+ markdown: {
233
+ title: content.slice(0, 50),
234
+ text: content,
235
+ },
236
+ }
237
+ : {
238
+ msgtype: 'text',
239
+ text: {
240
+ content,
241
+ },
242
+ };
243
+ return new Promise((resolve, reject) => {
244
+ const url = new URL(sessionWebhook);
245
+ const req = https.request({
246
+ hostname: url.hostname,
247
+ path: url.pathname + url.search,
248
+ method: 'POST',
249
+ headers: {
250
+ 'Content-Type': 'application/json',
251
+ 'x-acs-dingtalk-access-token': token,
252
+ },
253
+ }, (res) => {
254
+ const chunks = [];
255
+ res.on('data', (chunk) => chunks.push(chunk));
256
+ res.on('end', () => {
257
+ const body = Buffer.concat(chunks).toString('utf-8');
258
+ if (res.statusCode && res.statusCode >= 400) {
259
+ reject(new Error(`DingTalk HTTP failed (${res.statusCode}): ${body}`));
260
+ return;
261
+ }
262
+ // Also check DingTalk API-level errcode
263
+ try {
264
+ const data = JSON.parse(body);
265
+ logger.info({
266
+ statusCode: res.statusCode,
267
+ errcode: data.errcode,
268
+ errmsg: data.errmsg,
269
+ }, 'DingTalk sendViaSessionWebhook response');
270
+ if (data.errcode && data.errcode !== 0) {
271
+ reject(new Error(`DingTalk API error: ${data.errcode} ${data.errmsg}`));
272
+ return;
273
+ }
274
+ }
275
+ catch {
276
+ // Not JSON, ignore
277
+ }
278
+ resolve();
279
+ });
280
+ res.on('error', reject);
281
+ });
282
+ req.on('error', reject);
283
+ req.write(JSON.stringify(body));
284
+ req.end();
285
+ });
286
+ }
287
+ /**
288
+ * Common helper: POST /v1.0/robot/oToMessages/batchSend
289
+ * Used by C2C text, file, and image message senders.
290
+ */
291
+ async function batchSendToUser(userIds, robotCode, token, msgKey, msgParam) {
292
+ const body = JSON.stringify({ robotCode, userIds, msgKey, msgParam });
293
+ return new Promise((resolve, reject) => {
294
+ const req = https.request({
295
+ hostname: 'api.dingtalk.com',
296
+ path: '/v1.0/robot/oToMessages/batchSend',
297
+ method: 'POST',
298
+ headers: {
299
+ 'Content-Type': 'application/json',
300
+ 'x-acs-dingtalk-access-token': token,
301
+ },
302
+ }, (res) => {
303
+ const chunks = [];
304
+ res.on('data', (chunk) => chunks.push(chunk));
305
+ res.on('end', () => {
306
+ const respBody = Buffer.concat(chunks).toString('utf8');
307
+ if (res.statusCode && res.statusCode >= 400) {
308
+ reject(new Error(`DingTalk batchSend HTTP failed (${res.statusCode}): ${respBody}`));
309
+ return;
310
+ }
311
+ try {
312
+ const data = JSON.parse(respBody);
313
+ if (data.errcode && data.errcode !== 0) {
314
+ reject(new Error(`DingTalk batchSend API error: ${data.errcode} ${data.errmsg}`));
315
+ return;
316
+ }
317
+ }
318
+ catch {
319
+ // Not JSON, ignore
320
+ }
321
+ resolve();
322
+ });
323
+ res.on('error', reject);
324
+ });
325
+ req.on('error', reject);
326
+ req.write(body);
327
+ req.end();
328
+ });
329
+ }
330
+ /**
331
+ * Send a C2C text message via the persistent chatbot API (oToMessages/batchSend).
332
+ * This is the correct API for proactive C2C messages — sessionWebhook is only
333
+ * for reply scenarios within the stream connection.
334
+ * Uses senderStaffId (enterprise user ID) which was stored when the user messaged us.
335
+ */
336
+ async function sendViaPersistentAPI(senderStaffId, content) {
337
+ const token = await getAccessToken();
338
+ const robotCode = config.clientId;
339
+ const msgParam = JSON.stringify({ content });
340
+ await batchSendToUser([senderStaffId], robotCode, token, 'sampleText', msgParam);
341
+ }
342
+ /**
343
+ * Send a group message via the persistent robot/groupMessages API.
344
+ * Uses openConversationId (stable group ID) instead of ephemeral sessionWebhook.
345
+ * Ref: https://open.dingtalk.com/document/group/the-robot-sends-a-group-message
346
+ */
347
+ async function sendViaGroupMessagesAPI(openConversationId, msgKey, msgParam) {
348
+ const token = await getAccessToken();
349
+ const robotCode = config.clientId;
350
+ const body = JSON.stringify({
351
+ openConversationId,
352
+ robotCode,
353
+ msgKey,
354
+ msgParam,
355
+ });
356
+ return new Promise((resolve, reject) => {
357
+ const req = https.request({
358
+ hostname: 'api.dingtalk.com',
359
+ path: '/v1.0/robot/groupMessages/send',
360
+ method: 'POST',
361
+ headers: {
362
+ 'Content-Type': 'application/json',
363
+ 'x-acs-dingtalk-access-token': token,
364
+ },
365
+ }, (res) => {
366
+ const chunks = [];
367
+ res.on('data', (chunk) => chunks.push(chunk));
368
+ res.on('end', () => {
369
+ const respBody = Buffer.concat(chunks).toString('utf8');
370
+ if (res.statusCode && res.statusCode >= 400) {
371
+ reject(new Error(`DingTalk groupMessages API HTTP failed (${res.statusCode}): ${respBody}`));
372
+ return;
373
+ }
374
+ try {
375
+ const data = JSON.parse(respBody);
376
+ logger.info({
377
+ statusCode: res.statusCode,
378
+ errcode: data.errcode,
379
+ errmsg: data.errmsg,
380
+ processQueryKey: data.processQueryKey,
381
+ }, 'DingTalk sendViaGroupMessagesAPI response');
382
+ if (data.errcode && data.errcode !== 0) {
383
+ reject(new Error(`DingTalk groupMessages API error: ${data.errcode} ${data.errmsg}`));
384
+ return;
385
+ }
386
+ }
387
+ catch {
388
+ // Not JSON, ignore
389
+ }
390
+ resolve();
391
+ });
392
+ res.on('error', reject);
393
+ });
394
+ req.on('error', reject);
395
+ req.write(body);
396
+ req.end();
397
+ });
398
+ }
399
+ // ─── File Download ─────────────────────────────────────────
400
+ async function downloadDingTalkImageAsBase64(url) {
401
+ try {
402
+ const buffer = await new Promise((resolve, reject) => {
403
+ const doRequest = (reqUrl, redirectCount = 0) => {
404
+ if (redirectCount > 5) {
405
+ reject(new Error('Too many redirects'));
406
+ return;
407
+ }
408
+ const parsedUrl = new URL(reqUrl);
409
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
410
+ protocol
411
+ .get(reqUrl, (res) => {
412
+ if (res.statusCode &&
413
+ res.statusCode >= 300 &&
414
+ res.statusCode < 400 &&
415
+ res.headers.location) {
416
+ doRequest(res.headers.location, redirectCount + 1);
417
+ return;
418
+ }
419
+ const chunks = [];
420
+ let total = 0;
421
+ res.on('data', (chunk) => {
422
+ total += chunk.length;
423
+ if (total > MAX_FILE_SIZE) {
424
+ res.destroy(new Error('Image exceeds MAX_FILE_SIZE'));
425
+ return;
426
+ }
427
+ chunks.push(chunk);
428
+ });
429
+ res.on('end', () => resolve(Buffer.concat(chunks)));
430
+ res.on('error', reject);
431
+ })
432
+ .on('error', reject);
433
+ };
434
+ doRequest(url);
435
+ });
436
+ if (buffer.length === 0)
437
+ return null;
438
+ const mimeType = detectImageMimeType(buffer);
439
+ return { base64: buffer.toString('base64'), mimeType };
440
+ }
441
+ catch (err) {
442
+ logger.warn({ err }, 'Failed to download DingTalk image as base64');
443
+ return null;
444
+ }
445
+ }
446
+ /**
447
+ * Fetch a temporary download URL for a robot message file/image.
448
+ * POST /v1.0/robot/messageFiles/download → { downloadUrl }
449
+ */
450
+ async function fetchDingTalkDownloadUrl(downloadCode, robotCode, token) {
451
+ const downloadUrlResp = await new Promise((resolve, reject) => {
452
+ const body = JSON.stringify({ downloadCode, robotCode });
453
+ const req = https.request({
454
+ hostname: 'api.dingtalk.com',
455
+ path: '/v1.0/robot/messageFiles/download',
456
+ method: 'POST',
457
+ headers: {
458
+ 'Content-Type': 'application/json',
459
+ 'x-acs-dingtalk-access-token': token,
460
+ },
461
+ }, (res) => {
462
+ const statusCode = res.statusCode ?? 0;
463
+ const chunks = [];
464
+ res.on('data', (chunk) => chunks.push(chunk));
465
+ res.on('end', () => {
466
+ const buf = Buffer.concat(chunks);
467
+ if (statusCode < 200 || statusCode >= 300) {
468
+ logger.warn({
469
+ statusCode,
470
+ bodyUtf8: buf.toString('utf8').slice(0, 300),
471
+ }, 'DingTalk download URL API non-2xx response');
472
+ reject(new Error(`DingTalk download URL API HTTP failed (${statusCode}): ${buf.toString('utf8').slice(0, 200)}`));
473
+ return;
474
+ }
475
+ try {
476
+ resolve(JSON.parse(buf.toString('utf8')));
477
+ }
478
+ catch {
479
+ reject(new Error(`Invalid JSON from download URL API: ${buf.toString('utf8').slice(0, 200)}`));
480
+ }
481
+ });
482
+ res.on('error', reject);
483
+ });
484
+ req.on('error', reject);
485
+ req.write(body);
486
+ req.end();
487
+ });
488
+ const downloadUrl = downloadUrlResp?.downloadUrl;
489
+ if (!downloadUrl) {
490
+ throw new Error('DingTalk download URL API returned no downloadUrl');
491
+ }
492
+ return downloadUrl;
493
+ }
494
+ /**
495
+ * Download a DingTalk picture message using the downloadCode from the robot callback.
496
+ * Step 1: POST /v1.0/robot/messageFiles/download → get downloadUrl
497
+ * Step 2: GET downloadUrl → get actual image bytes
498
+ * Ref: https://open.dingtalk.com/document/orgapp/download-the-file-content-of-the-robot-receiving-message
499
+ */
500
+ async function downloadDingTalkImageByDownloadCode(downloadCode, robotCode) {
501
+ try {
502
+ const token = await getAccessToken();
503
+ // Step 1: Get temporary download URL
504
+ const downloadUrl = await fetchDingTalkDownloadUrl(downloadCode, robotCode, token);
505
+ // Step 2: Download the actual image from the temporary URL
506
+ const buffer = await new Promise((resolve, reject) => {
507
+ const isHttps = downloadUrl.startsWith('https://');
508
+ const urlObj = new URL(downloadUrl);
509
+ const protocol = isHttps ? https : http;
510
+ const req = protocol.request({
511
+ hostname: urlObj.hostname,
512
+ path: urlObj.pathname + urlObj.search,
513
+ method: 'GET',
514
+ }, (res) => {
515
+ if (!res.statusCode ||
516
+ res.statusCode < 200 ||
517
+ res.statusCode >= 300) {
518
+ reject(new Error(`DingTalk image GET HTTP failed (${res.statusCode})`));
519
+ return;
520
+ }
521
+ const chunks = [];
522
+ let total = 0;
523
+ res.on('data', (chunk) => {
524
+ total += chunk.length;
525
+ if (total > MAX_FILE_SIZE) {
526
+ res.destroy(new Error('Downloaded image exceeds MAX_FILE_SIZE'));
527
+ return;
528
+ }
529
+ chunks.push(chunk);
530
+ });
531
+ res.on('end', () => resolve(Buffer.concat(chunks)));
532
+ res.on('error', reject);
533
+ });
534
+ req.on('error', reject);
535
+ req.end();
536
+ });
537
+ if (buffer.length === 0)
538
+ return null;
539
+ // Validate buffer looks like a real image (has JPEG/PNG/GIF/WebP magic bytes)
540
+ const mimeType = detectImageMimeType(buffer);
541
+ if (!mimeType) {
542
+ logger.warn({
543
+ bufferLength: buffer.length,
544
+ firstBytes: buffer.slice(0, 20).toString('hex'),
545
+ }, 'DingTalk image download returned non-image data, skipping');
546
+ return null;
547
+ }
548
+ // Discard tiny responses that can't be real images (e.g. 54-byte fake JPEG headers)
549
+ if (buffer.length < MIN_IMAGE_SIZE) {
550
+ logger.warn({ bufferLength: buffer.length, minSize: MIN_IMAGE_SIZE }, 'DingTalk image download returned too-small data, skipping');
551
+ return null;
552
+ }
553
+ return { base64: buffer.toString('base64'), mimeType };
554
+ }
555
+ catch (err) {
556
+ logger.warn({ err }, 'Failed to download DingTalk image by downloadCode');
557
+ return null;
558
+ }
559
+ }
560
+ /**
561
+ * Download a file (any type) via DingTalk robot callback downloadCode.
562
+ * Step 1: POST /v1.0/robot/messageFiles/download → get downloadUrl
563
+ * Step 2: GET downloadUrl → get raw file bytes (no MIME magic-byte check)
564
+ * Ref: https://open.dingtalk.com/document/orgapp/download-the-file-content-of-the-robot-receiving-message
565
+ */
566
+ async function downloadDingTalkFileByDownloadCode(downloadCode, robotCode) {
567
+ try {
568
+ const token = await getAccessToken();
569
+ // Step 1: Get temporary download URL
570
+ const downloadUrl = await fetchDingTalkDownloadUrl(downloadCode, robotCode, token);
571
+ // Step 2: Download raw file bytes (no MIME check — any file type allowed)
572
+ const buffer = await new Promise((resolve, reject) => {
573
+ const isHttps = downloadUrl.startsWith('https://');
574
+ const urlObj = new URL(downloadUrl);
575
+ const protocol = isHttps ? https : http;
576
+ const req = protocol.request({
577
+ hostname: urlObj.hostname,
578
+ path: urlObj.pathname + urlObj.search,
579
+ method: 'GET',
580
+ }, (res) => {
581
+ if (!res.statusCode ||
582
+ res.statusCode < 200 ||
583
+ res.statusCode >= 300) {
584
+ reject(new Error(`DingTalk file GET HTTP failed (${res.statusCode})`));
585
+ return;
586
+ }
587
+ const chunks = [];
588
+ let total = 0;
589
+ res.on('data', (chunk) => {
590
+ total += chunk.length;
591
+ if (total > MAX_FILE_SIZE) {
592
+ res.destroy(new Error('Downloaded file exceeds MAX_FILE_SIZE'));
593
+ return;
594
+ }
595
+ chunks.push(chunk);
596
+ });
597
+ res.on('end', () => resolve(Buffer.concat(chunks)));
598
+ res.on('error', reject);
599
+ });
600
+ req.on('error', reject);
601
+ req.end();
602
+ });
603
+ if (buffer.length === 0)
604
+ return null;
605
+ return buffer;
606
+ }
607
+ catch (err) {
608
+ logger.warn({ err }, 'Failed to download DingTalk file by downloadCode');
609
+ return null;
610
+ }
611
+ }
612
+ // ─── File Upload & Send (for outgoing files) ─────────────
613
+ /**
614
+ * Upload a file buffer to DingTalk media API and return the media_id.
615
+ * @param fileBuffer Raw file bytes
616
+ * @param fileName Original file name (used as filename in multipart)
617
+ * @param type Media type: "image", "voice", "video", "file"
618
+ */
619
+ async function uploadDingTalkMedia(fileBuffer, fileName, type) {
620
+ try {
621
+ const token = await getAccessToken();
622
+ const boundary = `----FormBoundary${Date.now()}`;
623
+ const CRLF = '\r\n';
624
+ // Build multipart form body manually
625
+ const parts = [];
626
+ // type field
627
+ parts.push(Buffer.from(`--${boundary}${CRLF}` +
628
+ `Content-Disposition: form-data; name="type"${CRLF}${CRLF}` +
629
+ `${type}${CRLF}`, 'utf8'));
630
+ // media field with filename
631
+ const header = `--${boundary}${CRLF}` +
632
+ `Content-Disposition: form-data; name="media"; filename="${fileName}"${CRLF}` +
633
+ `Content-Type: application/octet-stream${CRLF}${CRLF}`;
634
+ parts.push(Buffer.from(header, 'utf8'));
635
+ parts.push(fileBuffer);
636
+ parts.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8'));
637
+ const body = Buffer.concat(parts);
638
+ const result = await new Promise((resolve, reject) => {
639
+ const req = https.request({
640
+ hostname: 'oapi.dingtalk.com',
641
+ path: `/media/upload?access_token=${token}&type=${type}`,
642
+ method: 'POST',
643
+ headers: {
644
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
645
+ 'Content-Length': body.length,
646
+ },
647
+ }, (res) => {
648
+ const chunks = [];
649
+ res.on('data', (chunk) => chunks.push(chunk));
650
+ res.on('end', () => {
651
+ try {
652
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
653
+ }
654
+ catch {
655
+ reject(new Error('Invalid JSON from DingTalk media upload'));
656
+ }
657
+ });
658
+ res.on('error', reject);
659
+ });
660
+ req.on('error', reject);
661
+ req.write(body);
662
+ req.end();
663
+ });
664
+ if (result.errcode && result.errcode !== 0) {
665
+ logger.warn({ errcode: result.errcode, errmsg: result.errmsg }, 'DingTalk media upload failed');
666
+ return null;
667
+ }
668
+ if (!result.media_id) {
669
+ logger.warn('DingTalk media upload: no media_id in response');
670
+ return null;
671
+ }
672
+ logger.info({ mediaId: result.media_id, fileName, type }, 'DingTalk media uploaded');
673
+ return result.media_id;
674
+ }
675
+ catch (err) {
676
+ logger.warn({ err }, 'Failed to upload DingTalk media');
677
+ return null;
678
+ }
679
+ }
680
+ /**
681
+ * Send a file message to a DingTalk user using batchSend API.
682
+ * @param userId The target user's senderId (from incoming messages)
683
+ * @param robotCode The robot code (from config or incoming message)
684
+ * @param mediaId The media_id from upload
685
+ * @param fileName Display name for the file
686
+ */
687
+ async function sendDingTalkFileMessage(userId, robotCode, mediaId, fileName, fileType) {
688
+ try {
689
+ const token = await getAccessToken();
690
+ const msgParam = JSON.stringify({ mediaId, fileName, fileType });
691
+ await batchSendToUser([userId], robotCode, token, 'sampleFile', msgParam);
692
+ logger.info({ userId, mediaId, fileName }, 'DingTalk file message sent');
693
+ }
694
+ catch (err) {
695
+ logger.error({ err, userId, mediaId, fileName }, 'Failed to send DingTalk file message');
696
+ throw err;
697
+ }
698
+ }
699
+ /**
700
+ * Send an image message to a DingTalk user using batchSend API.
701
+ * Uses sampleImageMsg with photoURL pointing to the uploaded mediaId.
702
+ */
703
+ async function sendDingTalkImageMessage(userId, robotCode, mediaId, fileName) {
704
+ try {
705
+ const token = await getAccessToken();
706
+ // sampleImageMsg uses photoURL field (not mediaId) - DingTalk API quirk
707
+ const msgParam = JSON.stringify({ photoURL: mediaId });
708
+ await batchSendToUser([userId], robotCode, token, 'sampleImageMsg', msgParam);
709
+ logger.info({ userId, mediaId, fileName }, 'DingTalk image message sent');
710
+ }
711
+ catch (err) {
712
+ logger.error({ err, userId, mediaId, fileName }, 'Failed to send DingTalk image message');
713
+ throw err;
714
+ }
715
+ }
716
+ /**
717
+ * Download a DingTalk image, optionally inline as base64, and save to disk.
718
+ * Unifies the handling of msgtype="picture" (downloadCode API) and
719
+ * msgtype="image" (contentUrl direct download).
720
+ */
721
+ async function normalizeDingTalkImage(jid, opts, downloader) {
722
+ const imageData = await downloader();
723
+ if (!imageData)
724
+ return null;
725
+ const imgBuffer = Buffer.from(imageData.base64, 'base64');
726
+ const imgSize = imgBuffer.length;
727
+ // Small images are inlined as base64 for Vision API
728
+ const attachments = imgSize <= IMAGE_MAX_BASE64_SIZE
729
+ ? [
730
+ {
731
+ type: 'image',
732
+ data: imageData.base64,
733
+ mimeType: imageData.mimeType,
734
+ },
735
+ ]
736
+ : [];
737
+ const groupFolder = opts.resolveGroupFolder?.(jid);
738
+ if (groupFolder) {
739
+ try {
740
+ const ext = imageData.mimeType.split('/')[1] || 'jpg';
741
+ const filename = `img_${Date.now()}.${ext}`;
742
+ const savedPath = await saveDownloadedFile(groupFolder, 'dingtalk', filename, imgBuffer);
743
+ return {
744
+ content: `[图片: ${savedPath}]`,
745
+ attachmentsJson: attachments.length > 0 ? JSON.stringify(attachments) : undefined,
746
+ };
747
+ }
748
+ catch {
749
+ return { content: '[图片]', attachmentsJson: undefined };
750
+ }
751
+ }
752
+ return {
753
+ content: '[图片]',
754
+ attachmentsJson: attachments.length > 0 ? JSON.stringify(attachments) : undefined,
755
+ };
756
+ }
757
+ // ─── Event Handlers ───────────────────────────────────────
758
+ async function handleRobotMessage(downstream, opts) {
759
+ try {
760
+ const data = JSON.parse(downstream.data);
761
+ const msgId = data.msgId;
762
+ logger.info({
763
+ msgId,
764
+ conversationType: data.conversationType,
765
+ msgtype: data.msgtype,
766
+ }, 'DingTalk handleRobotMessage start');
767
+ if (!msgId || isDuplicate(msgId)) {
768
+ logger.info({ msgId }, 'DingTalk dropped: duplicate or no msgId');
769
+ return;
770
+ }
771
+ markSeen(msgId);
772
+ // Skip stale messages from before connection (hot-reload scenario)
773
+ if (opts.ignoreMessagesBefore && data.createAt) {
774
+ const msgTime = data.createAt;
775
+ if (msgTime < opts.ignoreMessagesBefore) {
776
+ logger.info({ msgId, msgTime, ignoreBefore: opts.ignoreMessagesBefore }, 'DingTalk dropped: stale message');
777
+ return;
778
+ }
779
+ }
780
+ const conversationId = data.conversationId;
781
+ const conversationType = data.conversationType;
782
+ const isGroup = conversationType === '2'; // 1=C2C, 2=Group
783
+ const jid = isGroup
784
+ ? `dingtalk:group:${conversationId}`
785
+ : `dingtalk:c2c:${data.senderId}`;
786
+ const senderName = data.senderNick || '钉钉用户';
787
+ const chatName = isGroup
788
+ ? `钉钉群 ${conversationId.slice(0, 8)}`
789
+ : senderName;
790
+ // Store last message ID for reply context
791
+ lastMessageIds.set(jid, msgId);
792
+ // Store session webhook for sending replies
793
+ logger.debug({
794
+ jid,
795
+ hasSessionWebhook: !!data.sessionWebhook,
796
+ }, 'DingTalk message sessionWebhook');
797
+ if (data.sessionWebhook) {
798
+ lastSessionWebhooks.set(jid, data.sessionWebhook);
799
+ if (data.sessionWebhookExpiredTime) {
800
+ sessionWebhookExpiry.set(jid, data.sessionWebhookExpiredTime);
801
+ }
802
+ }
803
+ // Store sender ID for file sending
804
+ if (data.senderId) {
805
+ lastSenderIds.set(jid, data.senderId);
806
+ }
807
+ // Store sender staff ID (enterprise user ID) for batchSend API
808
+ if (data.senderStaffId) {
809
+ lastSenderStaffIds.set(jid, data.senderStaffId);
810
+ }
811
+ // Get message content and attachments
812
+ let content = '';
813
+ let attachmentsJson;
814
+ if (data.msgtype === 'text' && 'text' in data) {
815
+ content = data.text?.content?.trim() || '';
816
+ }
817
+ else if (data.msgtype === 'richText' && data.content) {
818
+ // richText: mixed content array with text segments and picture objects
819
+ // e.g. [{text:"hi"},{type:"picture",downloadCode:"...",pictureDownloadCode:"..."}]
820
+ const richText = data.content.richText ?? [];
821
+ const textParts = [];
822
+ const imageEntries = [];
823
+ for (const entry of richText) {
824
+ if (entry.text) {
825
+ textParts.push(entry.text);
826
+ }
827
+ else if (entry.type === 'picture' &&
828
+ (entry.downloadCode || entry.pictureDownloadCode)) {
829
+ imageEntries.push({
830
+ downloadCode: entry.downloadCode || entry.pictureDownloadCode || '',
831
+ pictureDownloadCode: entry.pictureDownloadCode || '',
832
+ });
833
+ }
834
+ }
835
+ logger.info({ msgId, textParts, imageEntriesCount: imageEntries.length }, 'DingTalk richText parsed');
836
+ content = textParts.join('').trim();
837
+ if (imageEntries.length > 0) {
838
+ // Download each image; first one's base64 goes to Vision, all saved to disk
839
+ const allAttachments = [];
840
+ for (let i = 0; i < imageEntries.length; i++) {
841
+ const entry = imageEntries[i];
842
+ logger.info({ msgId, downloadCode: entry.downloadCode, index: i }, 'DingTalk richText downloading image');
843
+ const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageByDownloadCode(entry.downloadCode || entry.pictureDownloadCode || '', data.robotCode ?? ''));
844
+ logger.info({ msgId, index: i, hasResult: !!normalized }, 'DingTalk richText image download complete');
845
+ if (normalized?.attachmentsJson) {
846
+ const parsed = JSON.parse(normalized.attachmentsJson);
847
+ allAttachments.push(...parsed);
848
+ }
849
+ }
850
+ if (allAttachments.length > 0) {
851
+ attachmentsJson = JSON.stringify(allAttachments);
852
+ // Prepend first image content if available
853
+ const firstImgContent = allAttachments[0] ? `[图片: base64]` : '';
854
+ content = (firstImgContent + (content ? ' ' + content : '')).trim();
855
+ }
856
+ }
857
+ logger.info({
858
+ msgId,
859
+ contentLen: content?.length,
860
+ hasAttachments: !!attachmentsJson,
861
+ }, 'DingTalk richText processing complete');
862
+ if (!content && !attachmentsJson) {
863
+ // All richText entries were pictures with no text
864
+ content = attachmentsJson ? '[图片]' : '';
865
+ }
866
+ }
867
+ else if (data.msgtype === 'picture' && 'content' in data) {
868
+ const pictureContent = data.content;
869
+ const downloadCode = pictureContent?.downloadCode || pictureContent?.pictureDownloadCode;
870
+ if (!downloadCode) {
871
+ logger.warn({ msgId }, 'DingTalk picture message missing both downloadCode and pictureDownloadCode');
872
+ return;
873
+ }
874
+ const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageByDownloadCode(downloadCode, data.robotCode ?? ''));
875
+ if (!normalized) {
876
+ logger.warn({ msgId }, 'DingTalk picture download failed, skipping');
877
+ return;
878
+ }
879
+ content = normalized.content;
880
+ attachmentsJson = normalized.attachmentsJson;
881
+ }
882
+ else if (data.msgtype === 'file' && 'content' in data) {
883
+ const fileContent = data.content;
884
+ const downloadCode = fileContent?.downloadCode;
885
+ const fileName = fileContent?.fileName || 'file';
886
+ if (!downloadCode) {
887
+ logger.warn({ msgId }, 'DingTalk file message missing downloadCode');
888
+ return;
889
+ }
890
+ const fileBuffer = await downloadDingTalkFileByDownloadCode(downloadCode, data.robotCode ?? '');
891
+ if (fileBuffer) {
892
+ const groupFolder = opts.resolveGroupFolder?.(jid);
893
+ if (groupFolder) {
894
+ try {
895
+ // Preserve original extension from filename
896
+ const ext = fileName.includes('.')
897
+ ? fileName.split('.').pop()
898
+ : '';
899
+ const savedFilename = ext
900
+ ? `file_${Date.now()}.${ext}`
901
+ : `file_${Date.now()}`;
902
+ const savedPath = await saveDownloadedFile(groupFolder, 'dingtalk', savedFilename, fileBuffer);
903
+ content = `[文件: ${savedPath}]`;
904
+ }
905
+ catch (err) {
906
+ logger.warn({ err }, 'Failed to save DingTalk file to disk');
907
+ content = `[文件: ${fileName}]`;
908
+ }
909
+ }
910
+ else {
911
+ content = `[文件: ${fileName}]`;
912
+ }
913
+ }
914
+ else {
915
+ logger.warn({ msgId }, 'DingTalk file download failed, skipping');
916
+ return;
917
+ }
918
+ }
919
+ else if (data.msgtype === 'image' && 'image' in data) {
920
+ // Image message via contentUrl (legacy/native format)
921
+ const contentUrl = data.image?.contentUrl;
922
+ if (!contentUrl) {
923
+ logger.warn({ msgId }, 'DingTalk image message missing contentUrl');
924
+ return;
925
+ }
926
+ const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageAsBase64(contentUrl));
927
+ if (!normalized) {
928
+ logger.warn({ msgId }, 'DingTalk image download failed, skipping');
929
+ return;
930
+ }
931
+ content = normalized.content;
932
+ attachmentsJson = normalized.attachmentsJson;
933
+ }
934
+ // Skip empty messages (text without content, or failed image)
935
+ if (!content && !attachmentsJson) {
936
+ return;
937
+ }
938
+ // ── /pair <code> command ──
939
+ const pairMatch = content.match(/^\/pair\s+(\S+)/i);
940
+ if (pairMatch && opts.onPairAttempt) {
941
+ const code = pairMatch[1];
942
+ try {
943
+ const success = await opts.onPairAttempt(jid, chatName, code);
944
+ const reply = success
945
+ ? '配对成功!此聊天已连接到你的账号。'
946
+ : '配对码无效或已过期,请在 Web 设置页重新生成。';
947
+ if (data.sessionWebhook) {
948
+ await sendViaSessionWebhook(data.sessionWebhook, reply, isGroup);
949
+ }
950
+ }
951
+ catch (err) {
952
+ logger.error({ err, jid }, 'DingTalk pair attempt error');
953
+ }
954
+ return;
955
+ }
956
+ // ── Authorization check ──
957
+ if (opts.isChatAuthorized && !opts.isChatAuthorized(jid)) {
958
+ logger.debug({ jid }, 'DingTalk chat not authorized');
959
+ return;
960
+ }
961
+ // ── Group mention check ──
962
+ if (isGroup &&
963
+ opts.shouldProcessGroupMessage &&
964
+ !opts.shouldProcessGroupMessage(jid)) {
965
+ logger.debug({ jid }, 'DingTalk group message dropped (mention required)');
966
+ return;
967
+ }
968
+ // ── Authorized: process message ──
969
+ storeChatMetadata(jid, new Date().toISOString());
970
+ updateChatName(jid, chatName);
971
+ opts.onNewChat(jid, chatName);
972
+ // Handle slash commands
973
+ const slashMatch = content.match(/^\/(\S+)(?:\s+(.*))?$/i);
974
+ if (slashMatch && opts.onCommand) {
975
+ const cmdBody = (slashMatch[1] + (slashMatch[2] ? ' ' + slashMatch[2] : '')).trim();
976
+ try {
977
+ const reply = await opts.onCommand(jid, cmdBody);
978
+ if (reply) {
979
+ const plainText = markdownToPlainText(reply);
980
+ if (data.sessionWebhook) {
981
+ await sendViaSessionWebhook(data.sessionWebhook, plainText, isGroup);
982
+ }
983
+ return;
984
+ }
985
+ }
986
+ catch (err) {
987
+ logger.error({ jid, err }, 'DingTalk slash command failed');
988
+ return;
989
+ }
990
+ }
991
+ // Route and store message
992
+ const agentRouting = opts.resolveEffectiveChatJid?.(jid);
993
+ const targetJid = agentRouting?.effectiveJid ?? jid;
994
+ const id = crypto.randomUUID();
995
+ const timestamp = data.createAt
996
+ ? new Date(data.createAt).toISOString()
997
+ : new Date().toISOString();
998
+ const senderId = `dingtalk:${data.senderId}`;
999
+ storeChatMetadata(targetJid, timestamp);
1000
+ storeMessageDirect(id, targetJid, senderId, senderName, content, timestamp, false, { attachments: attachmentsJson, sourceJid: jid });
1001
+ broadcastNewMessage(targetJid, {
1002
+ id,
1003
+ chat_jid: targetJid,
1004
+ source_jid: jid,
1005
+ sender: senderId,
1006
+ sender_name: senderName,
1007
+ content,
1008
+ timestamp,
1009
+ attachments: attachmentsJson,
1010
+ is_from_me: false,
1011
+ }, agentRouting?.agentId ?? undefined);
1012
+ notifyNewImMessage();
1013
+ if (agentRouting?.agentId) {
1014
+ opts.onAgentMessage?.(jid, agentRouting.agentId);
1015
+ logger.info({ jid, effectiveJid: targetJid, agentId: agentRouting.agentId }, 'DingTalk message routed to agent');
1016
+ }
1017
+ else {
1018
+ logger.info({ jid, sender: senderName, msgId }, 'DingTalk message stored');
1019
+ }
1020
+ }
1021
+ catch (err) {
1022
+ logger.error({ err }, 'Error handling DingTalk robot message');
1023
+ }
1024
+ }
1025
+ // ─── Connection Interface ─────────────────────────────────
1026
+ const connection = {
1027
+ async connect(opts) {
1028
+ if (!config.clientId || !config.clientSecret) {
1029
+ logger.info('DingTalk clientId/clientSecret not configured, skipping');
1030
+ return false;
1031
+ }
1032
+ stopping = false;
1033
+ readyFired = false;
1034
+ try {
1035
+ // 🔧 Fix proxy issue: dingtalk-stream SDK uses axios internally, which can be
1036
+ // affected by system PAC files. We temporarily disable the global proxy default
1037
+ // around DWClient creation, then restore the original value to avoid affecting
1038
+ // other modules (e.g., @larksuiteoapi/node-sdk) that also use axios.
1039
+ const axios = (await import('axios')).default;
1040
+ const originalProxy = axios.defaults?.proxy;
1041
+ if (axios.defaults) {
1042
+ axios.defaults.proxy = false;
1043
+ logger.debug('Temporarily disabled axios global proxy for dingtalk-stream SDK');
1044
+ }
1045
+ // Create DWClient
1046
+ client = new DWClient({
1047
+ clientId: config.clientId,
1048
+ clientSecret: config.clientSecret,
1049
+ debug: false,
1050
+ });
1051
+ // Restore original axios proxy setting after DWClient creation
1052
+ if (axios.defaults && originalProxy !== undefined) {
1053
+ axios.defaults.proxy = originalProxy;
1054
+ }
1055
+ // Register robot message callback using registerCallbackListener (not registerAllEventListener)
1056
+ client.registerCallbackListener(TOPIC_ROBOT, async (downstream) => {
1057
+ logger.info({ dataLen: downstream.data?.length }, 'DingTalk robot message received');
1058
+ // Ack immediately
1059
+ const messageId = downstream.headers?.messageId;
1060
+ if (messageId && client) {
1061
+ client.socketCallBackResponse(messageId, { success: true });
1062
+ logger.debug({ messageId }, 'DingTalk callback acknowledged');
1063
+ }
1064
+ // Process in background
1065
+ handleRobotMessage(downstream, opts).catch((err) => {
1066
+ logger.error({ err }, 'Error in DingTalk message handler');
1067
+ });
1068
+ });
1069
+ // Connect
1070
+ await client.connect();
1071
+ logger.info({ clientId: config.clientId.slice(0, 8) }, 'DingTalk Stream connected');
1072
+ // Monitor for subscription recovery: the SDK reconnects automatically after
1073
+ // network interruptions, but the server may drop our subscription registration.
1074
+ // Detect "connected but not subscribed" state and force a full re-register.
1075
+ let reconnectGuard = false;
1076
+ const startReconnectMonitor = () => {
1077
+ const check = async () => {
1078
+ if (stopping || reconnectGuard)
1079
+ return;
1080
+ const sdk = client;
1081
+ if (sdk?.connected && !sdk?.registered) {
1082
+ reconnectGuard = true;
1083
+ logger.warn('DingTalk reconnected but not registered, forcing re-register');
1084
+ try {
1085
+ const cur = client;
1086
+ if (cur) {
1087
+ cur.disconnect();
1088
+ await cur.connect();
1089
+ }
1090
+ }
1091
+ catch {
1092
+ // ignore — SDK will retry on next check
1093
+ }
1094
+ finally {
1095
+ reconnectGuard = false;
1096
+ }
1097
+ }
1098
+ };
1099
+ reconnectCheckInterval = setInterval(check, 15_000);
1100
+ void check(); // immediate first check
1101
+ };
1102
+ startReconnectMonitor();
1103
+ readyFired = true;
1104
+ opts.onReady?.();
1105
+ return true;
1106
+ }
1107
+ catch (err) {
1108
+ logger.error({ err }, 'DingTalk initial connection failed');
1109
+ return false;
1110
+ }
1111
+ },
1112
+ async disconnect() {
1113
+ stopping = true;
1114
+ if (reconnectCheckInterval) {
1115
+ clearInterval(reconnectCheckInterval);
1116
+ reconnectCheckInterval = null;
1117
+ }
1118
+ if (client) {
1119
+ try {
1120
+ client.disconnect();
1121
+ }
1122
+ catch (err) {
1123
+ logger.debug({ err }, 'Error disconnecting DingTalk client');
1124
+ }
1125
+ client = null;
1126
+ }
1127
+ tokenInfo = null;
1128
+ msgCache.clear();
1129
+ lastMessageIds.clear();
1130
+ lastSessionWebhooks.clear();
1131
+ sessionWebhookExpiry.clear();
1132
+ lastSenderIds.clear();
1133
+ lastSenderStaffIds.clear();
1134
+ logger.info('DingTalk bot disconnected');
1135
+ },
1136
+ async sendMessage(chatId, text, _localImagePaths) {
1137
+ const parsed = parseDingTalkChatId(chatId);
1138
+ if (!parsed) {
1139
+ logger.error({ chatId }, 'Invalid DingTalk chat ID format');
1140
+ return;
1141
+ }
1142
+ // Reconstruct the full jid to match how sessionWebhook/senderStaffId was stored
1143
+ const jidKey = parsed.type === 'c2c'
1144
+ ? `dingtalk:c2c:${parsed.conversationId}`
1145
+ : `dingtalk:group:${parsed.conversationId}`;
1146
+ logger.info({ chatId, textLen: text.length, text: text.slice(0, 200), jidKey }, 'DingTalk sendMessage called');
1147
+ // C2C messages require the persistent API with senderStaffId.
1148
+ // sessionWebhook is DingTalk's reply callback URL — only valid within the
1149
+ // stream connection and cannot be used for proactive C2C messages.
1150
+ if (parsed.type === 'c2c') {
1151
+ const senderStaffId = lastSenderStaffIds.get(jidKey);
1152
+ if (!senderStaffId) {
1153
+ logger.error({ chatId, jidKey }, 'DingTalk sendMessage: no senderStaffId found for C2C chat');
1154
+ return;
1155
+ }
1156
+ const plainText = markdownToPlainText(text);
1157
+ const chunks = splitTextChunks(plainText, MSG_SPLIT_LIMIT);
1158
+ logger.info({ chatId, jidKey, chunks: chunks.length }, 'DingTalk sendMessage: sending C2C via persistent API');
1159
+ for (const chunk of chunks) {
1160
+ await sendViaPersistentAPI(senderStaffId, chunk);
1161
+ }
1162
+ logger.info({ chatId }, 'DingTalk C2C message sent via persistent API');
1163
+ return;
1164
+ }
1165
+ // Group messages — use the persistent groupMessages API (openConversationId is
1166
+ // stable and does not expire like sessionWebhook). This also avoids the reconnect
1167
+ // invalidation issue that plagued sendViaSessionWebhook for group chats.
1168
+ const openConversationId = parsed.conversationId;
1169
+ // Group chats support markdown. Split first to stay within message size limits.
1170
+ const contentToSend = convertToDingTalkMarkdown(text);
1171
+ const chunks = splitTextChunks(contentToSend, MSG_SPLIT_LIMIT);
1172
+ // Try markdown first, fall back to plain text on error.
1173
+ for (const chunk of chunks) {
1174
+ const msgParam = JSON.stringify({
1175
+ title: chunk.slice(0, 50),
1176
+ text: chunk,
1177
+ });
1178
+ try {
1179
+ await sendViaGroupMessagesAPI(openConversationId, 'sampleMarkdown', msgParam);
1180
+ }
1181
+ catch {
1182
+ // Fall back to plain text
1183
+ const plainContent = markdownToPlainText(chunk);
1184
+ const plainMsgParam = JSON.stringify({ content: plainContent });
1185
+ await sendViaGroupMessagesAPI(openConversationId, 'sampleText', plainMsgParam);
1186
+ }
1187
+ }
1188
+ logger.info({ chatId }, 'DingTalk group message sent via persistent API');
1189
+ },
1190
+ async sendImage(chatId, imageBuffer, mimeType, caption, fileName) {
1191
+ // Look up sender info from the chat jid
1192
+ const parsed = parseDingTalkChatId(chatId);
1193
+ const jidKey = parsed
1194
+ ? parsed.type === 'c2c'
1195
+ ? `dingtalk:c2c:${parsed.conversationId}`
1196
+ : `dingtalk:group:${parsed.conversationId}`
1197
+ : chatId;
1198
+ const senderId = lastSenderIds.get(jidKey);
1199
+ const senderStaffId = lastSenderStaffIds.get(jidKey);
1200
+ if (!senderId) {
1201
+ logger.error({ chatId, jidKey }, 'DingTalk sendImage: no senderId found');
1202
+ throw new Error(`DingTalk sendImage: unknown chat ${chatId}`);
1203
+ }
1204
+ const fname = fileName || `image.${mimeType.split('/')[1] || 'png'}`;
1205
+ // Upload image to DingTalk media API
1206
+ const mediaId = await uploadDingTalkMedia(imageBuffer, fname, 'image');
1207
+ if (!mediaId) {
1208
+ throw new Error('DingTalk sendImage: media upload failed');
1209
+ }
1210
+ // For group chats: use persistent groupMessages API.
1211
+ // For C2C: use batchSend API.
1212
+ const isGroup = parsed?.type === 'group';
1213
+ const openConversationId = parsed?.conversationId;
1214
+ if (isGroup && openConversationId) {
1215
+ const msgParam = JSON.stringify({ photoURL: mediaId });
1216
+ try {
1217
+ await sendViaGroupMessagesAPI(openConversationId, 'sampleImageMsg', msgParam);
1218
+ logger.info({ chatId, mediaId, fileName: fname }, 'DingTalk group image sent via persistent API');
1219
+ }
1220
+ catch (err) {
1221
+ logger.error({ err, chatId }, 'DingTalk sendImage: group API failed');
1222
+ throw err;
1223
+ }
1224
+ return;
1225
+ }
1226
+ // C2C: use batchSend API
1227
+ const targetUserId = senderStaffId || senderId;
1228
+ const robotCode = config.clientId;
1229
+ try {
1230
+ await sendDingTalkImageMessage(targetUserId, robotCode, mediaId, fname);
1231
+ logger.info({ chatId, mediaId, fileName: fname }, 'DingTalk C2C image sent');
1232
+ }
1233
+ catch (err) {
1234
+ logger.error({ err, chatId }, 'DingTalk sendImage: failed');
1235
+ throw err;
1236
+ }
1237
+ },
1238
+ async sendFile(chatId, filePath, fileName) {
1239
+ logger.info({ chatId, filePath, fileName }, 'DingTalk sendFile called');
1240
+ // Look up senderId and senderStaffId stored from incoming message.
1241
+ // NOTE: lastSenderIds and lastSenderStaffIds are keyed by the full jid
1242
+ // (dingtalk:c2c:{id} or dingtalk:group:{id}), so we must reconstruct
1243
+ // the jid from chatId to match the storage key.
1244
+ // extractChatId gives bare ID, then we re-add the prefix for Map lookup.
1245
+ const parsed = parseDingTalkChatId(chatId);
1246
+ const jidKey = parsed
1247
+ ? parsed.type === 'c2c'
1248
+ ? `dingtalk:c2c:${parsed.conversationId}`
1249
+ : `dingtalk:group:${parsed.conversationId}`
1250
+ : chatId; // fallback for legacy format
1251
+ const senderId = lastSenderIds.get(jidKey);
1252
+ if (!senderId) {
1253
+ logger.error({ chatId, jidKey }, 'DingTalk sendFile: no senderId found for chat');
1254
+ throw new Error(`DingTalk sendFile: unknown chat ${chatId}`);
1255
+ }
1256
+ const senderStaffId = lastSenderStaffIds.get(jidKey);
1257
+ // Read file from disk
1258
+ let fileBuffer;
1259
+ try {
1260
+ fileBuffer = await fs.readFile(filePath);
1261
+ }
1262
+ catch (err) {
1263
+ logger.error({ err, filePath }, 'DingTalk sendFile: failed to read file');
1264
+ throw new Error(`DingTalk sendFile: failed to read file ${filePath}`);
1265
+ }
1266
+ if (fileBuffer.length === 0) {
1267
+ throw new Error('DingTalk sendFile: empty file');
1268
+ }
1269
+ if (fileBuffer.length > 20 * 1024 * 1024) {
1270
+ throw new Error('DingTalk sendFile: file exceeds 20MB limit');
1271
+ }
1272
+ // Determine media type
1273
+ const ext = fileName.includes('.')
1274
+ ? fileName.split('.').pop().toLowerCase()
1275
+ : '';
1276
+ const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
1277
+ const voiceExts = ['amr', 'mp3', 'wav'];
1278
+ const videoExts = ['mp4'];
1279
+ let mediaType = 'file';
1280
+ if (imageExts.includes(ext))
1281
+ mediaType = 'image';
1282
+ else if (voiceExts.includes(ext))
1283
+ mediaType = 'voice';
1284
+ else if (videoExts.includes(ext))
1285
+ mediaType = 'video';
1286
+ // Upload to DingTalk media API
1287
+ const mediaId = await uploadDingTalkMedia(fileBuffer, fileName, mediaType);
1288
+ if (!mediaId) {
1289
+ throw new Error('DingTalk sendFile: media upload failed');
1290
+ }
1291
+ // For group chats: use the persistent groupMessages API (openConversationId
1292
+ // is stable, unlike sessionWebhook which gets invalidated on reconnects).
1293
+ // For C2C chats: use the batchSend API with senderStaffId/senderId.
1294
+ const isGroup = parsed?.type === 'group';
1295
+ const openConversationId = parsed?.conversationId;
1296
+ if (isGroup && openConversationId) {
1297
+ // Send via persistent groupMessages API
1298
+ try {
1299
+ if (mediaType === 'image') {
1300
+ const msgParam = JSON.stringify({ photoURL: mediaId });
1301
+ await sendViaGroupMessagesAPI(openConversationId, 'sampleImageMsg', msgParam);
1302
+ }
1303
+ else {
1304
+ const msgParam = JSON.stringify({
1305
+ mediaId,
1306
+ fileName,
1307
+ fileType: ext,
1308
+ });
1309
+ await sendViaGroupMessagesAPI(openConversationId, 'sampleFile', msgParam);
1310
+ }
1311
+ logger.info({ chatId, fileName, mediaId }, 'DingTalk group file sent via persistent API');
1312
+ }
1313
+ catch (err) {
1314
+ logger.error({ err, chatId, fileName }, 'DingTalk sendFile: groupMessages API failed');
1315
+ throw err;
1316
+ }
1317
+ return;
1318
+ }
1319
+ // C2C: use batchSend API
1320
+ const targetUserId = senderStaffId || senderId;
1321
+ const robotCode = config.clientId;
1322
+ try {
1323
+ if (mediaType === 'image') {
1324
+ await sendDingTalkImageMessage(targetUserId, robotCode, mediaId, fileName);
1325
+ }
1326
+ else {
1327
+ await sendDingTalkFileMessage(targetUserId, robotCode, mediaId, fileName, ext);
1328
+ }
1329
+ logger.info({ chatId, fileName, mediaId, senderStaffId: !!senderStaffId }, 'DingTalk C2C file sent successfully');
1330
+ }
1331
+ catch (err) {
1332
+ logger.error({ err, chatId, fileName }, 'DingTalk sendFile: batchSend failed');
1333
+ throw err;
1334
+ }
1335
+ },
1336
+ async sendReaction(_chatId, _isTyping) {
1337
+ // DingTalk doesn't support typing indicators via Stream
1338
+ },
1339
+ isConnected() {
1340
+ return client !== null && !stopping;
1341
+ },
1342
+ getLastMessageId(chatId) {
1343
+ return lastMessageIds.get(chatId);
1344
+ },
1345
+ };
1346
+ return connection;
1347
+ }