cli-claw-kit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
package/dist/qq.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot API v2 Connection Factory
|
|
3
|
+
*
|
|
4
|
+
* Implements QQ Bot connection using official API v2 protocol:
|
|
5
|
+
* - OAuth Token management with auto-refresh
|
|
6
|
+
* - WebSocket connection for receiving events
|
|
7
|
+
* - REST API for sending messages
|
|
8
|
+
* - Message deduplication (LRU 1000 / 30min TTL)
|
|
9
|
+
*
|
|
10
|
+
* Reference: https://github.com/sliverp/qqbot (QQ Bot API v2)
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import https from 'node:https';
|
|
15
|
+
import WebSocket from 'ws';
|
|
16
|
+
import { storeChatMetadata, storeMessageDirect, updateChatName } from './db.js';
|
|
17
|
+
import { notifyNewImMessage } from './message-notifier.js';
|
|
18
|
+
import { broadcastNewMessage } from './web.js';
|
|
19
|
+
import { logger } from './logger.js';
|
|
20
|
+
import { saveDownloadedFile, MAX_FILE_SIZE } from './im-downloader.js';
|
|
21
|
+
import { detectImageMimeTypeStrict } from './image-detector.js';
|
|
22
|
+
import { markdownToPlainText, splitTextChunks } from './im-utils.js';
|
|
23
|
+
// ─── Constants ──────────────────────────────────────────────────
|
|
24
|
+
const QQ_TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken';
|
|
25
|
+
const QQ_API_BASE = 'https://api.sgroup.qq.com';
|
|
26
|
+
const TOKEN_REFRESH_BUFFER_MS = 300_000; // refresh 5min before expiry
|
|
27
|
+
const MSG_DEDUP_MAX = 1000;
|
|
28
|
+
const MSG_DEDUP_TTL = 30 * 60 * 1000; // 30min
|
|
29
|
+
const MSG_SPLIT_LIMIT = 5000;
|
|
30
|
+
const RECONNECT_DELAY_MS = 5000;
|
|
31
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
32
|
+
const IMAGE_EXT_MAP = {
|
|
33
|
+
'image/jpeg': '.jpg',
|
|
34
|
+
'image/png': '.png',
|
|
35
|
+
'image/gif': '.gif',
|
|
36
|
+
'image/webp': '.webp',
|
|
37
|
+
};
|
|
38
|
+
// Intents: PUBLIC_MESSAGES (C2C + group @bot)
|
|
39
|
+
const INTENTS = 1 << 25;
|
|
40
|
+
// WebSocket opcodes
|
|
41
|
+
const OP_DISPATCH = 0;
|
|
42
|
+
const OP_HEARTBEAT = 1;
|
|
43
|
+
const OP_IDENTIFY = 2;
|
|
44
|
+
const OP_RESUME = 6;
|
|
45
|
+
const OP_RECONNECT = 7;
|
|
46
|
+
const OP_INVALID_SESSION = 9;
|
|
47
|
+
const OP_HELLO = 10;
|
|
48
|
+
const OP_HEARTBEAT_ACK = 11;
|
|
49
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Parse JID to determine chat type and extract openid.
|
|
52
|
+
* qq:c2c:{user_openid} → { type: 'c2c', openid }
|
|
53
|
+
* qq:group:{group_openid} → { type: 'group', openid }
|
|
54
|
+
*/
|
|
55
|
+
function parseQQChatId(chatId) {
|
|
56
|
+
if (chatId.startsWith('c2c:')) {
|
|
57
|
+
return { type: 'c2c', openid: chatId.slice(4) };
|
|
58
|
+
}
|
|
59
|
+
if (chatId.startsWith('group:')) {
|
|
60
|
+
return { type: 'group', openid: chatId.slice(6) };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// ─── Factory Function ───────────────────────────────────────────
|
|
65
|
+
export function createQQConnection(config) {
|
|
66
|
+
// Token state
|
|
67
|
+
let tokenInfo = null;
|
|
68
|
+
let tokenRefreshPromise = null;
|
|
69
|
+
// WebSocket state
|
|
70
|
+
let ws = null;
|
|
71
|
+
let heartbeatTimer = null;
|
|
72
|
+
let reconnectTimer = null;
|
|
73
|
+
let reconnectAttempts = 0;
|
|
74
|
+
let lastSequence = null;
|
|
75
|
+
let sessionId = null;
|
|
76
|
+
let resumeGatewayUrl = null;
|
|
77
|
+
let stopping = false;
|
|
78
|
+
let readyFired = false;
|
|
79
|
+
// Message deduplication
|
|
80
|
+
const msgCache = new Map();
|
|
81
|
+
// Per-chat msg_seq counter for active messages
|
|
82
|
+
const msgSeqCounters = new Map();
|
|
83
|
+
// Rate-limit rejection messages
|
|
84
|
+
const rejectTimestamps = new Map();
|
|
85
|
+
const REJECT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
86
|
+
function isDuplicate(msgId) {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
// Map preserves insertion order; stop at first non-expired entry
|
|
89
|
+
for (const [id, ts] of msgCache.entries()) {
|
|
90
|
+
if (now - ts > MSG_DEDUP_TTL) {
|
|
91
|
+
msgCache.delete(id);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (msgCache.size >= MSG_DEDUP_MAX) {
|
|
98
|
+
const firstKey = msgCache.keys().next().value;
|
|
99
|
+
if (firstKey)
|
|
100
|
+
msgCache.delete(firstKey);
|
|
101
|
+
}
|
|
102
|
+
return msgCache.has(msgId);
|
|
103
|
+
}
|
|
104
|
+
function markSeen(msgId) {
|
|
105
|
+
// delete + set to refresh insertion order (move to end)
|
|
106
|
+
msgCache.delete(msgId);
|
|
107
|
+
msgCache.set(msgId, Date.now());
|
|
108
|
+
}
|
|
109
|
+
function getNextMsgSeq(chatId) {
|
|
110
|
+
const current = msgSeqCounters.get(chatId) ?? 0;
|
|
111
|
+
const next = current + 1;
|
|
112
|
+
msgSeqCounters.set(chatId, next);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
// ─── Token Management ──────────────────────────────────────
|
|
116
|
+
async function getAccessToken() {
|
|
117
|
+
// Check cached token
|
|
118
|
+
if (tokenInfo &&
|
|
119
|
+
Date.now() < tokenInfo.expiresAt - TOKEN_REFRESH_BUFFER_MS) {
|
|
120
|
+
return tokenInfo.accessToken;
|
|
121
|
+
}
|
|
122
|
+
// Singleflight: reuse in-flight refresh
|
|
123
|
+
if (tokenRefreshPromise) {
|
|
124
|
+
return tokenRefreshPromise;
|
|
125
|
+
}
|
|
126
|
+
tokenRefreshPromise = refreshToken();
|
|
127
|
+
try {
|
|
128
|
+
return await tokenRefreshPromise;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
tokenRefreshPromise = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function refreshToken() {
|
|
135
|
+
const body = JSON.stringify({
|
|
136
|
+
appId: config.appId,
|
|
137
|
+
clientSecret: config.appSecret,
|
|
138
|
+
});
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const url = new URL(QQ_TOKEN_URL);
|
|
141
|
+
const req = https.request({
|
|
142
|
+
hostname: url.hostname,
|
|
143
|
+
path: url.pathname,
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
'Content-Length': Buffer.byteLength(body),
|
|
148
|
+
},
|
|
149
|
+
}, (res) => {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
152
|
+
res.on('end', () => {
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
155
|
+
if (!data.access_token) {
|
|
156
|
+
reject(new Error(`QQ token response missing access_token: ${JSON.stringify(data)}`));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const expiresIn = Number(data.expires_in) || 7200;
|
|
160
|
+
tokenInfo = {
|
|
161
|
+
accessToken: data.access_token,
|
|
162
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
163
|
+
};
|
|
164
|
+
logger.info({ expiresIn }, 'QQ access token refreshed');
|
|
165
|
+
resolve(data.access_token);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
reject(err);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
res.on('error', reject);
|
|
172
|
+
});
|
|
173
|
+
req.on('error', reject);
|
|
174
|
+
req.write(body);
|
|
175
|
+
req.end();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ─── REST API ──────────────────────────────────────────────
|
|
179
|
+
async function apiRequest(method, path, body) {
|
|
180
|
+
const token = await getAccessToken();
|
|
181
|
+
const url = new URL(path, QQ_API_BASE);
|
|
182
|
+
const bodyStr = body ? JSON.stringify(body) : undefined;
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const req = https.request({
|
|
185
|
+
hostname: url.hostname,
|
|
186
|
+
path: url.pathname + url.search,
|
|
187
|
+
method,
|
|
188
|
+
headers: {
|
|
189
|
+
Authorization: `QQBot ${token}`,
|
|
190
|
+
'Content-Type': 'application/json',
|
|
191
|
+
...(bodyStr
|
|
192
|
+
? { 'Content-Length': String(Buffer.byteLength(bodyStr)) }
|
|
193
|
+
: {}),
|
|
194
|
+
},
|
|
195
|
+
}, (res) => {
|
|
196
|
+
const chunks = [];
|
|
197
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
198
|
+
res.on('end', () => {
|
|
199
|
+
const text = Buffer.concat(chunks).toString('utf-8');
|
|
200
|
+
try {
|
|
201
|
+
const data = JSON.parse(text);
|
|
202
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
203
|
+
const errMsg = data.message || data.msg || text;
|
|
204
|
+
reject(new Error(`QQ API ${method} ${path} failed (${res.statusCode}): ${errMsg}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
resolve(data);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
211
|
+
reject(new Error(`QQ API ${method} ${path} failed (${res.statusCode}): ${text}`));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Some endpoints return empty body on success
|
|
215
|
+
resolve({});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
res.on('error', reject);
|
|
220
|
+
});
|
|
221
|
+
req.on('error', reject);
|
|
222
|
+
if (bodyStr)
|
|
223
|
+
req.write(bodyStr);
|
|
224
|
+
req.end();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function getGatewayUrl() {
|
|
228
|
+
const data = await apiRequest('GET', '/gateway/bot');
|
|
229
|
+
return data.url;
|
|
230
|
+
}
|
|
231
|
+
// ─── Message Sending ──────────────────────────────────────
|
|
232
|
+
async function sendQQMessage(chatType, openid, content) {
|
|
233
|
+
const chatKey = `${chatType}:${openid}`;
|
|
234
|
+
const msgSeq = getNextMsgSeq(chatKey);
|
|
235
|
+
const endpoint = chatType === 'c2c'
|
|
236
|
+
? `/v2/users/${openid}/messages`
|
|
237
|
+
: `/v2/groups/${openid}/messages`;
|
|
238
|
+
await apiRequest('POST', endpoint, {
|
|
239
|
+
content,
|
|
240
|
+
msg_type: 0, // text
|
|
241
|
+
msg_seq: msgSeq,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// ─── File Download ─────────────────────────────────────────
|
|
245
|
+
async function downloadQQAttachment(url) {
|
|
246
|
+
try {
|
|
247
|
+
const buffer = await new Promise((resolve, reject) => {
|
|
248
|
+
const doRequest = (reqUrl, redirectCount = 0) => {
|
|
249
|
+
if (redirectCount > 5) {
|
|
250
|
+
reject(new Error('Too many redirects'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const parsedUrl = new URL(reqUrl);
|
|
254
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
255
|
+
protocol
|
|
256
|
+
.get(reqUrl, (res) => {
|
|
257
|
+
if (res.statusCode &&
|
|
258
|
+
res.statusCode >= 300 &&
|
|
259
|
+
res.statusCode < 400 &&
|
|
260
|
+
res.headers.location) {
|
|
261
|
+
doRequest(res.headers.location, redirectCount + 1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const chunks = [];
|
|
265
|
+
let total = 0;
|
|
266
|
+
res.on('data', (chunk) => {
|
|
267
|
+
total += chunk.length;
|
|
268
|
+
if (total > MAX_FILE_SIZE) {
|
|
269
|
+
res.destroy(new Error('File exceeds MAX_FILE_SIZE'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
chunks.push(chunk);
|
|
273
|
+
});
|
|
274
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
275
|
+
res.on('error', reject);
|
|
276
|
+
})
|
|
277
|
+
.on('error', reject);
|
|
278
|
+
};
|
|
279
|
+
doRequest(url);
|
|
280
|
+
});
|
|
281
|
+
if (buffer.length === 0)
|
|
282
|
+
return null;
|
|
283
|
+
return buffer;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
logger.warn({ err }, 'Failed to download QQ attachment');
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Process a QQ attachment (image or file): download, detect type, save to disk.
|
|
292
|
+
* Returns updated content string and optional attachmentsJson for vision.
|
|
293
|
+
*/
|
|
294
|
+
async function processQQAttachment(attachment, msgId, jid, content, opts, logContext) {
|
|
295
|
+
if (!attachment.url)
|
|
296
|
+
return { content };
|
|
297
|
+
const attachUrl = attachment.url.startsWith('http')
|
|
298
|
+
? attachment.url
|
|
299
|
+
: `https://${attachment.url}`;
|
|
300
|
+
const buffer = await downloadQQAttachment(attachUrl);
|
|
301
|
+
if (!buffer)
|
|
302
|
+
return { content };
|
|
303
|
+
const imageMime = detectImageMimeTypeStrict(buffer);
|
|
304
|
+
const groupFolder = opts.resolveGroupFolder?.(jid);
|
|
305
|
+
if (imageMime) {
|
|
306
|
+
const attachmentsJson = JSON.stringify([
|
|
307
|
+
{ type: 'image', data: buffer.toString('base64'), mimeType: imageMime },
|
|
308
|
+
]);
|
|
309
|
+
if (groupFolder) {
|
|
310
|
+
const ext = IMAGE_EXT_MAP[imageMime] ?? '.jpg';
|
|
311
|
+
const fileName = `qq_img_${msgId.slice(-8)}${ext}`;
|
|
312
|
+
try {
|
|
313
|
+
const relPath = await saveDownloadedFile(groupFolder, 'qq', fileName, buffer);
|
|
314
|
+
if (relPath)
|
|
315
|
+
content = `[图片: ${relPath}]\n${content}`.trim();
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
logger.warn({ err }, `Failed to save QQ ${logContext} image`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!content)
|
|
322
|
+
content = '[图片]';
|
|
323
|
+
return { content, attachmentsJson };
|
|
324
|
+
}
|
|
325
|
+
// Non-image file
|
|
326
|
+
const urlFilename = attachment.filename ||
|
|
327
|
+
attachUrl.split('/').pop()?.split('?')[0] ||
|
|
328
|
+
`qq_file_${msgId.slice(-8)}`;
|
|
329
|
+
const fileName = urlFilename.replace(/[^a-zA-Z0-9._\-\u4e00-\u9fff]/g, '_');
|
|
330
|
+
if (groupFolder) {
|
|
331
|
+
try {
|
|
332
|
+
const relPath = await saveDownloadedFile(groupFolder, 'qq', fileName, buffer);
|
|
333
|
+
if (relPath)
|
|
334
|
+
content = `[文件: ${relPath}]\n${content}`.trim();
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
logger.warn({ err }, `Failed to save QQ ${logContext} file`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!content)
|
|
341
|
+
content = '[文件]';
|
|
342
|
+
return { content };
|
|
343
|
+
}
|
|
344
|
+
// ─── WebSocket Connection ─────────────────────────────────
|
|
345
|
+
function clearTimers() {
|
|
346
|
+
if (heartbeatTimer) {
|
|
347
|
+
clearInterval(heartbeatTimer);
|
|
348
|
+
heartbeatTimer = null;
|
|
349
|
+
}
|
|
350
|
+
if (reconnectTimer) {
|
|
351
|
+
clearTimeout(reconnectTimer);
|
|
352
|
+
reconnectTimer = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function sendWs(payload) {
|
|
356
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
357
|
+
ws.send(JSON.stringify(payload));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function startHeartbeat(intervalMs) {
|
|
361
|
+
if (heartbeatTimer)
|
|
362
|
+
clearInterval(heartbeatTimer);
|
|
363
|
+
heartbeatTimer = setInterval(() => {
|
|
364
|
+
sendWs({ op: OP_HEARTBEAT, d: lastSequence });
|
|
365
|
+
}, intervalMs);
|
|
366
|
+
}
|
|
367
|
+
async function connectWs(opts, gatewayUrl, isResume = false) {
|
|
368
|
+
if (stopping)
|
|
369
|
+
return;
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
let settled = false;
|
|
372
|
+
ws = new WebSocket(gatewayUrl);
|
|
373
|
+
// Resolve once when session is ready (READY/RESUMED dispatched)
|
|
374
|
+
const onSessionReady = () => {
|
|
375
|
+
reconnectAttempts = 0;
|
|
376
|
+
if (!settled) {
|
|
377
|
+
settled = true;
|
|
378
|
+
resolve();
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
ws.on('open', () => {
|
|
382
|
+
logger.info({ gatewayUrl: gatewayUrl.slice(0, 50) }, 'QQ WebSocket connected');
|
|
383
|
+
// Don't reset reconnectAttempts here — wait until READY/RESUMED
|
|
384
|
+
});
|
|
385
|
+
ws.on('message', async (data) => {
|
|
386
|
+
try {
|
|
387
|
+
const payload = JSON.parse(data.toString());
|
|
388
|
+
await handleWsMessage(payload, opts, gatewayUrl, onSessionReady);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
logger.error({ err }, 'Error parsing QQ WebSocket message');
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
ws.on('close', (code, reason) => {
|
|
395
|
+
logger.info({ code, reason: reason.toString() }, 'QQ WebSocket closed');
|
|
396
|
+
clearTimers();
|
|
397
|
+
if (!settled) {
|
|
398
|
+
settled = true;
|
|
399
|
+
reject(new Error(`QQ WebSocket closed before ready: ${code}`));
|
|
400
|
+
}
|
|
401
|
+
else if (!stopping) {
|
|
402
|
+
scheduleReconnect(opts);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
ws.on('error', (err) => {
|
|
406
|
+
logger.error({ err }, 'QQ WebSocket error');
|
|
407
|
+
if (!settled) {
|
|
408
|
+
settled = true;
|
|
409
|
+
reject(err);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async function handleWsMessage(payload, opts, gatewayUrl, onSessionReady) {
|
|
415
|
+
switch (payload.op) {
|
|
416
|
+
case OP_HELLO: {
|
|
417
|
+
const heartbeatInterval = payload.d?.heartbeat_interval || 41250;
|
|
418
|
+
startHeartbeat(heartbeatInterval);
|
|
419
|
+
const token = await getAccessToken();
|
|
420
|
+
if (sessionId) {
|
|
421
|
+
// Resume existing session (after reconnect)
|
|
422
|
+
sendWs({
|
|
423
|
+
op: OP_RESUME,
|
|
424
|
+
d: {
|
|
425
|
+
token: `QQBot ${token}`,
|
|
426
|
+
session_id: sessionId,
|
|
427
|
+
seq: lastSequence,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Fresh identify
|
|
433
|
+
sendWs({
|
|
434
|
+
op: OP_IDENTIFY,
|
|
435
|
+
d: {
|
|
436
|
+
token: `QQBot ${token}`,
|
|
437
|
+
intents: INTENTS,
|
|
438
|
+
shard: [0, 1],
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case OP_DISPATCH: {
|
|
445
|
+
if (payload.s !== undefined) {
|
|
446
|
+
lastSequence = payload.s;
|
|
447
|
+
}
|
|
448
|
+
const eventType = payload.t;
|
|
449
|
+
const eventData = payload.d;
|
|
450
|
+
if (eventType === 'READY') {
|
|
451
|
+
sessionId = eventData.session_id;
|
|
452
|
+
resumeGatewayUrl = gatewayUrl;
|
|
453
|
+
logger.info({ sessionId }, 'QQ bot session ready');
|
|
454
|
+
onSessionReady?.();
|
|
455
|
+
if (!readyFired) {
|
|
456
|
+
readyFired = true;
|
|
457
|
+
opts.onReady?.();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
else if (eventType === 'RESUMED') {
|
|
461
|
+
logger.info('QQ bot session resumed');
|
|
462
|
+
onSessionReady?.();
|
|
463
|
+
}
|
|
464
|
+
else if (eventType === 'C2C_MESSAGE_CREATE') {
|
|
465
|
+
await handleC2CMessage(eventData, opts);
|
|
466
|
+
}
|
|
467
|
+
else if (eventType === 'GROUP_AT_MESSAGE_CREATE') {
|
|
468
|
+
await handleGroupMessage(eventData, opts);
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case OP_HEARTBEAT_ACK:
|
|
473
|
+
// Heartbeat acknowledged, all good
|
|
474
|
+
break;
|
|
475
|
+
case OP_RECONNECT:
|
|
476
|
+
logger.info('QQ server requested reconnect');
|
|
477
|
+
ws?.close();
|
|
478
|
+
break;
|
|
479
|
+
case OP_INVALID_SESSION: {
|
|
480
|
+
const canResume = payload.d === true;
|
|
481
|
+
logger.warn({ canResume }, 'QQ invalid session');
|
|
482
|
+
if (!canResume) {
|
|
483
|
+
sessionId = null;
|
|
484
|
+
lastSequence = null;
|
|
485
|
+
}
|
|
486
|
+
ws?.close();
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
default:
|
|
490
|
+
logger.debug({ op: payload.op }, 'QQ unknown WebSocket opcode');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function scheduleReconnect(opts) {
|
|
494
|
+
if (stopping)
|
|
495
|
+
return;
|
|
496
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
497
|
+
logger.error('QQ max reconnect attempts reached, giving up');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const delay = Math.min(RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts), 60000);
|
|
501
|
+
reconnectAttempts++;
|
|
502
|
+
logger.info({ delay, attempt: reconnectAttempts }, 'QQ scheduling reconnect');
|
|
503
|
+
reconnectTimer = setTimeout(async () => {
|
|
504
|
+
reconnectTimer = null;
|
|
505
|
+
if (stopping)
|
|
506
|
+
return;
|
|
507
|
+
try {
|
|
508
|
+
if (sessionId && resumeGatewayUrl) {
|
|
509
|
+
// Try to resume
|
|
510
|
+
await connectWs(opts, resumeGatewayUrl, true);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// Fresh connection
|
|
514
|
+
const url = await getGatewayUrl();
|
|
515
|
+
await connectWs(opts, url, false);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
logger.error({ err }, 'QQ reconnect failed');
|
|
520
|
+
scheduleReconnect(opts);
|
|
521
|
+
}
|
|
522
|
+
}, delay);
|
|
523
|
+
}
|
|
524
|
+
// ─── Event Handlers ───────────────────────────────────────
|
|
525
|
+
async function handleC2CMessage(data, opts) {
|
|
526
|
+
try {
|
|
527
|
+
const msgId = data.id;
|
|
528
|
+
if (!msgId || isDuplicate(msgId))
|
|
529
|
+
return;
|
|
530
|
+
markSeen(msgId);
|
|
531
|
+
// Skip stale messages from before connection (hot-reload scenario)
|
|
532
|
+
if (opts.ignoreMessagesBefore && data.timestamp) {
|
|
533
|
+
const msgTime = new Date(data.timestamp).getTime();
|
|
534
|
+
if (!isNaN(msgTime) && msgTime < opts.ignoreMessagesBefore)
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const userOpenId = data.author?.id || data.author?.user_openid;
|
|
538
|
+
if (!userOpenId)
|
|
539
|
+
return;
|
|
540
|
+
const jid = `qq:c2c:${userOpenId}`;
|
|
541
|
+
const senderName = data.author?.username || `QQ用户`;
|
|
542
|
+
const chatName = senderName;
|
|
543
|
+
// Strip bot mention from content
|
|
544
|
+
let content = (data.content || '').trim();
|
|
545
|
+
// ── /pair <code> command ──
|
|
546
|
+
const pairMatch = content.match(/^\/pair\s+(\S+)/i);
|
|
547
|
+
if (pairMatch && opts.onPairAttempt) {
|
|
548
|
+
const code = pairMatch[1];
|
|
549
|
+
try {
|
|
550
|
+
const success = await opts.onPairAttempt(jid, chatName, code);
|
|
551
|
+
const reply = success
|
|
552
|
+
? '配对成功!此聊天已连接到你的账号。'
|
|
553
|
+
: '配对码无效或已过期,请在 Web 设置页重新生成。';
|
|
554
|
+
await sendQQMessage('c2c', userOpenId, reply);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
logger.error({ err, jid }, 'QQ pair attempt error');
|
|
558
|
+
await sendQQMessage('c2c', userOpenId, '配对失败,请稍后重试。');
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// ── Authorization check ──
|
|
563
|
+
if (!opts.isChatAuthorized(jid)) {
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
const lastReject = rejectTimestamps.get(jid) ?? 0;
|
|
566
|
+
if (now - lastReject >= REJECT_COOLDOWN_MS) {
|
|
567
|
+
rejectTimestamps.set(jid, now);
|
|
568
|
+
await sendQQMessage('c2c', userOpenId, '此聊天尚未配对。请发送 /pair <code> 进行配对。\n' +
|
|
569
|
+
'你可以在 Web 设置页生成配对码。');
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// ── Authorized: process message ──
|
|
574
|
+
storeChatMetadata(jid, new Date().toISOString());
|
|
575
|
+
updateChatName(jid, chatName);
|
|
576
|
+
opts.onNewChat(jid, chatName);
|
|
577
|
+
// Handle slash commands
|
|
578
|
+
const slashMatch = content.match(/^\/(\S+)(?:\s+(.*))?$/i);
|
|
579
|
+
if (slashMatch && opts.onCommand) {
|
|
580
|
+
const cmdBody = (slashMatch[1] + (slashMatch[2] ? ' ' + slashMatch[2] : '')).trim();
|
|
581
|
+
try {
|
|
582
|
+
const reply = await opts.onCommand(jid, cmdBody);
|
|
583
|
+
if (reply) {
|
|
584
|
+
await sendQQMessage('c2c', userOpenId, markdownToPlainText(reply));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
logger.error({ jid, err }, 'QQ slash command failed');
|
|
590
|
+
await sendQQMessage('c2c', userOpenId, '命令执行失败,请稍后重试');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// Handle attachments (images / files)
|
|
595
|
+
let attachmentsJson;
|
|
596
|
+
if (data.attachments?.length) {
|
|
597
|
+
const result = await processQQAttachment(data.attachments[0], msgId, jid, content, opts, 'c2c');
|
|
598
|
+
content = result.content;
|
|
599
|
+
attachmentsJson = result.attachmentsJson;
|
|
600
|
+
}
|
|
601
|
+
// Route and store message
|
|
602
|
+
const agentRouting = opts.resolveEffectiveChatJid?.(jid);
|
|
603
|
+
const targetJid = agentRouting?.effectiveJid ?? jid;
|
|
604
|
+
const id = crypto.randomUUID();
|
|
605
|
+
let timestamp;
|
|
606
|
+
try {
|
|
607
|
+
timestamp = data.timestamp
|
|
608
|
+
? new Date(data.timestamp).toISOString()
|
|
609
|
+
: new Date().toISOString();
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
timestamp = new Date().toISOString();
|
|
613
|
+
}
|
|
614
|
+
const senderId = `qq:${userOpenId}`;
|
|
615
|
+
storeChatMetadata(targetJid, timestamp);
|
|
616
|
+
storeMessageDirect(id, targetJid, senderId, senderName, content, timestamp, false, { attachments: attachmentsJson, sourceJid: jid });
|
|
617
|
+
broadcastNewMessage(targetJid, {
|
|
618
|
+
id,
|
|
619
|
+
chat_jid: targetJid,
|
|
620
|
+
source_jid: jid,
|
|
621
|
+
sender: senderId,
|
|
622
|
+
sender_name: senderName,
|
|
623
|
+
content,
|
|
624
|
+
timestamp,
|
|
625
|
+
attachments: attachmentsJson,
|
|
626
|
+
is_from_me: false,
|
|
627
|
+
}, agentRouting?.agentId ?? undefined);
|
|
628
|
+
notifyNewImMessage();
|
|
629
|
+
if (agentRouting?.agentId) {
|
|
630
|
+
opts.onAgentMessage?.(jid, agentRouting.agentId);
|
|
631
|
+
logger.info({ jid, effectiveJid: targetJid, agentId: agentRouting.agentId }, 'QQ C2C message routed to agent');
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
logger.info({ jid, sender: senderName, msgId }, 'QQ C2C message stored');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
logger.error({ err }, 'Error handling QQ C2C message');
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function handleGroupMessage(data, opts) {
|
|
642
|
+
try {
|
|
643
|
+
const msgId = data.id;
|
|
644
|
+
if (!msgId || isDuplicate(msgId))
|
|
645
|
+
return;
|
|
646
|
+
markSeen(msgId);
|
|
647
|
+
// Skip stale messages from before connection (hot-reload scenario)
|
|
648
|
+
if (opts.ignoreMessagesBefore && data.timestamp) {
|
|
649
|
+
const msgTime = new Date(data.timestamp).getTime();
|
|
650
|
+
if (!isNaN(msgTime) && msgTime < opts.ignoreMessagesBefore)
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const groupOpenId = data.group_openid;
|
|
654
|
+
if (!groupOpenId)
|
|
655
|
+
return;
|
|
656
|
+
const jid = `qq:group:${groupOpenId}`;
|
|
657
|
+
const memberOpenId = data.author?.member_openid;
|
|
658
|
+
const senderName = data.author?.username || `QQ群成员`;
|
|
659
|
+
const chatName = `QQ群 ${groupOpenId.slice(0, 8)}`;
|
|
660
|
+
// Strip bot mention text (e.g. <@!bot_id>)
|
|
661
|
+
let content = (data.content || '').replace(/<@!\w+>/g, '').trim();
|
|
662
|
+
// ── /pair <code> command ──
|
|
663
|
+
const pairMatch = content.match(/^\/pair\s+(\S+)/i);
|
|
664
|
+
if (pairMatch && opts.onPairAttempt) {
|
|
665
|
+
const code = pairMatch[1];
|
|
666
|
+
try {
|
|
667
|
+
const success = await opts.onPairAttempt(jid, chatName, code);
|
|
668
|
+
const reply = success
|
|
669
|
+
? '配对成功!此群聊已连接。'
|
|
670
|
+
: '配对码无效或已过期,请在 Web 设置页重新生成。';
|
|
671
|
+
await sendQQMessage('group', groupOpenId, reply);
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
logger.error({ err, jid }, 'QQ group pair attempt error');
|
|
675
|
+
await sendQQMessage('group', groupOpenId, '配对失败,请稍后重试。');
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// ── Authorization check ──
|
|
680
|
+
if (!opts.isChatAuthorized(jid)) {
|
|
681
|
+
const now = Date.now();
|
|
682
|
+
const lastReject = rejectTimestamps.get(jid) ?? 0;
|
|
683
|
+
if (now - lastReject >= REJECT_COOLDOWN_MS) {
|
|
684
|
+
rejectTimestamps.set(jid, now);
|
|
685
|
+
await sendQQMessage('group', groupOpenId, '此群聊尚未配对。请发送 /pair <code> 进行配对。');
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// ── Authorized: process message ──
|
|
690
|
+
storeChatMetadata(jid, new Date().toISOString());
|
|
691
|
+
updateChatName(jid, chatName);
|
|
692
|
+
opts.onNewChat(jid, chatName);
|
|
693
|
+
// Handle slash commands
|
|
694
|
+
const slashMatch = content.match(/^\/(\S+)(?:\s+(.*))?$/i);
|
|
695
|
+
if (slashMatch && opts.onCommand) {
|
|
696
|
+
const cmdBody = (slashMatch[1] + (slashMatch[2] ? ' ' + slashMatch[2] : '')).trim();
|
|
697
|
+
try {
|
|
698
|
+
const reply = await opts.onCommand(jid, cmdBody);
|
|
699
|
+
if (reply) {
|
|
700
|
+
await sendQQMessage('group', groupOpenId, markdownToPlainText(reply));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
logger.error({ jid, err }, 'QQ group slash command failed');
|
|
706
|
+
await sendQQMessage('group', groupOpenId, '命令执行失败,请稍后重试');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Handle attachments (images / files)
|
|
711
|
+
let attachmentsJson;
|
|
712
|
+
if (data.attachments?.length) {
|
|
713
|
+
const result = await processQQAttachment(data.attachments[0], msgId, jid, content, opts, 'group');
|
|
714
|
+
content = result.content;
|
|
715
|
+
attachmentsJson = result.attachmentsJson;
|
|
716
|
+
}
|
|
717
|
+
// Route and store
|
|
718
|
+
const agentRouting = opts.resolveEffectiveChatJid?.(jid);
|
|
719
|
+
const targetJid = agentRouting?.effectiveJid ?? jid;
|
|
720
|
+
const id = crypto.randomUUID();
|
|
721
|
+
let timestamp;
|
|
722
|
+
try {
|
|
723
|
+
timestamp = data.timestamp
|
|
724
|
+
? new Date(data.timestamp).toISOString()
|
|
725
|
+
: new Date().toISOString();
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
timestamp = new Date().toISOString();
|
|
729
|
+
}
|
|
730
|
+
const senderId = memberOpenId ? `qq:${memberOpenId}` : 'qq:unknown';
|
|
731
|
+
storeChatMetadata(targetJid, timestamp);
|
|
732
|
+
storeMessageDirect(id, targetJid, senderId, senderName, content, timestamp, false, { attachments: attachmentsJson, sourceJid: jid });
|
|
733
|
+
broadcastNewMessage(targetJid, {
|
|
734
|
+
id,
|
|
735
|
+
chat_jid: targetJid,
|
|
736
|
+
source_jid: jid,
|
|
737
|
+
sender: senderId,
|
|
738
|
+
sender_name: senderName,
|
|
739
|
+
content,
|
|
740
|
+
timestamp,
|
|
741
|
+
attachments: attachmentsJson,
|
|
742
|
+
is_from_me: false,
|
|
743
|
+
}, agentRouting?.agentId ?? undefined);
|
|
744
|
+
notifyNewImMessage();
|
|
745
|
+
if (agentRouting?.agentId) {
|
|
746
|
+
opts.onAgentMessage?.(jid, agentRouting.agentId);
|
|
747
|
+
}
|
|
748
|
+
logger.info({ jid, sender: senderName, msgId }, 'QQ group message stored');
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
logger.error({ err }, 'Error handling QQ group message');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// ─── Connection Interface ─────────────────────────────────
|
|
755
|
+
const connection = {
|
|
756
|
+
async connect(opts) {
|
|
757
|
+
if (!config.appId || !config.appSecret) {
|
|
758
|
+
logger.info('QQ appId/appSecret not configured, skipping');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
stopping = false;
|
|
762
|
+
readyFired = false;
|
|
763
|
+
reconnectAttempts = 0;
|
|
764
|
+
sessionId = null;
|
|
765
|
+
lastSequence = null;
|
|
766
|
+
try {
|
|
767
|
+
// Validate token first
|
|
768
|
+
await getAccessToken();
|
|
769
|
+
// Get gateway and connect WebSocket
|
|
770
|
+
const gatewayUrl = await getGatewayUrl();
|
|
771
|
+
await connectWs(opts, gatewayUrl, false);
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
logger.error({ err }, 'QQ initial connection failed');
|
|
775
|
+
scheduleReconnect(opts);
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
async disconnect() {
|
|
779
|
+
stopping = true;
|
|
780
|
+
clearTimers();
|
|
781
|
+
if (ws) {
|
|
782
|
+
try {
|
|
783
|
+
ws.close(1000, 'Disconnecting');
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
logger.debug({ err }, 'Error closing QQ WebSocket');
|
|
787
|
+
}
|
|
788
|
+
ws = null;
|
|
789
|
+
}
|
|
790
|
+
tokenInfo = null;
|
|
791
|
+
sessionId = null;
|
|
792
|
+
lastSequence = null;
|
|
793
|
+
resumeGatewayUrl = null;
|
|
794
|
+
msgCache.clear();
|
|
795
|
+
msgSeqCounters.clear();
|
|
796
|
+
rejectTimestamps.clear();
|
|
797
|
+
logger.info('QQ bot disconnected');
|
|
798
|
+
},
|
|
799
|
+
async sendMessage(chatId, text) {
|
|
800
|
+
const parsed = parseQQChatId(chatId);
|
|
801
|
+
if (!parsed) {
|
|
802
|
+
logger.error({ chatId }, 'Invalid QQ chat ID format');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
const plainText = markdownToPlainText(text);
|
|
807
|
+
const chunks = splitTextChunks(plainText, MSG_SPLIT_LIMIT);
|
|
808
|
+
for (const chunk of chunks) {
|
|
809
|
+
await sendQQMessage(parsed.type, parsed.openid, chunk);
|
|
810
|
+
}
|
|
811
|
+
logger.info({ chatId }, 'QQ message sent');
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
logger.error({ err, chatId }, 'Failed to send QQ message');
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
async sendChatAction(_chatId, _action) {
|
|
819
|
+
// QQ Bot API v2 does not support typing indicators
|
|
820
|
+
},
|
|
821
|
+
isConnected() {
|
|
822
|
+
return ws !== null && ws.readyState === WebSocket.OPEN;
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
return connection;
|
|
826
|
+
}
|