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