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,1868 @@
1
+ // Configuration management routes
2
+ import { randomBytes, createHash } from 'node:crypto';
3
+ import { Agent as HttpsAgent } from 'node:https';
4
+ import { ProxyAgent } from 'proxy-agent';
5
+ import QRCode from 'qrcode';
6
+ import { Hono } from 'hono';
7
+ import { updateWeChatNoProxy } from '../config.js';
8
+ import { canAccessGroup, getWebDeps } from '../web-context.js';
9
+ import { getChannelType } from '../im-channel.js';
10
+ import { deleteRegisteredGroup, deleteChatHistory, getRegisteredGroup, setRegisteredGroup, getAgent, } from '../db.js';
11
+ import { authMiddleware, systemConfigMiddleware } from '../middleware/auth.js';
12
+ import { ClaudeCustomEnvSchema, FeishuConfigSchema, TelegramConfigSchema, QQConfigSchema, WeChatConfigSchema, DingTalkConfigSchema, RegistrationConfigSchema, AppearanceConfigSchema, SystemSettingsSchema, UnifiedProviderCreateSchema, UnifiedProviderPatchSchema, UnifiedProviderSecretsSchema, BalancingConfigSchema, } from '../schemas.js';
13
+ import { getClaudeProviderConfig, toPublicClaudeProviderConfig, appendClaudeConfigAudit, getProviders, getEnabledProviders, getBalancingConfig, saveBalancingConfig, createProvider, updateProvider, updateProviderSecrets, toggleProvider, deleteProvider, providerToConfig, toPublicProvider, getFeishuProviderConfig, getFeishuProviderConfigWithSource, toPublicFeishuProviderConfig, saveFeishuProviderConfig, getTelegramProviderConfig, getTelegramProviderConfigWithSource, toPublicTelegramProviderConfig, saveTelegramProviderConfig, getRegistrationConfig, saveRegistrationConfig, getAppearanceConfig, saveAppearanceConfig, getSystemSettings, saveSystemSettings, getUserFeishuConfig, saveUserFeishuConfig, getUserTelegramConfig, saveUserTelegramConfig, getUserQQConfig, saveUserQQConfig, getUserWeChatConfig, saveUserWeChatConfig, getUserDingTalkConfig, saveUserDingTalkConfig, updateAllSessionCredentials, } from '../runtime-config.js';
14
+ import { parseOAuthUsageBucket } from '../runtime-config.js';
15
+ import { logger } from '../logger.js';
16
+ import { checkImChannelLimit, isBillingEnabled, clearBillingEnabledCache, } from '../billing.js';
17
+ import { providerPool } from '../provider-pool.js';
18
+ const configRoutes = new Hono();
19
+ /**
20
+ * Count how many IM channels are currently enabled for a user, excluding the given channel.
21
+ * Used for billing limit checks when enabling a new channel.
22
+ */
23
+ function countOtherEnabledImChannels(userId, excludeChannel) {
24
+ let count = 0;
25
+ if (excludeChannel !== 'feishu' && getUserFeishuConfig(userId)?.enabled)
26
+ count++;
27
+ if (excludeChannel !== 'telegram' && getUserTelegramConfig(userId)?.enabled)
28
+ count++;
29
+ if (excludeChannel !== 'wechat' && getUserWeChatConfig(userId)?.enabled)
30
+ count++;
31
+ if (excludeChannel !== 'qq' && getUserQQConfig(userId)?.enabled)
32
+ count++;
33
+ if (excludeChannel !== 'dingtalk' && getUserDingTalkConfig(userId)?.enabled)
34
+ count++;
35
+ return count;
36
+ }
37
+ // Inject deps at runtime
38
+ let deps = null;
39
+ export function injectConfigDeps(d) {
40
+ deps = d;
41
+ }
42
+ function createTelegramApiAgent(proxyUrl) {
43
+ if (proxyUrl && proxyUrl.trim()) {
44
+ const fixedProxyUrl = proxyUrl.trim();
45
+ return new ProxyAgent({
46
+ getProxyForUrl: () => fixedProxyUrl,
47
+ });
48
+ }
49
+ return new HttpsAgent({ keepAlive: false, family: 4 });
50
+ }
51
+ function destroyTelegramApiAgent(agent) {
52
+ agent.destroy();
53
+ }
54
+ async function applyClaudeConfigToAllGroups(actor, metadata) {
55
+ if (!deps) {
56
+ throw new Error('Server not initialized');
57
+ }
58
+ const groupJids = Object.keys(deps.getRegisteredGroups());
59
+ const results = await Promise.allSettled(groupJids.map((jid) => deps.queue.stopGroup(jid)));
60
+ const failedCount = results.filter((r) => r.status === 'rejected').length;
61
+ const stoppedCount = groupJids.length - failedCount;
62
+ appendClaudeConfigAudit(actor, 'apply_to_all_flows', ['queue.stopGroup'], {
63
+ stoppedCount,
64
+ failedCount,
65
+ ...(metadata || {}),
66
+ });
67
+ if (failedCount > 0) {
68
+ return {
69
+ success: false,
70
+ stoppedCount,
71
+ failedCount,
72
+ error: `${failedCount} container(s) failed to stop`,
73
+ };
74
+ }
75
+ return {
76
+ success: true,
77
+ stoppedCount,
78
+ failedCount: 0,
79
+ };
80
+ }
81
+ // --- OAuth 常量 ---
82
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
83
+ const OAUTH_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
84
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference';
85
+ const OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
86
+ const OAUTH_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
87
+ const OAUTH_FLOW_TTL = 10 * 60 * 1000; // 10 minutes
88
+ const oauthFlows = new Map();
89
+ // Periodic cleanup of expired flows
90
+ setInterval(() => {
91
+ const now = Date.now();
92
+ for (const [key, flow] of oauthFlows) {
93
+ if (flow.expiresAt < now)
94
+ oauthFlows.delete(key);
95
+ }
96
+ }, 60_000);
97
+ // --- OAuth Usage Cache ---
98
+ const OAUTH_USAGE_API = 'https://api.anthropic.com/api/oauth/usage';
99
+ const USAGE_CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes
100
+ const usageCache = new Map();
101
+ const inFlightUsageRequests = new Map();
102
+ setInterval(() => {
103
+ const now = Date.now();
104
+ for (const [key, entry] of usageCache) {
105
+ if (now - entry.fetchedAt >= USAGE_CACHE_TTL_MS) {
106
+ usageCache.delete(key);
107
+ }
108
+ }
109
+ }, 5 * 60_000);
110
+ async function fetchOAuthUsage(providerId) {
111
+ const cached = usageCache.get(providerId);
112
+ if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
113
+ return cached;
114
+ }
115
+ // Deduplicate concurrent requests for the same provider
116
+ const inFlight = inFlightUsageRequests.get(providerId);
117
+ if (inFlight)
118
+ return inFlight;
119
+ const providers = getProviders();
120
+ const provider = providers.find((p) => p.id === providerId);
121
+ if (!provider) {
122
+ throw new Error('Provider not found');
123
+ }
124
+ if (!provider.claudeOAuthCredentials) {
125
+ throw new Error('Provider has no OAuth credentials');
126
+ }
127
+ const requestPromise = (async () => {
128
+ try {
129
+ const resp = await fetch(OAUTH_USAGE_API, {
130
+ headers: {
131
+ Authorization: `Bearer ${provider.claudeOAuthCredentials.accessToken}`,
132
+ 'anthropic-beta': 'oauth-2025-04-20',
133
+ },
134
+ });
135
+ if (!resp.ok) {
136
+ // Return stale cache if available, otherwise throw
137
+ if (cached) {
138
+ const stale = {
139
+ ...cached,
140
+ error: `HTTP ${resp.status}`,
141
+ };
142
+ usageCache.set(providerId, stale);
143
+ return stale;
144
+ }
145
+ throw new Error(`Usage API returned ${resp.status}`);
146
+ }
147
+ const raw = (await resp.json());
148
+ const data = {
149
+ five_hour: parseOAuthUsageBucket(raw.five_hour),
150
+ seven_day: parseOAuthUsageBucket(raw.seven_day),
151
+ seven_day_opus: parseOAuthUsageBucket(raw.seven_day_opus),
152
+ seven_day_sonnet: parseOAuthUsageBucket(raw.seven_day_sonnet),
153
+ };
154
+ const result = { data, fetchedAt: Date.now() };
155
+ usageCache.set(providerId, result);
156
+ return result;
157
+ }
158
+ finally {
159
+ inFlightUsageRequests.delete(providerId);
160
+ }
161
+ })();
162
+ inFlightUsageRequests.set(providerId, requestPromise);
163
+ return requestPromise;
164
+ }
165
+ // --- Routes ---
166
+ // ─── GET /claude — 兼容:返回第一个启用供应商的公开配置 ─────
167
+ configRoutes.get('/claude', authMiddleware, systemConfigMiddleware, (c) => {
168
+ try {
169
+ return c.json(toPublicClaudeProviderConfig(getClaudeProviderConfig()));
170
+ }
171
+ catch (err) {
172
+ logger.error({ err }, 'Failed to load Claude config');
173
+ return c.json({ error: 'Failed to load Claude config' }, 500);
174
+ }
175
+ });
176
+ // ─── GET /claude/providers — 列出所有供应商 + 健康 + 负载均衡配置 ─────
177
+ configRoutes.get('/claude/providers', authMiddleware, systemConfigMiddleware, (c) => {
178
+ try {
179
+ const providers = getProviders();
180
+ const balancing = getBalancingConfig();
181
+ const enabledProviders = getEnabledProviders();
182
+ // Refresh pool state for health info
183
+ providerPool.refreshFromConfig(enabledProviders, balancing);
184
+ const healthStatuses = providerPool.getHealthStatuses();
185
+ return c.json({
186
+ providers: providers.map((p) => ({
187
+ ...toPublicProvider(p),
188
+ health: healthStatuses.find((h) => h.profileId === p.id) || null,
189
+ })),
190
+ balancing,
191
+ enabledCount: enabledProviders.length,
192
+ });
193
+ }
194
+ catch (err) {
195
+ logger.error({ err }, 'Failed to list providers');
196
+ return c.json({ error: 'Failed to list providers' }, 500);
197
+ }
198
+ });
199
+ // ─── POST /claude/providers — 创建供应商 ─────
200
+ configRoutes.post('/claude/providers', authMiddleware, systemConfigMiddleware, async (c) => {
201
+ const body = await c.req.json().catch(() => ({}));
202
+ const validation = UnifiedProviderCreateSchema.safeParse(body);
203
+ if (!validation.success) {
204
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
205
+ }
206
+ const actor = c.get('user').username;
207
+ try {
208
+ const provider = createProvider(validation.data);
209
+ appendClaudeConfigAudit(actor, 'create_provider', [
210
+ `id:${provider.id}`,
211
+ `type:${provider.type}`,
212
+ `name:${provider.name}`,
213
+ ]);
214
+ return c.json(toPublicProvider(provider), 201);
215
+ }
216
+ catch (err) {
217
+ const message = err instanceof Error ? err.message : 'Failed to create provider';
218
+ logger.warn({ err }, 'Failed to create provider');
219
+ return c.json({ error: message }, 400);
220
+ }
221
+ });
222
+ // ─── PATCH /claude/providers/:id — 更新供应商非密钥字段 ─────
223
+ configRoutes.patch('/claude/providers/:id', authMiddleware, systemConfigMiddleware, async (c) => {
224
+ const { id } = c.req.param();
225
+ const body = await c.req.json().catch(() => ({}));
226
+ const validation = UnifiedProviderPatchSchema.safeParse(body);
227
+ if (!validation.success) {
228
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
229
+ }
230
+ const actor = c.get('user').username;
231
+ try {
232
+ const updated = updateProvider(id, validation.data);
233
+ const changedFields = Object.keys(validation.data).map((k) => `${k}:updated`);
234
+ appendClaudeConfigAudit(actor, 'update_provider', [
235
+ `id:${id}`,
236
+ ...changedFields,
237
+ ]);
238
+ // If this provider is enabled, apply to running containers
239
+ let applied = null;
240
+ if (updated.enabled) {
241
+ applied = await applyClaudeConfigToAllGroups(actor, {
242
+ trigger: 'provider_update',
243
+ providerId: id,
244
+ });
245
+ }
246
+ return c.json({
247
+ provider: toPublicProvider(updated),
248
+ ...(applied ? { applied } : {}),
249
+ });
250
+ }
251
+ catch (err) {
252
+ const message = err instanceof Error ? err.message : 'Failed to update provider';
253
+ logger.warn({ err }, 'Failed to update provider');
254
+ return c.json({ error: message }, 400);
255
+ }
256
+ });
257
+ // ─── PUT /claude/providers/:id/secrets — 更新密钥 ─────
258
+ configRoutes.put('/claude/providers/:id/secrets', authMiddleware, systemConfigMiddleware, async (c) => {
259
+ const { id } = c.req.param();
260
+ const body = await c.req.json().catch(() => ({}));
261
+ const validation = UnifiedProviderSecretsSchema.safeParse(body);
262
+ if (!validation.success) {
263
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
264
+ }
265
+ const actor = c.get('user').username;
266
+ try {
267
+ const updated = updateProviderSecrets(id, validation.data);
268
+ const changedFields = [];
269
+ if (validation.data.anthropicAuthToken !== undefined)
270
+ changedFields.push('anthropicAuthToken:set');
271
+ if (validation.data.clearAnthropicAuthToken)
272
+ changedFields.push('anthropicAuthToken:clear');
273
+ if (validation.data.anthropicApiKey !== undefined)
274
+ changedFields.push('anthropicApiKey:set');
275
+ if (validation.data.clearAnthropicApiKey)
276
+ changedFields.push('anthropicApiKey:clear');
277
+ if (validation.data.claudeCodeOauthToken !== undefined)
278
+ changedFields.push('claudeCodeOauthToken:set');
279
+ if (validation.data.clearClaudeCodeOauthToken)
280
+ changedFields.push('claudeCodeOauthToken:clear');
281
+ if (validation.data.claudeOAuthCredentials)
282
+ changedFields.push('claudeOAuthCredentials:set');
283
+ if (validation.data.clearClaudeOAuthCredentials)
284
+ changedFields.push('claudeOAuthCredentials:clear');
285
+ appendClaudeConfigAudit(actor, 'update_provider_secrets', [
286
+ `id:${id}`,
287
+ ...changedFields,
288
+ ]);
289
+ // Update .credentials.json if OAuth credentials changed
290
+ if (validation.data.claudeOAuthCredentials && updated.enabled) {
291
+ updateAllSessionCredentials(providerToConfig(updated));
292
+ deps?.queue?.closeAllActiveForCredentialRefresh();
293
+ }
294
+ // Apply if enabled
295
+ let applied = null;
296
+ if (updated.enabled) {
297
+ applied = await applyClaudeConfigToAllGroups(actor, {
298
+ trigger: 'provider_secrets_update',
299
+ providerId: id,
300
+ });
301
+ }
302
+ return c.json({
303
+ provider: toPublicProvider(updated),
304
+ ...(applied ? { applied } : {}),
305
+ });
306
+ }
307
+ catch (err) {
308
+ const message = err instanceof Error ? err.message : 'Failed to update secrets';
309
+ logger.warn({ err }, 'Failed to update provider secrets');
310
+ return c.json({ error: message }, 400);
311
+ }
312
+ });
313
+ // ─── DELETE /claude/providers/:id — 删除供应商 ─────
314
+ configRoutes.delete('/claude/providers/:id', authMiddleware, systemConfigMiddleware, (c) => {
315
+ const { id } = c.req.param();
316
+ const actor = c.get('user').username;
317
+ try {
318
+ deleteProvider(id);
319
+ appendClaudeConfigAudit(actor, 'delete_provider', [`id:${id}`]);
320
+ return c.json({ ok: true });
321
+ }
322
+ catch (err) {
323
+ const message = err instanceof Error ? err.message : 'Failed to delete provider';
324
+ logger.warn({ err }, 'Failed to delete provider');
325
+ return c.json({ error: message }, 400);
326
+ }
327
+ });
328
+ // ─── POST /claude/providers/:id/toggle — 切换 enabled ─────
329
+ configRoutes.post('/claude/providers/:id/toggle', authMiddleware, systemConfigMiddleware, async (c) => {
330
+ const { id } = c.req.param();
331
+ const actor = c.get('user').username;
332
+ try {
333
+ const updated = toggleProvider(id);
334
+ appendClaudeConfigAudit(actor, 'toggle_provider', [
335
+ `id:${id}`,
336
+ `enabled:${updated.enabled}`,
337
+ ]);
338
+ const applied = await applyClaudeConfigToAllGroups(actor, {
339
+ trigger: 'provider_toggle',
340
+ providerId: id,
341
+ });
342
+ return c.json({
343
+ provider: toPublicProvider(updated),
344
+ applied,
345
+ });
346
+ }
347
+ catch (err) {
348
+ const message = err instanceof Error ? err.message : 'Failed to toggle provider';
349
+ logger.warn({ err }, 'Failed to toggle provider');
350
+ return c.json({ error: message }, 400);
351
+ }
352
+ });
353
+ // ─── POST /claude/providers/:id/reset-health — 重置健康状态 ─────
354
+ configRoutes.post('/claude/providers/:id/reset-health', authMiddleware, systemConfigMiddleware, (c) => {
355
+ const { id } = c.req.param();
356
+ providerPool.resetHealth(id);
357
+ return c.json({ ok: true });
358
+ });
359
+ // ─── GET /claude/providers/health — 健康状态轮询 ─────
360
+ configRoutes.get('/claude/providers/health', authMiddleware, systemConfigMiddleware, (c) => {
361
+ // Refresh pool state
362
+ const enabledProviders = getEnabledProviders();
363
+ const balancing = getBalancingConfig();
364
+ providerPool.refreshFromConfig(enabledProviders, balancing);
365
+ return c.json({ statuses: providerPool.getHealthStatuses() });
366
+ });
367
+ // ─── GET /claude/providers/:id/usage — OAuth 用量数据 ─────
368
+ configRoutes.get('/claude/providers/:id/usage', authMiddleware, systemConfigMiddleware, async (c) => {
369
+ const { id } = c.req.param();
370
+ try {
371
+ const usage = await fetchOAuthUsage(id);
372
+ return c.json(usage);
373
+ }
374
+ catch (err) {
375
+ const msg = err instanceof Error ? err.message : 'Unknown error';
376
+ logger.warn({ err, providerId: id }, 'Failed to fetch OAuth usage');
377
+ return c.json({ error: msg }, 400);
378
+ }
379
+ });
380
+ // ─── PUT /claude/balancing — 更新负载均衡参数 ─────
381
+ configRoutes.put('/claude/balancing', authMiddleware, systemConfigMiddleware, async (c) => {
382
+ const body = await c.req.json().catch(() => ({}));
383
+ const validation = BalancingConfigSchema.safeParse(body);
384
+ if (!validation.success) {
385
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
386
+ }
387
+ const actor = c.get('user').username;
388
+ try {
389
+ const saved = saveBalancingConfig(validation.data);
390
+ appendClaudeConfigAudit(actor, 'update_balancing', [
391
+ ...Object.keys(validation.data),
392
+ ]);
393
+ return c.json(saved);
394
+ }
395
+ catch (err) {
396
+ const message = err instanceof Error ? err.message : 'Failed to update balancing';
397
+ return c.json({ error: message }, 400);
398
+ }
399
+ });
400
+ // ─── POST /claude/apply — 应用配置到所有容器 ─────
401
+ configRoutes.post('/claude/apply', authMiddleware, systemConfigMiddleware, async (c) => {
402
+ const actor = c.get('user').username;
403
+ try {
404
+ const result = await applyClaudeConfigToAllGroups(actor);
405
+ if (!result.success) {
406
+ return c.json(result, 207);
407
+ }
408
+ return c.json(result);
409
+ }
410
+ catch (err) {
411
+ logger.error({ err }, 'Failed to apply Claude config to all groups');
412
+ return c.json({ error: 'Server not initialized' }, 500);
413
+ }
414
+ });
415
+ // ─── POST /claude/oauth/start — 启动 OAuth PKCE 流程 ─────
416
+ configRoutes.post('/claude/oauth/start', authMiddleware, systemConfigMiddleware, async (c) => {
417
+ const body = await c.req.json().catch(() => ({}));
418
+ const targetProviderId = typeof body.targetProviderId === 'string'
419
+ ? body.targetProviderId
420
+ : undefined;
421
+ const state = randomBytes(32).toString('hex');
422
+ const codeVerifier = randomBytes(32).toString('base64url');
423
+ const codeChallenge = createHash('sha256')
424
+ .update(codeVerifier)
425
+ .digest('base64url');
426
+ oauthFlows.set(state, {
427
+ codeVerifier,
428
+ expiresAt: Date.now() + OAUTH_FLOW_TTL,
429
+ targetProviderId,
430
+ });
431
+ const params = new URLSearchParams({
432
+ response_type: 'code',
433
+ client_id: OAUTH_CLIENT_ID,
434
+ redirect_uri: OAUTH_REDIRECT_URI,
435
+ scope: OAUTH_SCOPES,
436
+ state,
437
+ code_challenge: codeChallenge,
438
+ code_challenge_method: 'S256',
439
+ });
440
+ return c.json({
441
+ authorizeUrl: `${OAUTH_AUTHORIZE_URL}?${params.toString()}`,
442
+ state,
443
+ });
444
+ });
445
+ // ─── POST /claude/oauth/callback — OAuth 回调 ─────
446
+ configRoutes.post('/claude/oauth/callback', authMiddleware, systemConfigMiddleware, async (c) => {
447
+ const body = await c.req.json().catch(() => ({}));
448
+ const { state, code } = body;
449
+ if (!state || !code) {
450
+ return c.json({ error: 'Missing state or code' }, 400);
451
+ }
452
+ const cleanedCode = code.trim().split('#')[0]?.split('&')[0] ?? code.trim();
453
+ const flow = oauthFlows.get(state);
454
+ if (!flow) {
455
+ return c.json({ error: 'Invalid or expired OAuth state' }, 400);
456
+ }
457
+ if (flow.expiresAt < Date.now()) {
458
+ oauthFlows.delete(state);
459
+ return c.json({ error: 'OAuth flow expired' }, 400);
460
+ }
461
+ oauthFlows.delete(state);
462
+ try {
463
+ const tokenResp = await fetch(OAUTH_TOKEN_URL, {
464
+ method: 'POST',
465
+ headers: {
466
+ 'Content-Type': 'application/json',
467
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
468
+ Accept: 'application/json, text/plain, */*',
469
+ Referer: 'https://claude.ai/',
470
+ Origin: 'https://claude.ai',
471
+ },
472
+ body: JSON.stringify({
473
+ grant_type: 'authorization_code',
474
+ client_id: OAUTH_CLIENT_ID,
475
+ code: cleanedCode,
476
+ redirect_uri: OAUTH_REDIRECT_URI,
477
+ code_verifier: flow.codeVerifier,
478
+ state,
479
+ expires_in: 31536000, // 1 year
480
+ }),
481
+ });
482
+ if (!tokenResp.ok) {
483
+ const errText = await tokenResp.text().catch(() => '');
484
+ logger.warn({ status: tokenResp.status, body: errText }, 'OAuth token exchange failed');
485
+ return c.json({ error: `Token exchange failed: ${tokenResp.status}` }, 400);
486
+ }
487
+ const tokenData = (await tokenResp.json());
488
+ if (!tokenData.access_token) {
489
+ return c.json({ error: 'No access_token in response' }, 400);
490
+ }
491
+ const actor = c.get('user').username;
492
+ let oauthCredentials = null;
493
+ if (tokenData.refresh_token) {
494
+ const expiresAt = tokenData.expires_in
495
+ ? Date.now() + tokenData.expires_in * 1000
496
+ : Date.now() + 8 * 60 * 60 * 1000;
497
+ oauthCredentials = {
498
+ accessToken: tokenData.access_token,
499
+ refreshToken: tokenData.refresh_token,
500
+ expiresAt,
501
+ scopes: tokenData.scope ? tokenData.scope.split(' ') : [],
502
+ };
503
+ }
504
+ let provider;
505
+ if (flow.targetProviderId) {
506
+ // Update existing provider's OAuth credentials
507
+ provider = updateProviderSecrets(flow.targetProviderId, {
508
+ claudeOAuthCredentials: oauthCredentials ?? undefined,
509
+ claudeCodeOauthToken: oauthCredentials
510
+ ? undefined
511
+ : tokenData.access_token,
512
+ clearAnthropicApiKey: true,
513
+ });
514
+ }
515
+ else {
516
+ // Create new official provider
517
+ provider = createProvider({
518
+ name: '官方 Claude (OAuth)',
519
+ type: 'official',
520
+ claudeOAuthCredentials: oauthCredentials,
521
+ claudeCodeOauthToken: oauthCredentials ? '' : tokenData.access_token,
522
+ enabled: true,
523
+ });
524
+ }
525
+ // Write .credentials.json to all sessions
526
+ if (oauthCredentials) {
527
+ updateAllSessionCredentials(providerToConfig(provider));
528
+ deps?.queue?.closeAllActiveForCredentialRefresh();
529
+ }
530
+ appendClaudeConfigAudit(actor, 'oauth_login', [
531
+ `providerId:${provider.id}`,
532
+ oauthCredentials
533
+ ? 'claudeOAuthCredentials:set'
534
+ : 'claudeCodeOauthToken:set',
535
+ ]);
536
+ return c.json(toPublicProvider(provider));
537
+ }
538
+ catch (err) {
539
+ logger.error({ err }, 'OAuth token exchange error');
540
+ const message = err instanceof Error ? err.message : 'OAuth token exchange failed';
541
+ return c.json({ error: message }, 500);
542
+ }
543
+ });
544
+ // ─── PUT /claude/custom-env — 更新当前启用供应商的自定义环境变量 ─────
545
+ configRoutes.put('/claude/custom-env', authMiddleware, systemConfigMiddleware, async (c) => {
546
+ const body = await c.req.json().catch(() => ({}));
547
+ const validation = ClaudeCustomEnvSchema.safeParse(body);
548
+ if (!validation.success) {
549
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
550
+ }
551
+ try {
552
+ // Find first enabled provider and update its customEnv
553
+ const enabled = getEnabledProviders();
554
+ if (enabled.length === 0) {
555
+ return c.json({ error: '没有启用的供应商' }, 400);
556
+ }
557
+ const updated = updateProvider(enabled[0].id, {
558
+ customEnv: validation.data.customEnv,
559
+ });
560
+ return c.json({ customEnv: updated.customEnv });
561
+ }
562
+ catch (err) {
563
+ const message = err instanceof Error ? err.message : 'Invalid custom env payload';
564
+ logger.warn({ err }, 'Invalid Claude custom env payload');
565
+ return c.json({ error: message }, 400);
566
+ }
567
+ });
568
+ // ─── Helpers ────────────────────────────────────────────────────
569
+ const _deprecationLogged = new Set();
570
+ function logDeprecationOnce(endpoint, replacement) {
571
+ if (_deprecationLogged.has(endpoint))
572
+ return;
573
+ logger.warn(`Deprecated: ${endpoint} — use ${replacement} instead`);
574
+ _deprecationLogged.add(endpoint);
575
+ }
576
+ function resolveProxyInfo(userProxy, sysProxy) {
577
+ return {
578
+ effectiveProxyUrl: userProxy || sysProxy,
579
+ proxySource: userProxy ? 'user' : sysProxy ? 'system' : 'none',
580
+ };
581
+ }
582
+ /** Persist a RegisteredGroup update and sync to the in-memory cache. */
583
+ function applyBindingUpdate(imJid, updated) {
584
+ setRegisteredGroup(imJid, updated);
585
+ const webDeps = getWebDeps();
586
+ if (webDeps) {
587
+ const groups = webDeps.getRegisteredGroups();
588
+ if (groups[imJid])
589
+ groups[imJid] = updated;
590
+ webDeps.clearImFailCounts?.(imJid);
591
+ }
592
+ }
593
+ configRoutes.get('/feishu', authMiddleware, systemConfigMiddleware, (c) => {
594
+ logDeprecationOnce('GET /api/config/feishu', 'GET /api/config/user-im/feishu');
595
+ try {
596
+ const { config, source } = getFeishuProviderConfigWithSource();
597
+ const pub = toPublicFeishuProviderConfig(config, source);
598
+ const connected = deps?.isFeishuConnected?.() ?? false;
599
+ return c.json({ ...pub, connected });
600
+ }
601
+ catch (err) {
602
+ logger.error({ err }, 'Failed to load Feishu config');
603
+ return c.json({ error: 'Failed to load Feishu config' }, 500);
604
+ }
605
+ });
606
+ configRoutes.put('/feishu', authMiddleware, systemConfigMiddleware, async (c) => {
607
+ const body = await c.req.json().catch(() => ({}));
608
+ const validation = FeishuConfigSchema.safeParse(body);
609
+ if (!validation.success) {
610
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
611
+ }
612
+ const current = getFeishuProviderConfig();
613
+ const next = { ...current };
614
+ if (typeof validation.data.appId === 'string') {
615
+ next.appId = validation.data.appId;
616
+ }
617
+ if (typeof validation.data.appSecret === 'string') {
618
+ next.appSecret = validation.data.appSecret;
619
+ }
620
+ else if (validation.data.clearAppSecret === true) {
621
+ next.appSecret = '';
622
+ }
623
+ if (typeof validation.data.enabled === 'boolean') {
624
+ next.enabled = validation.data.enabled;
625
+ }
626
+ try {
627
+ const saved = saveFeishuProviderConfig({
628
+ appId: next.appId,
629
+ appSecret: next.appSecret,
630
+ enabled: next.enabled,
631
+ });
632
+ // Hot-reload: reconnect/disconnect Feishu channel
633
+ let connected = false;
634
+ if (deps?.reloadFeishuConnection) {
635
+ try {
636
+ connected = await deps.reloadFeishuConnection(saved);
637
+ }
638
+ catch (err) {
639
+ logger.warn({ err }, 'Failed to reload Feishu connection');
640
+ }
641
+ }
642
+ return c.json({
643
+ ...toPublicFeishuProviderConfig(saved, 'runtime'),
644
+ connected,
645
+ });
646
+ }
647
+ catch (err) {
648
+ const message = err instanceof Error ? err.message : 'Invalid Feishu config payload';
649
+ logger.warn({ err }, 'Invalid Feishu config payload');
650
+ return c.json({ error: message }, 400);
651
+ }
652
+ });
653
+ // ─── Telegram config ─────────────────────────────────────────────
654
+ configRoutes.get('/telegram', authMiddleware, systemConfigMiddleware, (c) => {
655
+ logDeprecationOnce('GET /api/config/telegram', 'GET /api/config/user-im/telegram');
656
+ try {
657
+ const { config, source } = getTelegramProviderConfigWithSource();
658
+ const pub = toPublicTelegramProviderConfig(config, source);
659
+ const connected = deps?.isTelegramConnected?.() ?? false;
660
+ return c.json({ ...pub, connected });
661
+ }
662
+ catch (err) {
663
+ logger.error({ err }, 'Failed to load Telegram config');
664
+ return c.json({ error: 'Failed to load Telegram config' }, 500);
665
+ }
666
+ });
667
+ configRoutes.put('/telegram', authMiddleware, systemConfigMiddleware, async (c) => {
668
+ const body = await c.req.json().catch(() => ({}));
669
+ const validation = TelegramConfigSchema.safeParse(body);
670
+ if (!validation.success) {
671
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
672
+ }
673
+ const current = getTelegramProviderConfig();
674
+ const next = { ...current };
675
+ if (typeof validation.data.botToken === 'string') {
676
+ next.botToken = validation.data.botToken;
677
+ }
678
+ else if (validation.data.clearBotToken === true) {
679
+ next.botToken = '';
680
+ }
681
+ if (typeof validation.data.proxyUrl === 'string') {
682
+ next.proxyUrl = validation.data.proxyUrl;
683
+ }
684
+ else if (validation.data.clearProxyUrl === true) {
685
+ next.proxyUrl = '';
686
+ }
687
+ if (typeof validation.data.enabled === 'boolean') {
688
+ next.enabled = validation.data.enabled;
689
+ }
690
+ try {
691
+ const saved = saveTelegramProviderConfig({
692
+ botToken: next.botToken,
693
+ proxyUrl: next.proxyUrl,
694
+ enabled: next.enabled,
695
+ });
696
+ // Hot-reload: reconnect/disconnect Telegram channel
697
+ let connected = false;
698
+ if (deps?.reloadTelegramConnection) {
699
+ try {
700
+ connected = await deps.reloadTelegramConnection(saved);
701
+ }
702
+ catch (err) {
703
+ logger.warn({ err }, 'Failed to reload Telegram connection');
704
+ }
705
+ }
706
+ return c.json({
707
+ ...toPublicTelegramProviderConfig(saved, 'runtime'),
708
+ connected,
709
+ });
710
+ }
711
+ catch (err) {
712
+ const message = err instanceof Error ? err.message : 'Invalid Telegram config payload';
713
+ logger.warn({ err }, 'Invalid Telegram config payload');
714
+ return c.json({ error: message }, 400);
715
+ }
716
+ });
717
+ configRoutes.post('/telegram/test', authMiddleware, systemConfigMiddleware, async (c) => {
718
+ const config = getTelegramProviderConfig();
719
+ if (!config.botToken) {
720
+ return c.json({ error: 'Telegram bot token not configured' }, 400);
721
+ }
722
+ const agent = createTelegramApiAgent(config.proxyUrl);
723
+ try {
724
+ const { Bot } = await import('grammy');
725
+ const testBot = new Bot(config.botToken, {
726
+ client: {
727
+ timeoutSeconds: 15,
728
+ baseFetchConfig: {
729
+ agent,
730
+ },
731
+ },
732
+ });
733
+ let me = null;
734
+ let lastErr = null;
735
+ for (let i = 0; i < 3; i++) {
736
+ try {
737
+ me = await testBot.api.getMe();
738
+ break;
739
+ }
740
+ catch (err) {
741
+ lastErr = err;
742
+ // Small retry window for intermittent network timeouts.
743
+ if (i < 2)
744
+ await new Promise((resolve) => setTimeout(resolve, 300));
745
+ }
746
+ }
747
+ if (!me) {
748
+ throw lastErr instanceof Error
749
+ ? lastErr
750
+ : new Error('Telegram API request failed');
751
+ }
752
+ return c.json({
753
+ success: true,
754
+ bot_username: me.username,
755
+ bot_id: me.id,
756
+ bot_name: me.first_name,
757
+ });
758
+ }
759
+ catch (err) {
760
+ const message = err instanceof Error ? err.message : 'Failed to connect to Telegram';
761
+ logger.warn({ err }, 'Failed to test Telegram connection');
762
+ return c.json({ error: message }, 400);
763
+ }
764
+ finally {
765
+ destroyTelegramApiAgent(agent);
766
+ }
767
+ });
768
+ // ─── Registration config ─────────────────────────────────────────
769
+ configRoutes.get('/registration', authMiddleware, systemConfigMiddleware, (c) => {
770
+ try {
771
+ return c.json(getRegistrationConfig());
772
+ }
773
+ catch (err) {
774
+ logger.error({ err }, 'Failed to load registration config');
775
+ return c.json({ error: 'Failed to load registration config' }, 500);
776
+ }
777
+ });
778
+ configRoutes.put('/registration', authMiddleware, systemConfigMiddleware, async (c) => {
779
+ const body = await c.req.json().catch(() => ({}));
780
+ const validation = RegistrationConfigSchema.safeParse(body);
781
+ if (!validation.success) {
782
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
783
+ }
784
+ try {
785
+ const actor = c.get('user').username;
786
+ const saved = saveRegistrationConfig(validation.data);
787
+ appendClaudeConfigAudit(actor, 'update_registration_config', [
788
+ 'allowRegistration',
789
+ 'requireInviteCode',
790
+ ]);
791
+ return c.json(saved);
792
+ }
793
+ catch (err) {
794
+ const message = err instanceof Error
795
+ ? err.message
796
+ : 'Invalid registration config payload';
797
+ logger.warn({ err }, 'Invalid registration config payload');
798
+ return c.json({ error: message }, 400);
799
+ }
800
+ });
801
+ // ─── Appearance config ────────────────────────────────────────────
802
+ configRoutes.get('/appearance', authMiddleware, systemConfigMiddleware, (c) => {
803
+ try {
804
+ return c.json(getAppearanceConfig());
805
+ }
806
+ catch (err) {
807
+ logger.error({ err }, 'Failed to load appearance config');
808
+ return c.json({ error: 'Failed to load appearance config' }, 500);
809
+ }
810
+ });
811
+ configRoutes.put('/appearance', authMiddleware, systemConfigMiddleware, async (c) => {
812
+ const body = await c.req.json().catch(() => ({}));
813
+ const validation = AppearanceConfigSchema.safeParse(body);
814
+ if (!validation.success) {
815
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
816
+ }
817
+ try {
818
+ const saved = saveAppearanceConfig(validation.data);
819
+ return c.json(saved);
820
+ }
821
+ catch (err) {
822
+ const message = err instanceof Error
823
+ ? err.message
824
+ : 'Invalid appearance config payload';
825
+ logger.warn({ err }, 'Invalid appearance config payload');
826
+ return c.json({ error: message }, 400);
827
+ }
828
+ });
829
+ // Public endpoint — no auth required (like /api/auth/status)
830
+ configRoutes.get('/appearance/public', (c) => {
831
+ try {
832
+ const config = getAppearanceConfig();
833
+ return c.json({
834
+ appName: config.appName,
835
+ aiName: config.aiName,
836
+ aiAvatarEmoji: config.aiAvatarEmoji,
837
+ aiAvatarColor: config.aiAvatarColor,
838
+ });
839
+ }
840
+ catch (err) {
841
+ logger.error({ err }, 'Failed to load public appearance config');
842
+ return c.json({ error: 'Failed to load appearance config' }, 500);
843
+ }
844
+ });
845
+ // ─── System settings ───────────────────────────────────────────────
846
+ configRoutes.get('/system', authMiddleware, systemConfigMiddleware, (c) => {
847
+ try {
848
+ return c.json(getSystemSettings());
849
+ }
850
+ catch (err) {
851
+ logger.error({ err }, 'Failed to load system settings');
852
+ return c.json({ error: 'Failed to load system settings' }, 500);
853
+ }
854
+ });
855
+ configRoutes.put('/system', authMiddleware, systemConfigMiddleware, async (c) => {
856
+ const body = await c.req.json().catch(() => ({}));
857
+ const validation = SystemSettingsSchema.safeParse(body);
858
+ if (!validation.success) {
859
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
860
+ }
861
+ try {
862
+ const saved = saveSystemSettings(validation.data);
863
+ clearBillingEnabledCache();
864
+ return c.json(saved);
865
+ }
866
+ catch (err) {
867
+ const message = err instanceof Error ? err.message : 'Invalid system settings payload';
868
+ logger.warn({ err }, 'Invalid system settings payload');
869
+ return c.json({ error: message }, 400);
870
+ }
871
+ });
872
+ // ─── Per-user IM connection status ──────────────────────────────────
873
+ configRoutes.get('/user-im/status', authMiddleware, (c) => {
874
+ const user = c.get('user');
875
+ return c.json({
876
+ feishu: deps?.isUserFeishuConnected?.(user.id) ?? false,
877
+ telegram: deps?.isUserTelegramConnected?.(user.id) ?? false,
878
+ qq: deps?.isUserQQConnected?.(user.id) ?? false,
879
+ wechat: deps?.isUserWeChatConnected?.(user.id) ?? false,
880
+ dingtalk: deps?.isUserDingTalkConnected?.(user.id) ?? false,
881
+ });
882
+ });
883
+ // ─── Per-user IM config (all logged-in users) ─────────────────────
884
+ configRoutes.get('/user-im/feishu', authMiddleware, (c) => {
885
+ const user = c.get('user');
886
+ try {
887
+ const config = getUserFeishuConfig(user.id);
888
+ const connected = deps?.isUserFeishuConnected?.(user.id) ?? false;
889
+ if (!config) {
890
+ return c.json({
891
+ appId: '',
892
+ hasAppSecret: false,
893
+ appSecretMasked: null,
894
+ enabled: false,
895
+ updatedAt: null,
896
+ connected,
897
+ });
898
+ }
899
+ return c.json({
900
+ ...toPublicFeishuProviderConfig(config, 'runtime'),
901
+ connected,
902
+ });
903
+ }
904
+ catch (err) {
905
+ logger.error({ err }, 'Failed to load user Feishu config');
906
+ return c.json({ error: 'Failed to load user Feishu config' }, 500);
907
+ }
908
+ });
909
+ configRoutes.put('/user-im/feishu', authMiddleware, async (c) => {
910
+ const user = c.get('user');
911
+ const body = await c.req.json().catch(() => ({}));
912
+ const validation = FeishuConfigSchema.safeParse(body);
913
+ if (!validation.success) {
914
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
915
+ }
916
+ // Billing: check IM channel limit when enabling
917
+ if (validation.data.enabled === true && isBillingEnabled()) {
918
+ const currentFeishu = getUserFeishuConfig(user.id);
919
+ if (!currentFeishu?.enabled) {
920
+ const limit = checkImChannelLimit(user.id, user.role, countOtherEnabledImChannels(user.id, 'feishu'));
921
+ if (!limit.allowed) {
922
+ return c.json({ error: limit.reason }, 403);
923
+ }
924
+ }
925
+ }
926
+ const current = getUserFeishuConfig(user.id);
927
+ const next = {
928
+ appId: current?.appId || '',
929
+ appSecret: current?.appSecret || '',
930
+ enabled: current?.enabled ?? true,
931
+ updatedAt: current?.updatedAt || null,
932
+ };
933
+ if (typeof validation.data.appId === 'string') {
934
+ const appId = validation.data.appId.trim();
935
+ if (appId)
936
+ next.appId = appId;
937
+ }
938
+ if (typeof validation.data.appSecret === 'string') {
939
+ const appSecret = validation.data.appSecret.trim();
940
+ if (appSecret)
941
+ next.appSecret = appSecret;
942
+ }
943
+ else if (validation.data.clearAppSecret === true) {
944
+ next.appSecret = '';
945
+ }
946
+ if (typeof validation.data.enabled === 'boolean') {
947
+ next.enabled = validation.data.enabled;
948
+ }
949
+ else if (!current && (next.appId || next.appSecret)) {
950
+ // First-time config with credentials should connect immediately.
951
+ next.enabled = true;
952
+ }
953
+ try {
954
+ const saved = saveUserFeishuConfig(user.id, {
955
+ appId: next.appId,
956
+ appSecret: next.appSecret,
957
+ enabled: next.enabled,
958
+ });
959
+ // Hot-reload: reconnect user's Feishu channel
960
+ if (deps?.reloadUserIMConfig) {
961
+ try {
962
+ await deps.reloadUserIMConfig(user.id, 'feishu');
963
+ }
964
+ catch (err) {
965
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload user Feishu connection');
966
+ }
967
+ }
968
+ const connected = deps?.isUserFeishuConnected?.(user.id) ?? false;
969
+ return c.json({
970
+ ...toPublicFeishuProviderConfig(saved, 'runtime'),
971
+ connected,
972
+ });
973
+ }
974
+ catch (err) {
975
+ const message = err instanceof Error ? err.message : 'Invalid Feishu config payload';
976
+ logger.warn({ err }, 'Invalid user Feishu config payload');
977
+ return c.json({ error: message }, 400);
978
+ }
979
+ });
980
+ configRoutes.get('/user-im/telegram', authMiddleware, (c) => {
981
+ const user = c.get('user');
982
+ try {
983
+ const config = getUserTelegramConfig(user.id);
984
+ const connected = deps?.isUserTelegramConnected?.(user.id) ?? false;
985
+ const globalConfig = getTelegramProviderConfig();
986
+ const userProxy = config?.proxyUrl || '';
987
+ const sysProxy = globalConfig.proxyUrl || '';
988
+ const proxy = resolveProxyInfo(userProxy, sysProxy);
989
+ if (!config) {
990
+ return c.json({
991
+ hasBotToken: false,
992
+ botTokenMasked: null,
993
+ enabled: false,
994
+ updatedAt: null,
995
+ connected,
996
+ proxyUrl: '',
997
+ ...proxy,
998
+ });
999
+ }
1000
+ return c.json({
1001
+ ...toPublicTelegramProviderConfig(config, 'runtime'),
1002
+ connected,
1003
+ proxyUrl: userProxy,
1004
+ ...proxy,
1005
+ });
1006
+ }
1007
+ catch (err) {
1008
+ logger.error({ err }, 'Failed to load user Telegram config');
1009
+ return c.json({ error: 'Failed to load user Telegram config' }, 500);
1010
+ }
1011
+ });
1012
+ configRoutes.put('/user-im/telegram', authMiddleware, async (c) => {
1013
+ const user = c.get('user');
1014
+ const body = await c.req.json().catch(() => ({}));
1015
+ const validation = TelegramConfigSchema.safeParse(body);
1016
+ if (!validation.success) {
1017
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
1018
+ }
1019
+ // Billing: check IM channel limit when enabling
1020
+ if (validation.data.enabled === true && isBillingEnabled()) {
1021
+ const currentTg = getUserTelegramConfig(user.id);
1022
+ if (!currentTg?.enabled) {
1023
+ const limit = checkImChannelLimit(user.id, user.role, countOtherEnabledImChannels(user.id, 'telegram'));
1024
+ if (!limit.allowed) {
1025
+ return c.json({ error: limit.reason }, 403);
1026
+ }
1027
+ }
1028
+ }
1029
+ const current = getUserTelegramConfig(user.id);
1030
+ const next = {
1031
+ botToken: current?.botToken || '',
1032
+ proxyUrl: current?.proxyUrl || '',
1033
+ enabled: current?.enabled ?? true,
1034
+ updatedAt: current?.updatedAt || null,
1035
+ };
1036
+ if (typeof validation.data.botToken === 'string') {
1037
+ const botToken = validation.data.botToken.trim();
1038
+ if (botToken)
1039
+ next.botToken = botToken;
1040
+ }
1041
+ else if (validation.data.clearBotToken === true) {
1042
+ next.botToken = '';
1043
+ }
1044
+ if (typeof validation.data.proxyUrl === 'string') {
1045
+ next.proxyUrl = validation.data.proxyUrl.trim();
1046
+ }
1047
+ else if (validation.data.clearProxyUrl === true) {
1048
+ next.proxyUrl = '';
1049
+ }
1050
+ if (typeof validation.data.enabled === 'boolean') {
1051
+ next.enabled = validation.data.enabled;
1052
+ }
1053
+ else if (!current && next.botToken) {
1054
+ // First-time config with token should connect immediately.
1055
+ next.enabled = true;
1056
+ }
1057
+ try {
1058
+ const saved = saveUserTelegramConfig(user.id, {
1059
+ botToken: next.botToken,
1060
+ proxyUrl: next.proxyUrl || undefined,
1061
+ enabled: next.enabled,
1062
+ });
1063
+ // Hot-reload: reconnect user's Telegram channel
1064
+ if (deps?.reloadUserIMConfig) {
1065
+ try {
1066
+ await deps.reloadUserIMConfig(user.id, 'telegram');
1067
+ }
1068
+ catch (err) {
1069
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload user Telegram connection');
1070
+ }
1071
+ }
1072
+ const connected = deps?.isUserTelegramConnected?.(user.id) ?? false;
1073
+ const userProxy = saved.proxyUrl || '';
1074
+ const sysProxy = getTelegramProviderConfig().proxyUrl || '';
1075
+ return c.json({
1076
+ ...toPublicTelegramProviderConfig(saved, 'runtime'),
1077
+ connected,
1078
+ proxyUrl: userProxy,
1079
+ ...resolveProxyInfo(userProxy, sysProxy),
1080
+ });
1081
+ }
1082
+ catch (err) {
1083
+ const message = err instanceof Error ? err.message : 'Invalid Telegram config payload';
1084
+ logger.warn({ err }, 'Invalid user Telegram config payload');
1085
+ return c.json({ error: message }, 400);
1086
+ }
1087
+ });
1088
+ configRoutes.post('/user-im/telegram/test', authMiddleware, async (c) => {
1089
+ const user = c.get('user');
1090
+ const config = getUserTelegramConfig(user.id);
1091
+ if (!config?.botToken) {
1092
+ return c.json({ error: 'Telegram bot token not configured' }, 400);
1093
+ }
1094
+ const globalTelegramConfig = getTelegramProviderConfig();
1095
+ const effectiveProxy = config.proxyUrl || globalTelegramConfig.proxyUrl;
1096
+ const agent = createTelegramApiAgent(effectiveProxy);
1097
+ try {
1098
+ const { Bot } = await import('grammy');
1099
+ const testBot = new Bot(config.botToken, {
1100
+ client: {
1101
+ timeoutSeconds: 15,
1102
+ baseFetchConfig: {
1103
+ agent,
1104
+ },
1105
+ },
1106
+ });
1107
+ const me = await testBot.api.getMe();
1108
+ return c.json({
1109
+ success: true,
1110
+ bot_username: me.username,
1111
+ bot_id: me.id,
1112
+ bot_name: me.first_name,
1113
+ });
1114
+ }
1115
+ catch (err) {
1116
+ const message = err instanceof Error ? err.message : 'Failed to connect to Telegram';
1117
+ logger.warn({ err }, 'Failed to test user Telegram connection');
1118
+ return c.json({ error: message }, 400);
1119
+ }
1120
+ finally {
1121
+ destroyTelegramApiAgent(agent);
1122
+ }
1123
+ });
1124
+ configRoutes.post('/user-im/telegram/pairing-code', authMiddleware, async (c) => {
1125
+ const user = c.get('user');
1126
+ const config = getUserTelegramConfig(user.id);
1127
+ if (!config?.botToken) {
1128
+ return c.json({ error: 'Telegram bot token not configured' }, 400);
1129
+ }
1130
+ try {
1131
+ const { generatePairingCode } = await import('../telegram-pairing.js');
1132
+ const result = generatePairingCode(user.id);
1133
+ return c.json(result);
1134
+ }
1135
+ catch (err) {
1136
+ const message = err instanceof Error ? err.message : 'Failed to generate pairing code';
1137
+ logger.warn({ err }, 'Failed to generate pairing code');
1138
+ return c.json({ error: message }, 500);
1139
+ }
1140
+ });
1141
+ // List Telegram paired chats for the current user
1142
+ configRoutes.get('/user-im/telegram/paired-chats', authMiddleware, (c) => {
1143
+ const user = c.get('user');
1144
+ const groups = (deps?.getRegisteredGroups() ?? {});
1145
+ const chats = [];
1146
+ for (const [jid, group] of Object.entries(groups)) {
1147
+ if (jid.startsWith('telegram:') && group.created_by === user.id) {
1148
+ chats.push({ jid, name: group.name, addedAt: group.added_at });
1149
+ }
1150
+ }
1151
+ return c.json({ chats });
1152
+ });
1153
+ // Remove (unpair) a Telegram chat
1154
+ configRoutes.delete('/user-im/telegram/paired-chats/:jid', authMiddleware, (c) => {
1155
+ const user = c.get('user');
1156
+ const jid = decodeURIComponent(c.req.param('jid'));
1157
+ if (!jid.startsWith('telegram:')) {
1158
+ return c.json({ error: 'Invalid Telegram chat JID' }, 400);
1159
+ }
1160
+ const groups = deps?.getRegisteredGroups() ?? {};
1161
+ const group = groups[jid];
1162
+ if (!group) {
1163
+ return c.json({ error: 'Chat not found' }, 404);
1164
+ }
1165
+ if (group.created_by !== user.id) {
1166
+ return c.json({ error: 'Not authorized to remove this chat' }, 403);
1167
+ }
1168
+ deleteRegisteredGroup(jid);
1169
+ deleteChatHistory(jid);
1170
+ delete groups[jid];
1171
+ logger.info({ jid, userId: user.id }, 'Telegram chat unpaired');
1172
+ return c.json({ success: true });
1173
+ });
1174
+ // ─── QQ User IM Config ──────────────────────────────────────────
1175
+ function maskQQAppSecret(secret) {
1176
+ if (!secret)
1177
+ return null;
1178
+ if (secret.length <= 8)
1179
+ return '***';
1180
+ return secret.slice(0, 4) + '***' + secret.slice(-4);
1181
+ }
1182
+ configRoutes.get('/user-im/qq', authMiddleware, (c) => {
1183
+ const user = c.get('user');
1184
+ try {
1185
+ const config = getUserQQConfig(user.id);
1186
+ const connected = deps?.isUserQQConnected?.(user.id) ?? false;
1187
+ if (!config) {
1188
+ return c.json({
1189
+ appId: '',
1190
+ hasAppSecret: false,
1191
+ appSecretMasked: null,
1192
+ enabled: false,
1193
+ updatedAt: null,
1194
+ connected,
1195
+ });
1196
+ }
1197
+ return c.json({
1198
+ appId: config.appId,
1199
+ hasAppSecret: !!config.appSecret,
1200
+ appSecretMasked: maskQQAppSecret(config.appSecret),
1201
+ enabled: config.enabled ?? false,
1202
+ updatedAt: config.updatedAt,
1203
+ connected,
1204
+ });
1205
+ }
1206
+ catch (err) {
1207
+ logger.error({ err }, 'Failed to load user QQ config');
1208
+ return c.json({ error: 'Failed to load user QQ config' }, 500);
1209
+ }
1210
+ });
1211
+ configRoutes.put('/user-im/qq', authMiddleware, async (c) => {
1212
+ const user = c.get('user');
1213
+ const body = await c.req.json().catch(() => ({}));
1214
+ const validation = QQConfigSchema.safeParse(body);
1215
+ if (!validation.success) {
1216
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
1217
+ }
1218
+ // Billing: check IM channel limit when enabling
1219
+ if (validation.data.enabled === true && isBillingEnabled()) {
1220
+ const currentQQ = getUserQQConfig(user.id);
1221
+ if (!currentQQ?.enabled) {
1222
+ const limit = checkImChannelLimit(user.id, user.role, countOtherEnabledImChannels(user.id, 'qq'));
1223
+ if (!limit.allowed) {
1224
+ return c.json({ error: limit.reason }, 403);
1225
+ }
1226
+ }
1227
+ }
1228
+ const current = getUserQQConfig(user.id);
1229
+ const next = {
1230
+ appId: current?.appId || '',
1231
+ appSecret: current?.appSecret || '',
1232
+ enabled: current?.enabled ?? true,
1233
+ };
1234
+ if (typeof validation.data.appId === 'string') {
1235
+ next.appId = validation.data.appId.trim();
1236
+ }
1237
+ if (typeof validation.data.appSecret === 'string') {
1238
+ const appSecret = validation.data.appSecret.trim();
1239
+ if (appSecret)
1240
+ next.appSecret = appSecret;
1241
+ }
1242
+ else if (validation.data.clearAppSecret === true) {
1243
+ next.appSecret = '';
1244
+ }
1245
+ if (typeof validation.data.enabled === 'boolean') {
1246
+ next.enabled = validation.data.enabled;
1247
+ }
1248
+ else if (!current && next.appId && next.appSecret) {
1249
+ next.enabled = true;
1250
+ }
1251
+ try {
1252
+ const saved = saveUserQQConfig(user.id, {
1253
+ appId: next.appId,
1254
+ appSecret: next.appSecret,
1255
+ enabled: next.enabled,
1256
+ });
1257
+ // Hot-reload: reconnect user's QQ channel
1258
+ if (deps?.reloadUserIMConfig) {
1259
+ try {
1260
+ await deps.reloadUserIMConfig(user.id, 'qq');
1261
+ }
1262
+ catch (err) {
1263
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload user QQ connection');
1264
+ }
1265
+ }
1266
+ const connected = deps?.isUserQQConnected?.(user.id) ?? false;
1267
+ return c.json({
1268
+ appId: saved.appId,
1269
+ hasAppSecret: !!saved.appSecret,
1270
+ appSecretMasked: maskQQAppSecret(saved.appSecret),
1271
+ enabled: saved.enabled ?? false,
1272
+ updatedAt: saved.updatedAt,
1273
+ connected,
1274
+ });
1275
+ }
1276
+ catch (err) {
1277
+ const message = err instanceof Error ? err.message : 'Invalid QQ config payload';
1278
+ logger.warn({ err }, 'Invalid user QQ config payload');
1279
+ return c.json({ error: message }, 400);
1280
+ }
1281
+ });
1282
+ configRoutes.post('/user-im/qq/test', authMiddleware, async (c) => {
1283
+ const user = c.get('user');
1284
+ const config = getUserQQConfig(user.id);
1285
+ if (!config?.appId || !config?.appSecret) {
1286
+ return c.json({ error: 'QQ App ID and App Secret not configured' }, 400);
1287
+ }
1288
+ try {
1289
+ // Test by fetching access token
1290
+ const https = await import('node:https');
1291
+ const body = JSON.stringify({
1292
+ appId: config.appId,
1293
+ clientSecret: config.appSecret,
1294
+ });
1295
+ const result = await new Promise((resolve, reject) => {
1296
+ const url = new URL('https://bots.qq.com/app/getAppAccessToken');
1297
+ const req = https.request({
1298
+ hostname: url.hostname,
1299
+ path: url.pathname,
1300
+ method: 'POST',
1301
+ headers: {
1302
+ 'Content-Type': 'application/json',
1303
+ 'Content-Length': String(Buffer.byteLength(body)),
1304
+ },
1305
+ timeout: 15000,
1306
+ }, (res) => {
1307
+ const chunks = [];
1308
+ res.on('data', (chunk) => chunks.push(chunk));
1309
+ res.on('end', () => {
1310
+ try {
1311
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
1312
+ }
1313
+ catch (err) {
1314
+ reject(err);
1315
+ }
1316
+ });
1317
+ res.on('error', reject);
1318
+ });
1319
+ req.on('error', reject);
1320
+ req.on('timeout', () => {
1321
+ req.destroy(new Error('Request timeout'));
1322
+ });
1323
+ req.write(body);
1324
+ req.end();
1325
+ });
1326
+ if (!result.access_token) {
1327
+ return c.json({
1328
+ error: 'Failed to obtain access token. Please check App ID and App Secret.',
1329
+ }, 400);
1330
+ }
1331
+ return c.json({
1332
+ success: true,
1333
+ expires_in: result.expires_in,
1334
+ });
1335
+ }
1336
+ catch (err) {
1337
+ const message = err instanceof Error ? err.message : 'Failed to connect to QQ';
1338
+ logger.warn({ err }, 'Failed to test user QQ connection');
1339
+ return c.json({ error: message }, 400);
1340
+ }
1341
+ });
1342
+ configRoutes.post('/user-im/qq/pairing-code', authMiddleware, async (c) => {
1343
+ const user = c.get('user');
1344
+ const config = getUserQQConfig(user.id);
1345
+ if (!config?.appId || !config?.appSecret) {
1346
+ return c.json({ error: 'QQ App ID and App Secret not configured' }, 400);
1347
+ }
1348
+ try {
1349
+ const { generatePairingCode } = await import('../telegram-pairing.js');
1350
+ const result = generatePairingCode(user.id);
1351
+ return c.json(result);
1352
+ }
1353
+ catch (err) {
1354
+ const message = err instanceof Error ? err.message : 'Failed to generate pairing code';
1355
+ logger.warn({ err }, 'Failed to generate QQ pairing code');
1356
+ return c.json({ error: message }, 500);
1357
+ }
1358
+ });
1359
+ // List QQ paired chats for the current user
1360
+ configRoutes.get('/user-im/qq/paired-chats', authMiddleware, (c) => {
1361
+ const user = c.get('user');
1362
+ const groups = (deps?.getRegisteredGroups() ?? {});
1363
+ const chats = [];
1364
+ for (const [jid, group] of Object.entries(groups)) {
1365
+ if (jid.startsWith('qq:') && group.created_by === user.id) {
1366
+ chats.push({ jid, name: group.name, addedAt: group.added_at });
1367
+ }
1368
+ }
1369
+ return c.json({ chats });
1370
+ });
1371
+ // Remove (unpair) a QQ chat
1372
+ configRoutes.delete('/user-im/qq/paired-chats/:jid', authMiddleware, (c) => {
1373
+ const user = c.get('user');
1374
+ const jid = decodeURIComponent(c.req.param('jid'));
1375
+ if (!jid.startsWith('qq:')) {
1376
+ return c.json({ error: 'Invalid QQ chat JID' }, 400);
1377
+ }
1378
+ const groups = deps?.getRegisteredGroups() ?? {};
1379
+ const group = groups[jid];
1380
+ if (!group) {
1381
+ return c.json({ error: 'Chat not found' }, 404);
1382
+ }
1383
+ if (group.created_by !== user.id) {
1384
+ return c.json({ error: 'Not authorized to remove this chat' }, 403);
1385
+ }
1386
+ deleteRegisteredGroup(jid);
1387
+ deleteChatHistory(jid);
1388
+ delete groups[jid];
1389
+ logger.info({ jid, userId: user.id }, 'QQ chat unpaired');
1390
+ return c.json({ success: true });
1391
+ });
1392
+ // ─── Per-user DingTalk IM config ──────────────────────────────────
1393
+ configRoutes.get('/user-im/dingtalk', authMiddleware, (c) => {
1394
+ const user = c.get('user');
1395
+ try {
1396
+ const config = getUserDingTalkConfig(user.id);
1397
+ const connected = deps?.isUserDingTalkConnected?.(user.id) ?? false;
1398
+ if (!config) {
1399
+ return c.json({
1400
+ clientId: '',
1401
+ hasClientSecret: false,
1402
+ clientSecretMasked: null,
1403
+ enabled: false,
1404
+ updatedAt: null,
1405
+ connected,
1406
+ });
1407
+ }
1408
+ return c.json({
1409
+ clientId: config.clientId,
1410
+ hasClientSecret: !!config.clientSecret,
1411
+ clientSecretMasked: config.clientSecret
1412
+ ? config.clientSecret.slice(0, 4) +
1413
+ '***' +
1414
+ config.clientSecret.slice(-4)
1415
+ : null,
1416
+ enabled: config.enabled ?? false,
1417
+ updatedAt: config.updatedAt,
1418
+ connected,
1419
+ });
1420
+ }
1421
+ catch (err) {
1422
+ logger.error({ err }, 'Failed to load user DingTalk config');
1423
+ return c.json({ error: 'Failed to load DingTalk config' }, 500);
1424
+ }
1425
+ });
1426
+ configRoutes.put('/user-im/dingtalk', authMiddleware, async (c) => {
1427
+ const user = c.get('user');
1428
+ const body = await c.req.json().catch(() => ({}));
1429
+ const validation = DingTalkConfigSchema.safeParse(body);
1430
+ if (!validation.success) {
1431
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
1432
+ }
1433
+ // Billing: check IM channel limit when enabling
1434
+ if (validation.data.enabled === true && isBillingEnabled()) {
1435
+ const current = getUserDingTalkConfig(user.id);
1436
+ if (!current?.enabled) {
1437
+ const limit = checkImChannelLimit(user.id, user.role, countOtherEnabledImChannels(user.id, 'dingtalk'));
1438
+ if (!limit.allowed) {
1439
+ return c.json({ error: limit.reason }, 403);
1440
+ }
1441
+ }
1442
+ }
1443
+ const current = getUserDingTalkConfig(user.id);
1444
+ const next = {
1445
+ clientId: current?.clientId || '',
1446
+ clientSecret: current?.clientSecret || '',
1447
+ enabled: current?.enabled ?? true,
1448
+ };
1449
+ if (typeof validation.data.clientId === 'string') {
1450
+ next.clientId = validation.data.clientId.trim();
1451
+ }
1452
+ if (typeof validation.data.clientSecret === 'string') {
1453
+ const secret = validation.data.clientSecret.trim();
1454
+ if (secret)
1455
+ next.clientSecret = secret;
1456
+ }
1457
+ else if (validation.data.clearClientSecret === true) {
1458
+ next.clientSecret = '';
1459
+ }
1460
+ if (typeof validation.data.enabled === 'boolean') {
1461
+ next.enabled = validation.data.enabled;
1462
+ }
1463
+ else if (!current && (next.clientId || next.clientSecret)) {
1464
+ next.enabled = true;
1465
+ }
1466
+ try {
1467
+ const saved = saveUserDingTalkConfig(user.id, next);
1468
+ // Hot-reload: reconnect user's DingTalk channel
1469
+ if (deps?.reloadUserIMConfig) {
1470
+ try {
1471
+ await deps.reloadUserIMConfig(user.id, 'dingtalk');
1472
+ }
1473
+ catch (err) {
1474
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload DingTalk');
1475
+ }
1476
+ }
1477
+ const connected = deps?.isUserDingTalkConnected?.(user.id) ?? false;
1478
+ return c.json({
1479
+ clientId: saved.clientId,
1480
+ hasClientSecret: !!saved.clientSecret,
1481
+ clientSecretMasked: saved.clientSecret
1482
+ ? saved.clientSecret.slice(0, 4) + '***' + saved.clientSecret.slice(-4)
1483
+ : null,
1484
+ enabled: saved.enabled ?? false,
1485
+ updatedAt: saved.updatedAt,
1486
+ connected,
1487
+ });
1488
+ }
1489
+ catch (err) {
1490
+ const message = err instanceof Error ? err.message : 'Invalid config';
1491
+ logger.warn({ err }, 'Invalid DingTalk config');
1492
+ return c.json({ error: message }, 400);
1493
+ }
1494
+ });
1495
+ configRoutes.post('/user-im/dingtalk/test', authMiddleware, async (c) => {
1496
+ const user = c.get('user');
1497
+ const config = getUserDingTalkConfig(user.id);
1498
+ if (!config?.clientId || !config?.clientSecret) {
1499
+ return c.json({ error: 'DingTalk credentials not configured' }, 400);
1500
+ }
1501
+ try {
1502
+ // Test by initializing a client and getting access token
1503
+ const { DWClient } = await import('dingtalk-stream');
1504
+ const testClient = new DWClient({
1505
+ clientId: config.clientId,
1506
+ clientSecret: config.clientSecret,
1507
+ });
1508
+ // Try to get access token
1509
+ const token = await testClient.getAccessToken();
1510
+ if (!token) {
1511
+ testClient.disconnect?.();
1512
+ return c.json({ error: 'Failed to obtain access token' }, 400);
1513
+ }
1514
+ testClient.disconnect?.();
1515
+ return c.json({ success: true });
1516
+ }
1517
+ catch (err) {
1518
+ const message = err instanceof Error ? err.message : 'Connection test failed';
1519
+ logger.warn({ err }, 'DingTalk connection test failed');
1520
+ return c.json({ error: message }, 400);
1521
+ }
1522
+ });
1523
+ // ─── Per-user WeChat IM config ──────────────────────────────────
1524
+ const WECHAT_API_BASE = 'https://ilinkai.weixin.qq.com';
1525
+ const WECHAT_QR_BOT_TYPE = '3';
1526
+ function randomWechatUin() {
1527
+ const uint32 = randomBytes(4).readUInt32BE(0);
1528
+ return Buffer.from(String(uint32), 'utf-8').toString('base64');
1529
+ }
1530
+ function maskBotToken(token) {
1531
+ if (!token)
1532
+ return null;
1533
+ if (token.length <= 8)
1534
+ return '***';
1535
+ return token.slice(0, 4) + '***' + token.slice(-4);
1536
+ }
1537
+ configRoutes.get('/user-im/wechat', authMiddleware, (c) => {
1538
+ const user = c.get('user');
1539
+ try {
1540
+ const config = getUserWeChatConfig(user.id);
1541
+ const connected = deps?.isUserWeChatConnected?.(user.id) ?? false;
1542
+ if (!config) {
1543
+ return c.json({
1544
+ ilinkBotId: '',
1545
+ hasBotToken: false,
1546
+ botTokenMasked: null,
1547
+ bypassProxy: true,
1548
+ enabled: false,
1549
+ updatedAt: null,
1550
+ connected,
1551
+ });
1552
+ }
1553
+ return c.json({
1554
+ ilinkBotId: config.ilinkBotId || '',
1555
+ hasBotToken: !!config.botToken,
1556
+ botTokenMasked: maskBotToken(config.botToken),
1557
+ bypassProxy: config.bypassProxy ?? true,
1558
+ enabled: config.enabled ?? false,
1559
+ updatedAt: config.updatedAt,
1560
+ connected,
1561
+ });
1562
+ }
1563
+ catch (err) {
1564
+ logger.error({ err }, 'Failed to load user WeChat config');
1565
+ return c.json({ error: 'Failed to load user WeChat config' }, 500);
1566
+ }
1567
+ });
1568
+ configRoutes.put('/user-im/wechat', authMiddleware, async (c) => {
1569
+ const user = c.get('user');
1570
+ const body = await c.req.json().catch(() => ({}));
1571
+ const validation = WeChatConfigSchema.safeParse(body);
1572
+ if (!validation.success) {
1573
+ return c.json({ error: 'Invalid request body', details: validation.error.format() }, 400);
1574
+ }
1575
+ // Billing: check IM channel limit when enabling
1576
+ if (validation.data.enabled === true && isBillingEnabled()) {
1577
+ const currentWc = getUserWeChatConfig(user.id);
1578
+ if (!currentWc?.enabled) {
1579
+ const limit = checkImChannelLimit(user.id, user.role, countOtherEnabledImChannels(user.id, 'wechat'));
1580
+ if (!limit.allowed) {
1581
+ return c.json({ error: limit.reason }, 403);
1582
+ }
1583
+ }
1584
+ }
1585
+ const current = getUserWeChatConfig(user.id);
1586
+ const next = {
1587
+ botToken: current?.botToken || '',
1588
+ ilinkBotId: current?.ilinkBotId || '',
1589
+ baseUrl: current?.baseUrl,
1590
+ cdnBaseUrl: current?.cdnBaseUrl,
1591
+ getUpdatesBuf: current?.getUpdatesBuf,
1592
+ bypassProxy: current?.bypassProxy ?? true,
1593
+ enabled: current?.enabled ?? false,
1594
+ };
1595
+ if (validation.data.clearBotToken === true) {
1596
+ next.botToken = '';
1597
+ next.ilinkBotId = '';
1598
+ }
1599
+ if (typeof validation.data.enabled === 'boolean') {
1600
+ next.enabled = validation.data.enabled;
1601
+ }
1602
+ if (typeof validation.data.bypassProxy === 'boolean') {
1603
+ next.bypassProxy = validation.data.bypassProxy;
1604
+ }
1605
+ try {
1606
+ const saved = saveUserWeChatConfig(user.id, next);
1607
+ // Update NO_PROXY based on bypassProxy setting
1608
+ updateWeChatNoProxy(saved.bypassProxy ?? true);
1609
+ // Hot-reload: reconnect user's WeChat channel
1610
+ if (deps?.reloadUserIMConfig) {
1611
+ try {
1612
+ await deps.reloadUserIMConfig(user.id, 'wechat');
1613
+ }
1614
+ catch (err) {
1615
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload user WeChat connection');
1616
+ }
1617
+ }
1618
+ const connected = deps?.isUserWeChatConnected?.(user.id) ?? false;
1619
+ return c.json({
1620
+ ilinkBotId: saved.ilinkBotId || '',
1621
+ hasBotToken: !!saved.botToken,
1622
+ botTokenMasked: maskBotToken(saved.botToken),
1623
+ bypassProxy: saved.bypassProxy ?? true,
1624
+ enabled: saved.enabled ?? false,
1625
+ updatedAt: saved.updatedAt,
1626
+ connected,
1627
+ });
1628
+ }
1629
+ catch (err) {
1630
+ const message = err instanceof Error ? err.message : 'Invalid WeChat config payload';
1631
+ logger.warn({ err }, 'Invalid user WeChat config payload');
1632
+ return c.json({ error: message }, 400);
1633
+ }
1634
+ });
1635
+ // Generate QR code for WeChat iLink login
1636
+ configRoutes.post('/user-im/wechat/qrcode', authMiddleware, async (c) => {
1637
+ try {
1638
+ const url = `${WECHAT_API_BASE}/ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(WECHAT_QR_BOT_TYPE)}`;
1639
+ const res = await fetch(url);
1640
+ if (!res.ok) {
1641
+ const body = await res.text().catch(() => '');
1642
+ logger.error({ status: res.status, body }, 'WeChat QR code fetch failed');
1643
+ return c.json({ error: `Failed to fetch QR code: ${res.status}` }, 502);
1644
+ }
1645
+ const data = (await res.json());
1646
+ if (!data.qrcode) {
1647
+ return c.json({ error: 'No QR code in response' }, 502);
1648
+ }
1649
+ // qrcode_img_content is a URL string (WeChat deep link) to be encoded
1650
+ // INTO a QR code image, not an image URL itself.
1651
+ let qrcodeDataUri = '';
1652
+ if (data.qrcode_img_content) {
1653
+ try {
1654
+ qrcodeDataUri = await QRCode.toDataURL(data.qrcode_img_content, {
1655
+ width: 512,
1656
+ margin: 2,
1657
+ color: { dark: '#000000', light: '#ffffff' },
1658
+ });
1659
+ }
1660
+ catch (qrErr) {
1661
+ logger.warn({ err: qrErr }, 'Failed to generate QR code image');
1662
+ }
1663
+ }
1664
+ return c.json({
1665
+ qrcode: data.qrcode,
1666
+ qrcodeUrl: qrcodeDataUri,
1667
+ });
1668
+ }
1669
+ catch (err) {
1670
+ const message = err instanceof Error ? err.message : 'Failed to generate QR code';
1671
+ logger.error({ err }, 'WeChat QR code generation failed');
1672
+ return c.json({ error: message }, 500);
1673
+ }
1674
+ });
1675
+ // Poll QR code scan status
1676
+ configRoutes.get('/user-im/wechat/qrcode-status', authMiddleware, async (c) => {
1677
+ const user = c.get('user');
1678
+ const qrcode = c.req.query('qrcode');
1679
+ if (!qrcode) {
1680
+ return c.json({ error: 'qrcode query parameter required' }, 400);
1681
+ }
1682
+ try {
1683
+ const url = `${WECHAT_API_BASE}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
1684
+ const headers = {
1685
+ 'iLink-App-ClientVersion': '1',
1686
+ };
1687
+ const controller = new AbortController();
1688
+ const timer = setTimeout(() => controller.abort(), 35000);
1689
+ let res;
1690
+ try {
1691
+ res = await fetch(url, { headers, signal: controller.signal });
1692
+ clearTimeout(timer);
1693
+ }
1694
+ catch (err) {
1695
+ clearTimeout(timer);
1696
+ if (err instanceof Error && err.name === 'AbortError') {
1697
+ return c.json({ status: 'wait' });
1698
+ }
1699
+ throw err;
1700
+ }
1701
+ if (!res.ok) {
1702
+ const body = await res.text().catch(() => '');
1703
+ return c.json({ error: `QR status poll failed: ${res.status}`, body }, 502);
1704
+ }
1705
+ const data = (await res.json());
1706
+ if (data.status === 'confirmed' && data.bot_token && data.ilink_bot_id) {
1707
+ // Auto-save credentials and connect
1708
+ const saved = saveUserWeChatConfig(user.id, {
1709
+ botToken: data.bot_token,
1710
+ ilinkBotId: data.ilink_bot_id.replace(/[^a-zA-Z0-9@._-]/g, ''),
1711
+ baseUrl: data.baseurl || undefined,
1712
+ enabled: true,
1713
+ });
1714
+ // Note: ilink_user_id (the QR scanner) is NOT auto-paired here.
1715
+ // The scanner needs to send a message to the bot and use /pair <code>
1716
+ // to complete pairing, same as QQ/Telegram flow.
1717
+ // This ensures proper group registration via buildOnNewChat/registerGroup.
1718
+ // Hot-reload: connect WeChat
1719
+ if (deps?.reloadUserIMConfig) {
1720
+ try {
1721
+ await deps.reloadUserIMConfig(user.id, 'wechat');
1722
+ }
1723
+ catch (err) {
1724
+ logger.warn({ err, userId: user.id }, 'Failed to hot-reload WeChat after QR login');
1725
+ }
1726
+ }
1727
+ return c.json({
1728
+ status: 'confirmed',
1729
+ ilinkBotId: saved.ilinkBotId,
1730
+ });
1731
+ }
1732
+ return c.json({
1733
+ status: data.status || 'wait',
1734
+ });
1735
+ }
1736
+ catch (err) {
1737
+ const message = err instanceof Error ? err.message : 'QR status poll failed';
1738
+ logger.error({ err }, 'WeChat QR status poll failed');
1739
+ return c.json({ error: message }, 500);
1740
+ }
1741
+ });
1742
+ // Disconnect WeChat and clear token
1743
+ configRoutes.post('/user-im/wechat/disconnect', authMiddleware, async (c) => {
1744
+ const user = c.get('user');
1745
+ try {
1746
+ const current = getUserWeChatConfig(user.id);
1747
+ if (current) {
1748
+ saveUserWeChatConfig(user.id, {
1749
+ botToken: '',
1750
+ ilinkBotId: '',
1751
+ enabled: false,
1752
+ getUpdatesBuf: current.getUpdatesBuf,
1753
+ });
1754
+ }
1755
+ // Disconnect
1756
+ if (deps?.reloadUserIMConfig) {
1757
+ try {
1758
+ await deps.reloadUserIMConfig(user.id, 'wechat');
1759
+ }
1760
+ catch (err) {
1761
+ logger.warn({ err, userId: user.id }, 'Failed to disconnect WeChat');
1762
+ }
1763
+ }
1764
+ return c.json({ success: true });
1765
+ }
1766
+ catch (err) {
1767
+ const message = err instanceof Error ? err.message : 'Failed to disconnect WeChat';
1768
+ logger.error({ err }, 'WeChat disconnect failed');
1769
+ return c.json({ error: message }, 500);
1770
+ }
1771
+ });
1772
+ // ─── IM Binding management (bindings panoramic page) ────────────
1773
+ configRoutes.put('/user-im/bindings/:imJid', authMiddleware, async (c) => {
1774
+ const imJid = decodeURIComponent(c.req.param('imJid'));
1775
+ const user = c.get('user');
1776
+ // Validate IM JID
1777
+ const channelType = getChannelType(imJid);
1778
+ if (!channelType) {
1779
+ return c.json({ error: 'Invalid IM JID' }, 400);
1780
+ }
1781
+ const imGroup = getRegisteredGroup(imJid);
1782
+ if (!imGroup) {
1783
+ return c.json({ error: 'IM group not found' }, 404);
1784
+ }
1785
+ if (!canAccessGroup(user, { ...imGroup, jid: imJid })) {
1786
+ return c.json({ error: 'Forbidden' }, 403);
1787
+ }
1788
+ const body = await c.req.json().catch(() => ({}));
1789
+ // Unbind mode
1790
+ if (body.unbind === true) {
1791
+ const updated = {
1792
+ ...imGroup,
1793
+ target_main_jid: undefined,
1794
+ target_agent_id: undefined,
1795
+ };
1796
+ applyBindingUpdate(imJid, updated);
1797
+ logger.info({ imJid, userId: user.id }, 'IM group unbound (bindings page)');
1798
+ return c.json({ success: true });
1799
+ }
1800
+ // Bind to agent
1801
+ if (typeof body.target_agent_id === 'string' && body.target_agent_id.trim()) {
1802
+ const agentId = body.target_agent_id.trim();
1803
+ const agent = getAgent(agentId);
1804
+ if (!agent) {
1805
+ return c.json({ error: 'Agent not found' }, 404);
1806
+ }
1807
+ if (agent.kind !== 'conversation') {
1808
+ return c.json({ error: 'Only conversation agents can bind IM groups' }, 400);
1809
+ }
1810
+ // Check user can access the workspace that owns this agent
1811
+ const ownerGroup = getRegisteredGroup(agent.chat_jid);
1812
+ if (!ownerGroup ||
1813
+ !canAccessGroup(user, { ...ownerGroup, jid: agent.chat_jid })) {
1814
+ return c.json({ error: 'Forbidden' }, 403);
1815
+ }
1816
+ const force = body.force === true;
1817
+ const replyPolicy = body.reply_policy === 'mirror' ? 'mirror' : 'source_only';
1818
+ const hasConflict = (imGroup.target_agent_id && imGroup.target_agent_id !== agentId) ||
1819
+ !!imGroup.target_main_jid;
1820
+ if (hasConflict && !force) {
1821
+ return c.json({ error: 'IM group is already bound elsewhere' }, 409);
1822
+ }
1823
+ const updated = {
1824
+ ...imGroup,
1825
+ target_agent_id: agentId,
1826
+ target_main_jid: undefined,
1827
+ reply_policy: replyPolicy,
1828
+ };
1829
+ applyBindingUpdate(imJid, updated);
1830
+ logger.info({ imJid, agentId, userId: user.id }, 'IM group bound to agent (bindings page)');
1831
+ return c.json({ success: true });
1832
+ }
1833
+ // Bind to workspace main conversation
1834
+ if (typeof body.target_main_jid === 'string' && body.target_main_jid.trim()) {
1835
+ const targetMainJid = body.target_main_jid.trim();
1836
+ const targetGroup = getRegisteredGroup(targetMainJid);
1837
+ if (!targetGroup) {
1838
+ return c.json({ error: 'Target workspace not found' }, 404);
1839
+ }
1840
+ if (!canAccessGroup(user, { ...targetGroup, jid: targetMainJid })) {
1841
+ return c.json({ error: 'Forbidden' }, 403);
1842
+ }
1843
+ if (targetGroup.is_home) {
1844
+ return c.json({ error: 'Home workspace main conversation uses default IM routing' }, 400);
1845
+ }
1846
+ const force = body.force === true;
1847
+ const replyPolicy = body.reply_policy === 'mirror' ? 'mirror' : 'source_only';
1848
+ const legacyMainJid = `web:${targetGroup.folder}`;
1849
+ const hasConflict = !!imGroup.target_agent_id ||
1850
+ (imGroup.target_main_jid &&
1851
+ imGroup.target_main_jid !== targetMainJid &&
1852
+ imGroup.target_main_jid !== legacyMainJid);
1853
+ if (hasConflict && !force) {
1854
+ return c.json({ error: 'IM group is already bound elsewhere' }, 409);
1855
+ }
1856
+ const updated = {
1857
+ ...imGroup,
1858
+ target_main_jid: targetMainJid,
1859
+ target_agent_id: undefined,
1860
+ reply_policy: replyPolicy,
1861
+ };
1862
+ applyBindingUpdate(imJid, updated);
1863
+ logger.info({ imJid, targetMainJid, userId: user.id }, 'IM group bound to workspace (bindings page)');
1864
+ return c.json({ success: true });
1865
+ }
1866
+ return c.json({ error: 'Must provide target_main_jid, target_agent_id, or unbind' }, 400);
1867
+ });
1868
+ export default configRoutes;