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/db.js ADDED
@@ -0,0 +1,3683 @@
1
+ import crypto from 'crypto';
2
+ import Database from './sqlite-compat.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { resolveAppPath } from './app-root.js';
6
+ import { STORE_DIR, GROUPS_DIR } from './config.js';
7
+ import { logger } from './logger.js';
8
+ import { AGENT_MEMORY_TEMPLATE_FILENAME, getAgentMemoryPath, } from './project-memory.js';
9
+ import { getDefaultPermissions, normalizePermissions } from './permissions.js';
10
+ import { parseRuntimeIdentity, serializeRuntimeIdentity, } from './runtime-identity.js';
11
+ let db;
12
+ // Prepared statement cache — lazy-initialized on first use after initDatabase()
13
+ let _stmts = null;
14
+ const _newMsgStmtCache = new Map();
15
+ function stmts() {
16
+ if (!_stmts) {
17
+ _stmts = {
18
+ storeMessageSelect: db.prepare(`SELECT id FROM messages
19
+ WHERE chat_jid = ? AND turn_id = ? AND source_kind = 'sdk_final'
20
+ ORDER BY timestamp DESC LIMIT 1`),
21
+ storeMessageInsert: db.prepare(`INSERT OR REPLACE INTO messages (
22
+ id, chat_jid, source_jid, sender, sender_name, content, timestamp, is_from_me,
23
+ attachments, token_usage, runtime_identity, turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
24
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
25
+ insertUsageInsert: db.prepare(`INSERT INTO usage_records (id, user_id, group_folder, agent_id, message_id, model,
26
+ input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens,
27
+ cost_usd, duration_ms, num_turns, source, created_at)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
29
+ insertUsageUpsert: db.prepare(`INSERT INTO usage_daily_summary (user_id, model, date,
30
+ total_input_tokens, total_output_tokens,
31
+ total_cache_read_tokens, total_cache_creation_tokens,
32
+ total_cost_usd, request_count, updated_at)
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
34
+ ON CONFLICT(user_id, model, date) DO UPDATE SET
35
+ total_input_tokens = total_input_tokens + excluded.total_input_tokens,
36
+ total_output_tokens = total_output_tokens + excluded.total_output_tokens,
37
+ total_cache_read_tokens = total_cache_read_tokens + excluded.total_cache_read_tokens,
38
+ total_cache_creation_tokens = total_cache_creation_tokens + excluded.total_cache_creation_tokens,
39
+ total_cost_usd = total_cost_usd + excluded.total_cost_usd,
40
+ request_count = request_count + 1,
41
+ updated_at = datetime('now')`),
42
+ getSessionWithUser: db.prepare(`SELECT s.*, u.username, u.role, u.status, u.display_name, u.permissions, u.must_change_password
43
+ FROM user_sessions s
44
+ JOIN users u ON s.user_id = u.id
45
+ WHERE s.id = ?`),
46
+ deleteSession: db.prepare('DELETE FROM user_sessions WHERE id = ?'),
47
+ updateSessionLastActive: db.prepare('UPDATE user_sessions SET last_active_at = ? WHERE id = ?'),
48
+ updateTokenUsageById: db.prepare(`UPDATE messages SET token_usage = ?, cost_usd = ? WHERE id = ? AND chat_jid = ?`),
49
+ updateTokenUsageLatest: db.prepare(`UPDATE messages SET token_usage = ?, cost_usd = ?
50
+ WHERE rowid = (
51
+ SELECT rowid FROM messages
52
+ WHERE chat_jid = ? AND is_from_me = 1 AND token_usage IS NULL
53
+ AND COALESCE(source_kind, 'legacy') != 'sdk_send_message'
54
+ ORDER BY timestamp DESC LIMIT 1
55
+ )`),
56
+ getMessagesSince: db.prepare(`SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, attachments
57
+ FROM messages
58
+ WHERE chat_jid = ? AND (timestamp > ? OR (timestamp = ? AND id > ?)) AND is_from_me = 0
59
+ ORDER BY timestamp ASC, id ASC`),
60
+ getExpiredSessionIds: db.prepare('SELECT id FROM user_sessions WHERE expires_at < ?'),
61
+ };
62
+ }
63
+ return _stmts;
64
+ }
65
+ function getNewMessagesStmt(jidCount) {
66
+ let s = _newMsgStmtCache.get(jidCount);
67
+ if (!s) {
68
+ const placeholders = Array(jidCount).fill('?').join(',');
69
+ s = db.prepare(`SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, attachments
70
+ FROM messages
71
+ WHERE (timestamp > ? OR (timestamp = ? AND id > ?))
72
+ AND chat_jid IN (${placeholders})
73
+ AND is_from_me = 0
74
+ AND COALESCE(source_kind, '') != 'user_command'
75
+ ORDER BY timestamp ASC, id ASC`);
76
+ _newMsgStmtCache.set(jidCount, s);
77
+ }
78
+ return s;
79
+ }
80
+ function mapDbMessageRow(row) {
81
+ return {
82
+ ...row,
83
+ runtime_identity: parseRuntimeIdentity(row.runtime_identity),
84
+ is_from_me: row.is_from_me === 1,
85
+ };
86
+ }
87
+ function hasColumn(tableName, columnName) {
88
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
89
+ return columns.some((column) => column.name === columnName);
90
+ }
91
+ function ensureColumn(tableName, columnName, sqlTypeWithDefault) {
92
+ if (hasColumn(tableName, columnName))
93
+ return;
94
+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${sqlTypeWithDefault}`);
95
+ }
96
+ function assertSchema(tableName, requiredColumns, forbiddenColumns = []) {
97
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
98
+ const names = new Set(columns.map((c) => c.name));
99
+ const missing = requiredColumns.filter((c) => !names.has(c));
100
+ const forbidden = forbiddenColumns.filter((c) => names.has(c));
101
+ if (missing.length > 0 || forbidden.length > 0) {
102
+ throw new Error(`Incompatible DB schema in table "${tableName}". Missing: [${missing.join(', ')}], forbidden: [${forbidden.join(', ')}]. ` +
103
+ 'Please remove ~/.cli-claw/db/messages.db and restart.');
104
+ }
105
+ }
106
+ /** Internal helper — reads router_state before initDatabase exports are available. */
107
+ function getRouterStateInternal(key) {
108
+ try {
109
+ const row = db
110
+ .prepare('SELECT value FROM router_state WHERE key = ?')
111
+ .get(key);
112
+ return row?.value;
113
+ }
114
+ catch {
115
+ return undefined; // Table may not exist yet on first run
116
+ }
117
+ }
118
+ export function initDatabase() {
119
+ const dbPath = path.join(STORE_DIR, 'messages.db');
120
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
121
+ db = new Database(dbPath);
122
+ // Enable WAL mode for better concurrency and performance
123
+ db.exec('PRAGMA journal_mode = WAL');
124
+ db.exec('PRAGMA busy_timeout = 5000');
125
+ db.exec(`
126
+ CREATE TABLE IF NOT EXISTS chats (
127
+ jid TEXT PRIMARY KEY,
128
+ name TEXT,
129
+ last_message_time TEXT
130
+ );
131
+ CREATE TABLE IF NOT EXISTS messages (
132
+ id TEXT,
133
+ chat_jid TEXT,
134
+ source_jid TEXT,
135
+ sender TEXT,
136
+ sender_name TEXT,
137
+ content TEXT,
138
+ timestamp TEXT,
139
+ is_from_me INTEGER,
140
+ attachments TEXT,
141
+ token_usage TEXT,
142
+ runtime_identity TEXT,
143
+ turn_id TEXT,
144
+ session_id TEXT,
145
+ sdk_message_uuid TEXT,
146
+ source_kind TEXT,
147
+ finalization_reason TEXT,
148
+ PRIMARY KEY (id, chat_jid),
149
+ FOREIGN KEY (chat_jid) REFERENCES chats(jid)
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
152
+ CREATE INDEX IF NOT EXISTS idx_messages_jid_ts ON messages(chat_jid, timestamp);
153
+
154
+ CREATE TABLE IF NOT EXISTS scheduled_tasks (
155
+ id TEXT PRIMARY KEY,
156
+ group_folder TEXT NOT NULL,
157
+ chat_jid TEXT NOT NULL,
158
+ prompt TEXT NOT NULL,
159
+ schedule_type TEXT NOT NULL,
160
+ schedule_value TEXT NOT NULL,
161
+ context_mode TEXT DEFAULT 'isolated',
162
+ execution_type TEXT DEFAULT 'agent',
163
+ script_command TEXT,
164
+ next_run TEXT,
165
+ last_run TEXT,
166
+ last_result TEXT,
167
+ status TEXT DEFAULT 'active',
168
+ created_at TEXT NOT NULL,
169
+ created_by TEXT,
170
+ notify_channels TEXT
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
173
+ CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
174
+
175
+ CREATE TABLE IF NOT EXISTS task_run_logs (
176
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ task_id TEXT NOT NULL,
178
+ run_at TEXT NOT NULL,
179
+ duration_ms INTEGER NOT NULL,
180
+ status TEXT NOT NULL,
181
+ result TEXT,
182
+ error TEXT,
183
+ FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
184
+ );
185
+ CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
186
+ `);
187
+ // State tables (replacing JSON files)
188
+ db.exec(`
189
+ CREATE TABLE IF NOT EXISTS router_state (
190
+ key TEXT PRIMARY KEY,
191
+ value TEXT NOT NULL
192
+ );
193
+ CREATE TABLE IF NOT EXISTS sessions (
194
+ group_folder TEXT NOT NULL,
195
+ session_id TEXT NOT NULL,
196
+ agent_id TEXT NOT NULL DEFAULT '',
197
+ PRIMARY KEY (group_folder, agent_id)
198
+ );
199
+ CREATE TABLE IF NOT EXISTS registered_groups (
200
+ jid TEXT PRIMARY KEY,
201
+ name TEXT NOT NULL,
202
+ folder TEXT NOT NULL,
203
+ added_at TEXT NOT NULL,
204
+ container_config TEXT,
205
+ created_by TEXT,
206
+ is_home INTEGER DEFAULT 0
207
+ );
208
+ `);
209
+ // Auth tables
210
+ db.exec(`
211
+ CREATE TABLE IF NOT EXISTS users (
212
+ id TEXT PRIMARY KEY,
213
+ username TEXT NOT NULL UNIQUE,
214
+ password_hash TEXT NOT NULL,
215
+ display_name TEXT NOT NULL DEFAULT '',
216
+ role TEXT NOT NULL DEFAULT 'member',
217
+ status TEXT NOT NULL DEFAULT 'active',
218
+ permissions TEXT NOT NULL DEFAULT '[]',
219
+ must_change_password INTEGER NOT NULL DEFAULT 0,
220
+ disable_reason TEXT,
221
+ notes TEXT,
222
+ avatar_emoji TEXT,
223
+ avatar_color TEXT,
224
+ ai_name TEXT,
225
+ ai_avatar_emoji TEXT,
226
+ ai_avatar_color TEXT,
227
+ ai_avatar_url TEXT,
228
+ created_at TEXT NOT NULL,
229
+ updated_at TEXT NOT NULL,
230
+ last_login_at TEXT,
231
+ deleted_at TEXT
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS invite_codes (
235
+ code TEXT PRIMARY KEY,
236
+ created_by TEXT NOT NULL,
237
+ role TEXT NOT NULL DEFAULT 'member',
238
+ permission_template TEXT,
239
+ permissions TEXT NOT NULL DEFAULT '[]',
240
+ max_uses INTEGER NOT NULL DEFAULT 1,
241
+ used_count INTEGER NOT NULL DEFAULT 0,
242
+ expires_at TEXT,
243
+ created_at TEXT NOT NULL,
244
+ FOREIGN KEY (created_by) REFERENCES users(id)
245
+ );
246
+
247
+ CREATE TABLE IF NOT EXISTS user_sessions (
248
+ id TEXT PRIMARY KEY,
249
+ user_id TEXT NOT NULL,
250
+ ip_address TEXT,
251
+ user_agent TEXT,
252
+ created_at TEXT NOT NULL,
253
+ expires_at TEXT NOT NULL,
254
+ last_active_at TEXT NOT NULL,
255
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
256
+ );
257
+
258
+ CREATE TABLE IF NOT EXISTS auth_audit_log (
259
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
260
+ event_type TEXT NOT NULL,
261
+ username TEXT NOT NULL,
262
+ actor_username TEXT,
263
+ ip_address TEXT,
264
+ user_agent TEXT,
265
+ details TEXT,
266
+ created_at TEXT NOT NULL
267
+ );
268
+ CREATE INDEX IF NOT EXISTS idx_auth_audit_created ON auth_audit_log(created_at);
269
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
270
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);
271
+ CREATE INDEX IF NOT EXISTS idx_users_status_role ON users(status, role);
272
+ CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
273
+ CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invite_codes(created_at);
274
+ `);
275
+ // Group members table for shared workspaces
276
+ db.exec(`
277
+ CREATE TABLE IF NOT EXISTS group_members (
278
+ group_folder TEXT NOT NULL,
279
+ user_id TEXT NOT NULL,
280
+ role TEXT NOT NULL DEFAULT 'member',
281
+ added_at TEXT NOT NULL,
282
+ added_by TEXT,
283
+ PRIMARY KEY (group_folder, user_id)
284
+ );
285
+ CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
286
+ `);
287
+ // User pinned groups (per-user workspace pinning)
288
+ db.exec(`
289
+ CREATE TABLE IF NOT EXISTS user_pinned_groups (
290
+ user_id TEXT NOT NULL,
291
+ jid TEXT NOT NULL,
292
+ pinned_at TEXT NOT NULL,
293
+ PRIMARY KEY (user_id, jid)
294
+ );
295
+ `);
296
+ // Sub-agents table for multi-agent parallel execution
297
+ db.exec(`
298
+ CREATE TABLE IF NOT EXISTS agents (
299
+ id TEXT PRIMARY KEY,
300
+ group_folder TEXT NOT NULL,
301
+ chat_jid TEXT NOT NULL,
302
+ name TEXT NOT NULL,
303
+ prompt TEXT NOT NULL,
304
+ status TEXT NOT NULL DEFAULT 'running',
305
+ created_by TEXT,
306
+ created_at TEXT NOT NULL,
307
+ completed_at TEXT,
308
+ result_summary TEXT,
309
+ last_im_jid TEXT,
310
+ spawned_from_jid TEXT
311
+ );
312
+ CREATE INDEX IF NOT EXISTS idx_agents_group ON agents(group_folder);
313
+ CREATE INDEX IF NOT EXISTS idx_agents_jid ON agents(chat_jid);
314
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
315
+ `);
316
+ // Billing tables
317
+ db.exec(`
318
+ CREATE TABLE IF NOT EXISTS billing_plans (
319
+ id TEXT PRIMARY KEY,
320
+ name TEXT NOT NULL,
321
+ description TEXT,
322
+ tier INTEGER NOT NULL DEFAULT 0,
323
+ monthly_cost_usd REAL NOT NULL DEFAULT 0,
324
+ monthly_token_quota INTEGER,
325
+ monthly_cost_quota REAL,
326
+ daily_cost_quota REAL,
327
+ weekly_cost_quota REAL,
328
+ daily_token_quota INTEGER,
329
+ weekly_token_quota INTEGER,
330
+ rate_multiplier REAL NOT NULL DEFAULT 1.0,
331
+ trial_days INTEGER,
332
+ sort_order INTEGER NOT NULL DEFAULT 0,
333
+ display_price TEXT,
334
+ highlight INTEGER NOT NULL DEFAULT 0,
335
+ max_groups INTEGER,
336
+ max_concurrent_containers INTEGER,
337
+ max_im_channels INTEGER,
338
+ max_mcp_servers INTEGER,
339
+ max_storage_mb INTEGER,
340
+ allow_overage INTEGER NOT NULL DEFAULT 0,
341
+ features TEXT NOT NULL DEFAULT '[]',
342
+ is_default INTEGER NOT NULL DEFAULT 0,
343
+ is_active INTEGER NOT NULL DEFAULT 1,
344
+ created_at TEXT NOT NULL,
345
+ updated_at TEXT NOT NULL
346
+ );
347
+
348
+ CREATE TABLE IF NOT EXISTS user_subscriptions (
349
+ id TEXT PRIMARY KEY,
350
+ user_id TEXT NOT NULL,
351
+ plan_id TEXT NOT NULL,
352
+ status TEXT NOT NULL DEFAULT 'active',
353
+ started_at TEXT NOT NULL,
354
+ expires_at TEXT,
355
+ cancelled_at TEXT,
356
+ trial_ends_at TEXT,
357
+ notes TEXT,
358
+ auto_renew INTEGER NOT NULL DEFAULT 0,
359
+ created_at TEXT NOT NULL,
360
+ FOREIGN KEY (user_id) REFERENCES users(id),
361
+ FOREIGN KEY (plan_id) REFERENCES billing_plans(id)
362
+ );
363
+ CREATE INDEX IF NOT EXISTS idx_user_sub_user ON user_subscriptions(user_id);
364
+ CREATE INDEX IF NOT EXISTS idx_user_sub_status ON user_subscriptions(status);
365
+
366
+ CREATE TABLE IF NOT EXISTS user_balances (
367
+ user_id TEXT PRIMARY KEY,
368
+ balance_usd REAL NOT NULL DEFAULT 0,
369
+ total_deposited_usd REAL NOT NULL DEFAULT 0,
370
+ total_consumed_usd REAL NOT NULL DEFAULT 0,
371
+ updated_at TEXT NOT NULL,
372
+ FOREIGN KEY (user_id) REFERENCES users(id)
373
+ );
374
+
375
+ CREATE TABLE IF NOT EXISTS balance_transactions (
376
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
377
+ user_id TEXT NOT NULL,
378
+ type TEXT NOT NULL,
379
+ amount_usd REAL NOT NULL,
380
+ balance_after REAL NOT NULL,
381
+ description TEXT,
382
+ reference_type TEXT,
383
+ reference_id TEXT,
384
+ actor_id TEXT,
385
+ source TEXT NOT NULL DEFAULT 'system_adjustment',
386
+ operator_type TEXT NOT NULL DEFAULT 'system',
387
+ notes TEXT,
388
+ idempotency_key TEXT,
389
+ created_at TEXT NOT NULL
390
+ );
391
+ CREATE INDEX IF NOT EXISTS idx_bal_tx_user ON balance_transactions(user_id);
392
+ CREATE INDEX IF NOT EXISTS idx_bal_tx_created ON balance_transactions(created_at);
393
+
394
+ CREATE TABLE IF NOT EXISTS monthly_usage (
395
+ user_id TEXT NOT NULL,
396
+ month TEXT NOT NULL,
397
+ total_input_tokens INTEGER NOT NULL DEFAULT 0,
398
+ total_output_tokens INTEGER NOT NULL DEFAULT 0,
399
+ total_cost_usd REAL NOT NULL DEFAULT 0,
400
+ message_count INTEGER NOT NULL DEFAULT 0,
401
+ updated_at TEXT NOT NULL,
402
+ PRIMARY KEY (user_id, month)
403
+ );
404
+
405
+ CREATE TABLE IF NOT EXISTS redeem_codes (
406
+ code TEXT PRIMARY KEY,
407
+ type TEXT NOT NULL,
408
+ value_usd REAL,
409
+ plan_id TEXT,
410
+ duration_days INTEGER,
411
+ max_uses INTEGER NOT NULL DEFAULT 1,
412
+ used_count INTEGER NOT NULL DEFAULT 0,
413
+ expires_at TEXT,
414
+ created_by TEXT NOT NULL,
415
+ notes TEXT,
416
+ batch_id TEXT,
417
+ created_at TEXT NOT NULL
418
+ );
419
+
420
+ CREATE TABLE IF NOT EXISTS redeem_code_usage (
421
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
422
+ code TEXT NOT NULL,
423
+ user_id TEXT NOT NULL,
424
+ redeemed_at TEXT NOT NULL
425
+ );
426
+ CREATE INDEX IF NOT EXISTS idx_redeem_usage_user ON redeem_code_usage(user_id);
427
+
428
+ CREATE TABLE IF NOT EXISTS billing_audit_log (
429
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
430
+ event_type TEXT NOT NULL,
431
+ user_id TEXT NOT NULL,
432
+ actor_id TEXT,
433
+ details TEXT,
434
+ created_at TEXT NOT NULL
435
+ );
436
+ CREATE INDEX IF NOT EXISTS idx_bill_audit_user ON billing_audit_log(user_id);
437
+ CREATE INDEX IF NOT EXISTS idx_bill_audit_created ON billing_audit_log(created_at);
438
+
439
+ CREATE TABLE IF NOT EXISTS daily_usage (
440
+ user_id TEXT NOT NULL,
441
+ date TEXT NOT NULL,
442
+ total_input_tokens INTEGER NOT NULL DEFAULT 0,
443
+ total_output_tokens INTEGER NOT NULL DEFAULT 0,
444
+ total_cost_usd REAL NOT NULL DEFAULT 0,
445
+ message_count INTEGER NOT NULL DEFAULT 0,
446
+ PRIMARY KEY (user_id, date)
447
+ );
448
+ CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date);
449
+ CREATE INDEX IF NOT EXISTS idx_daily_usage_user_date ON daily_usage(user_id, date);
450
+ `);
451
+ // Token usage tracking tables
452
+ db.exec(`
453
+ CREATE TABLE IF NOT EXISTS usage_records (
454
+ id TEXT PRIMARY KEY,
455
+ user_id TEXT NOT NULL,
456
+ group_folder TEXT NOT NULL,
457
+ agent_id TEXT,
458
+ message_id TEXT,
459
+ model TEXT NOT NULL DEFAULT 'unknown',
460
+ input_tokens INTEGER NOT NULL DEFAULT 0,
461
+ output_tokens INTEGER NOT NULL DEFAULT 0,
462
+ cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
463
+ cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
464
+ cost_usd REAL NOT NULL DEFAULT 0,
465
+ duration_ms INTEGER DEFAULT 0,
466
+ num_turns INTEGER DEFAULT 0,
467
+ source TEXT NOT NULL DEFAULT 'agent',
468
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
469
+ );
470
+ CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage_records(user_id, created_at);
471
+ CREATE INDEX IF NOT EXISTS idx_usage_group_date ON usage_records(group_folder, created_at);
472
+ CREATE INDEX IF NOT EXISTS idx_usage_model_date ON usage_records(model, created_at);
473
+
474
+ CREATE TABLE IF NOT EXISTS usage_daily_summary (
475
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
476
+ user_id TEXT NOT NULL,
477
+ model TEXT NOT NULL,
478
+ date TEXT NOT NULL,
479
+ total_input_tokens INTEGER NOT NULL DEFAULT 0,
480
+ total_output_tokens INTEGER NOT NULL DEFAULT 0,
481
+ total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
482
+ total_cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
483
+ total_cost_usd REAL NOT NULL DEFAULT 0,
484
+ request_count INTEGER NOT NULL DEFAULT 0,
485
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
486
+ UNIQUE(user_id, model, date)
487
+ );
488
+ CREATE INDEX IF NOT EXISTS idx_daily_user_date ON usage_daily_summary(user_id, date);
489
+
490
+ CREATE TABLE IF NOT EXISTS user_quotas (
491
+ user_id TEXT PRIMARY KEY,
492
+ monthly_cost_limit_usd REAL NOT NULL DEFAULT -1,
493
+ monthly_token_limit INTEGER NOT NULL DEFAULT -1,
494
+ daily_cost_limit_usd REAL NOT NULL DEFAULT -1,
495
+ daily_request_limit INTEGER NOT NULL DEFAULT -1,
496
+ billing_cycle_start TEXT,
497
+ subscription_tier TEXT,
498
+ subscription_expires_at TEXT,
499
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
500
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
501
+ );
502
+ `);
503
+ // Lightweight migrations for existing DBs
504
+ ensureColumn('users', 'permissions', "TEXT NOT NULL DEFAULT '[]'");
505
+ ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
506
+ ensureColumn('users', 'disable_reason', 'TEXT');
507
+ ensureColumn('users', 'notes', 'TEXT');
508
+ ensureColumn('users', 'deleted_at', 'TEXT');
509
+ ensureColumn('invite_codes', 'permission_template', 'TEXT');
510
+ ensureColumn('invite_codes', 'permissions', "TEXT NOT NULL DEFAULT '[]'");
511
+ ensureColumn('users', 'avatar_emoji', 'TEXT');
512
+ ensureColumn('users', 'avatar_color', 'TEXT');
513
+ ensureColumn('registered_groups', 'execution_mode', "TEXT DEFAULT 'container'");
514
+ ensureColumn('registered_groups', 'agent_type', "TEXT DEFAULT 'claude'");
515
+ ensureColumn('registered_groups', 'model', 'TEXT');
516
+ ensureColumn('registered_groups', 'reasoning_effort', 'TEXT');
517
+ ensureColumn('registered_groups', 'custom_cwd', 'TEXT');
518
+ ensureColumn('registered_groups', 'init_source_path', 'TEXT');
519
+ ensureColumn('registered_groups', 'init_git_url', 'TEXT');
520
+ ensureColumn('messages', 'attachments', 'TEXT');
521
+ ensureColumn('messages', 'source_jid', 'TEXT');
522
+ ensureColumn('registered_groups', 'created_by', 'TEXT');
523
+ ensureColumn('registered_groups', 'is_home', 'INTEGER DEFAULT 0');
524
+ ensureColumn('users', 'avatar_url', 'TEXT');
525
+ ensureColumn('users', 'ai_name', 'TEXT');
526
+ ensureColumn('users', 'ai_avatar_emoji', 'TEXT');
527
+ ensureColumn('users', 'ai_avatar_color', 'TEXT');
528
+ ensureColumn('users', 'ai_avatar_url', 'TEXT');
529
+ ensureColumn('scheduled_tasks', 'created_by', 'TEXT');
530
+ ensureColumn('scheduled_tasks', 'execution_type', "TEXT DEFAULT 'agent'");
531
+ ensureColumn('scheduled_tasks', 'script_command', 'TEXT');
532
+ ensureColumn('scheduled_tasks', 'notify_channels', 'TEXT');
533
+ ensureColumn('scheduled_tasks', 'execution_mode', 'TEXT');
534
+ ensureColumn('scheduled_tasks', 'workspace_jid', 'TEXT');
535
+ ensureColumn('scheduled_tasks', 'workspace_folder', 'TEXT');
536
+ ensureColumn('registered_groups', 'selected_skills', 'TEXT');
537
+ ensureColumn('sessions', 'agent_id', "TEXT NOT NULL DEFAULT ''");
538
+ ensureColumn('agents', 'kind', "TEXT NOT NULL DEFAULT 'task'");
539
+ ensureColumn('registered_groups', 'target_agent_id', 'TEXT');
540
+ ensureColumn('registered_groups', 'target_main_jid', 'TEXT');
541
+ ensureColumn('registered_groups', 'reply_policy', "TEXT DEFAULT 'source_only'");
542
+ ensureColumn('registered_groups', 'require_mention', 'INTEGER DEFAULT 0');
543
+ ensureColumn('registered_groups', 'mcp_mode', "TEXT DEFAULT 'inherit'");
544
+ ensureColumn('registered_groups', 'selected_mcps', 'TEXT');
545
+ ensureColumn('registered_groups', 'activation_mode', "TEXT DEFAULT 'auto'");
546
+ ensureColumn('messages', 'token_usage', 'TEXT');
547
+ ensureColumn('messages', 'runtime_identity', 'TEXT');
548
+ ensureColumn('messages', 'turn_id', 'TEXT');
549
+ ensureColumn('messages', 'session_id', 'TEXT');
550
+ ensureColumn('messages', 'sdk_message_uuid', 'TEXT');
551
+ ensureColumn('messages', 'source_kind', 'TEXT');
552
+ ensureColumn('messages', 'finalization_reason', 'TEXT');
553
+ // Add index on target_agent_id for fast lookup of IM bindings
554
+ db.exec('CREATE INDEX IF NOT EXISTS idx_rg_target_agent ON registered_groups(target_agent_id)');
555
+ db.exec('CREATE INDEX IF NOT EXISTS idx_rg_target_main ON registered_groups(target_main_jid)');
556
+ // Migration: remove UNIQUE constraint from registered_groups.folder
557
+ // Multiple groups (web:main + feishu chats) share folder='main' by design.
558
+ // The old UNIQUE constraint caused INSERT OR REPLACE to silently delete
559
+ // the conflicting row, making web:main and feishu groups mutually exclusive.
560
+ const hasUniqueFolder = db
561
+ .prepare(`SELECT COUNT(*) as cnt FROM sqlite_master
562
+ WHERE type='index' AND tbl_name='registered_groups'
563
+ AND name='sqlite_autoindex_registered_groups_2'`)
564
+ .get().cnt > 0;
565
+ if (hasUniqueFolder) {
566
+ db.transaction(() => {
567
+ db.exec(`
568
+ CREATE TABLE registered_groups_new (
569
+ jid TEXT PRIMARY KEY,
570
+ name TEXT NOT NULL,
571
+ folder TEXT NOT NULL,
572
+ added_at TEXT NOT NULL,
573
+ container_config TEXT,
574
+ agent_type TEXT DEFAULT 'claude',
575
+ execution_mode TEXT DEFAULT 'container',
576
+ custom_cwd TEXT,
577
+ init_source_path TEXT,
578
+ init_git_url TEXT,
579
+ created_by TEXT,
580
+ is_home INTEGER DEFAULT 0
581
+ );
582
+ INSERT INTO registered_groups_new SELECT jid, name, folder, added_at, container_config, 'claude', execution_mode, custom_cwd, NULL, NULL, NULL, 0 FROM registered_groups;
583
+ DROP TABLE registered_groups;
584
+ ALTER TABLE registered_groups_new RENAME TO registered_groups;
585
+ `);
586
+ })();
587
+ }
588
+ // v19→v20 migration: add token_usage column to messages
589
+ ensureColumn('messages', 'token_usage', 'TEXT');
590
+ assertSchema('messages', [
591
+ 'id',
592
+ 'chat_jid',
593
+ 'source_jid',
594
+ 'sender',
595
+ 'sender_name',
596
+ 'content',
597
+ 'timestamp',
598
+ 'is_from_me',
599
+ 'attachments',
600
+ 'token_usage',
601
+ 'runtime_identity',
602
+ ]);
603
+ assertSchema('scheduled_tasks', [
604
+ 'id',
605
+ 'group_folder',
606
+ 'chat_jid',
607
+ 'prompt',
608
+ 'schedule_type',
609
+ 'schedule_value',
610
+ 'context_mode',
611
+ 'next_run',
612
+ 'last_run',
613
+ 'last_result',
614
+ 'status',
615
+ 'created_at',
616
+ 'created_by',
617
+ ]);
618
+ assertSchema('registered_groups', [
619
+ 'jid',
620
+ 'name',
621
+ 'folder',
622
+ 'added_at',
623
+ 'container_config',
624
+ 'agent_type',
625
+ 'execution_mode',
626
+ 'custom_cwd',
627
+ 'init_source_path',
628
+ 'init_git_url',
629
+ 'created_by',
630
+ 'is_home',
631
+ 'selected_skills',
632
+ 'target_agent_id',
633
+ 'target_main_jid',
634
+ 'reply_policy',
635
+ ], ['trigger_pattern', 'requires_trigger']);
636
+ assertSchema('users', [
637
+ 'id',
638
+ 'username',
639
+ 'password_hash',
640
+ 'display_name',
641
+ 'role',
642
+ 'status',
643
+ 'permissions',
644
+ 'must_change_password',
645
+ 'disable_reason',
646
+ 'notes',
647
+ 'avatar_emoji',
648
+ 'avatar_color',
649
+ 'avatar_url',
650
+ 'ai_name',
651
+ 'ai_avatar_emoji',
652
+ 'ai_avatar_color',
653
+ 'ai_avatar_url',
654
+ 'created_at',
655
+ 'updated_at',
656
+ 'last_login_at',
657
+ 'deleted_at',
658
+ ]);
659
+ assertSchema('user_sessions', [
660
+ 'id',
661
+ 'user_id',
662
+ 'ip_address',
663
+ 'user_agent',
664
+ 'created_at',
665
+ 'expires_at',
666
+ 'last_active_at',
667
+ ]);
668
+ assertSchema('invite_codes', [
669
+ 'code',
670
+ 'created_by',
671
+ 'role',
672
+ 'permission_template',
673
+ 'permissions',
674
+ 'max_uses',
675
+ 'used_count',
676
+ 'expires_at',
677
+ 'created_at',
678
+ ]);
679
+ assertSchema('auth_audit_log', [
680
+ 'id',
681
+ 'event_type',
682
+ 'username',
683
+ 'actor_username',
684
+ 'ip_address',
685
+ 'user_agent',
686
+ 'details',
687
+ 'created_at',
688
+ ]);
689
+ // Store schema version after all migrations complete
690
+ // Migrate existing web groups: assign to first admin
691
+ db.exec(`
692
+ UPDATE registered_groups SET created_by = (
693
+ SELECT id FROM users WHERE role = 'admin' AND status = 'active' ORDER BY created_at ASC LIMIT 1
694
+ ) WHERE jid LIKE 'web:%' AND folder != 'main' AND created_by IS NULL
695
+ `);
696
+ // Backfill owner for legacy web:main if missing.
697
+ db.exec(`
698
+ UPDATE registered_groups SET created_by = (
699
+ SELECT id FROM users WHERE role = 'admin' AND status = 'active' ORDER BY created_at ASC LIMIT 1
700
+ ) WHERE jid = 'web:main' AND created_by IS NULL
701
+ `);
702
+ // Backfill created_by for feishu/telegram groups by matching sibling groups in the same folder.
703
+ // Only backfill when the folder has exactly one distinct owner; otherwise keep NULL
704
+ // to avoid misrouting in ambiguous folders (e.g., shared admin main).
705
+ db.exec(`
706
+ UPDATE registered_groups
707
+ SET created_by = (
708
+ SELECT MIN(rg2.created_by)
709
+ FROM registered_groups rg2
710
+ WHERE rg2.folder = registered_groups.folder
711
+ AND rg2.created_by IS NOT NULL
712
+ )
713
+ WHERE (jid LIKE 'feishu:%' OR jid LIKE 'telegram:%')
714
+ AND created_by IS NULL
715
+ AND (
716
+ SELECT COUNT(DISTINCT rg3.created_by)
717
+ FROM registered_groups rg3
718
+ WHERE rg3.folder = registered_groups.folder
719
+ AND rg3.created_by IS NOT NULL
720
+ ) = 1
721
+ `);
722
+ // v13 migration: mark existing web:main group as is_home=1
723
+ db.exec(`
724
+ UPDATE registered_groups SET is_home = 1
725
+ WHERE jid = 'web:main' AND folder = 'main' AND is_home = 0
726
+ `);
727
+ // v15 migration: backfill group_members for existing web groups
728
+ const currentVersion = getRouterStateInternal('schema_version');
729
+ if (!currentVersion || parseInt(currentVersion, 10) < 15) {
730
+ db.transaction(() => {
731
+ // Backfill owner records for all web groups with created_by set
732
+ const webGroups = db
733
+ .prepare("SELECT DISTINCT folder, created_by FROM registered_groups WHERE jid LIKE 'web:%' AND created_by IS NOT NULL")
734
+ .all();
735
+ for (const g of webGroups) {
736
+ db.prepare(`INSERT OR IGNORE INTO group_members (group_folder, user_id, role, added_at, added_by)
737
+ VALUES (?, ?, 'owner', ?, ?)`).run(g.folder, g.created_by, new Date().toISOString(), g.created_by);
738
+ }
739
+ })();
740
+ }
741
+ // v16→v17 migration: rebuild sessions table with composite primary key
742
+ // Old PK was (group_folder), which cannot store multiple agent sessions per folder.
743
+ // New PK is (group_folder, COALESCE(agent_id, '')) to support per-agent sessions.
744
+ const curVer = getRouterStateInternal('schema_version');
745
+ if (curVer && parseInt(curVer, 10) < 17) {
746
+ db.transaction(() => {
747
+ // Check if the old table has single-column PK by inspecting table_info
748
+ const pkCols = db.prepare("PRAGMA table_info('sessions')").all().filter((c) => c.pk > 0);
749
+ // Old schema: single PK column 'group_folder'. New schema: composite PK needs rebuild.
750
+ if (pkCols.length === 1 && pkCols[0].name === 'group_folder') {
751
+ db.exec(`
752
+ CREATE TABLE sessions_new (
753
+ group_folder TEXT NOT NULL,
754
+ session_id TEXT NOT NULL,
755
+ agent_id TEXT NOT NULL DEFAULT '',
756
+ PRIMARY KEY (group_folder, agent_id)
757
+ );
758
+ INSERT OR IGNORE INTO sessions_new (group_folder, session_id, agent_id)
759
+ SELECT group_folder, session_id, COALESCE(agent_id, '') FROM sessions;
760
+ DROP TABLE sessions;
761
+ ALTER TABLE sessions_new RENAME TO sessions;
762
+ `);
763
+ }
764
+ })();
765
+ }
766
+ // v22: Fix target_main_jid that used folder-based JID (web:${folder})
767
+ // instead of actual registered group JID (web:${uuid}).
768
+ // Only affects non-home workspaces where folder != uuid.
769
+ if (curVer && parseInt(curVer, 10) < 22) {
770
+ const rows = db
771
+ .prepare("SELECT jid, target_main_jid FROM registered_groups WHERE target_main_jid IS NOT NULL AND target_main_jid != ''")
772
+ .all();
773
+ for (const row of rows) {
774
+ const targetJid = row.target_main_jid;
775
+ // Check if target_main_jid is a real registered group JID
776
+ const exists = db
777
+ .prepare('SELECT 1 FROM registered_groups WHERE jid = ?')
778
+ .get(targetJid);
779
+ if (exists)
780
+ continue;
781
+ // Not a valid JID — try to resolve via folder
782
+ if (!targetJid.startsWith('web:'))
783
+ continue;
784
+ const folder = targetJid.slice(4);
785
+ const candidates = db
786
+ .prepare("SELECT jid FROM registered_groups WHERE folder = ? AND jid LIKE 'web:%'")
787
+ .all(folder);
788
+ if (candidates.length === 1) {
789
+ db.prepare('UPDATE registered_groups SET target_main_jid = ? WHERE jid = ?').run(candidates[0].jid, row.jid);
790
+ }
791
+ }
792
+ }
793
+ // v23→v24 migration: billing system initialization
794
+ ensureColumn('users', 'subscription_plan_id', 'TEXT');
795
+ const v24Ver = getRouterStateInternal('schema_version');
796
+ if (!v24Ver || parseInt(v24Ver, 10) < 24) {
797
+ db.transaction(() => {
798
+ // Ensure a default free plan exists
799
+ const existingDefault = db
800
+ .prepare('SELECT id FROM billing_plans WHERE is_default = 1')
801
+ .get();
802
+ if (!existingDefault) {
803
+ const now = new Date().toISOString();
804
+ db.prepare(`INSERT OR IGNORE INTO billing_plans (id, name, description, tier, monthly_cost_usd, allow_overage, features, is_default, is_active, created_at, updated_at)
805
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('free', '免费版', '基础免费套餐', 0, 0, 0, '[]', 1, 1, now, now);
806
+ }
807
+ // Initialize balances for all existing users
808
+ const users = db
809
+ .prepare("SELECT id FROM users WHERE status != 'deleted'")
810
+ .all();
811
+ const now = new Date().toISOString();
812
+ for (const u of users) {
813
+ db.prepare('INSERT OR IGNORE INTO user_balances (user_id, balance_usd, total_deposited_usd, total_consumed_usd, updated_at) VALUES (?, 0, 0, 0, ?)').run(u.id, now);
814
+ }
815
+ // Create active subscriptions for existing users → free plan
816
+ const freePlan = db
817
+ .prepare('SELECT id FROM billing_plans WHERE is_default = 1')
818
+ .get();
819
+ if (freePlan) {
820
+ for (const u of users) {
821
+ const existing = db
822
+ .prepare("SELECT id FROM user_subscriptions WHERE user_id = ? AND status = 'active'")
823
+ .get(u.id);
824
+ if (!existing) {
825
+ const subId = `sub_${u.id}_${Date.now()}`;
826
+ db.prepare(`INSERT INTO user_subscriptions (id, user_id, plan_id, status, started_at, created_at)
827
+ VALUES (?, ?, ?, 'active', ?, ?)`).run(subId, u.id, freePlan.id, now, now);
828
+ }
829
+ }
830
+ }
831
+ })();
832
+ }
833
+ // v24→v25 migration: billing system enhancement (daily/weekly quotas, rate_multiplier, trial)
834
+ ensureColumn('billing_plans', 'daily_cost_quota', 'REAL');
835
+ ensureColumn('billing_plans', 'weekly_cost_quota', 'REAL');
836
+ ensureColumn('billing_plans', 'daily_token_quota', 'INTEGER');
837
+ ensureColumn('billing_plans', 'weekly_token_quota', 'INTEGER');
838
+ ensureColumn('billing_plans', 'rate_multiplier', 'REAL NOT NULL DEFAULT 1.0');
839
+ ensureColumn('billing_plans', 'trial_days', 'INTEGER');
840
+ ensureColumn('billing_plans', 'sort_order', 'INTEGER NOT NULL DEFAULT 0');
841
+ ensureColumn('billing_plans', 'display_price', 'TEXT');
842
+ ensureColumn('billing_plans', 'highlight', 'INTEGER NOT NULL DEFAULT 0');
843
+ ensureColumn('user_subscriptions', 'trial_ends_at', 'TEXT');
844
+ ensureColumn('user_subscriptions', 'notes', 'TEXT');
845
+ ensureColumn('redeem_codes', 'batch_id', 'TEXT');
846
+ // v25→v26 migration: cost_usd on messages + idempotency key for balance transactions
847
+ ensureColumn('messages', 'cost_usd', 'REAL');
848
+ // idempotency key for balance transactions
849
+ ensureColumn('balance_transactions', 'idempotency_key', 'TEXT');
850
+ ensureColumn('balance_transactions', 'source', "TEXT NOT NULL DEFAULT 'system_adjustment'");
851
+ ensureColumn('balance_transactions', 'operator_type', "TEXT NOT NULL DEFAULT 'system'");
852
+ ensureColumn('balance_transactions', 'notes', 'TEXT');
853
+ // Create unique index only if it doesn't exist
854
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bal_tx_idempotency ON balance_transactions(idempotency_key) WHERE idempotency_key IS NOT NULL`);
855
+ // v26→v27 migration: wallet-first commercialization baseline
856
+ const v27Ver = getRouterStateInternal('schema_version');
857
+ if (!v27Ver || parseInt(v27Ver, 10) < 27) {
858
+ db.transaction(() => {
859
+ const now = new Date().toISOString();
860
+ const users = db
861
+ .prepare("SELECT id, role FROM users WHERE status != 'deleted' AND role != 'admin'")
862
+ .all();
863
+ for (const user of users) {
864
+ db.prepare(`INSERT OR IGNORE INTO user_balances (
865
+ user_id, balance_usd, total_deposited_usd, total_consumed_usd, updated_at
866
+ ) VALUES (?, 0, 0, 0, ?)`).run(user.id, now);
867
+ db.prepare(`UPDATE user_balances
868
+ SET balance_usd = 0, total_deposited_usd = 0, total_consumed_usd = 0, updated_at = ?
869
+ WHERE user_id = ?`).run(now, user.id);
870
+ const hasOpening = db
871
+ .prepare("SELECT 1 FROM balance_transactions WHERE user_id = ? AND source = 'migration_opening' LIMIT 1")
872
+ .get(user.id);
873
+ if (!hasOpening) {
874
+ db.prepare(`INSERT INTO balance_transactions (
875
+ user_id, type, amount_usd, balance_after, description, reference_type,
876
+ reference_id, actor_id, source, operator_type, notes, idempotency_key, created_at
877
+ ) VALUES (?, 'adjustment', 0, 0, ?, NULL, NULL, NULL, 'migration_opening', 'system', ?, NULL, ?)`).run(user.id, '商业化计费上线初始化', '上线迁移:普通用户默认余额归零,需充值后使用', now);
878
+ }
879
+ }
880
+ })();
881
+ }
882
+ // v27→v28: Token usage tables + history migration
883
+ const v28Check = getRouterStateInternal('schema_version');
884
+ if (!v28Check || parseInt(v28Check, 10) < 28) {
885
+ db.transaction(() => {
886
+ // Count messages with token_usage for logging
887
+ const countBefore = db
888
+ .prepare("SELECT COUNT(*) as cnt FROM messages WHERE token_usage IS NOT NULL AND json_extract(token_usage, '$.modelUsage') IS NOT NULL")
889
+ .get().cnt;
890
+ // Migrate from messages.token_usage modelUsage into usage_records
891
+ db.exec(`
892
+ INSERT OR IGNORE INTO usage_records (id, user_id, group_folder, message_id, model,
893
+ input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens,
894
+ cost_usd, duration_ms, num_turns, source, created_at)
895
+ SELECT
896
+ lower(hex(randomblob(16))),
897
+ COALESCE(rg.created_by, 'system'),
898
+ COALESCE(rg.folder, m.chat_jid),
899
+ m.id,
900
+ COALESCE(jme.key, 'unknown'),
901
+ COALESCE(json_extract(jme.value, '$.inputTokens'), 0),
902
+ COALESCE(json_extract(jme.value, '$.outputTokens'), 0),
903
+ 0, 0,
904
+ COALESCE(json_extract(jme.value, '$.costUSD'), 0),
905
+ COALESCE(json_extract(m.token_usage, '$.durationMs'), 0),
906
+ COALESCE(json_extract(m.token_usage, '$.numTurns'), 0),
907
+ 'agent',
908
+ m.timestamp
909
+ FROM messages m
910
+ JOIN json_each(json_extract(m.token_usage, '$.modelUsage')) jme
911
+ LEFT JOIN registered_groups rg ON rg.jid = m.chat_jid
912
+ WHERE m.token_usage IS NOT NULL
913
+ AND json_extract(m.token_usage, '$.modelUsage') IS NOT NULL
914
+ `);
915
+ // Migrate messages without modelUsage (legacy) using root-level fields
916
+ db.exec(`
917
+ INSERT OR IGNORE INTO usage_records (id, user_id, group_folder, message_id, model,
918
+ input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens,
919
+ cost_usd, duration_ms, num_turns, source, created_at)
920
+ SELECT
921
+ lower(hex(randomblob(16))),
922
+ COALESCE(rg.created_by, 'system'),
923
+ COALESCE(rg.folder, m.chat_jid),
924
+ m.id,
925
+ 'legacy-unknown',
926
+ COALESCE(json_extract(m.token_usage, '$.inputTokens'), 0),
927
+ COALESCE(json_extract(m.token_usage, '$.outputTokens'), 0),
928
+ COALESCE(json_extract(m.token_usage, '$.cacheReadInputTokens'), 0),
929
+ COALESCE(json_extract(m.token_usage, '$.cacheCreationInputTokens'), 0),
930
+ COALESCE(json_extract(m.token_usage, '$.costUSD'), 0),
931
+ COALESCE(json_extract(m.token_usage, '$.durationMs'), 0),
932
+ COALESCE(json_extract(m.token_usage, '$.numTurns'), 0),
933
+ 'agent',
934
+ m.timestamp
935
+ FROM messages m
936
+ LEFT JOIN registered_groups rg ON rg.jid = m.chat_jid
937
+ WHERE m.token_usage IS NOT NULL
938
+ AND (json_extract(m.token_usage, '$.modelUsage') IS NULL
939
+ OR json_type(json_extract(m.token_usage, '$.modelUsage')) != 'object')
940
+ `);
941
+ // Build daily summary from usage_records
942
+ db.exec(`
943
+ INSERT OR REPLACE INTO usage_daily_summary (user_id, model, date,
944
+ total_input_tokens, total_output_tokens,
945
+ total_cache_read_tokens, total_cache_creation_tokens,
946
+ total_cost_usd, request_count, updated_at)
947
+ SELECT
948
+ user_id, model, date(created_at, 'localtime'),
949
+ SUM(input_tokens), SUM(output_tokens),
950
+ SUM(cache_read_input_tokens), SUM(cache_creation_input_tokens),
951
+ SUM(cost_usd), COUNT(*), datetime('now')
952
+ FROM usage_records
953
+ GROUP BY user_id, model, date(created_at, 'localtime')
954
+ `);
955
+ const countAfter = db.prepare('SELECT COUNT(*) as cnt FROM usage_records').get().cnt;
956
+ logger.info({ countBefore, countAfter }, 'Token usage migration v27→v28 completed');
957
+ })();
958
+ }
959
+ // v29 → v30: Add last_im_jid to agents table (#225)
960
+ if (!db
961
+ .prepare("PRAGMA table_info('agents')")
962
+ .all()
963
+ .some((c) => c.name === 'last_im_jid')) {
964
+ db.exec('ALTER TABLE agents ADD COLUMN last_im_jid TEXT');
965
+ }
966
+ // v31 → v32: Add spawned_from_jid to agents table (spawn parallel tasks)
967
+ if (!db
968
+ .prepare("PRAGMA table_info('agents')")
969
+ .all()
970
+ .some((c) => c.name === 'spawned_from_jid')) {
971
+ db.exec('ALTER TABLE agents ADD COLUMN spawned_from_jid TEXT');
972
+ }
973
+ const SCHEMA_VERSION = '33';
974
+ db.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
975
+ }
976
+ /**
977
+ * Store chat metadata only (no message content).
978
+ * Used for all chats to enable group discovery without storing sensitive content.
979
+ */
980
+ export function storeChatMetadata(chatJid, timestamp, name) {
981
+ if (name) {
982
+ // Update with name, preserving existing timestamp if newer
983
+ db.prepare(`
984
+ INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
985
+ ON CONFLICT(jid) DO UPDATE SET
986
+ name = excluded.name,
987
+ last_message_time = MAX(last_message_time, excluded.last_message_time)
988
+ `).run(chatJid, name, timestamp);
989
+ }
990
+ else {
991
+ // Update timestamp only, preserve existing name if any
992
+ db.prepare(`
993
+ INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
994
+ ON CONFLICT(jid) DO UPDATE SET
995
+ last_message_time = MAX(last_message_time, excluded.last_message_time)
996
+ `).run(chatJid, chatJid, timestamp);
997
+ }
998
+ }
999
+ /**
1000
+ * Update chat name without changing timestamp for existing chats.
1001
+ * New chats get the current time as their initial timestamp.
1002
+ * Used during group metadata sync.
1003
+ */
1004
+ export function updateChatName(chatJid, name) {
1005
+ db.prepare(`
1006
+ INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
1007
+ ON CONFLICT(jid) DO UPDATE SET name = excluded.name
1008
+ `).run(chatJid, name, new Date().toISOString());
1009
+ }
1010
+ /**
1011
+ * Get all known chats, ordered by most recent activity.
1012
+ */
1013
+ export function getAllChats() {
1014
+ return db
1015
+ .prepare(`
1016
+ SELECT jid, name, last_message_time
1017
+ FROM chats
1018
+ ORDER BY last_message_time DESC
1019
+ `)
1020
+ .all();
1021
+ }
1022
+ /**
1023
+ * Get timestamp of last group metadata sync.
1024
+ */
1025
+ export function getLastGroupSync() {
1026
+ // Store sync time in a special chat entry
1027
+ const row = db
1028
+ .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`)
1029
+ .get();
1030
+ return row?.last_message_time || null;
1031
+ }
1032
+ /**
1033
+ * Record that group metadata was synced.
1034
+ */
1035
+ export function setLastGroupSync() {
1036
+ const now = new Date().toISOString();
1037
+ db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`).run(now);
1038
+ }
1039
+ /**
1040
+ * Ensure a chat row exists in the chats table (avoids FK violation on messages insert).
1041
+ */
1042
+ export function ensureChatExists(chatJid) {
1043
+ db.prepare(`INSERT OR IGNORE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`).run(chatJid, chatJid, new Date().toISOString());
1044
+ }
1045
+ /**
1046
+ * Store a message with full content (channel-agnostic).
1047
+ * Only call this for registered groups where message history is needed.
1048
+ */
1049
+ export function storeMessageDirect(msgId, chatJid, sender, senderName, content, timestamp, isFromMe, opts) {
1050
+ const { attachments, tokenUsage, sourceJid, meta } = opts ?? {};
1051
+ const existingFinalRow = meta?.sourceKind === 'sdk_final' && meta.turnId
1052
+ ? stmts().storeMessageSelect.get(chatJid, meta.turnId)
1053
+ : undefined;
1054
+ const effectiveMsgId = existingFinalRow?.id || msgId;
1055
+ stmts().storeMessageInsert.run(effectiveMsgId, chatJid, sourceJid ?? chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0, attachments ?? null, tokenUsage ?? null, serializeRuntimeIdentity(meta?.runtimeIdentity), meta?.turnId ?? null, meta?.sessionId ?? null, meta?.sdkMessageUuid ?? null, meta?.sourceKind ?? null, meta?.finalizationReason ?? null);
1056
+ return effectiveMsgId;
1057
+ }
1058
+ /**
1059
+ * Update the token_usage field on a specific agent message, or fall back to
1060
+ * the most recent agent message without token_usage for the given chat.
1061
+ * When msgId is provided, uses precise `WHERE id = ? AND chat_jid = ?` match
1062
+ * to avoid race conditions in concurrent scenarios.
1063
+ */
1064
+ export function updateLatestMessageTokenUsage(chatJid, tokenUsage, msgId, costUsd) {
1065
+ if (msgId) {
1066
+ stmts().updateTokenUsageById.run(tokenUsage, costUsd ?? null, msgId, chatJid);
1067
+ }
1068
+ else {
1069
+ stmts().updateTokenUsageLatest.run(tokenUsage, costUsd ?? null, chatJid);
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Get token usage statistics aggregated by date.
1074
+ */
1075
+ export function getTokenUsageStats(days, chatJids) {
1076
+ const since = new Date();
1077
+ since.setDate(since.getDate() - days);
1078
+ const sinceStr = since.toISOString();
1079
+ const jidFilter = chatJids && chatJids.length > 0
1080
+ ? `AND m.chat_jid IN (${chatJids.map(() => '?').join(',')})`
1081
+ : '';
1082
+ const params = [sinceStr, ...(chatJids || [])];
1083
+ const baseQuery = `
1084
+ SELECT
1085
+ date(m.timestamp) as date,
1086
+ json_extract(m.token_usage, '$.modelUsage') as model_usage_json,
1087
+ json_extract(m.token_usage, '$.inputTokens') as input_tokens,
1088
+ json_extract(m.token_usage, '$.outputTokens') as output_tokens,
1089
+ json_extract(m.token_usage, '$.cacheReadInputTokens') as cache_read_tokens,
1090
+ json_extract(m.token_usage, '$.cacheCreationInputTokens') as cache_creation_tokens,
1091
+ json_extract(m.token_usage, '$.costUSD') as cost_usd
1092
+ FROM messages m
1093
+ WHERE m.token_usage IS NOT NULL
1094
+ AND m.timestamp >= ?
1095
+ ${jidFilter}
1096
+ ORDER BY m.timestamp ASC
1097
+ `;
1098
+ const rows = db.prepare(baseQuery).all(...params);
1099
+ const aggregated = new Map();
1100
+ function addToAggregated(date, model, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd) {
1101
+ const key = `${date}|${model}`;
1102
+ const existing = aggregated.get(key);
1103
+ if (existing) {
1104
+ existing.input_tokens += inputTokens;
1105
+ existing.output_tokens += outputTokens;
1106
+ existing.cache_read_tokens += cacheReadTokens;
1107
+ existing.cache_creation_tokens += cacheCreationTokens;
1108
+ existing.cost_usd += costUsd;
1109
+ existing.message_count += 1;
1110
+ }
1111
+ else {
1112
+ aggregated.set(key, {
1113
+ date,
1114
+ model,
1115
+ input_tokens: inputTokens,
1116
+ output_tokens: outputTokens,
1117
+ cache_read_tokens: cacheReadTokens,
1118
+ cache_creation_tokens: cacheCreationTokens,
1119
+ cost_usd: costUsd,
1120
+ message_count: 1,
1121
+ });
1122
+ }
1123
+ }
1124
+ for (const row of rows) {
1125
+ if (row.model_usage_json) {
1126
+ try {
1127
+ const modelUsage = JSON.parse(row.model_usage_json);
1128
+ for (const [model, usage] of Object.entries(modelUsage)) {
1129
+ addToAggregated(row.date, model, usage.inputTokens || 0, usage.outputTokens || 0, 0, 0, usage.costUSD || 0);
1130
+ }
1131
+ }
1132
+ catch (e) {
1133
+ logger.warn({ date: row.date, error: e }, 'Failed to parse model_usage_json');
1134
+ // fallback: use aggregate fields
1135
+ addToAggregated(row.date, 'unknown', row.input_tokens || 0, row.output_tokens || 0, row.cache_read_tokens || 0, row.cache_creation_tokens || 0, row.cost_usd || 0);
1136
+ }
1137
+ }
1138
+ else {
1139
+ addToAggregated(row.date, 'unknown', row.input_tokens || 0, row.output_tokens || 0, row.cache_read_tokens || 0, row.cache_creation_tokens || 0, row.cost_usd || 0);
1140
+ }
1141
+ }
1142
+ return Array.from(aggregated.values());
1143
+ }
1144
+ /**
1145
+ * Get token usage summary totals.
1146
+ */
1147
+ export function getTokenUsageSummary(days, chatJids) {
1148
+ const since = new Date();
1149
+ since.setDate(since.getDate() - days);
1150
+ const sinceStr = since.toISOString();
1151
+ const jidFilter = chatJids && chatJids.length > 0
1152
+ ? `AND chat_jid IN (${chatJids.map(() => '?').join(',')})`
1153
+ : '';
1154
+ const params = [sinceStr, ...(chatJids || [])];
1155
+ const row = db
1156
+ .prepare(`
1157
+ SELECT
1158
+ COALESCE(SUM(json_extract(token_usage, '$.inputTokens')), 0) as total_input,
1159
+ COALESCE(SUM(json_extract(token_usage, '$.outputTokens')), 0) as total_output,
1160
+ COALESCE(SUM(json_extract(token_usage, '$.cacheReadInputTokens')), 0) as total_cache_read,
1161
+ COALESCE(SUM(json_extract(token_usage, '$.cacheCreationInputTokens')), 0) as total_cache_creation,
1162
+ COALESCE(SUM(json_extract(token_usage, '$.costUSD')), 0) as total_cost,
1163
+ COUNT(*) as total_messages,
1164
+ COUNT(DISTINCT date(timestamp)) as total_active_days
1165
+ FROM messages
1166
+ WHERE token_usage IS NOT NULL AND timestamp >= ?
1167
+ ${jidFilter}
1168
+ `)
1169
+ .get(...params);
1170
+ return {
1171
+ totalInputTokens: row.total_input,
1172
+ totalOutputTokens: row.total_output,
1173
+ totalCacheReadTokens: row.total_cache_read,
1174
+ totalCacheCreationTokens: row.total_cache_creation,
1175
+ totalCostUSD: row.total_cost,
1176
+ totalMessages: row.total_messages,
1177
+ totalActiveDays: row.total_active_days,
1178
+ };
1179
+ }
1180
+ /**
1181
+ * Get a local timezone date string (YYYY-MM-DD) from a Date or ISO string.
1182
+ */
1183
+ function toLocalDateString(date) {
1184
+ const d = date ? new Date(date) : new Date();
1185
+ const year = d.getFullYear();
1186
+ const month = String(d.getMonth() + 1).padStart(2, '0');
1187
+ const day = String(d.getDate()).padStart(2, '0');
1188
+ return `${year}-${month}-${day}`;
1189
+ }
1190
+ /**
1191
+ * Insert a usage record and update daily summary.
1192
+ */
1193
+ export function insertUsageRecord(record) {
1194
+ const id = crypto.randomUUID();
1195
+ const now = new Date().toISOString();
1196
+ const localDate = toLocalDateString();
1197
+ db.transaction(() => {
1198
+ stmts().insertUsageInsert.run(id, record.userId, record.groupFolder, record.agentId ?? null, record.messageId ?? null, record.model, record.inputTokens, record.outputTokens, record.cacheReadInputTokens, record.cacheCreationInputTokens, record.costUSD, record.durationMs ?? 0, record.numTurns ?? 0, record.source ?? 'agent', now);
1199
+ stmts().insertUsageUpsert.run(record.userId, record.model, localDate, record.inputTokens, record.outputTokens, record.cacheReadInputTokens, record.cacheCreationInputTokens, record.costUSD);
1200
+ })();
1201
+ }
1202
+ /**
1203
+ * Get usage stats from daily summary table (fixes timezone + token KPI issues).
1204
+ */
1205
+ export function getUsageDailyStats(days, userId, modelFilter) {
1206
+ const sinceDate = toLocalDateString(new Date(Date.now() - days * 86400000));
1207
+ const conditions = ['date >= ?'];
1208
+ const params = [sinceDate];
1209
+ if (userId) {
1210
+ conditions.push('user_id = ?');
1211
+ params.push(userId);
1212
+ }
1213
+ if (modelFilter) {
1214
+ conditions.push('model = ?');
1215
+ params.push(modelFilter);
1216
+ }
1217
+ const whereClause = conditions.join(' AND ');
1218
+ return db
1219
+ .prepare(`
1220
+ SELECT date, model, user_id,
1221
+ total_input_tokens as input_tokens,
1222
+ total_output_tokens as output_tokens,
1223
+ total_cache_read_tokens as cache_read_tokens,
1224
+ total_cache_creation_tokens as cache_creation_tokens,
1225
+ total_cost_usd as cost_usd,
1226
+ request_count
1227
+ FROM usage_daily_summary
1228
+ WHERE ${whereClause}
1229
+ ORDER BY date ASC
1230
+ `)
1231
+ .all(...params);
1232
+ }
1233
+ /**
1234
+ * Get usage summary from daily summary table.
1235
+ */
1236
+ export function getUsageDailySummary(days, userId, modelFilter) {
1237
+ const sinceDate = toLocalDateString(new Date(Date.now() - days * 86400000));
1238
+ const conditions = ['date >= ?'];
1239
+ const params = [sinceDate];
1240
+ if (userId) {
1241
+ conditions.push('user_id = ?');
1242
+ params.push(userId);
1243
+ }
1244
+ if (modelFilter) {
1245
+ conditions.push('model = ?');
1246
+ params.push(modelFilter);
1247
+ }
1248
+ const whereClause = conditions.join(' AND ');
1249
+ const row = db
1250
+ .prepare(`
1251
+ SELECT
1252
+ COALESCE(SUM(total_input_tokens), 0) as total_input,
1253
+ COALESCE(SUM(total_output_tokens), 0) as total_output,
1254
+ COALESCE(SUM(total_cache_read_tokens), 0) as total_cache_read,
1255
+ COALESCE(SUM(total_cache_creation_tokens), 0) as total_cache_creation,
1256
+ COALESCE(SUM(total_cost_usd), 0) as total_cost,
1257
+ COALESCE(SUM(request_count), 0) as total_messages,
1258
+ COUNT(DISTINCT date) as total_active_days
1259
+ FROM usage_daily_summary
1260
+ WHERE ${whereClause}
1261
+ `)
1262
+ .get(...params);
1263
+ return {
1264
+ totalInputTokens: row.total_input,
1265
+ totalOutputTokens: row.total_output,
1266
+ totalCacheReadTokens: row.total_cache_read,
1267
+ totalCacheCreationTokens: row.total_cache_creation,
1268
+ totalCostUSD: row.total_cost,
1269
+ totalMessages: row.total_messages,
1270
+ totalActiveDays: row.total_active_days,
1271
+ };
1272
+ }
1273
+ /**
1274
+ * Get list of all models that have usage data.
1275
+ */
1276
+ export function getUsageModels() {
1277
+ const rows = db
1278
+ .prepare('SELECT DISTINCT model FROM usage_daily_summary ORDER BY model')
1279
+ .all();
1280
+ return rows.map((r) => r.model);
1281
+ }
1282
+ /**
1283
+ * Get list of users that have usage data.
1284
+ */
1285
+ export function getUsageUsers() {
1286
+ const rows = db
1287
+ .prepare(`
1288
+ SELECT DISTINCT uds.user_id as id, COALESCE(u.username, uds.user_id) as username
1289
+ FROM usage_daily_summary uds
1290
+ LEFT JOIN users u ON u.id = uds.user_id
1291
+ ORDER BY u.username
1292
+ `)
1293
+ .all();
1294
+ return rows;
1295
+ }
1296
+ export function getNewMessages(jids, cursor) {
1297
+ if (jids.length === 0)
1298
+ return { messages: [], newCursor: cursor };
1299
+ const rows = getNewMessagesStmt(jids.length).all(cursor.timestamp, cursor.timestamp, cursor.id, ...jids);
1300
+ const messages = rows.map((row) => ({
1301
+ ...mapDbMessageRow(row),
1302
+ }));
1303
+ const last = messages[messages.length - 1];
1304
+ return {
1305
+ messages,
1306
+ newCursor: last ? { timestamp: last.timestamp, id: last.id } : cursor,
1307
+ };
1308
+ }
1309
+ export function getMessagesSince(chatJid, cursor) {
1310
+ const rows = stmts().getMessagesSince.all(chatJid, cursor.timestamp, cursor.timestamp, cursor.id);
1311
+ return rows.map((row) => ({
1312
+ ...row,
1313
+ runtime_identity: parseRuntimeIdentity(row.runtime_identity),
1314
+ }));
1315
+ }
1316
+ export function createTask(task) {
1317
+ db.prepare(`
1318
+ INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, execution_type, script_command, execution_mode, next_run, status, created_at, created_by, notify_channels)
1319
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1320
+ `).run(task.id, task.group_folder, task.chat_jid, task.prompt, task.schedule_type, task.schedule_value, task.context_mode || 'group', task.execution_type || 'agent', task.script_command ?? null, task.execution_mode ?? null, task.next_run, task.status, task.created_at, task.created_by ?? null, task.notify_channels != null ? JSON.stringify(task.notify_channels) : null);
1321
+ }
1322
+ /** Parse notify_channels from JSON string stored in DB and normalize new fields */
1323
+ function mapTaskRow(row) {
1324
+ const r = row;
1325
+ if (typeof r.notify_channels === 'string') {
1326
+ try {
1327
+ r.notify_channels = JSON.parse(r.notify_channels);
1328
+ }
1329
+ catch {
1330
+ r.notify_channels = null;
1331
+ }
1332
+ }
1333
+ else if (r.notify_channels === undefined) {
1334
+ r.notify_channels = null;
1335
+ }
1336
+ // Normalize new nullable fields
1337
+ if (r.execution_mode === undefined)
1338
+ r.execution_mode = null;
1339
+ if (r.workspace_jid === undefined)
1340
+ r.workspace_jid = null;
1341
+ if (r.workspace_folder === undefined)
1342
+ r.workspace_folder = null;
1343
+ return r;
1344
+ }
1345
+ export function getTaskById(id) {
1346
+ const row = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id);
1347
+ return row ? mapTaskRow(row) : undefined;
1348
+ }
1349
+ export function getTasksForGroup(groupFolder) {
1350
+ return db
1351
+ .prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC')
1352
+ .all(groupFolder)
1353
+ .map(mapTaskRow);
1354
+ }
1355
+ export function getAllTasks() {
1356
+ return db
1357
+ .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
1358
+ .all()
1359
+ .map(mapTaskRow);
1360
+ }
1361
+ export function updateTask(id, updates) {
1362
+ const fields = [];
1363
+ const values = [];
1364
+ if (updates.prompt !== undefined) {
1365
+ fields.push('prompt = ?');
1366
+ values.push(updates.prompt);
1367
+ }
1368
+ if (updates.schedule_type !== undefined) {
1369
+ fields.push('schedule_type = ?');
1370
+ values.push(updates.schedule_type);
1371
+ }
1372
+ if (updates.schedule_value !== undefined) {
1373
+ fields.push('schedule_value = ?');
1374
+ values.push(updates.schedule_value);
1375
+ }
1376
+ if (updates.context_mode !== undefined) {
1377
+ fields.push('context_mode = ?');
1378
+ values.push(updates.context_mode);
1379
+ }
1380
+ if (updates.execution_type !== undefined) {
1381
+ fields.push('execution_type = ?');
1382
+ values.push(updates.execution_type);
1383
+ }
1384
+ if (updates.execution_mode !== undefined) {
1385
+ fields.push('execution_mode = ?');
1386
+ values.push(updates.execution_mode);
1387
+ }
1388
+ if (updates.script_command !== undefined) {
1389
+ fields.push('script_command = ?');
1390
+ values.push(updates.script_command);
1391
+ }
1392
+ if (updates.next_run !== undefined) {
1393
+ fields.push('next_run = ?');
1394
+ values.push(updates.next_run);
1395
+ }
1396
+ if (updates.status !== undefined) {
1397
+ fields.push('status = ?');
1398
+ values.push(updates.status);
1399
+ }
1400
+ if (updates.notify_channels !== undefined) {
1401
+ fields.push('notify_channels = ?');
1402
+ values.push(updates.notify_channels != null
1403
+ ? JSON.stringify(updates.notify_channels)
1404
+ : null);
1405
+ }
1406
+ if (fields.length === 0)
1407
+ return;
1408
+ values.push(id);
1409
+ db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
1410
+ }
1411
+ export function updateTaskWorkspace(id, workspaceJid, workspaceFolder) {
1412
+ db.prepare('UPDATE scheduled_tasks SET workspace_jid = ?, workspace_folder = ? WHERE id = ?').run(workspaceJid, workspaceFolder, id);
1413
+ }
1414
+ export function deleteTask(id) {
1415
+ // Delete child records first (FK constraint)
1416
+ db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
1417
+ db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
1418
+ }
1419
+ export function deleteTasksForGroup(groupFolder) {
1420
+ const tx = db.transaction((folder) => {
1421
+ db.prepare(`
1422
+ DELETE FROM task_run_logs
1423
+ WHERE task_id IN (
1424
+ SELECT id FROM scheduled_tasks WHERE group_folder = ?
1425
+ )
1426
+ `).run(folder);
1427
+ db.prepare('DELETE FROM scheduled_tasks WHERE group_folder = ?').run(folder);
1428
+ });
1429
+ tx(groupFolder);
1430
+ }
1431
+ export function getDueTasks() {
1432
+ const now = new Date().toISOString();
1433
+ return db
1434
+ .prepare(`
1435
+ SELECT * FROM scheduled_tasks
1436
+ WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
1437
+ ORDER BY next_run
1438
+ `)
1439
+ .all(now)
1440
+ .map(mapTaskRow);
1441
+ }
1442
+ export function updateTaskAfterRun(id, nextRun, lastResult) {
1443
+ const now = new Date().toISOString();
1444
+ db.prepare(`
1445
+ UPDATE scheduled_tasks
1446
+ SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
1447
+ WHERE id = ?
1448
+ `).run(nextRun, now, lastResult, nextRun, id);
1449
+ }
1450
+ export function logTaskRun(log) {
1451
+ db.prepare(`
1452
+ INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
1453
+ VALUES (?, ?, ?, ?, ?, ?)
1454
+ `).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error);
1455
+ }
1456
+ export function logTaskRunStart(taskId) {
1457
+ const result = db
1458
+ .prepare(`
1459
+ INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
1460
+ VALUES (?, ?, 0, 'running', NULL, NULL)
1461
+ `)
1462
+ .run(taskId, new Date().toISOString());
1463
+ return Number(result.lastInsertRowid);
1464
+ }
1465
+ export function updateTaskRunLog(id, updates) {
1466
+ db.prepare(`
1467
+ UPDATE task_run_logs SET duration_ms = ?, status = ?, result = ?, error = ?
1468
+ WHERE id = ?
1469
+ `).run(updates.duration_ms, updates.status, updates.result, updates.error, id);
1470
+ }
1471
+ export function cleanupStaleRunningLogs() {
1472
+ const result = db
1473
+ .prepare(`
1474
+ UPDATE task_run_logs SET status = 'error', error = 'Process crashed before completion'
1475
+ WHERE status = 'running'
1476
+ `)
1477
+ .run();
1478
+ return result.changes;
1479
+ }
1480
+ export function cleanupOldTaskRunLogs(retentionDays = 30) {
1481
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
1482
+ const result = db
1483
+ .prepare(`DELETE FROM task_run_logs WHERE run_at < ?`)
1484
+ .run(cutoff);
1485
+ return result.changes;
1486
+ }
1487
+ export function cleanupOldDailyUsage(retentionDays = 90) {
1488
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
1489
+ .toISOString()
1490
+ .slice(0, 10);
1491
+ const result = db
1492
+ .prepare('DELETE FROM daily_usage WHERE date < ?')
1493
+ .run(cutoff);
1494
+ return result.changes;
1495
+ }
1496
+ export function cleanupOldBillingAuditLog(retentionDays = 365) {
1497
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
1498
+ const result = db
1499
+ .prepare('DELETE FROM billing_audit_log WHERE created_at < ?')
1500
+ .run(cutoff);
1501
+ return result.changes;
1502
+ }
1503
+ // --- Router state accessors ---
1504
+ export function getRouterState(key) {
1505
+ const row = db
1506
+ .prepare('SELECT value FROM router_state WHERE key = ?')
1507
+ .get(key);
1508
+ return row?.value;
1509
+ }
1510
+ export function setRouterState(key, value) {
1511
+ db.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)').run(key, value);
1512
+ }
1513
+ export function deleteRouterState(key) {
1514
+ db.prepare('DELETE FROM router_state WHERE key = ?').run(key);
1515
+ }
1516
+ export function getRouterStateByPrefix(prefix) {
1517
+ return db
1518
+ .prepare('SELECT key, value FROM router_state WHERE key LIKE ?')
1519
+ .all(`${prefix}%`);
1520
+ }
1521
+ // --- Session accessors ---
1522
+ export function getSession(groupFolder, agentId) {
1523
+ const effectiveAgentId = agentId || '';
1524
+ const row = db
1525
+ .prepare('SELECT session_id FROM sessions WHERE group_folder = ? AND agent_id = ?')
1526
+ .get(groupFolder, effectiveAgentId);
1527
+ return row?.session_id;
1528
+ }
1529
+ export function setSession(groupFolder, sessionId, agentId) {
1530
+ const effectiveAgentId = agentId || '';
1531
+ db.prepare(`INSERT INTO sessions (group_folder, session_id, agent_id) VALUES (?, ?, ?)
1532
+ ON CONFLICT(group_folder, agent_id) DO UPDATE SET session_id = excluded.session_id`).run(groupFolder, sessionId, effectiveAgentId);
1533
+ }
1534
+ export function deleteSession(groupFolder, agentId) {
1535
+ const effectiveAgentId = agentId || '';
1536
+ db.prepare('DELETE FROM sessions WHERE group_folder = ? AND agent_id = ?').run(groupFolder, effectiveAgentId);
1537
+ }
1538
+ export function deleteAllSessionsForFolder(groupFolder) {
1539
+ db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
1540
+ }
1541
+ export function getAllSessions() {
1542
+ const rows = db
1543
+ .prepare("SELECT group_folder, session_id FROM sessions WHERE agent_id = ''")
1544
+ .all();
1545
+ const result = {};
1546
+ for (const row of rows) {
1547
+ result[row.group_folder] = row.session_id;
1548
+ }
1549
+ return result;
1550
+ }
1551
+ // --- Registered group accessors ---
1552
+ function parseExecutionMode(raw, context) {
1553
+ if (raw === 'container' || raw === 'host')
1554
+ return raw;
1555
+ if (raw !== null && raw !== '') {
1556
+ console.warn(`Invalid execution_mode "${raw}" for ${context}, falling back to "container"`);
1557
+ }
1558
+ return 'container';
1559
+ }
1560
+ function parseAgentType(raw) {
1561
+ return raw === 'codex' ? 'codex' : 'claude';
1562
+ }
1563
+ /** Convert a raw DB row into a RegisteredGroup domain object. */
1564
+ function parseGroupRow(row) {
1565
+ return {
1566
+ jid: row.jid,
1567
+ name: row.name,
1568
+ folder: row.folder,
1569
+ added_at: row.added_at,
1570
+ containerConfig: row.container_config
1571
+ ? JSON.parse(row.container_config)
1572
+ : undefined,
1573
+ agentType: parseAgentType(row.agent_type),
1574
+ executionMode: parseExecutionMode(row.execution_mode, `group ${row.jid}`),
1575
+ model: row.model ?? null,
1576
+ reasoningEffort: row.reasoning_effort ?? null,
1577
+ customCwd: row.custom_cwd ?? undefined,
1578
+ initSourcePath: row.init_source_path ?? undefined,
1579
+ initGitUrl: row.init_git_url ?? undefined,
1580
+ created_by: row.created_by ?? undefined,
1581
+ is_home: row.is_home === 1,
1582
+ target_agent_id: row.target_agent_id ?? undefined,
1583
+ target_main_jid: row.target_main_jid ?? undefined,
1584
+ reply_policy: row.reply_policy === 'mirror' ? 'mirror' : 'source_only',
1585
+ require_mention: row.require_mention === 1,
1586
+ activation_mode: parseActivationMode(row.activation_mode),
1587
+ };
1588
+ }
1589
+ const VALID_ACTIVATION_MODES = new Set([
1590
+ 'auto',
1591
+ 'always',
1592
+ 'when_mentioned',
1593
+ 'disabled',
1594
+ ]);
1595
+ function parseActivationMode(raw) {
1596
+ if (raw && VALID_ACTIVATION_MODES.has(raw))
1597
+ return raw;
1598
+ return 'auto';
1599
+ }
1600
+ export function getRegisteredGroup(jid) {
1601
+ const row = db
1602
+ .prepare('SELECT * FROM registered_groups WHERE jid = ?')
1603
+ .get(jid);
1604
+ if (!row)
1605
+ return undefined;
1606
+ return parseGroupRow(row);
1607
+ }
1608
+ export function setRegisteredGroup(jid, group) {
1609
+ db.prepare(`INSERT OR REPLACE INTO registered_groups (jid, name, folder, added_at, container_config, agent_type, execution_mode, model, reasoning_effort, custom_cwd, init_source_path, init_git_url, created_by, is_home, selected_skills, target_agent_id, target_main_jid, reply_policy, require_mention, activation_mode, mcp_mode, selected_mcps)
1610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(jid, group.name, group.folder, group.added_at, group.containerConfig ? JSON.stringify(group.containerConfig) : null, group.agentType ?? 'claude', group.executionMode ?? 'container', group.model ?? null, group.reasoningEffort ?? null, group.customCwd ?? null, group.initSourcePath ?? null, group.initGitUrl ?? null, group.created_by ?? null, group.is_home ? 1 : 0, null, // selected_skills: deprecated, always null (user-level skills apply globally)
1611
+ group.target_agent_id ?? null, group.target_main_jid ?? null, group.reply_policy ?? 'source_only', group.require_mention === true ? 1 : 0, group.activation_mode ?? 'auto', 'inherit', // mcp_mode: deprecated, always inherit (user-level MCP applies globally)
1612
+ null);
1613
+ }
1614
+ export function deleteRegisteredGroup(jid) {
1615
+ db.prepare('DELETE FROM registered_groups WHERE jid = ?').run(jid);
1616
+ }
1617
+ /** Get all JIDs that share the same folder (e.g., all JIDs with folder='main'). */
1618
+ export function getJidsByFolder(folder) {
1619
+ const rows = db
1620
+ .prepare('SELECT jid FROM registered_groups WHERE folder = ?')
1621
+ .all(folder);
1622
+ return rows.map((r) => r.jid);
1623
+ }
1624
+ /** Check if any registered group uses container execution mode (efficient targeted query). */
1625
+ export function hasContainerModeGroups() {
1626
+ const row = db
1627
+ .prepare("SELECT 1 FROM registered_groups WHERE execution_mode = 'container' OR execution_mode IS NULL LIMIT 1")
1628
+ .get();
1629
+ return row !== undefined;
1630
+ }
1631
+ export function getAllRegisteredGroups() {
1632
+ const rows = db
1633
+ .prepare('SELECT * FROM registered_groups')
1634
+ .all();
1635
+ const result = {};
1636
+ for (const row of rows) {
1637
+ result[row.jid] = parseGroupRow(row);
1638
+ }
1639
+ return result;
1640
+ }
1641
+ /**
1642
+ * Get all registered groups that route to a specific conversation agent.
1643
+ * Returns array of { jid, group } for each IM group targeting the given agentId.
1644
+ */
1645
+ export function getGroupsByTargetAgent(agentId) {
1646
+ const rows = db
1647
+ .prepare('SELECT * FROM registered_groups WHERE target_agent_id = ?')
1648
+ .all(agentId);
1649
+ return rows.map((row) => ({ jid: row.jid, group: parseGroupRow(row) }));
1650
+ }
1651
+ /**
1652
+ * Get all registered groups that route to a specific workspace's main conversation.
1653
+ */
1654
+ export function getGroupsByTargetMainJid(webJid) {
1655
+ const rows = db
1656
+ .prepare('SELECT * FROM registered_groups WHERE target_main_jid = ?')
1657
+ .all(webJid);
1658
+ return rows.map((row) => ({ jid: row.jid, group: parseGroupRow(row) }));
1659
+ }
1660
+ /**
1661
+ * Find a user's home group (is_home=1 + created_by=userId).
1662
+ * For admin users, also matches web:main even if created_by differs
1663
+ * (all admins share folder=main).
1664
+ */
1665
+ export function getUserHomeGroup(userId) {
1666
+ // First try exact match: is_home=1 AND created_by=userId
1667
+ let row = db
1668
+ .prepare('SELECT * FROM registered_groups WHERE is_home = 1 AND created_by = ?')
1669
+ .get(userId);
1670
+ // Fallback for admin users: all admins share web:main (folder=main).
1671
+ // If no exact match, check if the user is an admin and web:main exists.
1672
+ if (!row) {
1673
+ const user = db
1674
+ .prepare("SELECT role FROM users WHERE id = ? AND status = 'active'")
1675
+ .get(userId);
1676
+ if (user?.role === 'admin') {
1677
+ row = db
1678
+ .prepare("SELECT * FROM registered_groups WHERE jid = 'web:main' AND is_home = 1")
1679
+ .get();
1680
+ }
1681
+ }
1682
+ if (!row)
1683
+ return undefined;
1684
+ return parseGroupRow(row);
1685
+ }
1686
+ /**
1687
+ * Ensure a user has a home group. If not, create one.
1688
+ * Admin gets folder='main' with executionMode='host'.
1689
+ * Member gets folder='home-{userId}' with executionMode='container'.
1690
+ * Returns the JID of the home group.
1691
+ */
1692
+ export function ensureUserHomeGroup(userId, role, username) {
1693
+ const existing = getUserHomeGroup(userId);
1694
+ if (existing)
1695
+ return existing.jid;
1696
+ const now = new Date().toISOString();
1697
+ const isAdmin = role === 'admin';
1698
+ const jid = isAdmin ? 'web:main' : `web:home-${userId}`;
1699
+ const folder = isAdmin ? 'main' : `home-${userId}`;
1700
+ // For admin: check if web:main already exists (created by another admin)
1701
+ // In that case, reuse it rather than overwriting created_by
1702
+ if (isAdmin) {
1703
+ const existingMain = getRegisteredGroup(jid);
1704
+ if (existingMain) {
1705
+ // web:main already exists.
1706
+ // Ensure is_home, created_by, and executionMode are correct for owner-based routing.
1707
+ const patched = { ...existingMain };
1708
+ let changed = false;
1709
+ if (!patched.is_home) {
1710
+ patched.is_home = true;
1711
+ changed = true;
1712
+ }
1713
+ if (!patched.created_by) {
1714
+ patched.created_by = userId;
1715
+ changed = true;
1716
+ }
1717
+ // Admin home container must use host mode
1718
+ if (patched.executionMode !== 'host') {
1719
+ patched.executionMode = 'host';
1720
+ changed = true;
1721
+ }
1722
+ if (changed) {
1723
+ setRegisteredGroup(jid, patched);
1724
+ }
1725
+ ensureChatExists(jid);
1726
+ return jid;
1727
+ }
1728
+ }
1729
+ const name = username ? `${username} Home` : isAdmin ? 'Main' : 'Home';
1730
+ const group = {
1731
+ name,
1732
+ folder,
1733
+ added_at: now,
1734
+ agentType: 'claude',
1735
+ executionMode: isAdmin ? 'host' : 'container',
1736
+ created_by: userId,
1737
+ is_home: true,
1738
+ };
1739
+ setRegisteredGroup(jid, group);
1740
+ // Ensure chat row exists
1741
+ ensureChatExists(jid);
1742
+ // Create user-global memory directory and initialize AGENTS.md from template
1743
+ const userGlobalDir = path.join(GROUPS_DIR, 'user-global', userId);
1744
+ fs.mkdirSync(userGlobalDir, { recursive: true });
1745
+ const userAgentMemory = getAgentMemoryPath(userGlobalDir);
1746
+ if (!fs.existsSync(userAgentMemory)) {
1747
+ const templatePath = resolveAppPath('config', AGENT_MEMORY_TEMPLATE_FILENAME);
1748
+ if (fs.existsSync(templatePath)) {
1749
+ try {
1750
+ fs.writeFileSync(userAgentMemory, fs.readFileSync(templatePath, 'utf-8'), {
1751
+ flag: 'wx',
1752
+ });
1753
+ }
1754
+ catch {
1755
+ // EEXIST race or read error — ignore
1756
+ }
1757
+ }
1758
+ }
1759
+ return jid;
1760
+ }
1761
+ export function deleteChatHistory(chatJid) {
1762
+ const tx = db.transaction((jid) => {
1763
+ db.prepare('DELETE FROM messages WHERE chat_jid = ?').run(jid);
1764
+ db.prepare('DELETE FROM chats WHERE jid = ?').run(jid);
1765
+ });
1766
+ tx(chatJid);
1767
+ }
1768
+ export function deleteGroupData(jid, folder) {
1769
+ const tx = db.transaction(() => {
1770
+ // 1. 删除定时任务运行日志 + 定时任务
1771
+ db.prepare('DELETE FROM task_run_logs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE group_folder = ?)').run(folder);
1772
+ db.prepare('DELETE FROM scheduled_tasks WHERE group_folder = ?').run(folder);
1773
+ // 2. 删除成员记录
1774
+ db.prepare('DELETE FROM group_members WHERE group_folder = ?').run(folder);
1775
+ // 3. 删除注册信息
1776
+ db.prepare('DELETE FROM registered_groups WHERE jid = ?').run(jid);
1777
+ // 4. 删除会话
1778
+ db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(folder);
1779
+ // 5. 删除聊天记录
1780
+ db.prepare('DELETE FROM messages WHERE chat_jid = ?').run(jid);
1781
+ db.prepare('DELETE FROM chats WHERE jid = ?').run(jid);
1782
+ // 6. 删除 pin 记录
1783
+ db.prepare('DELETE FROM user_pinned_groups WHERE jid = ?').run(jid);
1784
+ // 7. 清除定时任务的工作区关联(任务本身不删,只断开绑定)
1785
+ db.prepare('UPDATE scheduled_tasks SET workspace_jid = NULL, workspace_folder = NULL WHERE workspace_jid = ?').run(jid);
1786
+ });
1787
+ tx();
1788
+ }
1789
+ // --- User pinned groups ---
1790
+ export function getUserPinnedGroups(userId) {
1791
+ const rows = db
1792
+ .prepare('SELECT jid, pinned_at FROM user_pinned_groups WHERE user_id = ?')
1793
+ .all(userId);
1794
+ const result = {};
1795
+ for (const row of rows)
1796
+ result[row.jid] = row.pinned_at;
1797
+ return result;
1798
+ }
1799
+ export function pinGroup(userId, jid) {
1800
+ const pinned_at = new Date().toISOString();
1801
+ db.prepare('INSERT OR REPLACE INTO user_pinned_groups (user_id, jid, pinned_at) VALUES (?, ?, ?)').run(userId, jid, pinned_at);
1802
+ return pinned_at;
1803
+ }
1804
+ export function unpinGroup(userId, jid) {
1805
+ db.prepare('DELETE FROM user_pinned_groups WHERE user_id = ? AND jid = ?').run(userId, jid);
1806
+ }
1807
+ // --- Web API accessors ---
1808
+ /**
1809
+ * Get paginated messages for a chat, cursor-based pagination.
1810
+ * Returns messages in descending timestamp order (newest first).
1811
+ */
1812
+ export function getMessagesPage(chatJid, before, limit = 50) {
1813
+ const normalizedBefore = normalizeHistoryCursor(before, chatJid);
1814
+ const sql = normalizedBefore
1815
+ ? normalizedBefore.precise
1816
+ ? `
1817
+ SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1818
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1819
+ FROM messages
1820
+ WHERE chat_jid = ?
1821
+ AND (timestamp < ? OR (timestamp = ? AND id < ?))
1822
+ ORDER BY timestamp DESC, id DESC
1823
+ LIMIT ?
1824
+ `
1825
+ : `
1826
+ SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1827
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1828
+ FROM messages
1829
+ WHERE chat_jid = ? AND timestamp < ?
1830
+ ORDER BY timestamp DESC
1831
+ LIMIT ?
1832
+ `
1833
+ : `
1834
+ SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1835
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1836
+ FROM messages
1837
+ WHERE chat_jid = ?
1838
+ ORDER BY timestamp DESC
1839
+ LIMIT ?
1840
+ `;
1841
+ const params = normalizedBefore
1842
+ ? normalizedBefore.precise
1843
+ ? [
1844
+ chatJid,
1845
+ normalizedBefore.timestamp,
1846
+ normalizedBefore.timestamp,
1847
+ normalizedBefore.id,
1848
+ limit,
1849
+ ]
1850
+ : [chatJid, normalizedBefore.timestamp, limit]
1851
+ : [chatJid, limit];
1852
+ const rows = db.prepare(sql).all(...params);
1853
+ return rows.map(mapDbMessageRow);
1854
+ }
1855
+ /**
1856
+ * Get messages after a given timestamp (for polling new messages).
1857
+ * Returns in ASC order (oldest first).
1858
+ */
1859
+ export function getMessagesAfter(chatJid, after, limit = 50) {
1860
+ const normalizedAfter = normalizeHistoryCursor(after, chatJid);
1861
+ const rows = db
1862
+ .prepare(normalizedAfter?.precise
1863
+ ? `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1864
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1865
+ FROM messages
1866
+ WHERE chat_jid = ?
1867
+ AND (timestamp > ? OR (timestamp = ? AND id > ?))
1868
+ ORDER BY timestamp ASC, id ASC
1869
+ LIMIT ?`
1870
+ : `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1871
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1872
+ FROM messages
1873
+ WHERE chat_jid = ? AND timestamp > ?
1874
+ ORDER BY timestamp ASC
1875
+ LIMIT ?`)
1876
+ .all(...(normalizedAfter?.precise
1877
+ ? [
1878
+ chatJid,
1879
+ normalizedAfter.timestamp,
1880
+ normalizedAfter.timestamp,
1881
+ normalizedAfter.id,
1882
+ limit,
1883
+ ]
1884
+ : [chatJid, normalizedAfter?.timestamp || '', limit]));
1885
+ return rows.map(mapDbMessageRow);
1886
+ }
1887
+ function normalizeHistoryCursor(cursor, fallbackChatJid) {
1888
+ if (!cursor)
1889
+ return undefined;
1890
+ if (typeof cursor === 'string') {
1891
+ return {
1892
+ timestamp: cursor,
1893
+ chat_jid: fallbackChatJid || '',
1894
+ id: '',
1895
+ precise: false,
1896
+ };
1897
+ }
1898
+ const timestamp = typeof cursor.timestamp === 'string' ? cursor.timestamp : undefined;
1899
+ if (!timestamp)
1900
+ return undefined;
1901
+ const id = typeof cursor.id === 'string' ? cursor.id : '';
1902
+ const chat_jid = typeof cursor.chat_jid === 'string'
1903
+ ? cursor.chat_jid
1904
+ : fallbackChatJid || '';
1905
+ return {
1906
+ timestamp,
1907
+ chat_jid,
1908
+ id,
1909
+ precise: !!id && !!chat_jid,
1910
+ };
1911
+ }
1912
+ /**
1913
+ * 多 JID 分页查询(用于主容器合并 web:main + feishu:xxx 消息)。
1914
+ */
1915
+ export function getMessagesPageMulti(chatJids, before, limit = 50) {
1916
+ if (chatJids.length === 0)
1917
+ return [];
1918
+ if (chatJids.length === 1)
1919
+ return getMessagesPage(chatJids[0], before, limit);
1920
+ const normalizedBefore = normalizeHistoryCursor(before);
1921
+ const placeholders = chatJids.map(() => '?').join(',');
1922
+ const sql = normalizedBefore
1923
+ ? normalizedBefore.precise
1924
+ ? `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1925
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1926
+ FROM messages
1927
+ WHERE chat_jid IN (${placeholders})
1928
+ AND (
1929
+ timestamp < ?
1930
+ OR (timestamp = ? AND chat_jid < ?)
1931
+ OR (timestamp = ? AND chat_jid = ? AND id < ?)
1932
+ )
1933
+ ORDER BY timestamp DESC, chat_jid DESC, id DESC
1934
+ LIMIT ?`
1935
+ : `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1936
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1937
+ FROM messages
1938
+ WHERE chat_jid IN (${placeholders}) AND timestamp < ?
1939
+ ORDER BY timestamp DESC
1940
+ LIMIT ?`
1941
+ : `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1942
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1943
+ FROM messages
1944
+ WHERE chat_jid IN (${placeholders})
1945
+ ORDER BY timestamp DESC, chat_jid DESC, id DESC
1946
+ LIMIT ?`;
1947
+ const params = normalizedBefore
1948
+ ? normalizedBefore.precise
1949
+ ? [
1950
+ ...chatJids,
1951
+ normalizedBefore.timestamp,
1952
+ normalizedBefore.timestamp,
1953
+ normalizedBefore.chat_jid,
1954
+ normalizedBefore.timestamp,
1955
+ normalizedBefore.chat_jid,
1956
+ normalizedBefore.id,
1957
+ limit,
1958
+ ]
1959
+ : [...chatJids, normalizedBefore.timestamp, limit]
1960
+ : [...chatJids, limit];
1961
+ const rows = db.prepare(sql).all(...params);
1962
+ return rows.map(mapDbMessageRow);
1963
+ }
1964
+ /**
1965
+ * 多 JID 增量查询(用于主容器轮询合并消息)。
1966
+ */
1967
+ export function getMessagesAfterMulti(chatJids, after, limit = 50) {
1968
+ if (chatJids.length === 0)
1969
+ return [];
1970
+ if (chatJids.length === 1)
1971
+ return getMessagesAfter(chatJids[0], after, limit);
1972
+ const normalizedAfter = normalizeHistoryCursor(after);
1973
+ const placeholders = chatJids.map(() => '?').join(',');
1974
+ const rows = db
1975
+ .prepare(normalizedAfter?.precise
1976
+ ? `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1977
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1978
+ FROM messages
1979
+ WHERE chat_jid IN (${placeholders})
1980
+ AND (
1981
+ timestamp > ?
1982
+ OR (timestamp = ? AND chat_jid > ?)
1983
+ OR (timestamp = ? AND chat_jid = ? AND id > ?)
1984
+ )
1985
+ ORDER BY timestamp ASC, chat_jid ASC, id ASC
1986
+ LIMIT ?`
1987
+ : `SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments, token_usage,
1988
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
1989
+ FROM messages
1990
+ WHERE chat_jid IN (${placeholders}) AND timestamp > ?
1991
+ ORDER BY timestamp ASC
1992
+ LIMIT ?`)
1993
+ .all(...(normalizedAfter?.precise
1994
+ ? [
1995
+ ...chatJids,
1996
+ normalizedAfter.timestamp,
1997
+ normalizedAfter.timestamp,
1998
+ normalizedAfter.chat_jid,
1999
+ normalizedAfter.timestamp,
2000
+ normalizedAfter.chat_jid,
2001
+ normalizedAfter.id,
2002
+ limit,
2003
+ ]
2004
+ : [...chatJids, normalizedAfter?.timestamp || '', limit]));
2005
+ return rows.map(mapDbMessageRow);
2006
+ }
2007
+ /**
2008
+ * Get task run logs for a specific task, ordered by most recent first.
2009
+ */
2010
+ export function getTaskRunLogs(taskId, limit = 20) {
2011
+ return db
2012
+ .prepare(`
2013
+ SELECT id, task_id, run_at, duration_ms, status, result, error
2014
+ FROM task_run_logs
2015
+ WHERE task_id = ?
2016
+ ORDER BY run_at DESC
2017
+ LIMIT ?
2018
+ `)
2019
+ .all(taskId, limit);
2020
+ }
2021
+ // ===================== Daily Summary Queries =====================
2022
+ /**
2023
+ * Get messages for a chat within a time range, ordered by timestamp ASC.
2024
+ */
2025
+ export function getMessagesByTimeRange(chatJid, startTs, endTs, limit = 500) {
2026
+ const startIso = new Date(startTs).toISOString();
2027
+ const endIso = new Date(endTs).toISOString();
2028
+ const rows = db
2029
+ .prepare(`SELECT id, chat_jid, source_jid, runtime_identity, sender, sender_name, content, timestamp, is_from_me, attachments,
2030
+ turn_id, session_id, sdk_message_uuid, source_kind, finalization_reason
2031
+ FROM messages
2032
+ WHERE chat_jid = ? AND timestamp >= ? AND timestamp < ?
2033
+ ORDER BY timestamp ASC
2034
+ LIMIT ?`)
2035
+ .all(chatJid, startIso, endIso, limit);
2036
+ return rows.map(mapDbMessageRow);
2037
+ }
2038
+ /**
2039
+ * Get all registered groups owned by a specific user.
2040
+ */
2041
+ export function getGroupsByOwner(userId) {
2042
+ const rows = db
2043
+ .prepare('SELECT * FROM registered_groups WHERE created_by = ?')
2044
+ .all(userId);
2045
+ return rows.map((row) => ({
2046
+ jid: row.jid,
2047
+ name: row.name,
2048
+ folder: row.folder,
2049
+ added_at: row.added_at,
2050
+ containerConfig: row.container_config
2051
+ ? JSON.parse(row.container_config)
2052
+ : undefined,
2053
+ agentType: parseAgentType(row.agent_type),
2054
+ executionMode: parseExecutionMode(row.execution_mode, `group ${row.jid}`),
2055
+ customCwd: row.custom_cwd ?? undefined,
2056
+ initSourcePath: row.init_source_path ?? undefined,
2057
+ initGitUrl: row.init_git_url ?? undefined,
2058
+ created_by: row.created_by ?? undefined,
2059
+ is_home: row.is_home === 1,
2060
+ }));
2061
+ }
2062
+ // ===================== Auth CRUD =====================
2063
+ function parseUserRole(value) {
2064
+ return value === 'admin' ? 'admin' : 'member';
2065
+ }
2066
+ function parseUserStatus(value) {
2067
+ if (value === 'deleted')
2068
+ return 'deleted';
2069
+ if (value === 'disabled')
2070
+ return 'disabled';
2071
+ return 'active';
2072
+ }
2073
+ function parsePermissionsFromDb(raw, role) {
2074
+ if (typeof raw === 'string') {
2075
+ try {
2076
+ const parsed = normalizePermissions(JSON.parse(raw));
2077
+ if (parsed.length > 0)
2078
+ return parsed;
2079
+ }
2080
+ catch {
2081
+ // ignore and fall back to role defaults
2082
+ }
2083
+ }
2084
+ return getDefaultPermissions(role);
2085
+ }
2086
+ function parseJsonDetails(raw) {
2087
+ if (typeof raw !== 'string' || !raw.trim())
2088
+ return null;
2089
+ try {
2090
+ const parsed = JSON.parse(raw);
2091
+ return typeof parsed === 'object' && parsed !== null
2092
+ ? parsed
2093
+ : null;
2094
+ }
2095
+ catch {
2096
+ return null;
2097
+ }
2098
+ }
2099
+ function mapUserRow(row) {
2100
+ const role = parseUserRole(row.role);
2101
+ const status = parseUserStatus(row.status);
2102
+ return {
2103
+ id: String(row.id),
2104
+ username: String(row.username),
2105
+ password_hash: String(row.password_hash),
2106
+ display_name: String(row.display_name ?? ''),
2107
+ role,
2108
+ status,
2109
+ permissions: parsePermissionsFromDb(row.permissions, role),
2110
+ must_change_password: !!row.must_change_password,
2111
+ disable_reason: typeof row.disable_reason === 'string' ? row.disable_reason : null,
2112
+ notes: typeof row.notes === 'string' ? row.notes : null,
2113
+ avatar_emoji: typeof row.avatar_emoji === 'string' ? row.avatar_emoji : null,
2114
+ avatar_color: typeof row.avatar_color === 'string' ? row.avatar_color : null,
2115
+ avatar_url: typeof row.avatar_url === 'string' ? row.avatar_url : null,
2116
+ ai_name: typeof row.ai_name === 'string' ? row.ai_name : null,
2117
+ ai_avatar_emoji: typeof row.ai_avatar_emoji === 'string' ? row.ai_avatar_emoji : null,
2118
+ ai_avatar_color: typeof row.ai_avatar_color === 'string' ? row.ai_avatar_color : null,
2119
+ ai_avatar_url: typeof row.ai_avatar_url === 'string' ? row.ai_avatar_url : null,
2120
+ created_at: String(row.created_at),
2121
+ updated_at: String(row.updated_at),
2122
+ last_login_at: typeof row.last_login_at === 'string' ? row.last_login_at : null,
2123
+ deleted_at: typeof row.deleted_at === 'string' ? row.deleted_at : null,
2124
+ };
2125
+ }
2126
+ function toUserPublic(user, lastActiveAt) {
2127
+ return {
2128
+ id: user.id,
2129
+ username: user.username,
2130
+ display_name: user.display_name,
2131
+ role: user.role,
2132
+ status: user.status,
2133
+ permissions: user.permissions,
2134
+ must_change_password: user.must_change_password,
2135
+ disable_reason: user.disable_reason,
2136
+ notes: user.notes,
2137
+ avatar_emoji: user.avatar_emoji,
2138
+ avatar_color: user.avatar_color,
2139
+ avatar_url: user.avatar_url,
2140
+ ai_name: user.ai_name,
2141
+ ai_avatar_emoji: user.ai_avatar_emoji,
2142
+ ai_avatar_color: user.ai_avatar_color,
2143
+ ai_avatar_url: user.ai_avatar_url,
2144
+ created_at: user.created_at,
2145
+ last_login_at: user.last_login_at,
2146
+ last_active_at: lastActiveAt,
2147
+ deleted_at: user.deleted_at,
2148
+ };
2149
+ }
2150
+ function initializeBillingForUser(userId, role, createdAt) {
2151
+ const now = createdAt || new Date().toISOString();
2152
+ db.prepare('INSERT OR IGNORE INTO user_balances (user_id, balance_usd, total_deposited_usd, total_consumed_usd, updated_at) VALUES (?, 0, 0, 0, ?)').run(userId, now);
2153
+ if (role === 'admin')
2154
+ return;
2155
+ const defaultPlan = getDefaultBillingPlan();
2156
+ if (!defaultPlan)
2157
+ return;
2158
+ const activeSubscription = db
2159
+ .prepare("SELECT id FROM user_subscriptions WHERE user_id = ? AND status = 'active'")
2160
+ .get(userId);
2161
+ if (activeSubscription)
2162
+ return;
2163
+ const subId = `sub_${userId}_${Date.now()}`;
2164
+ db.prepare(`INSERT INTO user_subscriptions (id, user_id, plan_id, status, started_at, created_at)
2165
+ VALUES (?, ?, ?, 'active', ?, ?)`).run(subId, userId, defaultPlan.id, now, now);
2166
+ db.prepare('UPDATE users SET subscription_plan_id = ? WHERE id = ?').run(defaultPlan.id, userId);
2167
+ const hasOpening = db
2168
+ .prepare("SELECT 1 FROM balance_transactions WHERE user_id = ? AND source = 'migration_opening' LIMIT 1")
2169
+ .get(userId);
2170
+ if (!hasOpening) {
2171
+ db.prepare(`INSERT INTO balance_transactions (
2172
+ user_id, type, amount_usd, balance_after, description, reference_type,
2173
+ reference_id, actor_id, source, operator_type, notes, idempotency_key, created_at
2174
+ ) VALUES (?, 'adjustment', 0, 0, ?, NULL, NULL, NULL, 'migration_opening', 'system', ?, NULL, ?)`).run(userId, '用户钱包初始化', '新用户默认余额为 0,需管理员充值或兑换后方可消费', now);
2175
+ }
2176
+ }
2177
+ export function createUser(user) {
2178
+ const permissions = normalizePermissions(user.permissions ?? getDefaultPermissions(user.role));
2179
+ db.prepare(`INSERT INTO users (
2180
+ id, username, password_hash, display_name, role, status, permissions, must_change_password,
2181
+ disable_reason, notes, created_at, updated_at, last_login_at, deleted_at
2182
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(user.id, user.username, user.password_hash, user.display_name, user.role, user.status, JSON.stringify(permissions), user.must_change_password ? 1 : 0, user.disable_reason ?? null, user.notes ?? null, user.created_at, user.updated_at, user.last_login_at ?? null, user.deleted_at ?? null);
2183
+ initializeBillingForUser(user.id, user.role, user.created_at);
2184
+ }
2185
+ export function createInitialAdminUser(user) {
2186
+ const tx = db.transaction((input) => {
2187
+ const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
2188
+ if (row.count > 0)
2189
+ return { ok: false, reason: 'already_initialized' };
2190
+ createUser(input);
2191
+ return { ok: true };
2192
+ });
2193
+ try {
2194
+ return tx(user);
2195
+ }
2196
+ catch (err) {
2197
+ if (err instanceof Error &&
2198
+ err.message.includes('UNIQUE constraint failed: users.username')) {
2199
+ return { ok: false, reason: 'username_taken' };
2200
+ }
2201
+ throw err;
2202
+ }
2203
+ }
2204
+ export function getUserById(id) {
2205
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
2206
+ return row ? mapUserRow(row) : undefined;
2207
+ }
2208
+ export function getUserByUsername(username) {
2209
+ const row = db
2210
+ .prepare('SELECT * FROM users WHERE username = ?')
2211
+ .get(username);
2212
+ return row ? mapUserRow(row) : undefined;
2213
+ }
2214
+ export function listUsers(options = {}) {
2215
+ const role = options.role && options.role !== 'all' ? options.role : null;
2216
+ const status = options.status && options.status !== 'all' ? options.status : null;
2217
+ const query = options.query?.trim() || '';
2218
+ const page = Math.max(1, Math.floor(options.page || 1));
2219
+ const pageSize = Math.min(200, Math.max(1, Math.floor(options.pageSize || 50)));
2220
+ const offset = (page - 1) * pageSize;
2221
+ const whereParts = [];
2222
+ const params = [];
2223
+ if (role) {
2224
+ whereParts.push('u.role = ?');
2225
+ params.push(role);
2226
+ }
2227
+ if (status) {
2228
+ whereParts.push('u.status = ?');
2229
+ params.push(status);
2230
+ }
2231
+ if (query) {
2232
+ whereParts.push("(u.username LIKE ? OR u.display_name LIKE ? OR COALESCE(u.notes, '') LIKE ?)");
2233
+ const like = `%${query}%`;
2234
+ params.push(like, like, like);
2235
+ }
2236
+ const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
2237
+ const totalRow = db
2238
+ .prepare(`SELECT COUNT(*) as count FROM users u ${whereClause}`)
2239
+ .get(...params);
2240
+ const rows = db
2241
+ .prepare(`
2242
+ SELECT u.*, MAX(s.last_active_at) AS last_active_at
2243
+ FROM users u
2244
+ LEFT JOIN user_sessions s ON s.user_id = u.id
2245
+ ${whereClause}
2246
+ GROUP BY u.id
2247
+ ORDER BY
2248
+ CASE u.status
2249
+ WHEN 'active' THEN 0
2250
+ WHEN 'disabled' THEN 1
2251
+ ELSE 2
2252
+ END,
2253
+ u.created_at DESC
2254
+ LIMIT ? OFFSET ?
2255
+ `)
2256
+ .all(...params, pageSize, offset);
2257
+ return {
2258
+ users: rows.map((row) => {
2259
+ const user = mapUserRow(row);
2260
+ const lastActiveAt = typeof row.last_active_at === 'string' ? row.last_active_at : null;
2261
+ return toUserPublic(user, lastActiveAt);
2262
+ }),
2263
+ total: totalRow.count,
2264
+ page,
2265
+ pageSize,
2266
+ };
2267
+ }
2268
+ export function getAllUsers() {
2269
+ return listUsers({ role: 'all', status: 'all', page: 1, pageSize: 1000 })
2270
+ .users;
2271
+ }
2272
+ export function getUserCount(includeDeleted = false) {
2273
+ const row = includeDeleted
2274
+ ? db.prepare('SELECT COUNT(*) as count FROM users').get()
2275
+ : db
2276
+ .prepare('SELECT COUNT(*) as count FROM users WHERE status != ?')
2277
+ .get('deleted');
2278
+ return row.count;
2279
+ }
2280
+ export function getActiveAdminCount() {
2281
+ const row = db
2282
+ .prepare(`SELECT COUNT(*) as count
2283
+ FROM users
2284
+ WHERE role = 'admin' AND status = 'active'`)
2285
+ .get();
2286
+ return row.count;
2287
+ }
2288
+ export function updateUserFields(id, updates) {
2289
+ const fields = [];
2290
+ const values = [];
2291
+ if (updates.username !== undefined) {
2292
+ fields.push('username = ?');
2293
+ values.push(updates.username);
2294
+ }
2295
+ if (updates.display_name !== undefined) {
2296
+ fields.push('display_name = ?');
2297
+ values.push(updates.display_name);
2298
+ }
2299
+ if (updates.role !== undefined) {
2300
+ fields.push('role = ?');
2301
+ values.push(updates.role);
2302
+ }
2303
+ if (updates.status !== undefined) {
2304
+ fields.push('status = ?');
2305
+ values.push(updates.status);
2306
+ }
2307
+ if (updates.password_hash !== undefined) {
2308
+ fields.push('password_hash = ?');
2309
+ values.push(updates.password_hash);
2310
+ }
2311
+ if (updates.last_login_at !== undefined) {
2312
+ fields.push('last_login_at = ?');
2313
+ values.push(updates.last_login_at);
2314
+ }
2315
+ if (updates.permissions !== undefined) {
2316
+ fields.push('permissions = ?');
2317
+ values.push(JSON.stringify(normalizePermissions(updates.permissions)));
2318
+ }
2319
+ if (updates.must_change_password !== undefined) {
2320
+ fields.push('must_change_password = ?');
2321
+ values.push(updates.must_change_password ? 1 : 0);
2322
+ }
2323
+ if (updates.disable_reason !== undefined) {
2324
+ fields.push('disable_reason = ?');
2325
+ values.push(updates.disable_reason);
2326
+ }
2327
+ if (updates.notes !== undefined) {
2328
+ fields.push('notes = ?');
2329
+ values.push(updates.notes);
2330
+ }
2331
+ if (updates.avatar_emoji !== undefined) {
2332
+ fields.push('avatar_emoji = ?');
2333
+ values.push(updates.avatar_emoji);
2334
+ }
2335
+ if (updates.avatar_color !== undefined) {
2336
+ fields.push('avatar_color = ?');
2337
+ values.push(updates.avatar_color);
2338
+ }
2339
+ if (updates.avatar_url !== undefined) {
2340
+ fields.push('avatar_url = ?');
2341
+ values.push(updates.avatar_url);
2342
+ }
2343
+ if (updates.ai_name !== undefined) {
2344
+ fields.push('ai_name = ?');
2345
+ values.push(updates.ai_name);
2346
+ }
2347
+ if (updates.ai_avatar_emoji !== undefined) {
2348
+ fields.push('ai_avatar_emoji = ?');
2349
+ values.push(updates.ai_avatar_emoji);
2350
+ }
2351
+ if (updates.ai_avatar_color !== undefined) {
2352
+ fields.push('ai_avatar_color = ?');
2353
+ values.push(updates.ai_avatar_color);
2354
+ }
2355
+ if (updates.ai_avatar_url !== undefined) {
2356
+ fields.push('ai_avatar_url = ?');
2357
+ values.push(updates.ai_avatar_url);
2358
+ }
2359
+ if (updates.deleted_at !== undefined) {
2360
+ fields.push('deleted_at = ?');
2361
+ values.push(updates.deleted_at);
2362
+ }
2363
+ if (fields.length === 0)
2364
+ return;
2365
+ fields.push('updated_at = ?');
2366
+ values.push(new Date().toISOString());
2367
+ values.push(id);
2368
+ db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
2369
+ }
2370
+ export function deleteUser(id) {
2371
+ const now = new Date().toISOString();
2372
+ const tx = db.transaction((userId) => {
2373
+ db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(userId);
2374
+ db.prepare(`UPDATE users
2375
+ SET status = 'deleted', deleted_at = ?, disable_reason = COALESCE(disable_reason, 'deleted_by_admin'), updated_at = ?
2376
+ WHERE id = ?`).run(now, now, userId);
2377
+ });
2378
+ tx(id);
2379
+ }
2380
+ export function restoreUser(id) {
2381
+ db.prepare(`UPDATE users
2382
+ SET status = 'disabled', deleted_at = NULL, disable_reason = NULL, updated_at = ?
2383
+ WHERE id = ?`).run(new Date().toISOString(), id);
2384
+ }
2385
+ // --- User Sessions ---
2386
+ export function createUserSession(session) {
2387
+ db.prepare(`INSERT INTO user_sessions (id, user_id, ip_address, user_agent, created_at, expires_at, last_active_at)
2388
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(session.id, session.user_id, session.ip_address, session.user_agent, session.created_at, session.expires_at, session.last_active_at);
2389
+ }
2390
+ export function getSessionWithUser(sessionId) {
2391
+ const row = stmts().getSessionWithUser.get(sessionId);
2392
+ if (!row)
2393
+ return undefined;
2394
+ const role = parseUserRole(row.role);
2395
+ return {
2396
+ id: String(row.id),
2397
+ user_id: String(row.user_id),
2398
+ ip_address: typeof row.ip_address === 'string' ? row.ip_address : null,
2399
+ user_agent: typeof row.user_agent === 'string' ? row.user_agent : null,
2400
+ created_at: String(row.created_at),
2401
+ expires_at: String(row.expires_at),
2402
+ last_active_at: String(row.last_active_at),
2403
+ username: String(row.username),
2404
+ role,
2405
+ status: parseUserStatus(row.status),
2406
+ display_name: String(row.display_name ?? ''),
2407
+ permissions: parsePermissionsFromDb(row.permissions, role),
2408
+ must_change_password: !!row.must_change_password,
2409
+ };
2410
+ }
2411
+ export function getUserSessions(userId) {
2412
+ return db
2413
+ .prepare(`SELECT * FROM user_sessions WHERE user_id = ? ORDER BY last_active_at DESC`)
2414
+ .all(userId);
2415
+ }
2416
+ export function deleteUserSession(sessionId) {
2417
+ stmts().deleteSession.run(sessionId);
2418
+ }
2419
+ export function deleteUserSessionsByUserId(userId) {
2420
+ db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(userId);
2421
+ }
2422
+ export function updateSessionLastActive(sessionId) {
2423
+ stmts().updateSessionLastActive.run(new Date().toISOString(), sessionId);
2424
+ }
2425
+ export function getExpiredSessionIds() {
2426
+ const now = new Date().toISOString();
2427
+ return stmts().getExpiredSessionIds.all(now).map((r) => r.id);
2428
+ }
2429
+ export function deleteExpiredSessions() {
2430
+ const now = new Date().toISOString();
2431
+ const result = db
2432
+ .prepare('DELETE FROM user_sessions WHERE expires_at < ?')
2433
+ .run(now);
2434
+ return result.changes;
2435
+ }
2436
+ // --- Invite Codes ---
2437
+ export function createInviteCode(invite) {
2438
+ const permissions = normalizePermissions(invite.permissions);
2439
+ db.prepare(`INSERT INTO invite_codes (code, created_by, role, permission_template, permissions, max_uses, used_count, expires_at, created_at)
2440
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(invite.code, invite.created_by, invite.role, invite.permission_template ?? null, JSON.stringify(permissions), invite.max_uses, invite.used_count, invite.expires_at, invite.created_at);
2441
+ }
2442
+ export function getInviteCode(code) {
2443
+ const row = db
2444
+ .prepare('SELECT * FROM invite_codes WHERE code = ?')
2445
+ .get(code);
2446
+ if (!row)
2447
+ return undefined;
2448
+ const role = parseUserRole(row.role);
2449
+ return {
2450
+ code: String(row.code),
2451
+ created_by: String(row.created_by),
2452
+ role,
2453
+ permission_template: typeof row.permission_template === 'string'
2454
+ ? row.permission_template
2455
+ : null,
2456
+ permissions: parsePermissionsFromDb(row.permissions, role),
2457
+ max_uses: Number(row.max_uses),
2458
+ used_count: Number(row.used_count),
2459
+ expires_at: typeof row.expires_at === 'string' ? row.expires_at : null,
2460
+ created_at: String(row.created_at),
2461
+ };
2462
+ }
2463
+ export function registerUserWithInvite(input) {
2464
+ const tx = db.transaction((params) => {
2465
+ const inviteRow = db
2466
+ .prepare(`SELECT code, role, permissions, max_uses, expires_at
2467
+ FROM invite_codes
2468
+ WHERE code = ?`)
2469
+ .get(params.invite_code);
2470
+ if (!inviteRow)
2471
+ return { ok: false, reason: 'invalid_or_expired_invite' };
2472
+ const inviteRole = parseUserRole(inviteRow.role);
2473
+ const invitePermissions = parsePermissionsFromDb(inviteRow.permissions, inviteRole);
2474
+ const inviteExpiresAt = typeof inviteRow.expires_at === 'string' ? inviteRow.expires_at : null;
2475
+ if (inviteExpiresAt) {
2476
+ const expiresAt = Date.parse(inviteExpiresAt);
2477
+ if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) {
2478
+ return { ok: false, reason: 'invalid_or_expired_invite' };
2479
+ }
2480
+ }
2481
+ const existing = db
2482
+ .prepare('SELECT id FROM users WHERE username = ?')
2483
+ .get(params.username);
2484
+ if (existing)
2485
+ return { ok: false, reason: 'username_taken' };
2486
+ const inviteUsage = db
2487
+ .prepare(`UPDATE invite_codes
2488
+ SET used_count = used_count + 1
2489
+ WHERE code = ?
2490
+ AND (max_uses = 0 OR used_count < max_uses)`)
2491
+ .run(params.invite_code);
2492
+ if (inviteUsage.changes === 0) {
2493
+ return { ok: false, reason: 'invite_exhausted' };
2494
+ }
2495
+ const permissions = normalizePermissions(invitePermissions);
2496
+ db.prepare(`INSERT INTO users (
2497
+ id, username, password_hash, display_name, role, status, permissions, must_change_password,
2498
+ disable_reason, notes, created_at, updated_at, last_login_at, deleted_at
2499
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(params.id, params.username, params.password_hash, params.display_name, inviteRole, 'active', JSON.stringify(permissions), 0, null, null, params.created_at, params.updated_at, null, null);
2500
+ initializeBillingForUser(params.id, inviteRole, params.created_at);
2501
+ return { ok: true, role: inviteRole, permissions };
2502
+ });
2503
+ try {
2504
+ return tx(input);
2505
+ }
2506
+ catch (err) {
2507
+ if (err instanceof Error &&
2508
+ err.message.includes('UNIQUE constraint failed: users.username')) {
2509
+ return { ok: false, reason: 'username_taken' };
2510
+ }
2511
+ throw err;
2512
+ }
2513
+ }
2514
+ export function registerUserWithoutInvite(input) {
2515
+ const role = 'member';
2516
+ const permissions = [];
2517
+ try {
2518
+ db.prepare(`INSERT INTO users (
2519
+ id, username, password_hash, display_name, role, status, permissions, must_change_password,
2520
+ disable_reason, notes, created_at, updated_at, last_login_at, deleted_at
2521
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.username, input.password_hash, input.display_name, role, 'active', JSON.stringify(permissions), 0, null, null, input.created_at, input.updated_at, null, null);
2522
+ initializeBillingForUser(input.id, role, input.created_at);
2523
+ return { ok: true, role, permissions };
2524
+ }
2525
+ catch (err) {
2526
+ if (err instanceof Error &&
2527
+ err.message.includes('UNIQUE constraint failed: users.username')) {
2528
+ return { ok: false, reason: 'username_taken' };
2529
+ }
2530
+ throw err;
2531
+ }
2532
+ }
2533
+ export function getAllInviteCodes() {
2534
+ const rows = db
2535
+ .prepare(`SELECT i.*, u.username as creator_username
2536
+ FROM invite_codes i
2537
+ JOIN users u ON i.created_by = u.id
2538
+ ORDER BY i.created_at DESC`)
2539
+ .all();
2540
+ return rows.map((row) => {
2541
+ const role = parseUserRole(row.role);
2542
+ return {
2543
+ code: String(row.code),
2544
+ created_by: String(row.created_by),
2545
+ creator_username: String(row.creator_username),
2546
+ role,
2547
+ permission_template: typeof row.permission_template === 'string'
2548
+ ? row.permission_template
2549
+ : null,
2550
+ permissions: parsePermissionsFromDb(row.permissions, role),
2551
+ max_uses: Number(row.max_uses),
2552
+ used_count: Number(row.used_count),
2553
+ expires_at: typeof row.expires_at === 'string' ? row.expires_at : null,
2554
+ created_at: String(row.created_at),
2555
+ };
2556
+ });
2557
+ }
2558
+ export function deleteInviteCode(code) {
2559
+ db.prepare('DELETE FROM invite_codes WHERE code = ?').run(code);
2560
+ }
2561
+ // --- Auth Audit Log ---
2562
+ export function logAuthEvent(event) {
2563
+ db.prepare(`INSERT INTO auth_audit_log (event_type, username, actor_username, ip_address, user_agent, details, created_at)
2564
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(event.event_type, event.username, event.actor_username ?? null, event.ip_address ?? null, event.user_agent ?? null, event.details ? JSON.stringify(event.details) : null, new Date().toISOString());
2565
+ }
2566
+ export function queryAuthAuditLogs(query = {}) {
2567
+ const limit = Math.min(500, Math.max(1, Math.floor(query.limit || 100)));
2568
+ const offset = Math.max(0, Math.floor(query.offset || 0));
2569
+ const whereParts = [];
2570
+ const params = [];
2571
+ if (query.event_type && query.event_type !== 'all') {
2572
+ whereParts.push('event_type = ?');
2573
+ params.push(query.event_type);
2574
+ }
2575
+ if (query.username?.trim()) {
2576
+ whereParts.push('username LIKE ?');
2577
+ params.push(`%${query.username.trim()}%`);
2578
+ }
2579
+ if (query.actor_username?.trim()) {
2580
+ whereParts.push('actor_username LIKE ?');
2581
+ params.push(`%${query.actor_username.trim()}%`);
2582
+ }
2583
+ if (query.from) {
2584
+ whereParts.push('created_at >= ?');
2585
+ params.push(query.from);
2586
+ }
2587
+ if (query.to) {
2588
+ whereParts.push('created_at <= ?');
2589
+ params.push(query.to);
2590
+ }
2591
+ const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
2592
+ const total = db
2593
+ .prepare(`SELECT COUNT(*) as count FROM auth_audit_log ${whereClause}`)
2594
+ .get(...params).count;
2595
+ const rows = db
2596
+ .prepare(`SELECT * FROM auth_audit_log ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
2597
+ .all(...params, limit, offset);
2598
+ const logs = rows.map((row) => ({
2599
+ id: Number(row.id),
2600
+ event_type: row.event_type,
2601
+ username: String(row.username),
2602
+ actor_username: typeof row.actor_username === 'string' ? row.actor_username : null,
2603
+ ip_address: typeof row.ip_address === 'string' ? row.ip_address : null,
2604
+ user_agent: typeof row.user_agent === 'string' ? row.user_agent : null,
2605
+ details: parseJsonDetails(row.details),
2606
+ created_at: String(row.created_at),
2607
+ }));
2608
+ return { logs, total, limit, offset };
2609
+ }
2610
+ export function getAuthAuditLogs(limit = 100, offset = 0) {
2611
+ return queryAuthAuditLogs({ limit, offset }).logs;
2612
+ }
2613
+ export function checkLoginRateLimitFromAudit(username, ip, maxAttempts, lockoutMinutes) {
2614
+ if (maxAttempts <= 0)
2615
+ return { allowed: true, attempts: 0 };
2616
+ const windowStart = new Date(Date.now() - lockoutMinutes * 60 * 1000).toISOString();
2617
+ const rows = db
2618
+ .prepare(`
2619
+ SELECT created_at
2620
+ FROM auth_audit_log
2621
+ WHERE event_type = 'login_failed'
2622
+ AND username = ?
2623
+ AND ip_address = ?
2624
+ AND created_at >= ?
2625
+ AND (details IS NULL OR details NOT LIKE '%"reason":"rate_limited"%')
2626
+ ORDER BY created_at ASC
2627
+ `)
2628
+ .all(username, ip, windowStart);
2629
+ const attempts = rows.length;
2630
+ if (attempts < maxAttempts)
2631
+ return { allowed: true, attempts };
2632
+ const oldest = rows[0]?.created_at;
2633
+ const oldestTs = oldest ? Date.parse(oldest) : Date.now();
2634
+ const retryAt = oldestTs + lockoutMinutes * 60 * 1000;
2635
+ const retryAfterSeconds = Math.max(1, Math.ceil((retryAt - Date.now()) / 1000));
2636
+ return { allowed: false, retryAfterSeconds, attempts };
2637
+ }
2638
+ // ===================== Group Members =====================
2639
+ export function addGroupMember(groupFolder, userId, role, addedBy) {
2640
+ db.prepare(`INSERT INTO group_members (group_folder, user_id, role, added_at, added_by)
2641
+ VALUES (?, ?, ?, ?, ?)
2642
+ ON CONFLICT(group_folder, user_id) DO UPDATE SET
2643
+ role = CASE WHEN excluded.role = 'owner' THEN 'owner'
2644
+ WHEN group_members.role = 'owner' THEN 'owner'
2645
+ ELSE excluded.role END,
2646
+ added_by = COALESCE(excluded.added_by, group_members.added_by)`).run(groupFolder, userId, role, new Date().toISOString(), addedBy ?? null);
2647
+ }
2648
+ export function removeGroupMember(groupFolder, userId) {
2649
+ db.prepare('DELETE FROM group_members WHERE group_folder = ? AND user_id = ?').run(groupFolder, userId);
2650
+ }
2651
+ export function getGroupMembers(groupFolder) {
2652
+ const rows = db
2653
+ .prepare(`SELECT gm.user_id, gm.role, gm.added_at, gm.added_by,
2654
+ u.username, COALESCE(u.display_name, '') as display_name
2655
+ FROM group_members gm
2656
+ JOIN users u ON gm.user_id = u.id
2657
+ WHERE gm.group_folder = ?
2658
+ ORDER BY gm.role DESC, gm.added_at ASC`)
2659
+ .all(groupFolder);
2660
+ return rows.map((r) => ({
2661
+ user_id: r.user_id,
2662
+ role: r.role,
2663
+ added_at: r.added_at,
2664
+ added_by: r.added_by ?? undefined,
2665
+ username: r.username,
2666
+ display_name: r.display_name,
2667
+ }));
2668
+ }
2669
+ export function getGroupMemberRole(groupFolder, userId) {
2670
+ const row = db
2671
+ .prepare('SELECT role FROM group_members WHERE group_folder = ? AND user_id = ?')
2672
+ .get(groupFolder, userId);
2673
+ if (!row)
2674
+ return null;
2675
+ return row.role;
2676
+ }
2677
+ export function getUserMemberFolders(userId) {
2678
+ const rows = db
2679
+ .prepare('SELECT group_folder, role FROM group_members WHERE user_id = ?')
2680
+ .all(userId);
2681
+ return rows.map((r) => ({
2682
+ group_folder: r.group_folder,
2683
+ role: r.role,
2684
+ }));
2685
+ }
2686
+ // ===================== Sub-Agent CRUD =====================
2687
+ export function createAgent(agent) {
2688
+ db.prepare(`INSERT INTO agents (id, group_folder, chat_jid, name, prompt, status, kind, created_by, created_at, completed_at, result_summary, spawned_from_jid)
2689
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(agent.id, agent.group_folder, agent.chat_jid, agent.name, agent.prompt, agent.status, agent.kind || 'task', agent.created_by ?? null, agent.created_at, agent.completed_at ?? null, agent.result_summary ?? null, agent.spawned_from_jid ?? null);
2690
+ }
2691
+ export function getAgent(id) {
2692
+ const row = db.prepare('SELECT * FROM agents WHERE id = ?').get(id);
2693
+ if (!row)
2694
+ return undefined;
2695
+ return mapAgentRow(row);
2696
+ }
2697
+ export function listAgentsByFolder(folder) {
2698
+ const rows = db
2699
+ .prepare('SELECT * FROM agents WHERE group_folder = ? ORDER BY created_at DESC')
2700
+ .all(folder);
2701
+ return rows.map(mapAgentRow);
2702
+ }
2703
+ export function listAgentsByJid(chatJid) {
2704
+ const rows = db
2705
+ .prepare('SELECT * FROM agents WHERE chat_jid = ? ORDER BY created_at DESC')
2706
+ .all(chatJid);
2707
+ return rows.map(mapAgentRow);
2708
+ }
2709
+ export function updateAgentStatus(id, status, resultSummary) {
2710
+ const completedAt = status !== 'running' && status !== 'idle' ? new Date().toISOString() : null;
2711
+ db.prepare('UPDATE agents SET status = ?, completed_at = ?, result_summary = ? WHERE id = ?').run(status, completedAt, resultSummary ?? null, id);
2712
+ }
2713
+ export function updateAgentLastImJid(id, lastImJid) {
2714
+ db.prepare('UPDATE agents SET last_im_jid = ? WHERE id = ?').run(lastImJid, id);
2715
+ }
2716
+ export function updateAgentInfo(id, name, prompt) {
2717
+ db.prepare('UPDATE agents SET name = ?, prompt = ? WHERE id = ?').run(name, prompt, id);
2718
+ }
2719
+ export function deleteCompletedAgents(beforeTimestamp) {
2720
+ const result = db
2721
+ .prepare("DELETE FROM agents WHERE kind IN ('task', 'spawn') AND status IN ('completed', 'error') AND completed_at IS NOT NULL AND completed_at < ?")
2722
+ .run(beforeTimestamp);
2723
+ return result.changes;
2724
+ }
2725
+ export function getRunningTaskAgentsByChat(chatJid) {
2726
+ const rows = db
2727
+ .prepare("SELECT * FROM agents WHERE chat_jid = ? AND kind = 'task' AND status = 'running'")
2728
+ .all(chatJid);
2729
+ return rows.map(mapAgentRow);
2730
+ }
2731
+ export function markRunningTaskAgentsAsError(chatJid) {
2732
+ const now = new Date().toISOString();
2733
+ const result = db
2734
+ .prepare("UPDATE agents SET status = 'error', completed_at = ? WHERE chat_jid = ? AND kind = 'task' AND status = 'running'")
2735
+ .run(now, chatJid);
2736
+ return result.changes;
2737
+ }
2738
+ export function markAllRunningTaskAgentsAsError(summary = '进程重启,任务中断') {
2739
+ const now = new Date().toISOString();
2740
+ const result = db
2741
+ .prepare("UPDATE agents SET status = 'error', completed_at = ?, result_summary = COALESCE(result_summary, ?) WHERE kind = 'task' AND status = 'running'")
2742
+ .run(now, summary);
2743
+ return result.changes;
2744
+ }
2745
+ /**
2746
+ * Mark stale spawn agents (idle/running) as error at startup.
2747
+ * After a process restart, spawn agents that were idle or running can never
2748
+ * resume — their in-memory task callbacks are lost. Mark them as error so
2749
+ * they don't render as "正在思考..." in the frontend.
2750
+ */
2751
+ export function markStaleSpawnAgentsAsError(summary = '进程重启,并行任务中断') {
2752
+ const now = new Date().toISOString();
2753
+ const result = db
2754
+ .prepare("UPDATE agents SET status = 'error', completed_at = ?, result_summary = COALESCE(result_summary, ?) WHERE kind = 'spawn' AND status IN ('idle', 'running')")
2755
+ .run(now, summary);
2756
+ return result.changes;
2757
+ }
2758
+ export function listActiveConversationAgents() {
2759
+ return db
2760
+ .prepare("SELECT * FROM agents WHERE kind IN ('conversation', 'spawn') AND status IN ('running', 'idle')")
2761
+ .all().map(mapAgentRow);
2762
+ }
2763
+ export function deleteAgent(id) {
2764
+ // Delete associated session
2765
+ db.prepare('DELETE FROM sessions WHERE agent_id = ?').run(id);
2766
+ db.prepare('DELETE FROM agents WHERE id = ?').run(id);
2767
+ }
2768
+ function mapAgentRow(row) {
2769
+ return {
2770
+ id: String(row.id),
2771
+ group_folder: String(row.group_folder),
2772
+ chat_jid: String(row.chat_jid),
2773
+ name: String(row.name),
2774
+ prompt: String(row.prompt),
2775
+ status: row.status || 'running',
2776
+ kind: row.kind || 'task',
2777
+ created_by: typeof row.created_by === 'string' ? row.created_by : null,
2778
+ created_at: String(row.created_at),
2779
+ completed_at: typeof row.completed_at === 'string' ? row.completed_at : null,
2780
+ result_summary: typeof row.result_summary === 'string' ? row.result_summary : null,
2781
+ last_im_jid: typeof row.last_im_jid === 'string' ? row.last_im_jid : null,
2782
+ spawned_from_jid: typeof row.spawned_from_jid === 'string' ? row.spawned_from_jid : null,
2783
+ };
2784
+ }
2785
+ export function deleteMessagesForChatJid(chatJid) {
2786
+ db.prepare('DELETE FROM messages WHERE chat_jid = ?').run(chatJid);
2787
+ db.prepare('DELETE FROM chats WHERE jid = ?').run(chatJid);
2788
+ }
2789
+ export function getMessage(chatJid, messageId) {
2790
+ const row = db
2791
+ .prepare('SELECT id, chat_jid, sender, is_from_me FROM messages WHERE id = ? AND chat_jid = ?')
2792
+ .get(messageId, chatJid);
2793
+ return row ?? null;
2794
+ }
2795
+ export function deleteMessage(chatJid, messageId) {
2796
+ const result = db
2797
+ .prepare('DELETE FROM messages WHERE id = ? AND chat_jid = ?')
2798
+ .run(messageId, chatJid);
2799
+ return result.changes > 0;
2800
+ }
2801
+ export function isGroupShared(groupFolder) {
2802
+ const row = db
2803
+ .prepare('SELECT COUNT(*) as cnt FROM group_members WHERE group_folder = ?')
2804
+ .get(groupFolder);
2805
+ return row.cnt > 1;
2806
+ }
2807
+ // --- Billing CRUD functions ---
2808
+ export function getBillingPlan(id) {
2809
+ const row = db.prepare('SELECT * FROM billing_plans WHERE id = ?').get(id);
2810
+ return row ? mapBillingPlanRow(row) : undefined;
2811
+ }
2812
+ export function getActiveBillingPlans() {
2813
+ return db
2814
+ .prepare('SELECT * FROM billing_plans WHERE is_active = 1 ORDER BY tier ASC, name ASC')
2815
+ .all().map(mapBillingPlanRow);
2816
+ }
2817
+ export function getAllBillingPlans() {
2818
+ return db
2819
+ .prepare('SELECT * FROM billing_plans ORDER BY tier ASC, name ASC')
2820
+ .all().map(mapBillingPlanRow);
2821
+ }
2822
+ export function getDefaultBillingPlan() {
2823
+ const row = db
2824
+ .prepare('SELECT * FROM billing_plans WHERE is_default = 1')
2825
+ .get();
2826
+ return row ? mapBillingPlanRow(row) : undefined;
2827
+ }
2828
+ export function createBillingPlan(plan) {
2829
+ db.transaction(() => {
2830
+ // Clear old default BEFORE inserting the new plan to avoid brief dual-default
2831
+ if (plan.is_default) {
2832
+ db.prepare('UPDATE billing_plans SET is_default = 0 WHERE is_default = 1').run();
2833
+ }
2834
+ db.prepare(`INSERT INTO billing_plans (id, name, description, tier, monthly_cost_usd, monthly_token_quota, monthly_cost_quota,
2835
+ daily_cost_quota, weekly_cost_quota, daily_token_quota, weekly_token_quota,
2836
+ rate_multiplier, trial_days, sort_order, display_price, highlight,
2837
+ max_groups, max_concurrent_containers, max_im_channels, max_mcp_servers, max_storage_mb,
2838
+ allow_overage, features, is_default, is_active, created_at, updated_at)
2839
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(plan.id, plan.name, plan.description, plan.tier, plan.monthly_cost_usd, plan.monthly_token_quota, plan.monthly_cost_quota, plan.daily_cost_quota, plan.weekly_cost_quota, plan.daily_token_quota, plan.weekly_token_quota, plan.rate_multiplier, plan.trial_days, plan.sort_order, plan.display_price, plan.highlight ? 1 : 0, plan.max_groups, plan.max_concurrent_containers, plan.max_im_channels, plan.max_mcp_servers, plan.max_storage_mb, plan.allow_overage ? 1 : 0, JSON.stringify(plan.features), plan.is_default ? 1 : 0, plan.is_active ? 1 : 0, plan.created_at, plan.updated_at);
2840
+ })();
2841
+ }
2842
+ export function updateBillingPlan(id, updates) {
2843
+ const fields = [];
2844
+ const values = [];
2845
+ if (updates.name !== undefined) {
2846
+ fields.push('name = ?');
2847
+ values.push(updates.name);
2848
+ }
2849
+ if (updates.description !== undefined) {
2850
+ fields.push('description = ?');
2851
+ values.push(updates.description);
2852
+ }
2853
+ if (updates.tier !== undefined) {
2854
+ fields.push('tier = ?');
2855
+ values.push(updates.tier);
2856
+ }
2857
+ if (updates.monthly_cost_usd !== undefined) {
2858
+ fields.push('monthly_cost_usd = ?');
2859
+ values.push(updates.monthly_cost_usd);
2860
+ }
2861
+ if (updates.monthly_token_quota !== undefined) {
2862
+ fields.push('monthly_token_quota = ?');
2863
+ values.push(updates.monthly_token_quota);
2864
+ }
2865
+ if (updates.monthly_cost_quota !== undefined) {
2866
+ fields.push('monthly_cost_quota = ?');
2867
+ values.push(updates.monthly_cost_quota);
2868
+ }
2869
+ if (updates.daily_cost_quota !== undefined) {
2870
+ fields.push('daily_cost_quota = ?');
2871
+ values.push(updates.daily_cost_quota);
2872
+ }
2873
+ if (updates.weekly_cost_quota !== undefined) {
2874
+ fields.push('weekly_cost_quota = ?');
2875
+ values.push(updates.weekly_cost_quota);
2876
+ }
2877
+ if (updates.daily_token_quota !== undefined) {
2878
+ fields.push('daily_token_quota = ?');
2879
+ values.push(updates.daily_token_quota);
2880
+ }
2881
+ if (updates.weekly_token_quota !== undefined) {
2882
+ fields.push('weekly_token_quota = ?');
2883
+ values.push(updates.weekly_token_quota);
2884
+ }
2885
+ if (updates.rate_multiplier !== undefined) {
2886
+ fields.push('rate_multiplier = ?');
2887
+ values.push(updates.rate_multiplier);
2888
+ }
2889
+ if (updates.trial_days !== undefined) {
2890
+ fields.push('trial_days = ?');
2891
+ values.push(updates.trial_days);
2892
+ }
2893
+ if (updates.sort_order !== undefined) {
2894
+ fields.push('sort_order = ?');
2895
+ values.push(updates.sort_order);
2896
+ }
2897
+ if (updates.display_price !== undefined) {
2898
+ fields.push('display_price = ?');
2899
+ values.push(updates.display_price);
2900
+ }
2901
+ if (updates.highlight !== undefined) {
2902
+ fields.push('highlight = ?');
2903
+ values.push(updates.highlight ? 1 : 0);
2904
+ }
2905
+ if (updates.max_groups !== undefined) {
2906
+ fields.push('max_groups = ?');
2907
+ values.push(updates.max_groups);
2908
+ }
2909
+ if (updates.max_concurrent_containers !== undefined) {
2910
+ fields.push('max_concurrent_containers = ?');
2911
+ values.push(updates.max_concurrent_containers);
2912
+ }
2913
+ if (updates.max_im_channels !== undefined) {
2914
+ fields.push('max_im_channels = ?');
2915
+ values.push(updates.max_im_channels);
2916
+ }
2917
+ if (updates.max_mcp_servers !== undefined) {
2918
+ fields.push('max_mcp_servers = ?');
2919
+ values.push(updates.max_mcp_servers);
2920
+ }
2921
+ if (updates.max_storage_mb !== undefined) {
2922
+ fields.push('max_storage_mb = ?');
2923
+ values.push(updates.max_storage_mb);
2924
+ }
2925
+ if (updates.allow_overage !== undefined) {
2926
+ fields.push('allow_overage = ?');
2927
+ values.push(updates.allow_overage ? 1 : 0);
2928
+ }
2929
+ if (updates.features !== undefined) {
2930
+ fields.push('features = ?');
2931
+ values.push(JSON.stringify(updates.features));
2932
+ }
2933
+ if (updates.is_default !== undefined) {
2934
+ fields.push('is_default = ?');
2935
+ values.push(updates.is_default ? 1 : 0);
2936
+ }
2937
+ if (updates.is_active !== undefined) {
2938
+ fields.push('is_active = ?');
2939
+ values.push(updates.is_active ? 1 : 0);
2940
+ }
2941
+ if (fields.length === 0)
2942
+ return;
2943
+ fields.push('updated_at = ?');
2944
+ values.push(new Date().toISOString());
2945
+ values.push(id);
2946
+ db.transaction(() => {
2947
+ // Clear old default BEFORE setting new one to avoid brief dual-default state
2948
+ if (updates.is_default) {
2949
+ db.prepare('UPDATE billing_plans SET is_default = 0 WHERE id != ?').run(id);
2950
+ }
2951
+ db.prepare(`UPDATE billing_plans SET ${fields.join(', ')} WHERE id = ?`).run(...values);
2952
+ })();
2953
+ }
2954
+ export function deleteBillingPlan(id) {
2955
+ // Don't delete if users are subscribed
2956
+ const hasSubscribers = db
2957
+ .prepare("SELECT COUNT(*) as cnt FROM user_subscriptions WHERE plan_id = ? AND status = 'active'")
2958
+ .get(id);
2959
+ if (hasSubscribers.cnt > 0)
2960
+ return false;
2961
+ const result = db.prepare('DELETE FROM billing_plans WHERE id = ?').run(id);
2962
+ return result.changes > 0;
2963
+ }
2964
+ function mapBillingPlanRow(row) {
2965
+ return {
2966
+ id: String(row.id),
2967
+ name: String(row.name),
2968
+ description: typeof row.description === 'string' ? row.description : null,
2969
+ tier: Number(row.tier) || 0,
2970
+ monthly_cost_usd: Number(row.monthly_cost_usd) || 0,
2971
+ monthly_token_quota: row.monthly_token_quota != null ? Number(row.monthly_token_quota) : null,
2972
+ monthly_cost_quota: row.monthly_cost_quota != null ? Number(row.monthly_cost_quota) : null,
2973
+ daily_cost_quota: row.daily_cost_quota != null ? Number(row.daily_cost_quota) : null,
2974
+ weekly_cost_quota: row.weekly_cost_quota != null ? Number(row.weekly_cost_quota) : null,
2975
+ daily_token_quota: row.daily_token_quota != null ? Number(row.daily_token_quota) : null,
2976
+ weekly_token_quota: row.weekly_token_quota != null ? Number(row.weekly_token_quota) : null,
2977
+ rate_multiplier: Number(row.rate_multiplier) || 1.0,
2978
+ trial_days: row.trial_days != null ? Number(row.trial_days) : null,
2979
+ sort_order: Number(row.sort_order) || 0,
2980
+ display_price: typeof row.display_price === 'string' ? row.display_price : null,
2981
+ highlight: !!row.highlight,
2982
+ max_groups: row.max_groups != null ? Number(row.max_groups) : null,
2983
+ max_concurrent_containers: row.max_concurrent_containers != null
2984
+ ? Number(row.max_concurrent_containers)
2985
+ : null,
2986
+ max_im_channels: row.max_im_channels != null ? Number(row.max_im_channels) : null,
2987
+ max_mcp_servers: row.max_mcp_servers != null ? Number(row.max_mcp_servers) : null,
2988
+ max_storage_mb: row.max_storage_mb != null ? Number(row.max_storage_mb) : null,
2989
+ allow_overage: !!row.allow_overage,
2990
+ features: safeParseJsonArray(row.features),
2991
+ is_default: !!row.is_default,
2992
+ is_active: !!row.is_active,
2993
+ created_at: String(row.created_at),
2994
+ updated_at: String(row.updated_at),
2995
+ };
2996
+ }
2997
+ function safeParseJsonArray(val) {
2998
+ if (typeof val !== 'string')
2999
+ return [];
3000
+ try {
3001
+ const parsed = JSON.parse(val);
3002
+ return Array.isArray(parsed) ? parsed : [];
3003
+ }
3004
+ catch {
3005
+ return [];
3006
+ }
3007
+ }
3008
+ // --- User Subscriptions ---
3009
+ export function getUserActiveSubscription(userId) {
3010
+ const row = db
3011
+ .prepare(`SELECT s.*, p.name as plan_name FROM user_subscriptions s
3012
+ JOIN billing_plans p ON s.plan_id = p.id
3013
+ WHERE s.user_id = ? AND s.status = 'active'
3014
+ ORDER BY s.created_at DESC LIMIT 1`)
3015
+ .get(userId);
3016
+ if (!row)
3017
+ return undefined;
3018
+ const plan = getBillingPlan(String(row.plan_id));
3019
+ if (!plan)
3020
+ return undefined;
3021
+ return { ...mapSubscriptionRow(row), plan };
3022
+ }
3023
+ export function createUserSubscription(sub) {
3024
+ // Cancel existing active subscriptions
3025
+ db.prepare("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = ? WHERE user_id = ? AND status = 'active'").run(new Date().toISOString(), sub.user_id);
3026
+ db.prepare(`INSERT INTO user_subscriptions (id, user_id, plan_id, status, started_at, expires_at, cancelled_at, trial_ends_at, notes, auto_renew, created_at)
3027
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sub.id, sub.user_id, sub.plan_id, sub.status, sub.started_at, sub.expires_at, sub.cancelled_at, sub.trial_ends_at, sub.notes, sub.auto_renew ? 1 : 0, sub.created_at);
3028
+ // Update user's subscription_plan_id
3029
+ db.prepare('UPDATE users SET subscription_plan_id = ? WHERE id = ?').run(sub.plan_id, sub.user_id);
3030
+ }
3031
+ export function cancelUserSubscription(userId) {
3032
+ const now = new Date().toISOString();
3033
+ db.prepare("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = ? WHERE user_id = ? AND status = 'active'").run(now, userId);
3034
+ db.prepare('UPDATE users SET subscription_plan_id = NULL WHERE id = ?').run(userId);
3035
+ }
3036
+ export function expireSubscriptions() {
3037
+ const now = new Date().toISOString();
3038
+ // Phase 1: Handle auto_renew=1 subscriptions — renew them instead of expiring
3039
+ const renewableRows = db
3040
+ .prepare("SELECT * FROM user_subscriptions WHERE status = 'active' AND auto_renew = 1 AND expires_at IS NOT NULL AND expires_at <= ?")
3041
+ .all(now);
3042
+ let renewed = 0;
3043
+ for (const row of renewableRows) {
3044
+ const userId = String(row.user_id);
3045
+ const planId = String(row.plan_id);
3046
+ const oldId = String(row.id);
3047
+ const oldStarted = String(row.started_at);
3048
+ const oldExpires = String(row.expires_at);
3049
+ // Calculate same duration as original subscription
3050
+ const startMs = new Date(oldStarted).getTime();
3051
+ const expiresMs = new Date(oldExpires).getTime();
3052
+ const durationMs = expiresMs - startMs;
3053
+ if (durationMs <= 0)
3054
+ continue;
3055
+ const plan = getBillingPlan(planId);
3056
+ if (!plan || !plan.is_active) {
3057
+ // Plan no longer active, expire instead
3058
+ continue;
3059
+ }
3060
+ // Check if user has sufficient balance for paid plans
3061
+ if (plan.monthly_cost_usd > 0) {
3062
+ const balance = getUserBalance(userId);
3063
+ if (balance.balance_usd < plan.monthly_cost_usd) {
3064
+ // Insufficient balance, expire instead
3065
+ logBillingAudit('subscription_expired', userId, null, {
3066
+ planId,
3067
+ planName: plan.name,
3068
+ reason: 'insufficient_balance_for_renewal',
3069
+ balance: balance.balance_usd,
3070
+ required: plan.monthly_cost_usd,
3071
+ });
3072
+ continue;
3073
+ }
3074
+ }
3075
+ // Wrap the entire renewal in a transaction for atomicity
3076
+ const renewTx = db.transaction(() => {
3077
+ // Deduct subscription cost (if paid plan)
3078
+ if (plan.monthly_cost_usd > 0) {
3079
+ adjustUserBalance(userId, -plan.monthly_cost_usd, 'deduction', `自动续费: ${plan.name}`, 'subscription', oldId, null, null, {
3080
+ source: 'subscription_renewal',
3081
+ operatorType: 'system',
3082
+ notes: `自动续费扣款: ${plan.name}`,
3083
+ });
3084
+ }
3085
+ // Expire old subscription
3086
+ db.prepare("UPDATE user_subscriptions SET status = 'expired' WHERE id = ?").run(oldId);
3087
+ // Create new subscription with same duration
3088
+ const newNow = new Date();
3089
+ const newExpires = new Date(newNow.getTime() + durationMs).toISOString();
3090
+ const newSub = {
3091
+ id: `sub_${userId}_${Date.now()}_renew`,
3092
+ user_id: userId,
3093
+ plan_id: planId,
3094
+ status: 'active',
3095
+ started_at: newNow.toISOString(),
3096
+ expires_at: newExpires,
3097
+ cancelled_at: null,
3098
+ trial_ends_at: null,
3099
+ notes: '自动续费',
3100
+ auto_renew: 1,
3101
+ created_at: newNow.toISOString(),
3102
+ };
3103
+ db.prepare(`INSERT INTO user_subscriptions (id, user_id, plan_id, status, started_at, expires_at, cancelled_at, trial_ends_at, notes, auto_renew, created_at)
3104
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newSub.id, newSub.user_id, newSub.plan_id, newSub.status, newSub.started_at, newSub.expires_at, newSub.cancelled_at, newSub.trial_ends_at, newSub.notes, newSub.auto_renew, newSub.created_at);
3105
+ logBillingAudit('subscription_assigned', userId, null, {
3106
+ planId,
3107
+ planName: plan.name,
3108
+ autoRenew: true,
3109
+ renewedFrom: oldId,
3110
+ });
3111
+ });
3112
+ try {
3113
+ renewTx();
3114
+ renewed++;
3115
+ }
3116
+ catch (err) {
3117
+ logBillingAudit('subscription_expired', userId, null, {
3118
+ planId,
3119
+ planName: plan.name,
3120
+ reason: 'renewal_transaction_failed',
3121
+ error: String(err),
3122
+ });
3123
+ }
3124
+ }
3125
+ // Phase 2: Expire remaining (non-auto-renew or failed renewal)
3126
+ const result = db
3127
+ .prepare("UPDATE user_subscriptions SET status = 'expired' WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?")
3128
+ .run(now);
3129
+ return result.changes + renewed;
3130
+ }
3131
+ export function updateSubscriptionAutoRenew(userId, autoRenew) {
3132
+ const result = db
3133
+ .prepare("UPDATE user_subscriptions SET auto_renew = ? WHERE user_id = ? AND status = 'active'")
3134
+ .run(autoRenew ? 1 : 0, userId);
3135
+ return result.changes > 0;
3136
+ }
3137
+ function mapSubscriptionRow(row) {
3138
+ return {
3139
+ id: String(row.id),
3140
+ user_id: String(row.user_id),
3141
+ plan_id: String(row.plan_id),
3142
+ status: String(row.status),
3143
+ started_at: String(row.started_at),
3144
+ expires_at: typeof row.expires_at === 'string' ? row.expires_at : null,
3145
+ cancelled_at: typeof row.cancelled_at === 'string' ? row.cancelled_at : null,
3146
+ trial_ends_at: typeof row.trial_ends_at === 'string' ? row.trial_ends_at : null,
3147
+ notes: typeof row.notes === 'string' ? row.notes : null,
3148
+ auto_renew: !!row.auto_renew,
3149
+ created_at: String(row.created_at),
3150
+ };
3151
+ }
3152
+ // --- User Balances ---
3153
+ export function getUserBalance(userId) {
3154
+ const row = db
3155
+ .prepare('SELECT * FROM user_balances WHERE user_id = ?')
3156
+ .get(userId);
3157
+ if (!row) {
3158
+ // Auto-init balance
3159
+ const now = new Date().toISOString();
3160
+ db.prepare('INSERT OR IGNORE INTO user_balances (user_id, balance_usd, total_deposited_usd, total_consumed_usd, updated_at) VALUES (?, 0, 0, 0, ?)').run(userId, now);
3161
+ return {
3162
+ user_id: userId,
3163
+ balance_usd: 0,
3164
+ total_deposited_usd: 0,
3165
+ total_consumed_usd: 0,
3166
+ updated_at: now,
3167
+ };
3168
+ }
3169
+ return {
3170
+ user_id: String(row.user_id),
3171
+ balance_usd: Number(row.balance_usd) || 0,
3172
+ total_deposited_usd: Number(row.total_deposited_usd) || 0,
3173
+ total_consumed_usd: Number(row.total_consumed_usd) || 0,
3174
+ updated_at: String(row.updated_at),
3175
+ };
3176
+ }
3177
+ export function adjustUserBalance(userId, amount, type, description, referenceType, referenceId, actorId, idempotencyKey, options) {
3178
+ const source = options?.source ?? 'system_adjustment';
3179
+ const operatorType = options?.operatorType ?? 'system';
3180
+ const notes = options?.notes ?? description ?? null;
3181
+ const allowNegative = options?.allowNegative ?? false;
3182
+ // Idempotency check: if key already used, return the existing transaction
3183
+ if (idempotencyKey) {
3184
+ const existing = db
3185
+ .prepare('SELECT * FROM balance_transactions WHERE idempotency_key = ?')
3186
+ .get(idempotencyKey);
3187
+ if (existing) {
3188
+ return {
3189
+ id: Number(existing.id),
3190
+ user_id: String(existing.user_id),
3191
+ type: String(existing.type),
3192
+ amount_usd: Number(existing.amount_usd),
3193
+ balance_after: Number(existing.balance_after),
3194
+ description: typeof existing.description === 'string'
3195
+ ? existing.description
3196
+ : null,
3197
+ reference_type: typeof existing.reference_type === 'string'
3198
+ ? existing.reference_type
3199
+ : null,
3200
+ reference_id: typeof existing.reference_id === 'string'
3201
+ ? existing.reference_id
3202
+ : null,
3203
+ actor_id: typeof existing.actor_id === 'string' ? existing.actor_id : null,
3204
+ source: typeof existing.source === 'string'
3205
+ ? existing.source
3206
+ : 'system_adjustment',
3207
+ operator_type: typeof existing.operator_type === 'string'
3208
+ ? existing.operator_type
3209
+ : 'system',
3210
+ notes: typeof existing.notes === 'string' ? existing.notes : null,
3211
+ idempotency_key: typeof existing.idempotency_key === 'string'
3212
+ ? existing.idempotency_key
3213
+ : null,
3214
+ created_at: String(existing.created_at),
3215
+ };
3216
+ }
3217
+ }
3218
+ const now = new Date().toISOString();
3219
+ // Wrap read-check-update-record in a transaction for atomicity
3220
+ const txFn = db.transaction(() => {
3221
+ // Ensure balance row exists
3222
+ db.prepare('INSERT OR IGNORE INTO user_balances (user_id, balance_usd, total_deposited_usd, total_consumed_usd, updated_at) VALUES (?, 0, 0, 0, ?)').run(userId, now);
3223
+ const currentRow = db
3224
+ .prepare('SELECT balance_usd FROM user_balances WHERE user_id = ?')
3225
+ .get(userId);
3226
+ const currentBalance = Number(currentRow.balance_usd);
3227
+ const nextBalance = currentBalance + amount;
3228
+ if (!allowNegative && nextBalance < 0) {
3229
+ throw new Error(`Balance cannot be negative: current=${currentBalance.toFixed(2)} next=${nextBalance.toFixed(2)}`);
3230
+ }
3231
+ // Update balance
3232
+ if (amount > 0) {
3233
+ db.prepare('UPDATE user_balances SET balance_usd = balance_usd + ?, total_deposited_usd = total_deposited_usd + ?, updated_at = ? WHERE user_id = ?').run(amount, amount, now, userId);
3234
+ }
3235
+ else {
3236
+ db.prepare('UPDATE user_balances SET balance_usd = balance_usd + ?, total_consumed_usd = total_consumed_usd + ?, updated_at = ? WHERE user_id = ?').run(amount, Math.abs(amount), now, userId);
3237
+ }
3238
+ // Read new balance within the same transaction
3239
+ const newRow = db
3240
+ .prepare('SELECT balance_usd FROM user_balances WHERE user_id = ?')
3241
+ .get(userId);
3242
+ const balanceAfter = Number(newRow.balance_usd);
3243
+ // Record transaction
3244
+ const result = db
3245
+ .prepare(`INSERT INTO balance_transactions (
3246
+ user_id, type, amount_usd, balance_after, description, reference_type,
3247
+ reference_id, actor_id, source, operator_type, notes, created_at, idempotency_key
3248
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
3249
+ .run(userId, type, amount, balanceAfter, description, referenceType, referenceId, actorId, source, operatorType, notes, now, idempotencyKey ?? null);
3250
+ return {
3251
+ id: Number(result.lastInsertRowid),
3252
+ balanceAfter,
3253
+ };
3254
+ });
3255
+ const { id: txId, balanceAfter } = txFn();
3256
+ return {
3257
+ id: txId,
3258
+ user_id: userId,
3259
+ type,
3260
+ amount_usd: amount,
3261
+ balance_after: balanceAfter,
3262
+ description,
3263
+ reference_type: referenceType,
3264
+ reference_id: referenceId,
3265
+ actor_id: actorId,
3266
+ source,
3267
+ operator_type: operatorType,
3268
+ notes,
3269
+ idempotency_key: idempotencyKey ?? null,
3270
+ created_at: now,
3271
+ };
3272
+ }
3273
+ export function getBalanceTransactions(userId, limit = 50, offset = 0) {
3274
+ const total = db
3275
+ .prepare('SELECT COUNT(*) as cnt FROM balance_transactions WHERE user_id = ?')
3276
+ .get(userId).cnt;
3277
+ const rows = db
3278
+ .prepare('SELECT * FROM balance_transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?')
3279
+ .all(userId, limit, offset);
3280
+ return {
3281
+ transactions: rows.map((r) => ({
3282
+ id: Number(r.id),
3283
+ user_id: String(r.user_id),
3284
+ type: String(r.type),
3285
+ amount_usd: Number(r.amount_usd),
3286
+ balance_after: Number(r.balance_after),
3287
+ description: typeof r.description === 'string' ? r.description : null,
3288
+ reference_type: typeof r.reference_type === 'string'
3289
+ ? r.reference_type
3290
+ : null,
3291
+ reference_id: typeof r.reference_id === 'string' ? r.reference_id : null,
3292
+ actor_id: typeof r.actor_id === 'string' ? r.actor_id : null,
3293
+ source: typeof r.source === 'string'
3294
+ ? r.source
3295
+ : 'system_adjustment',
3296
+ operator_type: typeof r.operator_type === 'string'
3297
+ ? r.operator_type
3298
+ : 'system',
3299
+ notes: typeof r.notes === 'string' ? r.notes : null,
3300
+ idempotency_key: typeof r.idempotency_key === 'string' ? r.idempotency_key : null,
3301
+ created_at: String(r.created_at),
3302
+ })),
3303
+ total,
3304
+ };
3305
+ }
3306
+ // --- Monthly Usage ---
3307
+ function mapMonthlyUsageRow(row) {
3308
+ return {
3309
+ user_id: String(row.user_id),
3310
+ month: String(row.month),
3311
+ total_input_tokens: Number(row.total_input_tokens) || 0,
3312
+ total_output_tokens: Number(row.total_output_tokens) || 0,
3313
+ total_cost_usd: Number(row.total_cost_usd) || 0,
3314
+ message_count: Number(row.message_count) || 0,
3315
+ updated_at: String(row.updated_at),
3316
+ };
3317
+ }
3318
+ export function getMonthlyUsage(userId, month) {
3319
+ const row = db
3320
+ .prepare('SELECT * FROM monthly_usage WHERE user_id = ? AND month = ?')
3321
+ .get(userId, month);
3322
+ if (!row)
3323
+ return undefined;
3324
+ return mapMonthlyUsageRow(row);
3325
+ }
3326
+ export function incrementMonthlyUsage(userId, month, inputTokens, outputTokens, costUsd) {
3327
+ const now = new Date().toISOString();
3328
+ db.prepare(`INSERT INTO monthly_usage (user_id, month, total_input_tokens, total_output_tokens, total_cost_usd, message_count, updated_at)
3329
+ VALUES (?, ?, ?, ?, ?, 1, ?)
3330
+ ON CONFLICT(user_id, month) DO UPDATE SET
3331
+ total_input_tokens = total_input_tokens + excluded.total_input_tokens,
3332
+ total_output_tokens = total_output_tokens + excluded.total_output_tokens,
3333
+ total_cost_usd = total_cost_usd + excluded.total_cost_usd,
3334
+ message_count = message_count + 1,
3335
+ updated_at = excluded.updated_at`).run(userId, month, inputTokens, outputTokens, costUsd, now);
3336
+ }
3337
+ export function getUserMonthlyUsageHistory(userId, months = 6) {
3338
+ return db
3339
+ .prepare('SELECT * FROM monthly_usage WHERE user_id = ? ORDER BY month DESC LIMIT ?')
3340
+ .all(userId, months).map(mapMonthlyUsageRow);
3341
+ }
3342
+ // --- Redeem Codes ---
3343
+ export function getRedeemCode(code) {
3344
+ const row = db
3345
+ .prepare('SELECT * FROM redeem_codes WHERE code = ?')
3346
+ .get(code);
3347
+ if (!row)
3348
+ return undefined;
3349
+ return mapRedeemCodeRow(row);
3350
+ }
3351
+ export function getAllRedeemCodes() {
3352
+ return db
3353
+ .prepare('SELECT * FROM redeem_codes ORDER BY created_at DESC')
3354
+ .all().map(mapRedeemCodeRow);
3355
+ }
3356
+ export function createRedeemCode(code) {
3357
+ db.prepare(`INSERT INTO redeem_codes (code, type, value_usd, plan_id, duration_days, max_uses, used_count, expires_at, created_by, notes, batch_id, created_at)
3358
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code.code, code.type, code.value_usd, code.plan_id, code.duration_days, code.max_uses, code.used_count, code.expires_at, code.created_by, code.notes, code.batch_id, code.created_at);
3359
+ }
3360
+ export function incrementRedeemCodeUsage(code, userId) {
3361
+ const now = new Date().toISOString();
3362
+ db.prepare('UPDATE redeem_codes SET used_count = used_count + 1 WHERE code = ?').run(code);
3363
+ db.prepare('INSERT INTO redeem_code_usage (code, user_id, redeemed_at) VALUES (?, ?, ?)').run(code, userId, now);
3364
+ }
3365
+ export function deleteRedeemCode(code) {
3366
+ const result = db
3367
+ .prepare('DELETE FROM redeem_codes WHERE code = ?')
3368
+ .run(code);
3369
+ return result.changes > 0;
3370
+ }
3371
+ export function hasUserRedeemedCode(userId, code) {
3372
+ const row = db
3373
+ .prepare('SELECT COUNT(*) as cnt FROM redeem_code_usage WHERE user_id = ? AND code = ?')
3374
+ .get(userId, code);
3375
+ return row.cnt > 0;
3376
+ }
3377
+ function mapRedeemCodeRow(row) {
3378
+ return {
3379
+ code: String(row.code),
3380
+ type: String(row.type),
3381
+ value_usd: row.value_usd != null ? Number(row.value_usd) : null,
3382
+ plan_id: typeof row.plan_id === 'string' ? row.plan_id : null,
3383
+ duration_days: row.duration_days != null ? Number(row.duration_days) : null,
3384
+ max_uses: Number(row.max_uses) || 1,
3385
+ used_count: Number(row.used_count) || 0,
3386
+ expires_at: typeof row.expires_at === 'string' ? row.expires_at : null,
3387
+ created_by: String(row.created_by),
3388
+ notes: typeof row.notes === 'string' ? row.notes : null,
3389
+ batch_id: typeof row.batch_id === 'string' ? row.batch_id : null,
3390
+ created_at: String(row.created_at),
3391
+ };
3392
+ }
3393
+ // --- Billing Audit Log ---
3394
+ export function logBillingAudit(eventType, userId, actorId, details) {
3395
+ db.prepare('INSERT INTO billing_audit_log (event_type, user_id, actor_id, details, created_at) VALUES (?, ?, ?, ?, ?)').run(eventType, userId, actorId, details ? JSON.stringify(details) : null, new Date().toISOString());
3396
+ }
3397
+ export function getBillingAuditLog(limit = 50, offset = 0, userId, eventType) {
3398
+ const conditions = [];
3399
+ const params = [];
3400
+ if (userId) {
3401
+ conditions.push('user_id = ?');
3402
+ params.push(userId);
3403
+ }
3404
+ if (eventType) {
3405
+ conditions.push('event_type = ?');
3406
+ params.push(eventType);
3407
+ }
3408
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
3409
+ const total = db
3410
+ .prepare(`SELECT COUNT(*) as cnt FROM billing_audit_log ${where}`)
3411
+ .get(...params).cnt;
3412
+ const rows = db
3413
+ .prepare(`SELECT * FROM billing_audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
3414
+ .all(...params, limit, offset);
3415
+ return {
3416
+ logs: rows.map((r) => ({
3417
+ id: Number(r.id),
3418
+ event_type: String(r.event_type),
3419
+ user_id: String(r.user_id),
3420
+ actor_id: typeof r.actor_id === 'string' ? r.actor_id : null,
3421
+ details: typeof r.details === 'string'
3422
+ ? JSON.parse(r.details)
3423
+ : null,
3424
+ created_at: String(r.created_at),
3425
+ })),
3426
+ total,
3427
+ };
3428
+ }
3429
+ // --- Billing summary helpers ---
3430
+ export function getUserGroupCount(userId) {
3431
+ const row = db
3432
+ .prepare("SELECT COUNT(DISTINCT rg.folder) as cnt FROM registered_groups rg WHERE rg.created_by = ? AND rg.jid LIKE 'web:%'")
3433
+ .get(userId);
3434
+ return row.cnt;
3435
+ }
3436
+ export function getAllUserBillingOverview() {
3437
+ const month = new Date().toISOString().slice(0, 7);
3438
+ return db
3439
+ .prepare(`SELECT u.id as user_id, u.username, u.display_name, u.role,
3440
+ s.plan_id, p.name as plan_name,
3441
+ COALESCE(b.balance_usd, 0) as balance_usd,
3442
+ COALESCE(mu.total_cost_usd, 0) as current_month_cost
3443
+ FROM users u
3444
+ LEFT JOIN user_subscriptions s ON s.user_id = u.id AND s.status = 'active'
3445
+ LEFT JOIN billing_plans p ON p.id = s.plan_id
3446
+ LEFT JOIN user_balances b ON b.user_id = u.id
3447
+ LEFT JOIN monthly_usage mu ON mu.user_id = u.id AND mu.month = ?
3448
+ WHERE u.status != 'deleted'
3449
+ ORDER BY u.created_at ASC`)
3450
+ .all(month);
3451
+ }
3452
+ export function getRevenueStats() {
3453
+ const month = new Date().toISOString().slice(0, 7);
3454
+ const deposited = db
3455
+ .prepare('SELECT COALESCE(SUM(total_deposited_usd), 0) as total FROM user_balances')
3456
+ .get().total;
3457
+ const consumed = db
3458
+ .prepare('SELECT COALESCE(SUM(total_consumed_usd), 0) as total FROM user_balances')
3459
+ .get().total;
3460
+ const activeSubs = db
3461
+ .prepare("SELECT COUNT(*) as cnt FROM user_subscriptions WHERE status = 'active'")
3462
+ .get().cnt;
3463
+ const monthRevenue = db
3464
+ .prepare('SELECT COALESCE(SUM(total_cost_usd), 0) as total FROM monthly_usage WHERE month = ?')
3465
+ .get(month).total;
3466
+ return {
3467
+ totalDeposited: deposited,
3468
+ totalConsumed: consumed,
3469
+ activeSubscriptions: activeSubs,
3470
+ currentMonthRevenue: monthRevenue,
3471
+ };
3472
+ }
3473
+ // --- Daily Usage ---
3474
+ function mapDailyUsageRow(row) {
3475
+ return {
3476
+ user_id: String(row.user_id),
3477
+ date: String(row.date),
3478
+ total_input_tokens: Number(row.total_input_tokens) || 0,
3479
+ total_output_tokens: Number(row.total_output_tokens) || 0,
3480
+ total_cost_usd: Number(row.total_cost_usd) || 0,
3481
+ message_count: Number(row.message_count) || 0,
3482
+ };
3483
+ }
3484
+ export function incrementDailyUsage(userId, date, inputTokens, outputTokens, costUsd) {
3485
+ db.prepare(`INSERT INTO daily_usage (user_id, date, total_input_tokens, total_output_tokens, total_cost_usd, message_count)
3486
+ VALUES (?, ?, ?, ?, ?, 1)
3487
+ ON CONFLICT(user_id, date) DO UPDATE SET
3488
+ total_input_tokens = total_input_tokens + excluded.total_input_tokens,
3489
+ total_output_tokens = total_output_tokens + excluded.total_output_tokens,
3490
+ total_cost_usd = total_cost_usd + excluded.total_cost_usd,
3491
+ message_count = message_count + 1`).run(userId, date, inputTokens, outputTokens, costUsd);
3492
+ }
3493
+ export function getDailyUsage(userId, date) {
3494
+ const row = db
3495
+ .prepare('SELECT * FROM daily_usage WHERE user_id = ? AND date = ?')
3496
+ .get(userId, date);
3497
+ if (!row)
3498
+ return undefined;
3499
+ return mapDailyUsageRow(row);
3500
+ }
3501
+ export function getWeeklyUsageSummary(userId) {
3502
+ // Align to calendar week (Monday–Sunday) to match checkQuota() reset logic
3503
+ const now = new Date();
3504
+ const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ...
3505
+ const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
3506
+ const monday = new Date(now);
3507
+ monday.setDate(now.getDate() - daysSinceMonday);
3508
+ const startDate = monday.toISOString().slice(0, 10);
3509
+ const row = db
3510
+ .prepare(`SELECT COALESCE(SUM(total_cost_usd), 0) as totalCost,
3511
+ COALESCE(SUM(total_input_tokens + total_output_tokens), 0) as totalTokens
3512
+ FROM daily_usage WHERE user_id = ? AND date >= ?`)
3513
+ .get(userId, startDate);
3514
+ return { totalCost: row.totalCost, totalTokens: row.totalTokens };
3515
+ }
3516
+ export function getUserDailyUsageHistory(userId, days = 14) {
3517
+ return db
3518
+ .prepare('SELECT * FROM daily_usage WHERE user_id = ? ORDER BY date DESC LIMIT ?')
3519
+ .all(userId, days).map(mapDailyUsageRow);
3520
+ }
3521
+ export function getDailyUsageSumForMonth(userId, month) {
3522
+ const startDate = `${month}-01`;
3523
+ // End date: first day of next month
3524
+ const [y, m] = month.split('-').map(Number);
3525
+ const nextMonth = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`;
3526
+ const endDate = `${nextMonth}-01`;
3527
+ const row = db
3528
+ .prepare(`SELECT COALESCE(SUM(total_input_tokens), 0) as totalInputTokens,
3529
+ COALESCE(SUM(total_output_tokens), 0) as totalOutputTokens,
3530
+ COALESCE(SUM(total_cost_usd), 0) as totalCost,
3531
+ COALESCE(SUM(message_count), 0) as messageCount
3532
+ FROM daily_usage WHERE user_id = ? AND date >= ? AND date < ?`)
3533
+ .get(userId, startDate, endDate);
3534
+ return row;
3535
+ }
3536
+ export function correctMonthlyUsage(userId, month, inputTokens, outputTokens, costUsd, messageCount) {
3537
+ const now = new Date().toISOString();
3538
+ db.prepare(`INSERT INTO monthly_usage (user_id, month, total_input_tokens, total_output_tokens, total_cost_usd, message_count, updated_at)
3539
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3540
+ ON CONFLICT(user_id, month) DO UPDATE SET
3541
+ total_input_tokens = excluded.total_input_tokens,
3542
+ total_output_tokens = excluded.total_output_tokens,
3543
+ total_cost_usd = excluded.total_cost_usd,
3544
+ message_count = excluded.message_count,
3545
+ updated_at = excluded.updated_at`).run(userId, month, inputTokens, outputTokens, costUsd, messageCount, now);
3546
+ }
3547
+ export function getSubscriptionHistory(userId) {
3548
+ return db
3549
+ .prepare(`SELECT s.*, p.name as plan_name FROM user_subscriptions s
3550
+ JOIN billing_plans p ON s.plan_id = p.id
3551
+ WHERE s.user_id = ?
3552
+ ORDER BY s.created_at DESC`)
3553
+ .all(userId).map((row) => ({
3554
+ ...mapSubscriptionRow(row),
3555
+ plan_name: String(row.plan_name),
3556
+ }));
3557
+ }
3558
+ export function getRedeemCodeUsageDetails(code) {
3559
+ return db
3560
+ .prepare(`SELECT rcu.user_id, u.username, rcu.redeemed_at
3561
+ FROM redeem_code_usage rcu
3562
+ LEFT JOIN users u ON u.id = rcu.user_id
3563
+ WHERE rcu.code = ?
3564
+ ORDER BY rcu.redeemed_at DESC`)
3565
+ .all(code);
3566
+ }
3567
+ export function getDashboardStats() {
3568
+ const today = new Date().toISOString().slice(0, 10);
3569
+ const month = new Date().toISOString().slice(0, 7);
3570
+ const totalUsers = db
3571
+ .prepare("SELECT COUNT(*) as cnt FROM users WHERE status != 'deleted'")
3572
+ .get().cnt;
3573
+ const activeUsers = db
3574
+ .prepare('SELECT COUNT(DISTINCT user_id) as cnt FROM daily_usage WHERE date = ?')
3575
+ .get(today).cnt;
3576
+ const planDistribution = db
3577
+ .prepare(`SELECT COALESCE(p.name, '无套餐') as plan_name, COUNT(*) as count
3578
+ FROM users u
3579
+ LEFT JOIN user_subscriptions s ON s.user_id = u.id AND s.status = 'active'
3580
+ LEFT JOIN billing_plans p ON p.id = s.plan_id
3581
+ WHERE u.status != 'deleted'
3582
+ GROUP BY p.name
3583
+ ORDER BY count DESC`)
3584
+ .all();
3585
+ const todayCost = db
3586
+ .prepare('SELECT COALESCE(SUM(total_cost_usd), 0) as total FROM daily_usage WHERE date = ?')
3587
+ .get(today).total;
3588
+ const monthCost = db
3589
+ .prepare('SELECT COALESCE(SUM(total_cost_usd), 0) as total FROM monthly_usage WHERE month = ?')
3590
+ .get(month).total;
3591
+ const activeSubscriptions = db
3592
+ .prepare("SELECT COUNT(*) as cnt FROM user_subscriptions WHERE status = 'active'")
3593
+ .get().cnt;
3594
+ return {
3595
+ activeUsers,
3596
+ totalUsers,
3597
+ planDistribution,
3598
+ todayCost,
3599
+ monthCost,
3600
+ activeSubscriptions,
3601
+ };
3602
+ }
3603
+ export function getRevenueTrend(months = 6) {
3604
+ return db
3605
+ .prepare(`SELECT month, SUM(total_cost_usd) as revenue, COUNT(DISTINCT user_id) as users
3606
+ FROM monthly_usage
3607
+ GROUP BY month
3608
+ ORDER BY month DESC
3609
+ LIMIT ?`)
3610
+ .all(months);
3611
+ }
3612
+ export function batchAssignPlan(userIds, planId, actorId, durationDays) {
3613
+ const plan = getBillingPlan(planId);
3614
+ if (!plan)
3615
+ throw new Error(`Plan not found: ${planId}`);
3616
+ const now = new Date();
3617
+ const expiresAt = durationDays
3618
+ ? new Date(now.getTime() + durationDays * 24 * 60 * 60 * 1000).toISOString()
3619
+ : null;
3620
+ let count = 0;
3621
+ const txn = db.transaction(() => {
3622
+ for (const userId of userIds) {
3623
+ // Cancel existing
3624
+ db.prepare("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = ? WHERE user_id = ? AND status = 'active'").run(now.toISOString(), userId);
3625
+ const subId = `sub_${userId}_${Date.now()}_${count}`;
3626
+ db.prepare(`INSERT INTO user_subscriptions (id, user_id, plan_id, status, started_at, expires_at, auto_renew, created_at)
3627
+ VALUES (?, ?, ?, 'active', ?, ?, 0, ?)`).run(subId, userId, planId, now.toISOString(), expiresAt, now.toISOString());
3628
+ db.prepare('UPDATE users SET subscription_plan_id = ? WHERE id = ?').run(planId, userId);
3629
+ logBillingAudit('subscription_assigned', userId, actorId, {
3630
+ planId,
3631
+ planName: plan.name,
3632
+ durationDays: durationDays ?? null,
3633
+ batch: true,
3634
+ });
3635
+ count++;
3636
+ }
3637
+ });
3638
+ txn();
3639
+ return count;
3640
+ }
3641
+ export function getPlanSubscriberCount(planId) {
3642
+ const row = db
3643
+ .prepare("SELECT COUNT(*) as cnt FROM user_subscriptions WHERE plan_id = ? AND status = 'active'")
3644
+ .get(planId);
3645
+ return row.cnt;
3646
+ }
3647
+ export function getAllPlanSubscriberCounts() {
3648
+ const rows = db
3649
+ .prepare("SELECT plan_id, COUNT(*) as cnt FROM user_subscriptions WHERE status = 'active' GROUP BY plan_id")
3650
+ .all();
3651
+ const result = {};
3652
+ for (const row of rows) {
3653
+ result[row.plan_id] = row.cnt;
3654
+ }
3655
+ return result;
3656
+ }
3657
+ /**
3658
+ * Atomically increment redeem code usage with optimistic locking.
3659
+ * Returns true if the increment succeeded (used_count < max_uses).
3660
+ */
3661
+ export function tryIncrementRedeemCodeUsage(code, userId) {
3662
+ const now = new Date().toISOString();
3663
+ return db.transaction(() => {
3664
+ const result = db
3665
+ .prepare('UPDATE redeem_codes SET used_count = used_count + 1 WHERE code = ? AND used_count < max_uses')
3666
+ .run(code);
3667
+ if (result.changes === 0)
3668
+ return false;
3669
+ db.prepare('INSERT INTO redeem_code_usage (code, user_id, redeemed_at) VALUES (?, ?, ?)').run(code, userId, now);
3670
+ return true;
3671
+ })();
3672
+ }
3673
+ /**
3674
+ * Close the database connection.
3675
+ * Should be called during graceful shutdown.
3676
+ */
3677
+ export function closeDatabase() {
3678
+ _stmts = null;
3679
+ _newMsgStmtCache.clear();
3680
+ if (db) {
3681
+ db.close();
3682
+ }
3683
+ }