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.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- 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
|
+
}
|