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
package/dist/feishu.js ADDED
@@ -0,0 +1,1628 @@
1
+ import fs from 'fs';
2
+ import * as fsPromises from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as lark from '@larksuiteoapi/node-sdk';
5
+ import { setLastGroupSync, storeChatMetadata, storeMessageDirect, updateChatName, } from './db.js';
6
+ import { logger } from './logger.js';
7
+ import { saveDownloadedFile, MAX_FILE_SIZE, FileTooLargeError, } from './im-downloader.js';
8
+ import { notifyNewImMessage } from './message-notifier.js';
9
+ import { broadcastNewMessage } from './web.js';
10
+ import { detectImageMimeType } from './image-detector.js';
11
+ import { resolveJidByMessageId, getStreamingSession, } from './feishu-streaming-card.js';
12
+ import { optimizeMarkdownStyle } from './feishu-markdown-style.js';
13
+ // ─── Shared Helpers (pure functions, no instance state) ────────
14
+ // Max characters per markdown element in Feishu cards
15
+ const CARD_MD_LIMIT = 4000;
16
+ // Feishu card allows at most 5 markdown tables; beyond this, skip card and use post+md directly
17
+ const CARD_TABLE_LIMIT = 5;
18
+ const FEISHU_WS_READY_STATE_OPEN = 1;
19
+ const WS_HEALTH_CHECK_INTERVAL_MS = 15_000;
20
+ const WS_RECONNECT_CHECK_THRESHOLD = 4;
21
+ const WS_RECONNECT_MIN_INTERVAL_MS = 30_000;
22
+ const BACKFILL_LOOKBACK_MS = 5 * 60 * 1000;
23
+ const BACKFILL_PAGE_SIZE = 50;
24
+ const BACKFILL_MAX_PAGES_PER_CHAT = 5;
25
+ function toEpochMs(value) {
26
+ const numeric = typeof value === 'number' ? value : Number(value ?? 0);
27
+ if (!Number.isFinite(numeric) || numeric <= 0)
28
+ return 0;
29
+ return numeric < 1e12 ? Math.trunc(numeric * 1000) : Math.trunc(numeric);
30
+ }
31
+ /**
32
+ * Extract message content from Feishu message.
33
+ * Returns text content, optional image keys, and optional file infos for download.
34
+ */
35
+ function extractMessageContent(messageType, content) {
36
+ // merge_forward: WebSocket 推送的内容是纯字符串 "Merged and Forwarded Message"(非 JSON),
37
+ // 必须在 JSON.parse 之前单独处理,否则 parse 失败导致消息被丢弃
38
+ if (messageType === 'merge_forward') {
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(content);
42
+ }
43
+ catch {
44
+ return { text: '[合并转发消息]' };
45
+ }
46
+ const items = parsed.message_list || parsed.items || [];
47
+ if (!Array.isArray(items) || items.length === 0) {
48
+ return { text: '[合并转发消息]' };
49
+ }
50
+ const lines = ['[合并转发消息]:'];
51
+ for (const item of items.slice(0, 20)) {
52
+ const sender = item.sender_name || item.sender || '未知';
53
+ const body = item.body?.content || item.content || '';
54
+ let text = '';
55
+ try {
56
+ const subType = item.msg_type || item.message_type || 'text';
57
+ const sub = extractMessageContent(subType, body);
58
+ text = sub.text || '';
59
+ }
60
+ catch {
61
+ text = typeof body === 'string' ? body : '';
62
+ }
63
+ if (text) {
64
+ lines.push(`> ${sender}: ${text.split('\n')[0].slice(0, 200)}`);
65
+ }
66
+ }
67
+ if (items.length > 20) {
68
+ lines.push(`> ... 共 ${items.length} 条消息`);
69
+ }
70
+ return { text: lines.join('\n') };
71
+ }
72
+ try {
73
+ const parsed = JSON.parse(content);
74
+ if (messageType === 'text') {
75
+ return { text: parsed.text || '' };
76
+ }
77
+ if (messageType === 'post') {
78
+ // Extract text and inline images from rich post content.
79
+ const lines = [];
80
+ const imageKeys = [];
81
+ // 飞书 post 消息有三种已知格式:
82
+ // 1. 带 post + 语言包裹:{"post": {"zh_cn": {"title": "...", "content": [[...]]}}}
83
+ // 2. 仅语言包裹:{"zh_cn": {"title": "...", "content": [[...]]}}
84
+ // 3. 无包裹(直接 title+content):{"title": "...", "content": [[...]]}
85
+ const post = parsed.post || parsed;
86
+ if (!post || typeof post !== 'object') {
87
+ logger.warn({ keys: Object.keys(parsed) }, 'Empty post object in post message');
88
+ return { text: '' };
89
+ }
90
+ // 判断 contentData:如果 post 本身就有 content 数组,直接用;否则查找语言层
91
+ let contentData;
92
+ if (Array.isArray(post.content)) {
93
+ // 格式 3:无包裹,post 本身就是 {title, content}
94
+ contentData = post;
95
+ logger.debug('Post message using flat format (no locale wrapper)');
96
+ }
97
+ else {
98
+ // 格式 1/2:有语言层包裹
99
+ contentData = post.zh_cn || post.en_us || Object.values(post)[0];
100
+ }
101
+ if (!contentData || !Array.isArray(contentData.content)) {
102
+ logger.warn({ keys: Object.keys(post) }, 'Missing content array in post message');
103
+ return { text: '' };
104
+ }
105
+ // Include post title if present
106
+ if (contentData.title && typeof contentData.title === 'string') {
107
+ lines.push(contentData.title);
108
+ }
109
+ for (const paragraph of contentData.content) {
110
+ // Handle both array paragraphs and flat object segments
111
+ const segments = Array.isArray(paragraph)
112
+ ? paragraph
113
+ : paragraph && typeof paragraph === 'object'
114
+ ? [paragraph]
115
+ : null;
116
+ if (!segments)
117
+ continue;
118
+ const parts = [];
119
+ for (const segment of segments) {
120
+ if (!segment || typeof segment !== 'object')
121
+ continue;
122
+ if (segment.tag === 'text' && typeof segment.text === 'string') {
123
+ parts.push(segment.text);
124
+ }
125
+ else if (segment.tag === 'a' && typeof segment.text === 'string') {
126
+ parts.push(segment.text);
127
+ }
128
+ else if (segment.tag === 'at') {
129
+ const mentionName = typeof segment.user_name === 'string'
130
+ ? segment.user_name
131
+ : typeof segment.text === 'string'
132
+ ? segment.text
133
+ : typeof segment.name === 'string'
134
+ ? segment.name
135
+ : '用户';
136
+ parts.push(`@${mentionName}`);
137
+ }
138
+ else if (segment.tag === 'img' &&
139
+ typeof segment.image_key === 'string') {
140
+ imageKeys.push(segment.image_key);
141
+ parts.push('[图片]');
142
+ }
143
+ else if (segment.tag === 'media') {
144
+ parts.push('[视频]');
145
+ }
146
+ else if (segment.tag === 'emotion' &&
147
+ typeof segment.emoji_type === 'string') {
148
+ parts.push(`:${segment.emoji_type}:`);
149
+ }
150
+ else if (typeof segment.text === 'string') {
151
+ parts.push(segment.text);
152
+ }
153
+ }
154
+ if (parts.length > 0)
155
+ lines.push(parts.join(''));
156
+ }
157
+ return {
158
+ text: lines.join('\n'),
159
+ imageKeys: imageKeys.length > 0 ? imageKeys : undefined,
160
+ };
161
+ }
162
+ if (messageType === 'image') {
163
+ const imageKey = parsed.image_key;
164
+ if (imageKey) {
165
+ return { text: '', imageKeys: [imageKey] };
166
+ }
167
+ }
168
+ if (messageType === 'file') {
169
+ const fileKey = parsed.file_key;
170
+ const filename = parsed.file_name || '';
171
+ if (fileKey) {
172
+ return {
173
+ text: `[文件: ${filename || fileKey}]`,
174
+ fileInfos: [{ fileKey, filename }],
175
+ };
176
+ }
177
+ }
178
+ if (messageType === 'sticker') {
179
+ const stickerDesc = parsed.description || parsed.sticker_id || '表情包';
180
+ return { text: `[表情包: ${stickerDesc}]` };
181
+ }
182
+ if (messageType === 'audio') {
183
+ const duration = parsed.duration
184
+ ? `${Math.round(parsed.duration / 1000)}s`
185
+ : '';
186
+ return { text: `[语音消息${duration ? ': ' + duration : ''}]` };
187
+ }
188
+ if (messageType === 'share_chat') {
189
+ const chatName = parsed.chat_name || parsed.chat_id || '未知群聊';
190
+ return { text: `[分享群聊: ${chatName}]` };
191
+ }
192
+ if (messageType === 'share_user') {
193
+ const userName = parsed.user_name || parsed.user_id || '未知用户';
194
+ return { text: `[分享用户: ${userName}]` };
195
+ }
196
+ if (messageType === 'system') {
197
+ const body = parsed.body || parsed.content || '';
198
+ const systemText = typeof body === 'string' ? body : JSON.stringify(body);
199
+ return { text: `[系统消息: ${systemText.slice(0, 200)}]` };
200
+ }
201
+ if (messageType === 'interactive') {
202
+ // Extract title and text elements from interactive card messages
203
+ const parts = [];
204
+ if (parsed.title) {
205
+ parts.push(parsed.title);
206
+ }
207
+ if (Array.isArray(parsed.elements)) {
208
+ for (const row of parsed.elements) {
209
+ if (!Array.isArray(row))
210
+ continue;
211
+ for (const el of row) {
212
+ if (!el || typeof el !== 'object')
213
+ continue;
214
+ if (el.tag === 'text' && typeof el.text === 'string') {
215
+ parts.push(el.text);
216
+ }
217
+ else if (el.tag === 'a' && typeof el.text === 'string') {
218
+ parts.push(`[${el.text}](${el.href || ''})`);
219
+ }
220
+ else if (el.tag === 'note' && Array.isArray(el.elements)) {
221
+ const noteText = el.elements
222
+ .filter((n) => n.tag === 'text' && typeof n.text === 'string')
223
+ .map((n) => n.text)
224
+ .join('');
225
+ if (noteText)
226
+ parts.push(noteText);
227
+ }
228
+ // Skip buttons, hr, select_static, img — not useful as text
229
+ }
230
+ }
231
+ }
232
+ const cardText = parts.filter(Boolean).join('\n');
233
+ return { text: cardText || '[飞书卡片消息]' };
234
+ }
235
+ if (messageType === 'media') {
236
+ return { text: '[视频消息]' };
237
+ }
238
+ if (messageType === 'location') {
239
+ return {
240
+ text: `[位置: ${parsed.name || parsed.address || '未知位置'}]`,
241
+ };
242
+ }
243
+ if (messageType === 'share_calendar_event') {
244
+ return {
245
+ text: `[日程分享: ${parsed.summary || parsed.event_id || ''}]`,
246
+ };
247
+ }
248
+ if (messageType === 'video_chat') {
249
+ return { text: `[视频会议: ${parsed.topic || ''}]` };
250
+ }
251
+ if (messageType === 'todo') {
252
+ return {
253
+ text: `[待办: ${parsed.task_id || parsed.summary || ''}]`,
254
+ };
255
+ }
256
+ if (messageType === 'hongbao') {
257
+ return { text: '[红包消息]' };
258
+ }
259
+ // 未知消息类型:返回类型占位符,避免静默丢弃
260
+ return { text: `[${messageType}]` };
261
+ }
262
+ catch (err) {
263
+ logger.warn({ err, messageType, content }, 'Failed to parse message content');
264
+ return { text: `[${messageType}]` };
265
+ }
266
+ }
267
+ /**
268
+ * Split long text at paragraph boundaries to fit within card element limits.
269
+ */
270
+ function splitAtParagraphs(text, maxLen) {
271
+ if (text.length <= maxLen)
272
+ return [text];
273
+ const chunks = [];
274
+ let remaining = text;
275
+ while (remaining.length > maxLen) {
276
+ // Prefer splitting at double newline (paragraph break)
277
+ let idx = remaining.lastIndexOf('\n\n', maxLen);
278
+ if (idx < maxLen * 0.3) {
279
+ // Fallback to single newline
280
+ idx = remaining.lastIndexOf('\n', maxLen);
281
+ }
282
+ if (idx < maxLen * 0.3) {
283
+ // Hard split as last resort
284
+ idx = maxLen;
285
+ }
286
+ chunks.push(remaining.slice(0, idx).trim());
287
+ remaining = remaining.slice(idx).trim();
288
+ }
289
+ if (remaining)
290
+ chunks.push(remaining);
291
+ return chunks;
292
+ }
293
+ /**
294
+ * Map file extension to Feishu file type.
295
+ */
296
+ function getFileType(ext) {
297
+ const map = {
298
+ '.pdf': 'pdf',
299
+ '.doc': 'doc',
300
+ '.docx': 'doc',
301
+ '.xls': 'xls',
302
+ '.xlsx': 'xls',
303
+ '.ppt': 'ppt',
304
+ '.pptx': 'ppt',
305
+ '.mp4': 'mp4',
306
+ '.opus': 'opus',
307
+ };
308
+ return map[ext.toLowerCase()] || 'stream';
309
+ }
310
+ /**
311
+ * Build a Feishu interactive card (Schema 2.0) from markdown text.
312
+ * Applies optimizeMarkdownStyle() for proper rendering in Feishu cards:
313
+ * - Heading demotion (H1→H4, H2~H6→H5)
314
+ * - Code block / table spacing with <br>
315
+ * - Invalid image cleanup
316
+ */
317
+ /** Build a post+md fallback content string for when interactive card send fails. */
318
+ function buildPostMdFallback(text) {
319
+ return JSON.stringify({
320
+ zh_cn: {
321
+ content: [[{ tag: 'md', text: optimizeMarkdownStyle(text, 1) }]],
322
+ },
323
+ });
324
+ }
325
+ function buildInteractiveCard(text) {
326
+ const optimized = optimizeMarkdownStyle(text, 2);
327
+ const lines = text.split('\n');
328
+ let title = '';
329
+ let bodyStartIdx = 0;
330
+ // Extract title from first heading if present (use original text for title)
331
+ for (let i = 0; i < lines.length; i++) {
332
+ if (!lines[i].trim())
333
+ continue;
334
+ if (/^#{1,3}\s+/.test(lines[i])) {
335
+ title = lines[i].replace(/^#+\s*/, '').trim();
336
+ bodyStartIdx = i + 1;
337
+ }
338
+ break;
339
+ }
340
+ // Apply optimizeMarkdownStyle to body (title was already extracted from original)
341
+ const optimizedLines = optimized.split('\n');
342
+ // Skip lines corresponding to the title in optimized text
343
+ let optimizedBody;
344
+ if (bodyStartIdx > 0) {
345
+ // Find the first non-empty line in optimized text and skip it (it's the demoted title)
346
+ let skipIdx = 0;
347
+ for (let i = 0; i < optimizedLines.length; i++) {
348
+ if (!optimizedLines[i].trim())
349
+ continue;
350
+ skipIdx = i + 1;
351
+ break;
352
+ }
353
+ optimizedBody = optimizedLines.slice(skipIdx).join('\n').trim();
354
+ }
355
+ else {
356
+ optimizedBody = optimized.trim();
357
+ }
358
+ // Generate title if no heading found — use first line preview
359
+ if (!title) {
360
+ const firstLine = (lines.find((l) => l.trim()) || '')
361
+ .replace(/[*_`#\[\]]/g, '')
362
+ .trim();
363
+ title =
364
+ firstLine.length > 40
365
+ ? firstLine.slice(0, 37) + '...'
366
+ : firstLine || 'Reply';
367
+ }
368
+ // Build card elements
369
+ const elements = [];
370
+ const contentToRender = optimizedBody || optimized.trim();
371
+ if (contentToRender.length > CARD_MD_LIMIT) {
372
+ // Long content: split into multiple markdown elements
373
+ const chunks = splitAtParagraphs(contentToRender, CARD_MD_LIMIT);
374
+ for (const chunk of chunks) {
375
+ elements.push({ tag: 'markdown', content: chunk });
376
+ }
377
+ }
378
+ else if (contentToRender) {
379
+ // Split by horizontal rules for visual sections
380
+ const sections = contentToRender.split(/\n-{3,}\n/);
381
+ for (let i = 0; i < sections.length; i++) {
382
+ if (i > 0)
383
+ elements.push({ tag: 'hr' });
384
+ const s = sections[i].trim();
385
+ if (s)
386
+ elements.push({ tag: 'markdown', content: s });
387
+ }
388
+ }
389
+ // Ensure at least one element
390
+ if (elements.length === 0) {
391
+ elements.push({ tag: 'markdown', content: optimized.trim() });
392
+ }
393
+ return {
394
+ schema: '2.0',
395
+ config: {
396
+ wide_screen_mode: true,
397
+ summary: { content: title },
398
+ },
399
+ header: {
400
+ title: { tag: 'plain_text', content: title },
401
+ template: 'indigo',
402
+ },
403
+ body: { elements },
404
+ };
405
+ }
406
+ // ─── Factory Function ──────────────────────────────────────────
407
+ /**
408
+ * Create an independent Feishu connection instance.
409
+ * Each instance manages its own client, WebSocket, and state maps.
410
+ */
411
+ export function createFeishuConnection(config) {
412
+ // LRU deduplication cache
413
+ const MSG_DEDUP_MAX = 1000;
414
+ const MSG_DEDUP_TTL = 30 * 60 * 1000; // 30min
415
+ // Per-instance state
416
+ const msgCache = new Map();
417
+ const senderNameCache = new Map();
418
+ const lastMessageIdByChat = new Map();
419
+ const ackReactionByChat = new Map();
420
+ const typingReactionByChat = new Map();
421
+ const knownChatIds = new Set();
422
+ const chatTypeById = new Map(); // chatId → 'group' | 'p2p'
423
+ const lastCreateTimeByChat = new Map();
424
+ let client = null;
425
+ let wsClient = null;
426
+ let eventDispatcher = null;
427
+ let connectOptions = null;
428
+ let botOpenId = '';
429
+ let reconnecting = false;
430
+ let backfillRunning = false;
431
+ let reconnectRequestedAt = 0;
432
+ let lastWsStateConnected = false;
433
+ let disconnectedChecks = 0;
434
+ let disconnectedSince = null;
435
+ let healthTimer = null;
436
+ function rememberChatProgress(chatId, createTimeMs, chatType) {
437
+ knownChatIds.add(chatId);
438
+ if (chatType)
439
+ chatTypeById.set(chatId, chatType);
440
+ const prev = lastCreateTimeByChat.get(chatId) || 0;
441
+ if (createTimeMs > prev) {
442
+ lastCreateTimeByChat.set(chatId, createTimeMs);
443
+ }
444
+ }
445
+ /**
446
+ * 通过访问飞书 SDK 的私有属性(wsConfig、isConnecting)获取 WebSocket 连接状态。
447
+ *
448
+ * 注意事项:
449
+ * 1. 该函数依赖 @larksuiteoapi/node-sdk 内部未公开的属性结构,SDK 版本升级可能导致失效
450
+ * 2. 失效时函数会静默降级(捕获异常后返回 null),健康检查将跳过状态判断,不会触发误重连
451
+ * 3. 后续可考虑使用 SDK 公开 API getReconnectInfo() 替代私有属性访问
452
+ */
453
+ function getWsConnectionState() {
454
+ const rawClient = wsClient;
455
+ try {
456
+ const wsInstance = rawClient.wsConfig?.getWSInstance?.();
457
+ const reconnectInfo = rawClient.getReconnectInfo?.() || {};
458
+ return {
459
+ connected: wsInstance?.readyState === FEISHU_WS_READY_STATE_OPEN,
460
+ isConnecting: rawClient.isConnecting === true,
461
+ nextConnectTime: Number(reconnectInfo.nextConnectTime || 0),
462
+ };
463
+ }
464
+ catch (err) {
465
+ logger.debug({ err }, 'Failed to inspect Feishu WebSocket state');
466
+ return null;
467
+ }
468
+ }
469
+ function stopHealthMonitor() {
470
+ if (healthTimer) {
471
+ clearInterval(healthTimer);
472
+ healthTimer = null;
473
+ }
474
+ }
475
+ function startHealthMonitor() {
476
+ stopHealthMonitor();
477
+ healthTimer = setInterval(() => {
478
+ void checkConnectionHealth();
479
+ }, WS_HEALTH_CHECK_INTERVAL_MS);
480
+ healthTimer.unref?.();
481
+ }
482
+ function isDuplicate(msgId) {
483
+ const now = Date.now();
484
+ // Map preserves insertion order; stop at first non-expired entry
485
+ for (const [id, ts] of msgCache.entries()) {
486
+ if (now - ts > MSG_DEDUP_TTL) {
487
+ msgCache.delete(id);
488
+ }
489
+ else {
490
+ break;
491
+ }
492
+ }
493
+ if (msgCache.size >= MSG_DEDUP_MAX) {
494
+ const firstKey = msgCache.keys().next().value;
495
+ if (firstKey)
496
+ msgCache.delete(firstKey);
497
+ }
498
+ return msgCache.has(msgId);
499
+ }
500
+ function markSeen(msgId) {
501
+ msgCache.delete(msgId);
502
+ msgCache.set(msgId, Date.now());
503
+ }
504
+ async function downloadFeishuImage(messageId, fileKey) {
505
+ try {
506
+ const res = await client.im.messageResource.get({
507
+ path: {
508
+ message_id: messageId,
509
+ file_key: fileKey,
510
+ },
511
+ params: {
512
+ type: 'image',
513
+ },
514
+ });
515
+ const stream = res.getReadableStream();
516
+ const chunks = [];
517
+ for await (const chunk of stream) {
518
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
519
+ }
520
+ const buffer = Buffer.concat(chunks);
521
+ if (buffer.length === 0) {
522
+ logger.warn({ messageId, fileKey }, 'Empty response from image download');
523
+ return null;
524
+ }
525
+ const mimeType = detectImageMimeType(buffer);
526
+ return {
527
+ base64: buffer.toString('base64'),
528
+ mimeType,
529
+ };
530
+ }
531
+ catch (err) {
532
+ logger.warn({ err, messageId, fileKey }, 'Failed to download Feishu image');
533
+ return null;
534
+ }
535
+ }
536
+ /**
537
+ * 下载飞书文件(type='file')到工作区磁盘。
538
+ * 返回工作区相对路径(如 downloads/feishu/2026-03-01/report.pdf),失败返回 null。
539
+ */
540
+ async function downloadFeishuFileToDisk(messageId, fileKey, filename, groupFolder) {
541
+ try {
542
+ const res = await client.im.messageResource.get({
543
+ path: {
544
+ message_id: messageId,
545
+ file_key: fileKey,
546
+ },
547
+ params: {
548
+ type: 'file',
549
+ },
550
+ });
551
+ const stream = res.getReadableStream();
552
+ const chunks = [];
553
+ let totalSize = 0;
554
+ for await (const chunk of stream) {
555
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
556
+ totalSize += buf.length;
557
+ if (totalSize > MAX_FILE_SIZE) {
558
+ logger.warn({ messageId, fileKey, totalSize }, 'File exceeds MAX_FILE_SIZE during download');
559
+ return null;
560
+ }
561
+ chunks.push(buf);
562
+ }
563
+ const buffer = Buffer.concat(chunks);
564
+ if (buffer.length === 0) {
565
+ logger.warn({ messageId, fileKey }, 'Empty response from file download');
566
+ return null;
567
+ }
568
+ const effectiveName = filename || `file_${fileKey}`;
569
+ try {
570
+ const relPath = await saveDownloadedFile(groupFolder, 'feishu', effectiveName, buffer);
571
+ return relPath;
572
+ }
573
+ catch (err) {
574
+ if (err instanceof FileTooLargeError) {
575
+ logger.warn({ fileKey, filename }, 'Feishu file too large, skipping');
576
+ return null;
577
+ }
578
+ throw err;
579
+ }
580
+ }
581
+ catch (err) {
582
+ logger.warn({ err, messageId, fileKey }, 'Failed to download Feishu file to disk');
583
+ return null;
584
+ }
585
+ }
586
+ function getSenderName(openId) {
587
+ return senderNameCache.get(openId) || openId;
588
+ }
589
+ async function addReaction(messageId, emojiType) {
590
+ try {
591
+ const res = (await client.im.messageReaction.create({
592
+ path: { message_id: messageId },
593
+ data: {
594
+ reaction_type: { emoji_type: emojiType },
595
+ },
596
+ }));
597
+ return res.data?.reaction_id || null;
598
+ }
599
+ catch (err) {
600
+ logger.debug({ err, messageId, emojiType }, 'Failed to add reaction');
601
+ return null;
602
+ }
603
+ }
604
+ async function removeReaction(messageId, reactionId) {
605
+ try {
606
+ await client.im.messageReaction.delete({
607
+ path: { message_id: messageId, reaction_id: reactionId },
608
+ });
609
+ }
610
+ catch (err) {
611
+ logger.debug({ err, messageId, reactionId }, 'Failed to remove reaction');
612
+ }
613
+ }
614
+ async function sendTextToChat(chatId, text) {
615
+ if (!client)
616
+ return;
617
+ try {
618
+ const receive_id_type = chatId.startsWith('oc_') ? 'chat_id' : 'open_id';
619
+ await client.im.v1.message.create({
620
+ params: { receive_id_type },
621
+ data: {
622
+ receive_id: chatId,
623
+ msg_type: 'text',
624
+ content: JSON.stringify({ text }),
625
+ },
626
+ });
627
+ }
628
+ catch (err) {
629
+ logger.error({ chatId, err }, 'Failed to send Feishu text reply');
630
+ }
631
+ }
632
+ async function handleIncomingMessage(payload, source) {
633
+ const { onNewChat, ignoreMessagesBefore, onCommand, resolveGroupFolder, resolveEffectiveChatJid, onAgentMessage, shouldProcessGroupMessage, } = connectOptions || {};
634
+ const { chatId, messageId, createTimeMs, messageType, content: rawContent, mentions, chatType, senderOpenId = '', senderName, } = payload;
635
+ if (!chatId || !messageId)
636
+ return;
637
+ if (isDuplicate(messageId)) {
638
+ logger.debug({ messageId }, 'Duplicate message, skipping');
639
+ return;
640
+ }
641
+ markSeen(messageId);
642
+ logger.info({ messageId, messageType, chatId, source }, 'Feishu message received');
643
+ if (ignoreMessagesBefore &&
644
+ createTimeMs > 0 &&
645
+ createTimeMs < ignoreMessagesBefore) {
646
+ logger.info({
647
+ messageId,
648
+ createTime: createTimeMs,
649
+ threshold: ignoreMessagesBefore,
650
+ }, 'Skipping stale Feishu message from before reconnection');
651
+ return;
652
+ }
653
+ const extracted = extractMessageContent(messageType, rawContent);
654
+ let text = extracted.text;
655
+ if (!text?.trim() && !extracted.imageKeys && !extracted.fileInfos?.length) {
656
+ logger.info({ messageId, messageType }, 'No text or image content, skipping');
657
+ return;
658
+ }
659
+ if (mentions && Array.isArray(mentions)) {
660
+ for (const mention of mentions) {
661
+ if (mention.key) {
662
+ text = text.replace(mention.key, `@${mention.name || ''}`);
663
+ }
664
+ }
665
+ }
666
+ const chatJid = `feishu:${chatId}`;
667
+ const resolvedSenderName = senderName || getSenderName(senderOpenId);
668
+ const resolvedChatName = chatType === 'p2p' ? '飞书私聊' : '飞书群聊';
669
+ // 先注册会话,确保 resolveGroupFolder 能正确解析 folder(含首条文件消息场景)
670
+ onNewChat?.(chatJid, resolvedChatName);
671
+ let attachmentsJson;
672
+ if (extracted.imageKeys && extracted.imageKeys.length > 0) {
673
+ // 图片消息:下载后双轨处理
674
+ // 1. Vision 通道:base64 附件供模型看图
675
+ // 2. 存盘通道:写入工作区文件,agent 可直接操作(压缩、发送等)
676
+ const attachments = [];
677
+ const groupFolder = resolveGroupFolder?.(chatJid);
678
+ const savedPaths = [];
679
+ for (const imageKey of extracted.imageKeys) {
680
+ const imageData = await downloadFeishuImage(messageId, imageKey);
681
+ if (!imageData)
682
+ continue;
683
+ // Vision 附件
684
+ attachments.push({
685
+ type: 'image',
686
+ data: imageData.base64,
687
+ mimeType: imageData.mimeType,
688
+ });
689
+ // 存盘:扩展名从 mimeType 推断,对齐文件消息处理逻辑
690
+ if (groupFolder) {
691
+ const extMap = {
692
+ 'image/jpeg': '.jpg',
693
+ 'image/png': '.png',
694
+ 'image/gif': '.gif',
695
+ 'image/webp': '.webp',
696
+ 'image/bmp': '.bmp',
697
+ 'image/tiff': '.tiff',
698
+ };
699
+ const ext = extMap[imageData.mimeType] ?? '.jpg';
700
+ const fileName = `feishu_img_${imageKey.slice(-8)}${ext}`;
701
+ try {
702
+ const relPath = await saveDownloadedFile(groupFolder, 'feishu', fileName, Buffer.from(imageData.base64, 'base64'));
703
+ if (relPath)
704
+ savedPaths.push(relPath);
705
+ }
706
+ catch (err) {
707
+ logger.warn({ err, imageKey }, 'Failed to save Feishu image to disk');
708
+ }
709
+ }
710
+ }
711
+ if (attachments.length > 0) {
712
+ attachmentsJson = JSON.stringify(attachments);
713
+ // 在 content 中添加图片标记 + 磁盘路径,与文件消息保持一致
714
+ // agent 可通过路径直接操作文件,无需从 DB 解码 base64
715
+ const pathHints = savedPaths.map((p) => `[图片: ${p}]`).join('\n');
716
+ const imgMarker = pathHints || '[图片]';
717
+ text = text ? `${imgMarker}\n${text}` : imgMarker;
718
+ }
719
+ }
720
+ else if (extracted.fileInfos && extracted.fileInfos.length > 0) {
721
+ // 文件消息:下载到磁盘,路径内联替换
722
+ logger.info({
723
+ chatJid,
724
+ messageId,
725
+ messageType,
726
+ fileCount: extracted.fileInfos.length,
727
+ }, 'Processing Feishu file download');
728
+ const groupFolder = resolveGroupFolder?.(chatJid);
729
+ if (!groupFolder) {
730
+ logger.warn({ chatJid }, 'Cannot resolve group folder for file download');
731
+ for (const fi of extracted.fileInfos) {
732
+ const placeholder = `[文件: ${fi.filename || fi.fileKey}]`;
733
+ text = text.replace(placeholder, `[文件下载失败: ${fi.filename || fi.fileKey}]`);
734
+ }
735
+ }
736
+ else {
737
+ for (const fi of extracted.fileInfos) {
738
+ const relPath = await downloadFeishuFileToDisk(messageId, fi.fileKey, fi.filename, groupFolder);
739
+ const placeholder = `[文件: ${fi.filename || fi.fileKey}]`;
740
+ text = text.replace(placeholder, relPath
741
+ ? `[文件: ${relPath}]`
742
+ : `[文件下载失败: ${fi.filename || fi.fileKey}]`);
743
+ }
744
+ }
745
+ }
746
+ lastMessageIdByChat.set(chatId, messageId);
747
+ const resolvedCreateTimeMs = createTimeMs > 0 ? createTimeMs : Date.now();
748
+ const timestamp = new Date(resolvedCreateTimeMs).toISOString();
749
+ rememberChatProgress(chatId, resolvedCreateTimeMs, chatType);
750
+ // ── 斜杠指令:拦截已知 /xxx 命令,不进入消息流 ──
751
+ // 群聊中 @机器人 后跟斜杠命令,mention 替换后文本为 "@botname /cmd",
752
+ // 需要先 strip 掉开头的 @mention 前缀再匹配
753
+ const textForSlash = text?.trim().replace(/^@\S+\s+/, '') ?? '';
754
+ const slashMatch = textForSlash.match(/^\/(\S+)(.*)$/);
755
+ if (slashMatch && onCommand) {
756
+ const cmdBody = (slashMatch[1] + slashMatch[2]).trim();
757
+ logger.info({ chatJid, cmd: slashMatch[1], cmdBody }, 'Feishu slash command detected');
758
+ try {
759
+ const reply = await onCommand(chatJid, cmdBody);
760
+ logger.info({
761
+ chatJid,
762
+ cmd: slashMatch[1],
763
+ hasReply: !!reply,
764
+ replyLen: reply?.length,
765
+ }, 'Feishu slash command processed');
766
+ if (reply) {
767
+ await sendTextToChat(chatId, reply);
768
+ return; // 已知命令,拦截
769
+ }
770
+ // reply 为 null 表示未知命令,继续作为普通消息处理
771
+ }
772
+ catch (err) {
773
+ logger.error({ chatJid, cmd: slashMatch[1], err }, 'Feishu slash command failed');
774
+ try {
775
+ await sendTextToChat(chatId, '⚠️ 命令执行失败,请稍后重试');
776
+ }
777
+ catch (sendErr) {
778
+ logger.error({ chatJid, sendErr }, 'Failed to send slash command error feedback');
779
+ }
780
+ return;
781
+ }
782
+ }
783
+ // ── 群聊 Mention 过滤:require_mention 模式下,bot 未被 @ 则丢弃 ──
784
+ if (chatType === 'group' && shouldProcessGroupMessage) {
785
+ const isBotMentioned = botOpenId
786
+ ? (mentions?.some((m) => m.id?.open_id === botOpenId) ?? false)
787
+ : true; // 无 bot open_id 时默认放行(安全降级)
788
+ if (!isBotMentioned && !shouldProcessGroupMessage(chatJid)) {
789
+ logger.debug({ chatJid, messageId }, 'Dropped group message: mention required but bot not mentioned');
790
+ return;
791
+ }
792
+ }
793
+ // ── Ack Reaction:确认已收到消息(在 mention 过滤之后,避免对未处理的消息加表情) ──
794
+ if (source === 'ws') {
795
+ addReaction(messageId, 'OnIt')
796
+ .then((reactionId) => {
797
+ if (reactionId) {
798
+ ackReactionByChat.set(chatId, `${messageId}:${reactionId}`);
799
+ }
800
+ })
801
+ .catch(() => { });
802
+ }
803
+ // Store message and broadcast to WebSocket clients
804
+ const agentRouting = resolveEffectiveChatJid?.(chatJid);
805
+ const targetJid = agentRouting?.effectiveJid ?? chatJid;
806
+ const targetAgentId = agentRouting?.agentId;
807
+ storeChatMetadata(targetJid, timestamp);
808
+ storeMessageDirect(messageId, targetJid, senderOpenId, resolvedSenderName, text, timestamp, false, { attachments: attachmentsJson, sourceJid: chatJid });
809
+ broadcastNewMessage(targetJid, {
810
+ id: messageId,
811
+ chat_jid: targetJid,
812
+ source_jid: chatJid,
813
+ sender: senderOpenId,
814
+ sender_name: resolvedSenderName,
815
+ content: text,
816
+ timestamp,
817
+ attachments: attachmentsJson,
818
+ }, targetAgentId ?? undefined);
819
+ notifyNewImMessage();
820
+ if (agentRouting && agentRouting.agentId) {
821
+ onAgentMessage?.(chatJid, agentRouting.agentId);
822
+ logger.info({
823
+ chatJid,
824
+ effectiveJid: targetJid,
825
+ agentId: targetAgentId,
826
+ sender: resolvedSenderName,
827
+ messageId,
828
+ source,
829
+ }, 'Feishu message routed to conversation agent');
830
+ }
831
+ else if (agentRouting) {
832
+ // Routed to workspace main conversation (no agentId)
833
+ logger.info({
834
+ chatJid,
835
+ effectiveJid: targetJid,
836
+ sender: resolvedSenderName,
837
+ messageId,
838
+ source,
839
+ }, 'Feishu message routed to workspace main conversation');
840
+ }
841
+ else {
842
+ logger.info({ chatJid, sender: resolvedSenderName, messageId, source }, 'Feishu message stored');
843
+ }
844
+ }
845
+ async function backfillChatMessages(chatId, sinceMs) {
846
+ if (!client)
847
+ return;
848
+ const nowSec = Math.floor(Date.now() / 1000);
849
+ const startSec = Math.max(0, Math.floor(sinceMs / 1000));
850
+ const params = {
851
+ container_id_type: 'chat',
852
+ container_id: chatId,
853
+ sort_type: 'ByCreateTimeDesc',
854
+ start_time: String(startSec),
855
+ end_time: String(nowSec),
856
+ page_size: BACKFILL_PAGE_SIZE,
857
+ };
858
+ let pages = 0;
859
+ while (pages < BACKFILL_MAX_PAGES_PER_CHAT) {
860
+ const response = (await client.im.v1.message.list({ params }));
861
+ const list = response.data?.items || [];
862
+ const messages = list
863
+ .filter((item) => {
864
+ if (item.deleted === true || !item.message_id)
865
+ return false;
866
+ // 过滤 Bot 自身发送的消息,避免 backfill 将回复当作新消息处理
867
+ const senderType = item.sender?.sender_type;
868
+ if (senderType === 'app')
869
+ return false;
870
+ return true;
871
+ })
872
+ .map((item) => {
873
+ const senderOpenId = item.sender?.sender_id?.open_id || item.sender?.id || '';
874
+ return {
875
+ chatId,
876
+ messageId: item.message_id,
877
+ createTimeMs: toEpochMs(item.create_time),
878
+ messageType: item.msg_type || item.message_type || '',
879
+ content: item.body?.content || item.content || '',
880
+ chatType: item.chat_type || chatTypeById.get(chatId) || 'group',
881
+ mentions: item.mentions,
882
+ senderOpenId,
883
+ };
884
+ })
885
+ .sort((a, b) => a.createTimeMs - b.createTimeMs);
886
+ for (const message of messages) {
887
+ await handleIncomingMessage(message, 'backfill');
888
+ }
889
+ pages++;
890
+ if (!response.data?.has_more || !response.data.page_token) {
891
+ break;
892
+ }
893
+ params.page_token = response.data.page_token;
894
+ }
895
+ }
896
+ async function runBackfill(reason) {
897
+ if (!client || backfillRunning)
898
+ return;
899
+ const chatIds = Array.from(knownChatIds);
900
+ if (chatIds.length === 0)
901
+ return;
902
+ backfillRunning = true;
903
+ try {
904
+ const recoveredFrom = disconnectedSince ?? Date.now();
905
+ for (const chatId of chatIds) {
906
+ const lastSeen = lastCreateTimeByChat.get(chatId) || 0;
907
+ const baseTs = lastSeen > 0 ? lastSeen : recoveredFrom;
908
+ const sinceMs = Math.max(0, baseTs - BACKFILL_LOOKBACK_MS);
909
+ try {
910
+ await backfillChatMessages(chatId, sinceMs);
911
+ }
912
+ catch (err) {
913
+ logger.warn({ err, chatId, reason }, 'Feishu chat backfill failed');
914
+ }
915
+ }
916
+ logger.info({ reason, chatCount: chatIds.length }, 'Feishu backfill finished');
917
+ }
918
+ finally {
919
+ backfillRunning = false;
920
+ }
921
+ }
922
+ async function reconnectWebSocket(reason) {
923
+ if (reconnecting || !connectOptions)
924
+ return;
925
+ reconnecting = true;
926
+ reconnectRequestedAt = Date.now();
927
+ disconnectedChecks = 0;
928
+ try {
929
+ if (!eventDispatcher) {
930
+ logger.warn({ reason }, 'Skip Feishu reconnect: event dispatcher is missing');
931
+ return;
932
+ }
933
+ if (wsClient) {
934
+ try {
935
+ await wsClient.close();
936
+ }
937
+ catch (err) {
938
+ logger.debug({ err }, 'Error closing stale Feishu WS client before reconnect');
939
+ }
940
+ }
941
+ wsClient = new lark.WSClient({
942
+ appId: config.appId,
943
+ appSecret: config.appSecret,
944
+ loggerLevel: lark.LoggerLevel.info,
945
+ });
946
+ await wsClient.start({ eventDispatcher });
947
+ lastWsStateConnected = true;
948
+ logger.info({ reason }, 'Feishu WebSocket reconnected');
949
+ connectOptions.onReady();
950
+ // 先执行 backfill(需要读取 disconnectedSince 确定回填起点),完成后再重置
951
+ await runBackfill('reconnect');
952
+ disconnectedSince = null;
953
+ }
954
+ catch (err) {
955
+ logger.error({ err, reason }, 'Feishu WebSocket reconnect failed');
956
+ }
957
+ finally {
958
+ reconnecting = false;
959
+ }
960
+ }
961
+ async function checkConnectionHealth() {
962
+ if (!wsClient || reconnecting)
963
+ return;
964
+ const state = getWsConnectionState();
965
+ if (!state)
966
+ return;
967
+ if (state.connected) {
968
+ disconnectedChecks = 0;
969
+ if (!lastWsStateConnected) {
970
+ logger.info('Feishu WebSocket is back online');
971
+ await runBackfill('recovered');
972
+ disconnectedSince = null;
973
+ }
974
+ lastWsStateConnected = true;
975
+ return;
976
+ }
977
+ if (lastWsStateConnected) {
978
+ disconnectedSince = Date.now();
979
+ logger.warn({ isConnecting: state.isConnecting }, 'Feishu WebSocket appears offline');
980
+ }
981
+ lastWsStateConnected = false;
982
+ const now = Date.now();
983
+ const reconnectWindowReady = state.nextConnectTime <= 0 || state.nextConnectTime <= now;
984
+ if (!reconnectWindowReady)
985
+ return;
986
+ disconnectedChecks++;
987
+ if (disconnectedChecks >= WS_RECONNECT_CHECK_THRESHOLD &&
988
+ now - reconnectRequestedAt >= WS_RECONNECT_MIN_INTERVAL_MS) {
989
+ await reconnectWebSocket('health-check');
990
+ }
991
+ }
992
+ function extractCardActionValue(data) {
993
+ const candidates = [
994
+ data?.action?.option?.value,
995
+ data?.action?.value?.selected_value,
996
+ data?.action?.value?.selectedValue,
997
+ data?.action?.value?.value,
998
+ data?.action?.form_value?.value,
999
+ data?.action?.formValue?.value,
1000
+ ];
1001
+ for (const candidate of candidates) {
1002
+ if (typeof candidate === 'string' && candidate.trim()) {
1003
+ return candidate.trim();
1004
+ }
1005
+ }
1006
+ return null;
1007
+ }
1008
+ async function sendCardActionReply(chatJid, text) {
1009
+ if (!chatJid || !text.trim())
1010
+ return;
1011
+ const chatId = chatJid.startsWith('feishu:') ? chatJid.slice(7) : null;
1012
+ if (!chatId || !client)
1013
+ return;
1014
+ try {
1015
+ await connection.sendMessage(chatId, text.trim());
1016
+ }
1017
+ catch (err) {
1018
+ logger.debug({ err, chatJid }, 'Failed to send Feishu card action reply');
1019
+ }
1020
+ }
1021
+ const connection = {
1022
+ async connect(opts) {
1023
+ const { onReady } = opts;
1024
+ if (!config.appId || !config.appSecret) {
1025
+ logger.warn('Feishu config is empty, running in Web-only mode');
1026
+ return false;
1027
+ }
1028
+ connectOptions = opts;
1029
+ disconnectedChecks = 0;
1030
+ disconnectedSince = null;
1031
+ reconnectRequestedAt = Date.now();
1032
+ reconnecting = false;
1033
+ backfillRunning = false;
1034
+ // Initialize client
1035
+ client = new lark.Client({
1036
+ appId: config.appId,
1037
+ appSecret: config.appSecret,
1038
+ appType: lark.AppType.SelfBuild,
1039
+ });
1040
+ // Fetch bot open_id for mention detection (best-effort, non-blocking)
1041
+ try {
1042
+ const botInfoRes = await client.request({
1043
+ method: 'GET',
1044
+ url: '/open-apis/bot/v3/info/',
1045
+ });
1046
+ const info = botInfoRes;
1047
+ botOpenId = info?.bot?.open_id || info?.data?.bot?.open_id || '';
1048
+ if (botOpenId) {
1049
+ logger.info({ botOpenId }, 'Fetched bot open_id for mention detection');
1050
+ }
1051
+ else {
1052
+ logger.warn('Could not fetch bot open_id, mention gating will be bypassed');
1053
+ }
1054
+ }
1055
+ catch (err) {
1056
+ logger.warn({ err }, 'Failed to fetch bot info, mention gating will be bypassed');
1057
+ botOpenId = '';
1058
+ }
1059
+ // Create event dispatcher
1060
+ eventDispatcher = new lark.EventDispatcher({}).register({
1061
+ 'im.message.receive_v1': async (data) => {
1062
+ try {
1063
+ const message = data.message;
1064
+ await handleIncomingMessage({
1065
+ chatId: message.chat_id,
1066
+ messageId: message.message_id,
1067
+ createTimeMs: toEpochMs(message.create_time),
1068
+ messageType: message.message_type,
1069
+ content: message.content,
1070
+ chatType: message.chat_type,
1071
+ mentions: message.mentions,
1072
+ senderOpenId: data.sender.sender_id?.open_id || '',
1073
+ }, 'ws');
1074
+ }
1075
+ catch (err) {
1076
+ logger.error({ err }, 'Error handling Feishu message');
1077
+ }
1078
+ },
1079
+ 'im.chat.member.bot.added_v1': async (data) => {
1080
+ try {
1081
+ const chatId = data.chat_id;
1082
+ if (!chatId)
1083
+ return;
1084
+ const chatJid = `feishu:${chatId}`;
1085
+ const chatName = data.name || '飞书群聊';
1086
+ logger.info({ chatJid, chatName }, 'Bot added to Feishu group');
1087
+ connectOptions?.onBotAddedToGroup?.(chatJid, chatName);
1088
+ }
1089
+ catch (err) {
1090
+ logger.error({ err }, 'Error handling bot added to group event');
1091
+ }
1092
+ },
1093
+ 'im.chat.member.bot.deleted_v1': async (data) => {
1094
+ try {
1095
+ const chatId = data.chat_id;
1096
+ if (!chatId)
1097
+ return;
1098
+ const chatJid = `feishu:${chatId}`;
1099
+ logger.info({ chatJid }, 'Bot removed from Feishu group');
1100
+ connectOptions?.onBotRemovedFromGroup?.(chatJid);
1101
+ }
1102
+ catch (err) {
1103
+ logger.error({ err }, 'Error handling bot removed from group event');
1104
+ }
1105
+ },
1106
+ 'im.chat.disbanded_v1': async (data) => {
1107
+ try {
1108
+ const chatId = data.chat_id;
1109
+ if (!chatId)
1110
+ return;
1111
+ const chatJid = `feishu:${chatId}`;
1112
+ logger.info({ chatJid }, 'Feishu group disbanded');
1113
+ connectOptions?.onBotRemovedFromGroup?.(chatJid);
1114
+ }
1115
+ catch (err) {
1116
+ logger.error({ err }, 'Error handling group disbanded event');
1117
+ }
1118
+ },
1119
+ 'card.action.trigger': async (data) => {
1120
+ try {
1121
+ const action = data?.action?.value?.action;
1122
+ const messageId = data?.context?.open_message_id;
1123
+ const mappedChatJid = messageId
1124
+ ? resolveJidByMessageId(messageId)
1125
+ : undefined;
1126
+ if (action === 'interrupt_stream') {
1127
+ if (!messageId || !mappedChatJid) {
1128
+ logger.debug({ messageId }, 'Card action: interrupt ignored because mapping is missing');
1129
+ return;
1130
+ }
1131
+ const session = getStreamingSession(mappedChatJid);
1132
+ if (!session?.isActive()) {
1133
+ logger.debug({ chatJid: mappedChatJid, messageId }, 'Card action: session not active');
1134
+ return;
1135
+ }
1136
+ logger.info({ chatJid: mappedChatJid, messageId }, 'Card action: interrupt via button');
1137
+ connectOptions?.onCardInterrupt?.(mappedChatJid);
1138
+ return;
1139
+ }
1140
+ if (action === 'set_runtime_model' ||
1141
+ action === 'set_runtime_effort') {
1142
+ const chatJid = mappedChatJid ||
1143
+ (typeof data?.context?.open_chat_id === 'string'
1144
+ ? `feishu:${data.context.open_chat_id}`
1145
+ : null);
1146
+ if (!chatJid) {
1147
+ logger.debug({ action, messageId }, 'Card action: runtime update ignored because chat mapping is missing');
1148
+ return;
1149
+ }
1150
+ const selectedValue = extractCardActionValue(data);
1151
+ if (!selectedValue) {
1152
+ await sendCardActionReply(chatJid, '运行时切换失败:没有读取到要应用的预设值');
1153
+ return;
1154
+ }
1155
+ logger.info({ chatJid, action, selectedValue, messageId }, 'Card action: updating workspace runtime');
1156
+ const reply = await connectOptions?.onCardRuntimeUpdate?.(chatJid, {
1157
+ action,
1158
+ value: selectedValue,
1159
+ });
1160
+ if (reply) {
1161
+ await sendCardActionReply(chatJid, reply);
1162
+ }
1163
+ }
1164
+ }
1165
+ catch (err) {
1166
+ logger.error({ err }, 'Error handling card action trigger');
1167
+ }
1168
+ },
1169
+ });
1170
+ // Initialize WebSocket client
1171
+ wsClient = new lark.WSClient({
1172
+ appId: config.appId,
1173
+ appSecret: config.appSecret,
1174
+ loggerLevel: lark.LoggerLevel.info,
1175
+ });
1176
+ try {
1177
+ await wsClient.start({ eventDispatcher });
1178
+ logger.info('Feishu WebSocket client started');
1179
+ lastWsStateConnected = true;
1180
+ disconnectedSince = null;
1181
+ startHealthMonitor();
1182
+ onReady();
1183
+ return true;
1184
+ }
1185
+ catch (err) {
1186
+ logger.error({ err }, 'Failed to start Feishu client, running in Web-only mode');
1187
+ // Clean up partially initialized state
1188
+ stopHealthMonitor();
1189
+ connectOptions = null;
1190
+ eventDispatcher = null;
1191
+ client = null;
1192
+ wsClient = null;
1193
+ return false;
1194
+ }
1195
+ },
1196
+ async stop() {
1197
+ stopHealthMonitor();
1198
+ connectOptions = null;
1199
+ eventDispatcher = null;
1200
+ reconnecting = false;
1201
+ disconnectedSince = null;
1202
+ disconnectedChecks = 0;
1203
+ if (wsClient) {
1204
+ logger.info('Stopping Feishu client');
1205
+ try {
1206
+ await wsClient.close();
1207
+ logger.info('Feishu client stopped successfully');
1208
+ }
1209
+ catch (err) {
1210
+ logger.warn({ err }, 'Error stopping Feishu client');
1211
+ }
1212
+ wsClient = null;
1213
+ }
1214
+ client = null;
1215
+ lastWsStateConnected = false;
1216
+ },
1217
+ async sendMessage(chatId, text, localImagePaths) {
1218
+ if (!client) {
1219
+ logger.warn({ chatId }, 'Feishu client not initialized, skip sending message');
1220
+ return;
1221
+ }
1222
+ const clearAckReaction = () => {
1223
+ const ackStored = ackReactionByChat.get(chatId);
1224
+ if (ackStored) {
1225
+ const [ackMsgId, ackReactionId] = ackStored.split(':');
1226
+ removeReaction(ackMsgId, ackReactionId).catch(() => { });
1227
+ ackReactionByChat.delete(chatId);
1228
+ }
1229
+ };
1230
+ try {
1231
+ // Detect pre-built Feishu interactive card JSON — send directly without wrapping
1232
+ if (text.startsWith('{"type":"interactive"')) {
1233
+ try {
1234
+ const parsed = JSON.parse(text);
1235
+ if (parsed.type === 'interactive' && parsed.card) {
1236
+ const lastMsgId = lastMessageIdByChat.get(chatId);
1237
+ if (lastMsgId) {
1238
+ await client.im.message.reply({
1239
+ path: { message_id: lastMsgId },
1240
+ data: { content: text, msg_type: 'interactive' },
1241
+ });
1242
+ }
1243
+ else {
1244
+ await client.im.v1.message.create({
1245
+ params: { receive_id_type: 'chat_id' },
1246
+ data: {
1247
+ receive_id: chatId,
1248
+ msg_type: 'interactive',
1249
+ content: text,
1250
+ },
1251
+ });
1252
+ }
1253
+ clearAckReaction();
1254
+ return;
1255
+ }
1256
+ }
1257
+ catch {
1258
+ // Not valid card JSON, fall through to normal handling
1259
+ }
1260
+ }
1261
+ // Count markdown tables to decide format upfront — Feishu cards have a table limit
1262
+ // Each table has exactly one separator row (e.g. |---|---|), so counting those = table count
1263
+ const tableCount = (text.match(/^\|[\s:-]+\|/gm) || []).length;
1264
+ const usePostMd = tableCount > CARD_TABLE_LIMIT;
1265
+ if (usePostMd) {
1266
+ // Too many tables for card format, go directly to post+md
1267
+ const postContent = buildPostMdFallback(text);
1268
+ const lastMsgId = lastMessageIdByChat.get(chatId);
1269
+ if (lastMsgId) {
1270
+ await client.im.message.reply({
1271
+ path: { message_id: lastMsgId },
1272
+ data: { content: postContent, msg_type: 'post' },
1273
+ });
1274
+ }
1275
+ else {
1276
+ await client.im.v1.message.create({
1277
+ params: { receive_id_type: 'chat_id' },
1278
+ data: {
1279
+ receive_id: chatId,
1280
+ msg_type: 'post',
1281
+ content: postContent,
1282
+ },
1283
+ });
1284
+ }
1285
+ }
1286
+ else {
1287
+ const card = buildInteractiveCard(text);
1288
+ const content = JSON.stringify(card);
1289
+ const lastMsgId = lastMessageIdByChat.get(chatId);
1290
+ if (lastMsgId) {
1291
+ try {
1292
+ await client.im.message.reply({
1293
+ path: { message_id: lastMsgId },
1294
+ data: { content, msg_type: 'interactive' },
1295
+ });
1296
+ }
1297
+ catch (err) {
1298
+ logger.warn({ err, chatId }, 'Feishu interactive reply failed, fallback to post+md');
1299
+ await client.im.message.reply({
1300
+ path: { message_id: lastMsgId },
1301
+ data: {
1302
+ content: buildPostMdFallback(text),
1303
+ msg_type: 'post',
1304
+ },
1305
+ });
1306
+ }
1307
+ }
1308
+ else {
1309
+ try {
1310
+ await client.im.v1.message.create({
1311
+ params: { receive_id_type: 'chat_id' },
1312
+ data: {
1313
+ receive_id: chatId,
1314
+ msg_type: 'interactive',
1315
+ content,
1316
+ },
1317
+ });
1318
+ }
1319
+ catch (err) {
1320
+ logger.warn({ err, chatId }, 'Feishu interactive create failed, fallback to post+md');
1321
+ await client.im.v1.message.create({
1322
+ params: { receive_id_type: 'chat_id' },
1323
+ data: {
1324
+ receive_id: chatId,
1325
+ msg_type: 'post',
1326
+ content: buildPostMdFallback(text),
1327
+ },
1328
+ });
1329
+ }
1330
+ }
1331
+ }
1332
+ logger.debug({ chatId }, 'Sent Feishu card message');
1333
+ clearAckReaction();
1334
+ for (const localImagePath of localImagePaths || []) {
1335
+ try {
1336
+ const uploadRes = (await client.im.v1.image.create({
1337
+ data: {
1338
+ image_type: 'message',
1339
+ image: fs.createReadStream(localImagePath),
1340
+ },
1341
+ }));
1342
+ const imageKey = uploadRes?.image_key ?? uploadRes?.data?.image_key;
1343
+ if (!imageKey) {
1344
+ logger.warn({ chatId, localImagePath }, 'Feishu image upload returned no image_key');
1345
+ continue;
1346
+ }
1347
+ await client.im.v1.message.create({
1348
+ params: { receive_id_type: 'chat_id' },
1349
+ data: {
1350
+ receive_id: chatId,
1351
+ msg_type: 'image',
1352
+ content: JSON.stringify({ image_key: imageKey }),
1353
+ },
1354
+ });
1355
+ }
1356
+ catch (imageErr) {
1357
+ logger.warn({ chatId, localImagePath, err: imageErr }, 'Failed to send Feishu image attachment');
1358
+ }
1359
+ }
1360
+ }
1361
+ catch (err) {
1362
+ logger.error({ err, chatId }, 'Failed to send Feishu card message');
1363
+ clearAckReaction();
1364
+ }
1365
+ },
1366
+ async sendImage(chatId, imageBuffer, mimeType, caption, _fileName /* Feishu image API has no filename field, intentionally unused */) {
1367
+ if (!client) {
1368
+ logger.warn({ chatId }, 'Feishu client not initialized, skip sending image');
1369
+ return;
1370
+ }
1371
+ try {
1372
+ // Step 1: Upload image to Feishu to get image_key
1373
+ const uploadResult = (await client.im.v1.image.create({
1374
+ data: {
1375
+ image_type: 'message',
1376
+ image: imageBuffer,
1377
+ },
1378
+ }));
1379
+ const imageKey = uploadResult?.image_key ?? uploadResult?.data?.image_key;
1380
+ if (!imageKey) {
1381
+ logger.error({ chatId }, 'Feishu image upload failed: no image_key returned');
1382
+ throw new Error('Feishu image upload failed: no image_key in response');
1383
+ }
1384
+ // Step 2: Send image message
1385
+ // receive_id_type: group chat ids start with "oc_", DM open_ids start with "ou_"
1386
+ const receive_id_type = chatId.startsWith('oc_')
1387
+ ? 'chat_id'
1388
+ : 'open_id';
1389
+ const lastMsgId = lastMessageIdByChat.get(chatId);
1390
+ const content = JSON.stringify({ image_key: imageKey });
1391
+ if (lastMsgId) {
1392
+ await client.im.message.reply({
1393
+ path: { message_id: lastMsgId },
1394
+ data: { content, msg_type: 'image' },
1395
+ });
1396
+ }
1397
+ else {
1398
+ await client.im.v1.message.create({
1399
+ params: { receive_id_type },
1400
+ data: {
1401
+ receive_id: chatId,
1402
+ msg_type: 'image',
1403
+ content,
1404
+ },
1405
+ });
1406
+ }
1407
+ // Step 3: If caption provided, send it as a follow-up text message
1408
+ if (caption) {
1409
+ await client.im.v1.message.create({
1410
+ params: { receive_id_type },
1411
+ data: {
1412
+ receive_id: chatId,
1413
+ msg_type: 'text',
1414
+ content: JSON.stringify({ text: caption }),
1415
+ },
1416
+ });
1417
+ }
1418
+ logger.info({ chatId, imageKey, mimeType, size: imageBuffer.length }, 'Feishu image sent');
1419
+ }
1420
+ catch (err) {
1421
+ logger.error({ err, chatId, mimeType }, 'Failed to send Feishu image');
1422
+ throw err;
1423
+ }
1424
+ },
1425
+ async sendFile(chatId, filePath, fileName) {
1426
+ if (!client) {
1427
+ logger.warn({ chatId }, 'Feishu client not initialized, skip sending file');
1428
+ return;
1429
+ }
1430
+ try {
1431
+ const buffer = await fsPromises.readFile(filePath);
1432
+ // Check file size limit (30MB)
1433
+ const MAX_FILE_SIZE = 30 * 1024 * 1024;
1434
+ if (buffer.length > MAX_FILE_SIZE) {
1435
+ throw new Error(`文件大小超过 30MB 限制 (${(buffer.length / 1024 / 1024).toFixed(2)}MB)`);
1436
+ }
1437
+ const ext = path.extname(fileName);
1438
+ const fileType = getFileType(ext);
1439
+ // Upload file
1440
+ const uploadResult = (await client.im.v1.file.create({
1441
+ data: {
1442
+ file_type: fileType,
1443
+ file_name: fileName,
1444
+ file: buffer,
1445
+ },
1446
+ }));
1447
+ const fileKey = uploadResult?.file_key ?? uploadResult?.data?.file_key;
1448
+ if (!fileKey) {
1449
+ throw new Error('文件上传失败:未返回 file_key');
1450
+ }
1451
+ // Send file message
1452
+ const receive_id_type = chatId.startsWith('oc_')
1453
+ ? 'chat_id'
1454
+ : 'open_id';
1455
+ await client.im.v1.message.create({
1456
+ params: { receive_id_type },
1457
+ data: {
1458
+ receive_id: chatId,
1459
+ msg_type: 'file',
1460
+ content: JSON.stringify({ file_key: fileKey }),
1461
+ },
1462
+ });
1463
+ logger.info({ chatId, fileName, fileSize: buffer.length }, 'File sent to Feishu');
1464
+ }
1465
+ catch (err) {
1466
+ logger.error({ err, chatId, filePath }, 'Failed to send file to Feishu');
1467
+ throw err;
1468
+ }
1469
+ },
1470
+ async sendReaction(chatId, isTyping) {
1471
+ if (!client)
1472
+ return;
1473
+ const lastMsgId = lastMessageIdByChat.get(chatId);
1474
+ if (!lastMsgId)
1475
+ return;
1476
+ if (isTyping) {
1477
+ const reactionId = await addReaction(lastMsgId, 'OnIt');
1478
+ if (reactionId) {
1479
+ typingReactionByChat.set(chatId, `${lastMsgId}:${reactionId}`);
1480
+ }
1481
+ }
1482
+ else {
1483
+ const stored = typingReactionByChat.get(chatId);
1484
+ if (stored) {
1485
+ const [msgId, reactionId] = stored.split(':');
1486
+ await removeReaction(msgId, reactionId);
1487
+ typingReactionByChat.delete(chatId);
1488
+ }
1489
+ }
1490
+ },
1491
+ clearAckReaction(chatId) {
1492
+ const ackStored = ackReactionByChat.get(chatId);
1493
+ if (ackStored) {
1494
+ const [ackMsgId, ackReactionId] = ackStored.split(':');
1495
+ removeReaction(ackMsgId, ackReactionId).catch(() => { });
1496
+ ackReactionByChat.delete(chatId);
1497
+ }
1498
+ },
1499
+ isConnected() {
1500
+ return wsClient != null;
1501
+ },
1502
+ async getChatInfo(chatId) {
1503
+ if (!client)
1504
+ return null;
1505
+ try {
1506
+ const res = await client.im.v1.chat.get({
1507
+ path: { chat_id: chatId },
1508
+ });
1509
+ if (!res.data)
1510
+ return null;
1511
+ return {
1512
+ avatar: res.data.avatar,
1513
+ name: res.data.name,
1514
+ user_count: res.data.user_count,
1515
+ chat_type: res.data.chat_type,
1516
+ chat_mode: res.data.chat_mode,
1517
+ };
1518
+ }
1519
+ catch (err) {
1520
+ logger.warn({ err, chatId }, 'Failed to get Feishu chat info');
1521
+ return null;
1522
+ }
1523
+ },
1524
+ async syncGroups() {
1525
+ if (!client) {
1526
+ logger.debug('Feishu client not initialized, skip group sync');
1527
+ return;
1528
+ }
1529
+ try {
1530
+ let pageToken;
1531
+ let hasMore = true;
1532
+ while (hasMore) {
1533
+ const res = await client.im.v1.chat.list({
1534
+ params: {
1535
+ page_size: 100,
1536
+ page_token: pageToken,
1537
+ },
1538
+ });
1539
+ const items = res.data?.items || [];
1540
+ for (const chat of items) {
1541
+ if (chat.chat_id && chat.name) {
1542
+ updateChatName(`feishu:${chat.chat_id}`, chat.name);
1543
+ knownChatIds.add(chat.chat_id);
1544
+ }
1545
+ }
1546
+ hasMore = res.data?.has_more || false;
1547
+ pageToken = res.data?.page_token;
1548
+ }
1549
+ setLastGroupSync();
1550
+ logger.info('Feishu group sync completed');
1551
+ }
1552
+ catch (err) {
1553
+ logger.error({ err }, 'Failed to sync Feishu groups');
1554
+ }
1555
+ },
1556
+ getLarkClient() {
1557
+ return client;
1558
+ },
1559
+ getLastMessageId(chatId) {
1560
+ return lastMessageIdByChat.get(chatId);
1561
+ },
1562
+ };
1563
+ return connection;
1564
+ }
1565
+ // ─── Backward-compatible global singleton ──────────────────────
1566
+ // @deprecated — 旧的顶层导出函数,内部使用一个默认全局实例。
1567
+ // 后续由 imManager 替代。
1568
+ let _defaultInstance = null;
1569
+ /**
1570
+ * @deprecated Use createFeishuConnection() factory instead. Will be replaced by imManager.
1571
+ * Connect to Feishu via WebSocket and start receiving messages.
1572
+ */
1573
+ export async function connectFeishu(opts) {
1574
+ const { getFeishuProviderConfigWithSource } = await import('./runtime-config.js');
1575
+ const { config, source } = getFeishuProviderConfigWithSource();
1576
+ if (!config.appId || !config.appSecret) {
1577
+ logger.warn({ source }, 'Feishu config is empty, running in Web-only mode (set it in Settings -> Feishu config)');
1578
+ return false;
1579
+ }
1580
+ _defaultInstance = createFeishuConnection({
1581
+ appId: config.appId,
1582
+ appSecret: config.appSecret,
1583
+ });
1584
+ return _defaultInstance.connect(opts);
1585
+ }
1586
+ /**
1587
+ * @deprecated Use FeishuConnection.sendMessage() instead.
1588
+ */
1589
+ export async function sendFeishuMessage(chatId, text, localImagePaths) {
1590
+ if (!_defaultInstance) {
1591
+ logger.warn({ chatId }, 'Feishu client not initialized, skip sending message');
1592
+ return;
1593
+ }
1594
+ return _defaultInstance.sendMessage(chatId, text, localImagePaths);
1595
+ }
1596
+ /**
1597
+ * @deprecated Use FeishuConnection.sendReaction() instead.
1598
+ */
1599
+ export async function setFeishuTyping(chatId, isTyping) {
1600
+ if (!_defaultInstance)
1601
+ return;
1602
+ return _defaultInstance.sendReaction(chatId, isTyping);
1603
+ }
1604
+ /**
1605
+ * @deprecated Use FeishuConnection.syncGroups() instead.
1606
+ */
1607
+ export async function syncFeishuGroups() {
1608
+ if (!_defaultInstance) {
1609
+ logger.debug('Feishu client not initialized, skip group sync');
1610
+ return;
1611
+ }
1612
+ return _defaultInstance.syncGroups();
1613
+ }
1614
+ /**
1615
+ * @deprecated Use FeishuConnection.isConnected() instead.
1616
+ */
1617
+ export function isFeishuConnected() {
1618
+ return _defaultInstance?.isConnected() ?? false;
1619
+ }
1620
+ /**
1621
+ * @deprecated Use FeishuConnection.stop() instead.
1622
+ */
1623
+ export async function stopFeishu() {
1624
+ if (_defaultInstance) {
1625
+ await _defaultInstance.stop();
1626
+ _defaultInstance = null;
1627
+ }
1628
+ }