feat(channel): add QQ Bot (QQ机器人) channel adapter (#5202)

* feat(channel): add QQ Bot channel adapter

Add @qwen-code/channel-qqbot package implementing QQ Bot WebSocket
Gateway connection via the official QQ Bot API.

Supports:
- WebSocket Gateway (HELLO/IDENTIFY/HEARTBEAT/DISPATCH/RECONNECT)
- C2C single chat (C2C_MESSAGE_CREATE)
- Group @mention (GROUP_AT_MESSAGE_CREATE) — code path exists, unverified
- Streaming output via msg_id + msg_seq multi-block sending
- Auto-reconnect with exponential backoff
- Sandbox environment toggle

TODO (technical debt acknowledged):
- Group chat not verified end-to-end
- Single-file architecture (should split into gateway/send/auth modules
  like weixin channel)
- No tests (weixin has send.test.ts + media.test.ts)
- No typing indicator (onPromptStart/onPromptEnd not yet implemented)
- No channel instructions injection in connect()
- No structured error types

Closes #5201

* feat(qqbot): add QR login, group chat support with typed events

- Add QR code login via @tencent-connect/qqbot-connector with credential persistence
- Add Intent constants for C2C (1<<12) and GROUP_AT_MESSAGE (1<<25)
- Use QQGroupMessageEvent type in handleGroup instead of cast
- Remove resolved TODO comments for group chat verification
- Add msg_seq to send error log for debugging

* fix(qqbot): address PR review — lint errors, token refresh, security

- Use bracket notation for Record<string, unknown> to fix TS4111 lint errors
- Add chmodSync(credsFile, 0o600) for credential file permissions
- Implement token refresh at 80% TTL with expires_in tracking
- Fix RECONNECT opcode: use code 4000 + serverRequestedReconnect flag
- Fix connect() Promise: reject on close before READY via connectReject
- Log empty-token case in sendMessage, drain response body on error
- Clear chatTypeMap/replyMsgId/msgSeqMap in disconnect()
- Capture msgId at send-time to avoid race on replyMsgId
- Switch channel-registry.ts to Promise.allSettled (isolated channel failures)
- Add chatId validation (isValidChatId) to prevent SSRF

* fix(qqbot): add qqbot to build order, fix ESLint default-case

- Add packages/channels/qqbot to scripts/build.js buildOrder
  (CLI imports @qwen-code/channel-qqbot but it wasn't being built)
- Add default case to handleGatewayMessage switch

* feat(qqbot): prepend sender name in group messages for shared context

When sessionScope is set to 'thread', all group members share one
session. Prepending [senderName] helps the agent distinguish who
said what in the shared context.

* feat(qqbot): cross-server context continuation via SessionRouter persistence

- Persist SessionRouter mappings to disk via sessionsPath, surviving daemon restarts
- Persist QQ routing state (chatTypeMap, replyMsgId, msgSeqMap) to {name}-state.json
- Backup/restore global sessions.json on disconnect/connect to survive start.ts cleanup
- fixRestoredSessions() workaround for ACP LoadSessionResponse missing sessionId
- READY handler delays resolve() until restoreSessions() completes, preventing race

* feat(qqbot): add Session Resume + reconnect retry resilience

- Support WS session resume (RESUME opcode 6) on reconnect,
  falling back to full IDENTIFY when session is invalid
- Add reconnectWithRetry() loop: retries gateway fetch up to 5x
  with exponential backoff, then schedules 60s fallback retry
  (fixes silent death after GW HTTP 500)
- connect() now retries up to 3 times on initial failure
- Bump maxReconnectAttempts from 10 to 20
- Refresh token before each reconnect attempt

* fix(qqbot): address review feedback from wenshao

- fixRestoredSessions: use entry.target directly instead of tt.get(undefined)
  (fixes first restored session routing to wrong conversation when 2+ sessions)
- scheduleTokenRefresh: retry in 60s on token refresh failure, not just log
- sendMessage: move saveQQState() after chunk loop, avoid redundant disk I/O
- handleGroup: drop message when group_openid is missing instead of falling
  back to author.id (which would cause 404 on group message send)

* fix(qqbot): address 3rd review from doudouOUC (12 issues)

- QWEN_HOME: use getGlobalQwenDir() instead of homedir()
- name sanitization: prevent path traversal in file paths
- fetch timeouts: AbortSignal.timeout(15s) on all 3 fetch calls
- TOCTOU: writeFileSync with {mode: 0o600} instead of chmodSync after
- msg_seq gaps: only increment seq on send success, break on failure
- message dedup: seenMessages Map with 5min TTL cleanup timer
- disconnect: set disposed flag + flushQQState sync + clear timers
- heartbeat ACK: track lastHeartbeatAck, force close on 2x interval timeout
- reconnect exhaustion: FATAL log when max attempts reached post-connect
- debounced saveQQState: 500ms debounce, flush on disconnect
- handleGroup: skip [senderName] prefix for slash commands, log for audit
- disposed guard: connectGateway checks disposed before creating WS

* fix(qqbot): robustness round — RESUMED, token expiry, SSRF, disposed, typing stubs

- Handle RESUMED event on RESUME success (start heartbeat, restore sessions)
- Check token expiry before sendMessage, refresh if expired
- Tighten isValidChatId regex (remove . and /) to close path traversal
- Reset disposed flag in connect() for reusability
- Add onPromptStart/onPromptEnd stubs (QQ Bot has no typing API)
- Add robustness comments for splitText surrogate pairs, restoreQQState
  corruption, and senderId identity fragmentation across contexts

* refactor(qqbot): split into modules — api, accounts, login

Extract HTTP calls, credential I/O, and QR login into separate files
matching the weixin channel's architecture:

- api.ts: fetchAccessToken, fetchGatewayUrl, getApiBase, sendQQMessage
- accounts.ts: getCredsFilePath, loadCredentials, saveCredentials
- login.ts: qrCodeLogin (qrConnect wrapper)

QQChannel.ts drops inline fetch/credential/qrConnect logic and imports
from the new modules. Net -41 lines in the adapter.

* feat(qqbot): markdown message support (msg_type: 2)

Detect markdown syntax in AI responses and send as msg_type=2
with markdown.content field instead of plain-text msg_type=0.

Detection covers headers, code blocks, bold, italic, strikethrough,
inline code, links, and lists via a single regex.

* fix(qqbot): defensive patches from complete review

- reconnectWithRetry: guard against disposed channel to prevent infinite loop
- handleGroup: broaden @mention regex to match both legacy <@!id> and V2 <@openid>
- handleGroup: set isReplyToBot=true (every group msg is an @mention)
- fixRestoredSessions: document fragile private-field access
- saveCredentials: correct TOCTOU claim in comment
- hasMarkdownSyntax: document false-positive trade-off

* fix(qqbot): guard against empty content in C2C and group handlers

- handleC2C: return early when event.content is null/empty (image/sticker msgs)
- handleGroup: return early when cleanText is empty after @mention stripping

* fix(qqbot): close remaining review gaps — disposed guard, connectReject, token retry, RESUMED restore

* fix(qqbot): address wenshao review — RESUME restore removal, disposed guards, timer tracking, logging, heartbeat floor, requiredConfigFields, channel-registry error labels

* fix(qqbot): markdown fallback to plain text on rejection

* docs(qqbot): clarify markdown permission — Open Platform has no gate, FAQ is a different platform

* feat(qqbot): add Ark (msg_type=3) and Media (msg_type=7) message support

- types.ts: ArkKV, ArkPayload, FileType, MediaUploadRequest/Response, MediaPayload
- api.ts: uploadQQMedia() — file upload for rich media
- QQChannel.ts: sendArk(chatId, templateId, kv) + sendMedia(chatId, fileType, url, text?)
  - C2C/group upload paths separated (file_info not interchangeable)
  - file_type=4 (文件) blocked for groups per QQ API
  - Embed (msg_type=4) skipped — QQ频道专用, not available for Bot Open Platform

* feat(qqbot): auto-route !ark / !media commands from LLM text via sendMessage

LLM outputs text — the channel now parses structured commands inline:
  !ark(24, #TITLE#=标题, #META_DESC#=描述)
  !media(image, https://example.com/photo.jpg, caption text)

parseArkCommand / parseMediaCommand extract at sendMessage entry;
normal text/markdown flow unchanged.

* feat(qqbot): inject channel instructions for ark/media commands

Sets config.instructions on connect() so the LLM learns about:
  !ark(template_id, key=val, ...) — 3 default templates (23/24/37)
  !media(type, url, [caption])   — image/video/voice/file

Fixes known debt: 'No channel instructions'.

* feat(qqbot): gate ark/media behind config flags (enableArk/enableMedia)

Both features default to false — opt-in via settings.json:
  channels.my-qq.enableArk = true
  channels.my-qq.enableMedia = true

Instructions injected conditionally; command routing gated per-flag.

* refactor(qqbot): extract resolveRoute() to eliminate duplication across sendMessage/sendArk/sendMedia

disposed check, token refresh, chatId validation, sandbox path selection
now in one place. All three methods call resolveRoute() instead of
repeating the same 15-line preamble.

* chore(qqbot): remove Ark and Media message support

Remove !ark() / !media() text parsing, sendArk/sendMedia methods,
uploadQQMedia, and all related types. The text-parsing approach
was too fragile against LLM output formatting. Only text/markdown
messaging remains.

* fix(qqbot): robustness patches for review findings

- Add { mode: 0o600 } to all writeFileSync calls (state/session files)
- Guard against stale WebSocket close event nuking new connection
- Add isReconnecting guard to prevent parallel reconnectWithRetry chains
- Reset isReconnecting flag in READY, RESUMED, and exhaustion paths

* docs(channel): add QQ Bot user documentation

Add user-facing documentation for the QQ Bot channel adapter:

- New docs/users/features/channels/qqbot.md covering setup, configuration,
  QR code login, group chat, Markdown support, token management, connection
  resilience, and troubleshooting
- Update docs/users/features/channels/_meta.ts to include QQ Bot in nav
- Update docs/users/features/channels/overview.md to reference QQ Bot
  across the intro, quick start, type options, slash commands, and the
  media platform differences table

* docs(qqbot): fix prerequisites — QR login needs no developer account

QR code login via qrConnect() does not require a developer account or
manual app registration. First qwen channel start is all you need.

* docs(qqbot): emphasize QR login, keep developer portal as secondary path

Both paths work (config → persisted file → QR scan), confirmed against
fetchToken() code. Reposition QR code login as the primary setup flow,
remove redundant tips/troubleshooting entries.

* docs(qqbot): remove Images and Files section — not supported in channel code

handleC2C/handleGroup both skip messages with no text content.
No media download or upload logic exists in this channel adapter.

* test(qqbot): add unit tests for send utilities

Add vitest test suite for QQ Bot channel following the weixin channel
testing patterns. Extract isValidChatId, hasMarkdownSyntax, and splitText
as exported module-level functions to enable direct testing.

- 27 tests covering: chatId SSRF validation, Markdown syntax detection, and
  text chunking for QQ's 2000-char message limit
- Add vitest.config.ts and test script to qqbot package
- Register qqbot in root vitest workspace projects

Refs: #5202

* test(qqbot): add sendMessage flow tests with mocked API

Follow the weixin sendImage test pattern: mock sendQQMessage and
channel-base dependencies to test sendMessage end-to-end.

- C2C/group routing verification
- Markdown msg_type=2 vs plain text msg_type=0
- Markdown rejection fallback to plain text
- Disposed guard and error-stop behavior
- msg_id + msg_seq tracking for multi-chunk streaming

9 new tests, 36 total (all passing)

* test(qqbot): fix review issues — add missing edge cases

Self-review fixes:
- Fix misleading test name: 'returns early when chatId not in chatTypeMap'
  → 'defaults to C2C path for unknown chatId' (code doesn't return early)
- Add SSRF validation test: sendMessage rejects '../traversal' chatId
- Add network error test: thrown sendQQMessage caught by try/catch
- Add token expiration test: expired token + failed refresh → early return
- Hoist mockFetchAccessToken and set default resolved value in beforeEach
  to prevent silent undefined-access failures in accidental token-refresh paths

39 tests, all passing

* test(qqbot): add api and accounts unit tests

Add api.test.ts (13 tests) and accounts.test.ts (8 tests) following
weixin channel vitest patterns: vi.hoisted() mocks, vi.mock() module
replacement, and dynamic import() after mock setup.

api.test.ts covers getApiBase, sendQQMessage, fetchAccessToken, and
fetchGatewayUrl — including HTTP errors, missing fields, and request
body format.

accounts.test.ts covers getCredsFilePath, loadCredentials (missing file,
corrupt JSON, missing fields, valid data), and saveCredentials (dir
creation + 0o600 permissions).

All 60 tests pass (39 existing + 21 new). tsc --build and eslint clean.

* chore(qqbot): suppress CodeQL ReDoS false positives

Add codeql[js/polynomial-redos] suppression comments for two
regexes flagged by CodeQL:

- hasMarkdownSyntax(): input is LLM-generated reply text,
  never attacker-controlled in Qwen Code Channel context.
- handleGroup(): <@...> prefix is injected by QQ servers;
  openid is assigned by QQ, not attacker-chosen.

Both paths have no practical exploit vector — an adversary
would need to either control an LLM's output or register a
malicious openid with QQ, neither of which is achievable.

* fix(qqbot): allow QR-code-only login and guard qrConnect return

- requiredConfigFields: [] — fetchToken() already resolves credentials
  from config → persisted file → QR fallback chain. Blocking at config
  validation prevented QR-code-only users from starting the channel.
- qrCodeLogin(): add bounds check for empty qrConnect() return value.
  If the external library returns an empty array, throw descriptive
  error instead of crashing with TypeError on creds.appId.

* chore(qqbot): add comments for requiredConfigFields and qrConnect guard

- index.ts: explain why requiredConfigFields is empty — fetchToken()
  already resolves credentials via config → file → QR fallback chain.
  Requiring appID/appSecret at config level would block QR-only users
  from reaching the fallback through the built-in channel path.

- login.ts: clarify qrConnect() guard is a defensive robustness patch,
  not a response to an observed failure. Verified by removing appID
  from config and running qwen channel start — QR login triggers
  correctly and returns valid credentials.

* fix(qqbot): replace quadratic regexes with linear patterns, remove failed suppress comments

* fix(qqbot): split hasMarkdownSyntax into individual tests to pass CodeQL

* fix(qqbot): replace markdown link regex with indexOf to eliminate CodeQL ReDoS
This commit is contained in:
曹潇缤 2026-06-19 06:32:52 +08:00 committed by GitHub
parent 5a84ba016b
commit 8fcc43943d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2229 additions and 12 deletions

View file

@ -4,5 +4,6 @@ export default {
weixin: 'WeChat',
dingtalk: 'DingTalk',
feishu: 'Feishu',
qqbot: 'QQ Bot',
plugins: 'Plugins',
};

View file

@ -1,6 +1,6 @@
# Channels
Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, or DingTalk, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI.
Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, QQ, or DingTalk, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI.
## How It Works
@ -15,7 +15,7 @@ All channels share one agent process with isolated sessions per user. Each chann
## Quick Start
1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [DingTalk](./dingtalk))
1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [QQ Bot](./qqbot), [DingTalk](./dingtalk))
2. Add the channel configuration to `~/.qwen/settings.json`
3. Run `qwen channel start` to start all channels, or `qwen channel start <name>` for a single channel
@ -49,7 +49,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| Option | Required | Description |
| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) |
| `type` | Yes | Channel type: `telegram`, `weixin`, `qq`, `dingtalk`, `feishu`, or a custom type from an extension (see [Plugins](./plugins)) |
| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk |
| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax |
| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax |
@ -292,7 +292,7 @@ Channels support slash commands. These are handled locally (no agent round-trip)
All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the agent.
These commands work on all channel types (Telegram, WeChat, DingTalk).
These commands work on all channel types (Telegram, WeChat, QQ, DingTalk).
## Running

View file

@ -0,0 +1,179 @@
# QQ Bot (QQ机器人)
This guide covers setting up a Qwen Code channel on QQ via the official QQ Bot Open Platform API.
## Prerequisites
- A QQ account (mobile app for scanning the QR code)
## Setup
### QR Code Login
Start the channel — the first time it will show a QR code. Scan it with your QQ app to activate. No developer account or manual registration needed. Credentials are saved and reused automatically.
```json
{
"channels": {
"my-qq": {
"type": "qq"
}
}
}
```
```bash
qwen channel start my-qq
# Scan the QR code in the terminal with your QQ app
```
### Manual Configuration (Developer Portal)
You can also use credentials from the [QQ Bot Open Platform](https://q.qq.com/) developer portal if you already have an app registered there:
```json
{
"channels": {
"my-qq": {
"type": "qq",
"appID": "YOUR_APP_ID",
"appSecret": "$QQ_APP_SECRET"
}
}
}
```
Set the secret as an environment variable:
```bash
export QQ_APP_SECRET=<your-app-secret>
```
## Configuration
```json
{
"channels": {
"my-qq": {
"type": "qq",
"appID": "YOUR_APP_ID",
"appSecret": "$QQ_APP_SECRET",
"sandbox": false,
"senderPolicy": "open",
"sessionScope": "user",
"cwd": "/path/to/your/project",
"instructions": "你是一个通过 QQ Bot 对话的 AI 助手。回复控制在 2000 字符以内。",
"blockStreaming": "on",
"groupPolicy": "disabled",
"groups": {
"*": { "requireMention": true }
}
}
}
}
```
### QQ-Specific Options
| Option | Default | Description |
| ----------- | ------- | --------------------------------------------------------------------------------- |
| `appID` | — | QQ Bot AppID from developer portal. If omitted, QR code login is used. |
| `appSecret` | — | QQ Bot AppSecret. Supports `$ENV_VAR` syntax. If omitted, QR code login is used. |
| `sandbox` | `false` | Set to `true` to use the QQ sandbox API environment (`sandbox.api.sgroup.qq.com`) |
All standard channel options (see [Channel Overview](./overview#options)) are also supported:
`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `dispatchMode`, `blockStreaming`, `blockStreamingChunk`, `blockStreamingCoalesce`.
## Running
```bash
# Start only the QQ channel
qwen channel start my-qq
# Or start all configured channels together
qwen channel start
```
Open QQ and send a message to your bot. You should see the response arrive in your chat.
## Group Chats
To use the bot in QQ groups:
1. Set `groupPolicy` to `"allowlist"` or `"open"` in your channel config
2. Add the bot to a QQ group via the QQ Bot Open Platform dashboard or by having a group admin invite it
3. Group members must **@mention** the bot to trigger a response
QQ Bot API V2 only delivers group messages that @mention the bot — the bot does not see all group messages. By default, `requireMention` is `true` and should be left that way for QQ.
See [Group Chats](./overview#group-chats) for full details on group policies and mention gating.
## Markdown Support
The QQ Bot channel supports Markdown formatting (`msg_type=2`). The agent's Markdown responses are sent as-is, and QQ renders them with rich formatting (bold, italic, code blocks, links, lists).
If the QQ server rejects a Markdown message for any reason, the channel automatically retries it as plain text — so your messages always go through even if the bot's Markdown capability is restricted server-side.
This is the opposite of the WeChat channel, which strips all Markdown. You can let the agent use full Markdown with the QQ channel.
## Token Management
Access tokens expire after approximately 2 hours. The channel automatically refreshes them at 80% of their TTL (typically ~1.6 hours). If a refresh fails, it retries after 60 seconds.
Token refresh continues across WebSocket reconnects — the channel never goes offline due to an expired token as long as the AppID and AppSecret remain valid.
## Connection Resilience
- **Auto-reconnect:** On WebSocket disconnect, the channel retries with exponential backoff (up to 20 attempts, max 30 seconds between retries)
- **Session resume:** If the WebSocket drops briefly, the channel uses QQ's `RESUME` opcode to restore the session without losing in-flight messages
- **Cross-server context continuation:** Chat sessions and routing state are persisted to disk. If the daemon restarts, conversations continue from where they left off
- **Heartbeat monitoring:** HEARTBEAT_ACK timeouts are detected and force a reconnection to avoid zombie connections
- **Message deduplication:** Replayed messages after a reconnect are detected and skipped
## Tips
- **Use Markdown freely** — Unlike WeChat, QQ renders Markdown natively. Bold, code blocks, lists, and links all work.
- **Keep responses under 2000 characters** — Longer responses are automatically split into chunks. Adding a length hint to your instructions helps the agent stay concise.
- **Sandbox for testing** — Set `"sandbox": true` to use the sandbox API during development. No production messages will be affected.
- **Restrict access** — Use `senderPolicy: "allowlist"` for a fixed set of QQ users, or `"pairing"` to approve new users from the CLI. See [DM Pairing](./overview#dm-pairing) for details.
## Key Differences from Telegram
| Area | QQ Bot | Telegram |
| ---------------- | ------------------------------------------- | --------------------------------------------- |
| Authentication | QR code login or AppID/AppSecret | Static bot token from BotFather |
| Markdown | Native QQ Markdown with plaintext fallback | HTML-formatted from agent Markdown |
| Token lifecycle | 2h TTL, auto-refresh at 80% | Permanent bot token |
| Group messages | Only @mention messages are delivered to bot | Bot sees all messages (with privacy mode off) |
| Typing indicator | Not available (QQ API limitation) | "Working..." message |
| Sandbox mode | Supported for testing | Not available |
## Troubleshooting
### Bot doesn't respond
- Check the terminal output for errors
- Verify the channel is running (`qwen channel status`)
- If using `senderPolicy: "allowlist"`, make sure your QQ user ID is in `allowedUsers`
- On first start, a QR code will appear in the terminal — scan it with your QQ app
### Bot doesn't respond in groups
- Check that `groupPolicy` is set to `"allowlist"` or `"open"` (default is `"disabled"`)
- **You must @mention the bot** — QQ only delivers messages that tag the bot
- Verify the bot has been added to the group
### QR code login is stuck
- The QR code is displayed in the terminal. Scan it with your QQ mobile app (Me → Scan)
- If the QR code expires (typically after a few minutes), restart the channel to get a new one
### Markdown messages appear as plain text
- The QQ server may have rejected the Markdown message and the channel silently fell back to plain text. Check the terminal for `"Markdown rejected"` log messages
- This is unusual on the QQ Bot Open Platform but can happen if the bot's Markdown capability is restricted server-side
### Token expired after long downtime
- If the channel is offline for more than 2 hours, the access token will have expired. The channel fetches a fresh token on reconnect — no action needed
- If the AppSecret itself is invalid (e.g., rotated in the developer portal), update the `appSecret` field or delete `~/.qwen/channels/<name>-credentials.json` to re-trigger QR code login

38
package-lock.json generated
View file

@ -14,6 +14,7 @@
"packages/channels/weixin",
"packages/channels/dingtalk",
"packages/channels/feishu",
"packages/channels/qqbot",
"packages/channels/plugin-example",
"!packages/desktop"
],
@ -3401,6 +3402,10 @@
"resolved": "packages/channels/plugin-example",
"link": true
},
"node_modules/@qwen-code/channel-qqbot": {
"resolved": "packages/channels/qqbot",
"link": true
},
"node_modules/@qwen-code/channel-telegram": {
"resolved": "packages/channels/telegram",
"link": true
@ -4489,6 +4494,18 @@
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
}
},
"node_modules/@tencent-connect/qqbot-connector": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-connector/-/qqbot-connector-1.1.0.tgz",
"integrity": "sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==",
"license": "UNLICENSED",
"dependencies": {
"qrcode-terminal": "^0.12"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -17789,6 +17806,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
"bin": {
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@ -22445,6 +22470,18 @@
"@types/ws": "^8.5.0"
}
},
"packages/channels/qqbot": {
"name": "@qwen-code/channel-qqbot",
"version": "0.18.1",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"@tencent-connect/qqbot-connector": "^1.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
},
"packages/channels/telegram": {
"name": "@qwen-code/channel-telegram",
"version": "0.18.3",
@ -22480,6 +22517,7 @@
"@qwen-code/channel-base": "file:../channels/base",
"@qwen-code/channel-dingtalk": "file:../channels/dingtalk",
"@qwen-code/channel-feishu": "file:../channels/feishu",
"@qwen-code/channel-qqbot": "file:../channels/qqbot",
"@qwen-code/channel-telegram": "file:../channels/telegram",
"@qwen-code/channel-weixin": "file:../channels/weixin",
"@qwen-code/qwen-code-core": "file:../core",

View file

@ -12,6 +12,7 @@
"packages/channels/weixin",
"packages/channels/dingtalk",
"packages/channels/feishu",
"packages/channels/qqbot",
"packages/channels/plugin-example",
"!packages/desktop"
],

View file

@ -0,0 +1,29 @@
{
"name": "@qwen-code/channel-qqbot",
"version": "0.18.1",
"description": "QQ Bot (QQ机器人) channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --build",
"test": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"@tencent-connect/qqbot-connector": "^1.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,978 @@
/**
* QQ Bot channel adapter for Qwen Code.
*
* Connects QQ Bot via official QQ Bot WebSocket API.
* Extends ChannelBase for streaming, access control, and session routing.
* Supports QR code login, credential persistence, C2C and group chat.
*
* Cross-server context continuation: persists SessionRouter mappings and
* QQ-specific routing state (chatTypeMap, replyMsgId, msgSeqMap) to disk,
* restoring them on reconnect so conversations survive daemon restarts.
*
* @see https://bot.q.qq.com/wiki/develop/api-v2/
*/
import {
ChannelBase,
SessionRouter,
getGlobalQwenDir,
} from '@qwen-code/channel-base';
import type {
ChannelConfig,
ChannelBaseOptions,
AcpBridge,
} from '@qwen-code/channel-base';
import WebSocket from 'ws';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { OpCode, Intent } from './types.js';
import type {
QQChannelConfig,
QQMessageEvent,
QQGroupMessageEvent,
} from './types.js';
import {
getCredsFilePath,
loadCredentials,
saveCredentials,
} from './accounts.js';
import { qrCodeLogin } from './login.js';
import {
fetchAccessToken,
fetchGatewayUrl,
getApiBase,
sendQQMessage,
} from './api.js';
/** Validate chatId to prevent SSRF when constructing URLs. */
export function isValidChatId(id: string): boolean {
return /^[A-Za-z0-9_-]+$/.test(id) && id.length <= 128;
}
/**
* Detect whether text contains markdown syntax (for msg_type selection).
*
* The list-item patterns `^[-*+]\s` and `^\d+\.\s` trade precision for recall:
* text like "- temperature: 5°C" or "1. first thing" will trigger markdown
* mode. Sending non-markdown as msg_type=2 (markdown) is harmless QQ renders
* it as plain text so false positives are safe. False negatives (missing
* markdown in msg_type=0) would strip formatting, so we bias toward markdown.
*/
export function hasLinkSyntax(text: string): boolean {
const open = text.indexOf('[');
if (open === -1) return false;
const mid = text.indexOf('](', open + 1);
if (mid === -1) return false;
return text.indexOf(')', mid + 2) !== -1;
}
export function hasMarkdownSyntax(text: string): boolean {
return (
/^#{1,6}\s/m.test(text) ||
text.includes('```') ||
/\*\*|__|~~/.test(text) ||
/`[^`]+`/.test(text) ||
hasLinkSyntax(text) ||
/^[-*+]\s/m.test(text) ||
/^\d+\.\s/m.test(text)
);
}
/**
* Split long text into QQ-compatible chunks (max 2000 chars each).
*
* Uses UTF-16 code-unit length in the extremely rare case that the
* 2000-unit boundary falls in the middle of a surrogate pair (emoji),
* that character will be garbled. QQ chat messages rarely approach
* this limit at a boundary that aligns with a high-codepoint character.
*/
export function splitText(text: string): string[] {
const MAX = 2000;
if (text.length <= MAX) return [text];
const chunks: string[] = [];
for (let i = 0; i < text.length; i += MAX) {
chunks.push(text.slice(i, i + MAX));
}
return chunks;
}
export class QQChannel extends ChannelBase {
private ws: WebSocket | null = null;
private accessToken: string = '';
private tokenExpiresAt: number = 0;
private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: number = 45000;
private seq: number = 0;
private reconnectAttempts: number = 0;
private readonly maxReconnectAttempts: number = 20;
/** QQ Bot session_id from READY, used for RESUME on reconnect. */
private sessionId: string = '';
/** Whether this connection attempt should try RESUME first. */
private tryResume: boolean = false;
private readonly qqConfig: QQChannelConfig;
/** Set when server sends RECONNECT opcode — close handler uses this to force reconnect. */
private serverRequestedReconnect: boolean = false;
/** Pending connect promise reject — called when WebSocket closes before READY. */
private connectReject: ((err: Error) => void) | null = null;
/** Set to true when channel is disconnected — prevents orphaned connections. */
private disposed: boolean = false;
/** Deduplicate inbound messages on reconnect replay (messageId → timestamp). */
private seenMessages: Map<string, number> = new Map();
/** Cleanup timer for seenMessages TTL eviction. */
private seenCleanupTimer: ReturnType<typeof setInterval> | null = null;
/** Timestamp of last received HEARTBEAT_ACK, for zombie-connection detection. */
private lastHeartbeatAck: number = 0;
/** Debounce timer for saveQQState to avoid blocking event loop. */
private saveTimer: ReturnType<typeof setTimeout> | null = null;
/** Timer for reconnectWithRetry fallback (unref'd so it doesn't block exit). */
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Guard against parallel reconnectWithRetry chains from stale close events. */
private isReconnecting: boolean = false;
/** Track whether a chatId is a group or C2C for correct API routing. */
private chatTypeMap: Map<string, 'c2c' | 'group'> = new Map();
/** Track the latest user messageId per chatId for proper reply (msg_id). */
private replyMsgId: Map<string, string> = new Map();
/** msg_seq counter per user messageId, for multi-block streaming. */
private msgSeqMap: Map<string, number> = new Map();
/** Path to persisted QQ routing state: chatTypeMap, replyMsgId, msgSeqMap. */
private readonly qqStatePath: string;
/**
* Path to the global sessions.json managed by start.ts.
* start.ts deletes it on shutdown, so we back it up.
*/
private readonly globalSessionsPath: string;
/** Backup of sessions.json so conversations survive daemon restarts. */
private readonly sessionsBackupPath: string;
constructor(
name: string,
config: ChannelConfig & Record<string, unknown>,
bridge: AcpBridge,
options?: ChannelBaseOptions,
) {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, '_');
const stateDir = join(getGlobalQwenDir(), 'channels');
mkdirSync(stateDir, { recursive: true });
const sessionsPath = join(stateDir, `${safeName}-sessions.json`);
const router =
options?.router ??
new SessionRouter(bridge, config.cwd, config.sessionScope, sessionsPath);
super(name, config, bridge, { ...options, router });
this.qqConfig = config as unknown as QQChannelConfig;
this.qqStatePath = join(stateDir, `${safeName}-state.json`);
this.globalSessionsPath = join(stateDir, 'sessions.json');
this.sessionsBackupPath = join(
stateDir,
`${safeName}-sessions-backup.json`,
);
}
// ── ChannelBase interface ──────────────────────────────────────
async connect(): Promise<void> {
this.disposed = false;
if (!this.config.instructions) {
this.config.instructions = [
'## QQ Bot Channel',
'',
'你是通过 QQ Bot 与用户对话的 AI 助手。',
'回复控制在 2000 字符以内(超长会自动分块),支持 Markdown 格式。',
].join('\n');
}
for (let attempt = 0; attempt < 3; attempt++) {
try {
await this.fetchToken();
await this.connectGateway();
return;
} catch (e: unknown) {
if (attempt < 2) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${msg}, retrying...\n`,
);
await this.sleep(2000);
} else {
throw e;
}
}
}
}
async sendMessage(chatId: string, text: string): Promise<void> {
// ── Normal text / markdown flow ──────────────────────────
const route = await this.resolveRoute(chatId);
if (!route) return;
const msgId = this.replyMsgId.get(chatId);
const useMarkdown = hasMarkdownSyntax(text);
for (const chunk of splitText(text)) {
try {
const body: Record<string, unknown> = useMarkdown
? { msg_type: 2, markdown: { content: chunk } }
: { content: chunk, msg_type: 0 };
// Multi-block streaming: set msg_id + incrementing msg_seq
// seq incremented before send so we can track the next value
const nextSeq = msgId ? (this.msgSeqMap.get(msgId) ?? 0) + 1 : 0;
if (msgId) {
body['msg_id'] = msgId;
body['msg_seq'] = nextSeq;
}
let resp = await sendQQMessage(
route.base,
route.path,
this.accessToken,
body,
);
// Markdown is a fully available, zero-permission message type on the QQ
// Bot Open Platform — bot.q.qq.com API docs list msg_type=2 alongside
// text/ark/embed with no application gate. (q.qq.com/wiki/FAQ/robot
// mentions a markdown permission application, but that FAQ targets a
// different platform — likely older 群机器人 or mini-program bots —
// not the Open Platform API we use here.) We retry as plaintext as
// defense-in-depth against edge cases where a bot's markdown capability
// might be restricted server-side.
if (!resp.ok && useMarkdown) {
const errBody = await resp.text().catch(() => '');
process.stderr.write(
`[QQ:${this.name}] Markdown rejected (HTTP ${resp.status}: ${errBody.slice(0, 100)}), retrying as plain text\n`,
);
const plainBody: Record<string, unknown> = {
content: chunk,
msg_type: 0,
};
if (msgId) {
plainBody['msg_id'] = msgId;
plainBody['msg_seq'] = nextSeq;
}
resp = await sendQQMessage(
route.base,
route.path,
this.accessToken,
plainBody,
);
}
if (!resp.ok) {
// Drain response body to avoid socket leak
const errBody = await resp.text().catch(() => '');
process.stderr.write(
`[QQ:${this.name}] Send HTTP ${resp.status} (msg_seq=${body['msg_seq'] ?? '-'}): ${errBody.slice(0, 200)}\n`,
);
break; // stop sending on failure to avoid msg_seq gaps
}
// Only persist seq on success
if (msgId) this.msgSeqMap.set(msgId, nextSeq);
} catch (e) {
process.stderr.write(`[QQ:${this.name}] Send error: ${e}\n`);
break;
}
}
// Persist msgSeqMap once after all chunks are sent
if (msgId) this.saveQQState();
}
/**
* Resolve API routing: handles disposed check, token refresh, chatId validation,
* sandbox detection, and C2C/group path selection. Returns null if any guard fails.
*/
private async resolveRoute(
chatId: string,
): Promise<{ base: string; path: string } | null> {
if (this.disposed) return null;
if (Date.now() >= this.tokenExpiresAt) {
try {
await this.fetchToken();
} catch {
return null;
}
}
if (!this.accessToken || !isValidChatId(chatId)) return null;
const base = getApiBase(Boolean(this.qqConfig.sandbox));
const path =
this.chatTypeMap.get(chatId) === 'group'
? `/v2/groups/${chatId}/messages`
: `/v2/users/${chatId}/messages`;
return { base, path };
}
disconnect(): void {
this.disposed = true;
this.stopHeartbeat();
this.stopTokenRefresh();
if (this.seenCleanupTimer) {
clearInterval(this.seenCleanupTimer);
this.seenCleanupTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.flushQQState();
this.backupGlobalSessions();
if (this.ws) {
this.ws.close(1000);
this.ws = null;
}
if (this.connectReject) {
this.connectReject(new Error('Channel disconnected'));
this.connectReject = null;
}
this.chatTypeMap.clear();
this.replyMsgId.clear();
this.msgSeqMap.clear();
}
/**
* QQ Bot API V2 does not provide a typing indicator endpoint.
* ChannelBase calls these hooks to signal prompt start/end;
* they are intentionally no-ops for this channel.
*/
protected override onPromptStart(
_chatId: string,
_sessionId: string,
_messageId?: string,
): void {}
protected override onPromptEnd(
_chatId: string,
_sessionId: string,
_messageId?: string,
): void {}
// ── State Persistence (cross-server context continuation) ──────
/** Debounced state persistence to avoid blocking event loop. */
private saveQQState(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
try {
writeFileSync(
this.qqStatePath,
JSON.stringify({
chatTypeMap: Array.from(this.chatTypeMap.entries()),
replyMsgId: Array.from(this.replyMsgId.entries()),
msgSeqMap: Array.from(this.msgSeqMap.entries()),
}),
{ mode: 0o600 },
);
} catch {
/* best-effort */
}
}, 500);
}
/** Flush pending state writes immediately (called on disconnect). */
private flushQQState(): void {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
try {
writeFileSync(
this.qqStatePath,
JSON.stringify({
chatTypeMap: Array.from(this.chatTypeMap.entries()),
replyMsgId: Array.from(this.replyMsgId.entries()),
msgSeqMap: Array.from(this.msgSeqMap.entries()),
}),
);
} catch {
/* best-effort */
}
}
/**
* Restore QQ routing state from disk.
* Trusts persisted JSON if the file is corrupt, new Map() may create
* entries with undefined values, causing get()===undefined to fall through
* to default routing (C2C). This is acceptable for a rare edge case.
*/
private restoreQQState(): boolean {
try {
if (!existsSync(this.qqStatePath)) return false;
const raw = JSON.parse(readFileSync(this.qqStatePath, 'utf-8'));
if (raw.chatTypeMap) this.chatTypeMap = new Map(raw.chatTypeMap);
if (raw.replyMsgId) this.replyMsgId = new Map(raw.replyMsgId);
if (raw.msgSeqMap) this.msgSeqMap = new Map(raw.msgSeqMap);
return true;
} catch (e) {
process.stderr.write(
`[QQ:${this.name}] Failed to restore QQ state: ${e instanceof Error ? e.message : String(e)}\n`,
);
return false;
}
}
/**
* Backup the global sessions.json before start.ts deletes it on shutdown.
* Restored on next connect so conversations survive daemon restarts.
*/
private backupGlobalSessions(): void {
try {
if (existsSync(this.globalSessionsPath)) {
const data = readFileSync(this.globalSessionsPath, 'utf-8');
if (data.trim())
writeFileSync(this.sessionsBackupPath, data, { mode: 0o600 });
}
} catch {
/* best-effort */
}
}
private restoreGlobalSessions(): void {
try {
if (
!existsSync(this.globalSessionsPath) &&
existsSync(this.sessionsBackupPath)
) {
writeFileSync(
this.globalSessionsPath,
readFileSync(this.sessionsBackupPath, 'utf-8'),
{ mode: 0o600 },
);
}
} catch {
/* best-effort */
}
}
/**
* Workaround for SessionRouter.restoreSessions() storing undefined sessionIds
* when ACP bridge.loadSession() fails to return a session_id.
*
* **Fragile**: accesses SessionRouter's private `toSession`/`toTarget`/`toCwd`
* maps via type coercion. If SessionRouter internals change, this breaks
* silently. The only signal will be cross-server conversations failing to
* restore after daemon restart no crash, no log.
*
* If upstream SessionRouter adds a public fix for this, remove this method.
*/
private fixRestoredSessions(): void {
try {
if (!existsSync(this.globalSessionsPath)) return;
const raw = JSON.parse(readFileSync(this.globalSessionsPath, 'utf-8'));
const r = this.router as unknown as Record<string, unknown>;
const tm = r['toSession'] as Map<string, string> | undefined;
const tt = r['toTarget'] as Map<string, unknown> | undefined;
const tc = r['toCwd'] as Map<string, string> | undefined;
if (!tm || !tt) return;
for (const [key, sid] of tm) {
if (sid) continue;
const entry = raw[key] as
| { sessionId?: string; target?: unknown; cwd?: string }
| undefined;
if (!entry?.sessionId) continue;
const correctId: string = entry.sessionId;
// sid is undefined here — use entry.target directly instead of tt.get(undefined)
const target = entry.target;
tm.set(key, correctId);
tt.delete(undefined as unknown as string);
tt.set(correctId, target);
if (tc) {
tc.delete(undefined as unknown as string);
tc.set(correctId, entry.cwd || '');
}
}
} catch {
/* best-effort */
}
}
// ── Token ──────────────────────────────────────────────────────
private async fetchToken(): Promise<void> {
const safeName = this.name.replace(/[^A-Za-z0-9_-]/g, '_');
const credsFile = getCredsFilePath(safeName);
// Try load persisted credentials first, then fall back to config
let appID = this.qqConfig.appID;
let appSecret = this.qqConfig.appSecret;
if (!appID || !appSecret) {
const saved = loadCredentials(credsFile);
if (saved) {
appID = saved.appId;
appSecret = saved.appSecret;
this.qqConfig.appID = appID;
this.qqConfig.appSecret = appSecret;
}
}
// If still no credentials, launch QR code login
if (!appID || !appSecret) {
process.stderr.write(
`[QQ:${this.name}] No credentials, scan QR code with QQ...\n`,
);
const creds = await qrCodeLogin();
appID = creds.appId;
appSecret = creds.appSecret;
this.qqConfig.appID = appID;
this.qqConfig.appSecret = appSecret;
saveCredentials(credsFile, appID, appSecret);
}
const token = await fetchAccessToken(appID, appSecret);
this.accessToken = token.accessToken;
this.tokenExpiresAt = Date.now() + token.expiresIn * 1000;
this.scheduleTokenRefresh();
}
private scheduleTokenRefresh(): void {
if (this.disposed) return;
this.stopTokenRefresh();
const ttl = Math.max(0, this.tokenExpiresAt - Date.now());
// Refresh at 80% of TTL, minimum 60s before expiry
const delay = Math.max(Math.min(ttl * 0.8, ttl - 60_000), 60_000);
if (delay > 0) {
this.tokenRefreshTimer = setTimeout(() => {
this.fetchToken().catch((e) => {
if (this.disposed) return;
process.stderr.write(
`[QQ:${this.name}] Token refresh failed: ${e}, retrying in 60s\n`,
);
// Retry fetchToken directly instead of going through
// scheduleTokenRefresh (which would add another ~60s of
// delay from the stale tokenExpiresAt).
this.tokenRefreshTimer = setTimeout(() => {
if (this.disposed) return;
this.fetchToken().catch(() => {
process.stderr.write(
`[QQ:${this.name}] Token refresh failed again after retry\n`,
);
});
}, 60_000);
});
}, delay);
}
}
private stopTokenRefresh(): void {
if (this.tokenRefreshTimer) {
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
}
}
// ── WebSocket Gateway ──────────────────────────────────────────
private async connectGateway(): Promise<void> {
if (this.disposed) throw new Error('Channel disposed');
const url = await fetchGatewayUrl(
this.accessToken,
Boolean(this.qqConfig.sandbox),
);
return new Promise<void>((resolve, reject) => {
this.connectReject = reject;
this.dialGateway(url, resolve, reject);
});
}
private dialGateway(
url: string,
resolve: () => void,
reject: (err: Error) => void,
): void {
this.ws = new WebSocket(url);
const dialed = this.ws; // capture for stale-close guard
this.ws.on('open', () => {
process.stderr.write(`[QQ:${this.name}] WebSocket connected\n`);
});
this.ws.on('message', (data: Buffer) => {
try {
const msg = JSON.parse(data.toString());
this.handleGatewayMessage(msg, resolve);
} catch (e) {
process.stderr.write(
`[QQ:${this.name}] Malformed gateway message: ${e instanceof Error ? e.message : String(e)}\n`,
);
}
});
this.ws.on('close', (code: number) => {
// Stale-close guard: if a new dialGateway() call has since
// replaced this.ws, this close event belongs to a dead socket
// and must not nuke the live connection.
if (this.ws !== dialed) return;
process.stderr.write(
`[QQ:${this.name}] WebSocket closed (code=${code})\n`,
);
this.stopHeartbeat();
this.ws = null;
const shouldReconnect =
this.serverRequestedReconnect ||
(code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts);
this.serverRequestedReconnect = false;
if (shouldReconnect && this.connectReject) {
// Pre-READY close: reject so the caller's retry loop retries.
// connectReject is null after READY; when it's still set,
// we're waiting for the first READY and must not internal-reconnect
// (which would create a competing WebSocket and leak the Promise).
this.connectReject(
new Error(`WebSocket closed before READY (code=${code})`),
);
this.connectReject = null;
} else if (shouldReconnect) {
this.reconnectAttempts++;
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
process.stderr.write(
`[QQ:${this.name}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})\n`,
);
if (!this.isReconnecting) {
setTimeout(() => this.reconnectWithRetry(), delay);
}
} else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
process.stderr.write(
`[QQ:${this.name}] FATAL: reconnect exhausted after ${this.maxReconnectAttempts} attempts. Bot is offline until daemon restart.\n`,
);
// Reject pending connect promise if we're not reconnecting
if (this.connectReject) {
this.connectReject(
new Error(
`WebSocket closed (max reconnect attempts, code=${code})`,
),
);
this.connectReject = null;
}
} else {
// Reject pending connect promise if we're not reconnecting
if (this.connectReject) {
this.connectReject(
new Error(`WebSocket closed before READY (code=${code})`),
);
this.connectReject = null;
}
}
});
this.ws.on('error', (e: Error) => {
process.stderr.write(`[QQ:${this.name}] WebSocket error: ${e.message}\n`);
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(e);
}
});
}
private handleGatewayMessage(
msg: Record<string, unknown>,
onReady: () => void,
): void {
const op = msg['op'] as number;
switch (op) {
case OpCode.HELLO: {
this.heartbeatInterval = Math.max(
((msg['d'] as Record<string, unknown> | undefined)?.[
'heartbeat_interval'
] as number) || 45000,
5000,
);
this.sendIdentify();
break;
}
case OpCode.DISPATCH: {
const t = msg['t'] as string;
const s = msg['s'] as number | undefined;
if (s !== undefined) this.seq = s;
if (t === 'READY') {
this.reconnectAttempts = 0;
this.isReconnecting = false;
this.sessionId =
((msg['d'] as Record<string, unknown> | undefined)?.[
'session_id'
] as string) || '';
this.tryResume = true;
this.connectReject = null;
this.startHeartbeat();
this.restoreGlobalSessions();
this.restoreQQState();
this.router
.restoreSessions()
.then(() => {
this.fixRestoredSessions();
const all = (
this.router as unknown as {
getAll?: () => Array<{
target?: { chatId?: string };
sessionId?: string;
}>;
}
).getAll?.();
const sessions =
all
?.map((e) => `${e.target?.chatId}:${e.sessionId}`)
.join(', ') || 'none';
process.stderr.write(
`[QQ:${this.name}] Ready (sessions: ${sessions})\n`,
);
onReady();
})
.catch(() => onReady());
} else if (t === 'C2C_MESSAGE_CREATE') {
this.handleC2C(msg['d'] as unknown as QQMessageEvent);
} else if (t === 'GROUP_AT_MESSAGE_CREATE') {
this.handleGroup(msg['d'] as unknown as QQGroupMessageEvent);
} else if (t === 'RESUMED') {
// RESUME success — the process did NOT restart, all in-memory
// session state, QQ routing state, and global sessions.json are
// still intact. Calling restoreSessions() would drop and re-attach
// every session, aborting in-flight LLM prompts.
this.reconnectAttempts = 0;
this.isReconnecting = false;
this.connectReject = null;
this.startHeartbeat();
onReady();
}
break;
}
case OpCode.HEARTBEAT_ACK:
this.lastHeartbeatAck = Date.now();
break;
case OpCode.RECONNECT:
this.serverRequestedReconnect = true;
this.ws?.close(4000);
break;
case OpCode.INVALID_SESSION:
process.stderr.write(
`[QQ:${this.name}] Server sent INVALID_SESSION, falling back to IDENTIFY\n`,
);
this.tryResume = false;
this.sendIdentify();
break;
default:
break;
}
}
private sendIdentify(): void {
if (!this.ws) return;
if (this.tryResume && this.sessionId) {
process.stderr.write(
`[QQ:${this.name}] Sending RESUME (session: ${this.sessionId})\n`,
);
this.ws.send(
JSON.stringify({
op: OpCode.RESUME,
d: {
token: `QQBot ${this.accessToken}`,
session_id: this.sessionId,
seq: this.seq,
},
}),
);
return;
}
this.ws.send(
JSON.stringify({
op: OpCode.IDENTIFY,
d: {
token: `QQBot ${this.accessToken}`,
intents: Intent.C2C_MESSAGE | Intent.GROUP_AT_MESSAGE,
shard: [0, 1],
properties: {},
},
}),
);
}
/**
* Reconnect loop with retry on gateway fetch failures.
* Refreshes token before each attempt, and retries GW HTTP failures
* with exponential backoff. Keeps retrying until success.
*/
private async reconnectWithRetry(): Promise<void> {
// Guard: if the channel was disposed (daemon shutdown) while a reconnect
// timeout was pending, bail out immediately to avoid an infinite loop.
if (this.disposed) return;
// Guard: prevent parallel reconnection chains when multiple close events
// fire in rapid succession, each scheduling reconnectWithRetry.
if (this.isReconnecting) return;
this.isReconnecting = true;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
process.stderr.write(
`[QQ:${this.name}] RC: reconnect attempts exhausted, giving up\n`,
);
this.isReconnecting = false;
return;
}
const maxGwRetries = 5;
for (let attempt = 0; attempt < maxGwRetries; attempt++) {
try {
// Refresh token before reconnect attempt
try {
await this.fetchToken();
} catch {
process.stderr.write(
`[QQ:${this.name}] RC: token refresh failed, retrying...\n`,
);
await this.sleep(2000);
continue;
}
await this.connectGateway();
return; // success
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
const backoff = Math.min(1000 * 2 ** (attempt + 1), 30000);
process.stderr.write(
`[QQ:${this.name}] RC: ${msg} (retry in ${backoff}ms, attempt ${attempt + 1}/${maxGwRetries})\n`,
);
if (attempt < maxGwRetries - 1) await this.sleep(backoff);
}
}
process.stderr.write(
`[QQ:${this.name}] RC: exhausted ${maxGwRetries} gateway retries, will retry in 60s\n`,
);
this.tryResume = false; // fall back to full IDENTIFY next time
this.isReconnecting = false; // release guard for future retries
// Schedule another attempt with longer delay
this.reconnectTimer = setTimeout(() => this.reconnectWithRetry(), 60000);
this.reconnectTimer.unref();
}
private sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.lastHeartbeatAck = Date.now();
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState !== WebSocket.OPEN) return;
// Check if previous heartbeat was acknowledged
const elapsed = Date.now() - this.lastHeartbeatAck;
if (elapsed > this.heartbeatInterval * 2) {
process.stderr.write(
`[QQ:${this.name}] Heartbeat ACK timeout (${elapsed}ms), forcing reconnect\n`,
);
this.ws?.close(4001);
return;
}
this.ws.send(JSON.stringify({ op: OpCode.HEARTBEAT, d: this.seq }));
}, this.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// ── Message Handlers ───────────────────────────────────────────
/** Check if a message ID was already processed (reconnect replay dedup). */
private isDuplicate(eventId: string): boolean {
if (this.seenMessages.has(eventId)) return true;
const now = Date.now();
this.seenMessages.set(eventId, now);
// Evict entries older than 5 minutes
if (!this.seenCleanupTimer) {
this.seenCleanupTimer = setInterval(() => {
const cutoff = Date.now() - 300_000;
for (const [id, ts] of this.seenMessages) {
if (ts < cutoff) this.seenMessages.delete(id);
}
if (this.seenMessages.size === 0) {
clearInterval(this.seenCleanupTimer!);
this.seenCleanupTimer = null;
}
}, 60_000);
}
return false;
}
private handleC2C(event: QQMessageEvent): void {
if (this.isDuplicate(event.id)) return;
// Ignore messages with no text content (images, stickers, etc.)
if (!event.content?.trim()) return;
// user_openid and author.id are scoped differently — falling back to
// author.id may produce a different identity for the same user across
// C2C and group contexts, creating two separate sessions. QQ Bot does
// not expose a unified user identity, so this is unavoidable.
const chatId = event.author.user_openid || event.author.id;
this.chatTypeMap.set(chatId, 'c2c');
this.replyMsgId.set(chatId, event.id);
this.saveQQState();
this.handleInbound({
channelName: this.name,
senderId: chatId,
senderName: event.author.username || event.author.id || 'QQ User',
chatId,
text: event.content,
messageId: event.id,
isGroup: false,
isMentioned: true,
isReplyToBot: false,
}).catch((e) =>
process.stderr.write(`[QQ:${this.name}] C2C handler error: ${e}\n`),
);
}
private handleGroup(event: QQGroupMessageEvent): void {
if (this.isDuplicate(event.id)) return;
if (!event.group_openid) {
process.stderr.write(
`[QQ:${this.name}] Group message dropped: missing group_openid\n`,
);
return;
}
const chatId = event.group_openid;
this.chatTypeMap.set(chatId, 'group');
this.replyMsgId.set(chatId, event.id);
this.saveQQState();
const senderName = event.author.username || event.author.id || 'QQ User';
// Strip @mention tags from message content. QQ Bot API docs state the API
// cleans these, but the format varies across API versions:
// - Legacy: <@!12345> (numeric user ID with bang)
// - V2: <@D5B53C...> (hex openid, no bang)
// Use a broad pattern to handle both. Bound to 64 chars — QQ openids
// and user IDs are short; this prevents quadratic backtracking on <@<@... chains.
const cleanText = (event.content || '')
.replace(/<@[^>]{1,64}>/g, '')
.trim();
// Ignore messages that have no meaningful text after @mention stripping
// (pure @mention, image, or sticker messages).
if (!cleanText) return;
const isSlash = cleanText.startsWith('/');
// Log slash commands with senderName for audit trail
if (isSlash) {
process.stderr.write(
`[QQ:${this.name}] Slash cmd from ${senderName} (${chatId}): ${cleanText}\n`,
);
}
// Don't prefix slash commands, keep [senderName] for normal messages
const text = isSlash ? cleanText : `[${senderName}]: ${cleanText}`;
this.handleInbound({
channelName: this.name,
senderId: event.author.user_openid || event.author.id,
senderName,
chatId,
text,
messageId: event.id,
isGroup: true,
isMentioned: true,
// QQ Bot only receives group messages when explicitly @mentioned, so
// every group message is semantically a reply to the bot.
isReplyToBot: true,
}).catch((e) =>
process.stderr.write(`[QQ:${this.name}] Group handler error: ${e}\n`),
);
}
}

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi } from 'vitest';
const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } =
vi.hoisted(() => ({
mockReadFileSync: vi.fn(),
mockWriteFileSync: vi.fn(),
mockExistsSync: vi.fn(),
mockMkdirSync: vi.fn(),
}));
vi.mock('node:fs', () => ({
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
existsSync: mockExistsSync,
mkdirSync: mockMkdirSync,
}));
vi.mock('@qwen-code/channel-base', () => ({
getGlobalQwenDir: () => '/tmp/test-qwen',
}));
const { getCredsFilePath, loadCredentials, saveCredentials } = await import(
'./accounts.js'
);
describe('getCredsFilePath', () => {
it('returns path under channels dir with credentials suffix', () => {
expect(getCredsFilePath('mybot')).toBe(
'/tmp/test-qwen/channels/mybot-credentials.json',
);
});
it('includes the safeName in the filename', () => {
const path = getCredsFilePath('test_123');
expect(path).toContain('test_123-credentials.json');
});
});
describe('loadCredentials', () => {
it('returns null when file does not exist', () => {
mockExistsSync.mockReturnValue(false);
expect(loadCredentials('/path/to/creds.json')).toBeNull();
});
it('returns null when file is missing appId', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({ appSecret: 'secret-only' }),
);
expect(loadCredentials('/path/to/creds.json')).toBeNull();
});
it('returns null when file is missing appSecret', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify({ appId: 'app-only' }));
expect(loadCredentials('/path/to/creds.json')).toBeNull();
});
it('returns null on parse error (corrupt file)', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('not-valid-json');
expect(loadCredentials('/path/to/creds.json')).toBeNull();
});
it('returns credentials when both fields are present', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({ appId: 'my-app', appSecret: 'my-secret' }),
);
expect(loadCredentials('/path/to/creds.json')).toEqual({
appId: 'my-app',
appSecret: 'my-secret',
});
});
});
describe('saveCredentials', () => {
it('creates dir and writes file with 0o600 permissions', () => {
saveCredentials('/path/to/creds.json', 'app-id', 'app-secret');
expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/test-qwen/channels', {
recursive: true,
});
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/path/to/creds.json',
JSON.stringify({ appId: 'app-id', appSecret: 'app-secret' }),
{ mode: 0o600 },
);
});
});

View file

@ -0,0 +1,53 @@
/**
* QQ Bot credential persistence.
*
* Reads and writes appId/appSecret to a JSON file under
* `{qwenDir}/channels/{name}-credentials.json` with restrictive permissions.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { getGlobalQwenDir } from '@qwen-code/channel-base';
/** Build the credential file path for a given safe channel name. */
export function getCredsFilePath(safeName: string): string {
return join(getGlobalQwenDir(), 'channels', `${safeName}-credentials.json`);
}
/** Try to load persisted credentials. Returns null if file missing or corrupt. */
export function loadCredentials(
credsFile: string,
): { appId: string; appSecret: string } | null {
if (!existsSync(credsFile)) return null;
try {
const saved = JSON.parse(readFileSync(credsFile, 'utf-8'));
if (saved.appId && saved.appSecret) {
return { appId: saved.appId, appSecret: saved.appSecret };
}
return null;
} catch {
return null;
}
}
/**
* Persist credentials to disk.
*
* NOTE: writeFileSync with `mode: 0o600` is not atomic the file is created
* with default permissions (0o644) and then chmod'd. There is a sub-millisecond
* TOCTOU window where another local process could read the credentials.
* Exploiting this requires local shell access and precise timing; for a
* single-user dev machine, the risk is negligible. Using openSync(fd, 'w', 0o600)
* would close the window but adds complexity for no practical gain.
*/
export function saveCredentials(
credsFile: string,
appId: string,
appSecret: string,
): void {
const dir = join(getGlobalQwenDir(), 'channels');
mkdirSync(dir, { recursive: true });
writeFileSync(credsFile, JSON.stringify({ appId, appSecret }), {
mode: 0o600,
});
}

View file

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn<typeof fetch>(),
}));
vi.stubGlobal('fetch', mockFetch);
// Mock AbortSignal.timeout — our mock fetch doesn't actually use the signal,
// so returning a simple object is sufficient.
vi.stubGlobal(
'AbortSignal',
class {
static timeout(_ms: number) {
return { aborted: false } as AbortSignal;
}
},
);
const { fetchAccessToken, fetchGatewayUrl, getApiBase, sendQQMessage } =
await import('./api.js');
function mockResponse(ok: boolean, status: number, body: unknown): Response {
return {
ok,
status,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
json: async () => (typeof body === 'string' ? JSON.parse(body) : body),
} as Response;
}
describe('getApiBase', () => {
it('returns production host when sandbox is false', () => {
expect(getApiBase(false)).toBe('https://api.sgroup.qq.com');
});
it('returns sandbox host when sandbox is true', () => {
expect(getApiBase(true)).toBe('https://sandbox.api.sgroup.qq.com');
});
});
describe('sendQQMessage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue(mockResponse(true, 200, ''));
});
it('sends POST with JSON body and correct headers', async () => {
await sendQQMessage(
'https://api.example.com',
'/v2/users/abc/messages',
'token-123',
{ content: 'hello', msg_type: 0 },
);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.example.com/v2/users/abc/messages');
expect(init?.method).toBe('POST');
expect((init?.headers as Record<string, string>)?.['Content-Type']).toBe(
'application/json',
);
expect((init?.headers as Record<string, string>)?.['Authorization']).toBe(
'QQBot token-123',
);
expect(init?.body).toBe(JSON.stringify({ content: 'hello', msg_type: 0 }));
});
it('returns the fetch Response directly', async () => {
const fake = mockResponse(true, 200, '');
mockFetch.mockResolvedValue(fake);
const resp = await sendQQMessage(
'https://api.example.com',
'/v2/groups/g123/messages',
'tok',
{ content: 'hi', msg_type: 0 },
);
expect(resp).toBe(fake);
});
});
describe('fetchAccessToken', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns access token on success', async () => {
mockFetch.mockResolvedValue(
mockResponse(true, 200, {
access_token: 'tok-abc',
expires_in: 7200,
}),
);
const result = await fetchAccessToken('app-id', 'secret');
expect(result).toEqual({ accessToken: 'tok-abc', expiresIn: 7200 });
});
it('defaults expiresIn to 7200 when missing', async () => {
mockFetch.mockResolvedValue(
mockResponse(true, 200, { access_token: 'tok-no-exp' }),
);
const result = await fetchAccessToken('app-id', 'secret');
expect(result).toEqual({ accessToken: 'tok-no-exp', expiresIn: 7200 });
});
it('throws on HTTP error', async () => {
mockFetch.mockResolvedValue(mockResponse(false, 401, 'unauthorized'));
await expect(fetchAccessToken('bad', 'bad')).rejects.toThrow(
'QQ Bot token request failed (HTTP 401)',
);
});
it('throws when response is missing access_token', async () => {
mockFetch.mockResolvedValue(mockResponse(true, 200, { other: 'data' }));
await expect(fetchAccessToken('app-id', 'secret')).rejects.toThrow(
'QQ Bot token response missing access_token',
);
});
it('sends appId and clientSecret in JSON body', async () => {
mockFetch.mockResolvedValue(
mockResponse(true, 200, {
access_token: 'tok',
expires_in: 3600,
}),
);
await fetchAccessToken('my-app', 'my-secret');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://bots.qq.com/app/getAppAccessToken');
expect(init?.method).toBe('POST');
const body = JSON.parse(init?.body as string);
expect(body).toEqual({ appId: 'my-app', clientSecret: 'my-secret' });
});
});
describe('fetchGatewayUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns WebSocket URL from production gateway', async () => {
mockFetch.mockResolvedValue(
mockResponse(true, 200, { url: 'wss://gateway.qq.com/ws' }),
);
const url = await fetchGatewayUrl('tok', false);
expect(url).toBe('wss://gateway.qq.com/ws');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.sgroup.qq.com/gateway',
expect.objectContaining({
headers: { Authorization: 'QQBot tok' },
}),
);
});
it('uses sandbox gateway when sandbox is true', async () => {
mockFetch.mockResolvedValue(
mockResponse(true, 200, { url: 'wss://sandbox.gateway.qq.com/ws' }),
);
await fetchGatewayUrl('tok', true);
expect(mockFetch).toHaveBeenCalledWith(
'https://sandbox.api.sgroup.qq.com/gateway',
expect.anything(),
);
});
it('throws on HTTP error', async () => {
mockFetch.mockResolvedValue(mockResponse(false, 500, 'server error'));
await expect(fetchGatewayUrl('tok', false)).rejects.toThrow(
'QQ Bot gateway request failed (HTTP 500)',
);
});
it('throws when response is missing url field', async () => {
mockFetch.mockResolvedValue(mockResponse(true, 200, {}));
await expect(fetchGatewayUrl('tok', false)).rejects.toThrow(
'QQ Bot gateway response missing WebSocket URL',
);
});
});

View file

@ -0,0 +1,107 @@
/**
* QQ Bot HTTP API client.
*
* Encapsulates all REST calls to the QQ Bot API:
* - Access token issuance
* - WebSocket Gateway URL resolution
* - Message sending (text / markdown)
*/
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken';
const API_HOST = 'https://api.sgroup.qq.com';
const SANDBOX_HOST = 'https://sandbox.api.sgroup.qq.com';
/** Standard fetch timeout to avoid hanging on network failures. */
const FETCH_TIMEOUT = 15_000;
export interface TokenResponse {
accessToken: string;
expiresIn: number;
}
/**
* Obtain an access token via appId + clientSecret.
* Throws on HTTP errors or missing token in the response.
*/
export async function fetchAccessToken(
appId: string,
appSecret: string,
): Promise<TokenResponse> {
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId, clientSecret: appSecret }),
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(
`QQ Bot token request failed (HTTP ${resp.status}): ${body}`,
);
}
const data = (await resp.json()) as {
access_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new Error('QQ Bot token response missing access_token');
}
return {
accessToken: data.access_token,
expiresIn: data.expires_in ?? 7200,
};
}
/**
* Resolve the WebSocket Gateway URL.
* Throws on HTTP errors or missing URL in the response.
*/
export async function fetchGatewayUrl(
accessToken: string,
sandbox: boolean,
): Promise<string> {
const gw = sandbox ? `${SANDBOX_HOST}/gateway` : `${API_HOST}/gateway`;
const resp = await fetch(gw, {
headers: { Authorization: `QQBot ${accessToken}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (!resp.ok) {
throw new Error(`QQ Bot gateway request failed (HTTP ${resp.status})`);
}
const data = (await resp.json()) as { url?: string };
if (!data['url']) {
throw new Error('QQ Bot gateway response missing WebSocket URL');
}
return data['url'];
}
/** Determine the API base URL from the sandbox flag. */
export function getApiBase(sandbox: boolean): string {
return sandbox ? SANDBOX_HOST : API_HOST;
}
/**
* Send a message chunk to a QQ chat.
* Resolves on success; caller should handle errors and msg_seq tracking.
*/
export async function sendQQMessage(
base: string,
path: string,
accessToken: string,
body: Record<string, unknown>,
): Promise<Response> {
return fetch(`${base}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `QQBot ${accessToken}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
}

View file

@ -0,0 +1,18 @@
export { QQChannel } from './QQChannel.js';
import { QQChannel } from './QQChannel.js';
import type { ChannelPlugin } from '@qwen-code/channel-base';
export const plugin: ChannelPlugin = {
channelType: 'qq',
displayName: 'QQ',
// Both appID and appSecret are optional at config level because
// fetchToken() resolves them via a fallback chain:
// config values → persisted credentials file → QR code login
// If we required them here, parseChannelConfig() would reject the config
// before QQChannel is ever constructed — QR-only login would be unreachable
// through the built-in channel path.
requiredConfigFields: [],
createChannel: (name, config, bridge, options) =>
new QQChannel(name, config, bridge, options),
};

View file

@ -0,0 +1,31 @@
/**
* QQ Bot QR-code login flow.
*
* Delegates to @tencent-connect/qqbot-connector for the actual QR-code
* handshake, then returns the obtained credentials.
*/
import { qrConnect } from '@tencent-connect/qqbot-connector';
export interface QQCredentials {
appId: string;
appSecret: string;
}
/**
* Launch QR-code login and wait for the user to scan with QQ.
* Returns the obtained appId and appSecret.
*/
export async function qrCodeLogin(): Promise<QQCredentials> {
// In practice qrConnect() always returns a non-empty array — verified by
// removing appID from config and running `qwen channel start`, which
// correctly triggers QR login and returns valid credentials. The defensive
// destructuring + null-guard below is a robustness patch against unexpected
// external-library behaviour, not a response to an observed failure.
const results = await qrConnect();
const creds = results[0];
if (!creds?.appId || !creds?.appSecret) {
throw new Error('QR login failed: no credentials returned');
}
return { appId: creds.appId, appSecret: creds.appSecret };
}

View file

@ -0,0 +1,427 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isValidChatId, hasMarkdownSyntax, splitText } from './QQChannel.js';
const { mockSendQQMessage, mockFetchAccessToken } = vi.hoisted(() => ({
mockSendQQMessage: vi.fn(),
mockFetchAccessToken: vi.fn(),
}));
vi.mock('node:fs', () => ({
mkdirSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(() => false),
}));
vi.mock('./api.js', () => ({
sendQQMessage: mockSendQQMessage,
getApiBase: () => 'https://api.sgroup.qq.com',
fetchAccessToken: mockFetchAccessToken,
fetchGatewayUrl: vi.fn(),
}));
vi.mock('./accounts.js', () => ({
getCredsFilePath: () => '/tmp/test-creds.json',
loadCredentials: () => null,
saveCredentials: vi.fn(),
}));
vi.mock('./login.js', () => ({
qrCodeLogin: vi.fn(),
}));
vi.mock('@qwen-code/channel-base', () => ({
ChannelBase: class {
protected config: Record<string, unknown> = {};
protected bridge: Record<string, unknown> = {};
protected router: Record<string, unknown> = {};
protected name: string = '';
constructor(
name: string,
config: Record<string, unknown>,
bridge: Record<string, unknown>,
options?: Record<string, unknown>,
) {
this.name = name;
this.config = config;
this.bridge = bridge;
this.router = options?.router ?? {};
}
protected handleInbound(_env: unknown): Promise<void> {
return Promise.resolve();
}
},
SessionRouter: class {
restoreSessions(): Promise<void> {
return Promise.resolve();
}
},
getGlobalQwenDir: () => '/tmp/test-qwen',
}));
const { QQChannel } = await import('./QQChannel.js');
/** Create a mock Response-like object for sendQQMessage. */
function mockResponse(
ok: boolean,
status = 200,
body = '',
): { ok: boolean; status: number; text: () => Promise<string> } {
return { ok, status, text: async () => body };
}
describe('isValidChatId', () => {
it('accepts alphanumeric IDs', () => {
expect(isValidChatId('abc123')).toBe(true);
});
it('accepts IDs with underscores and hyphens', () => {
expect(isValidChatId('user_openid_123')).toBe(true);
expect(isValidChatId('group-id-456')).toBe(true);
});
it('accepts mixed-case IDs', () => {
expect(isValidChatId('AbC123_DeF')).toBe(true);
});
it('rejects empty string', () => {
expect(isValidChatId('')).toBe(false);
});
it('accepts max-length ID (128 chars)', () => {
const id = 'A'.repeat(128);
expect(isValidChatId(id)).toBe(true);
});
it('rejects IDs longer than 128 chars', () => {
const id = 'A'.repeat(129);
expect(isValidChatId(id)).toBe(false);
});
it('rejects IDs with slashes (path traversal)', () => {
expect(isValidChatId('abc/def')).toBe(false);
expect(isValidChatId('../etc')).toBe(false);
expect(isValidChatId('a\\b')).toBe(false);
});
it('rejects IDs with special characters', () => {
expect(isValidChatId('abc?def')).toBe(false);
expect(isValidChatId('abc#def')).toBe(false);
expect(isValidChatId('abc def')).toBe(false);
expect(isValidChatId('abc@def')).toBe(false);
});
it('rejects IDs with dots', () => {
expect(isValidChatId('abc.def')).toBe(false);
});
});
describe('hasMarkdownSyntax', () => {
it('detects headings', () => {
expect(hasMarkdownSyntax('# Title')).toBe(true);
expect(hasMarkdownSyntax('## Subtitle')).toBe(true);
expect(hasMarkdownSyntax('###### Deep heading')).toBe(true);
});
it('detects code blocks', () => {
expect(hasMarkdownSyntax('```js\ncode\n```')).toBe(true);
});
it('detects bold (double asterisk)', () => {
expect(hasMarkdownSyntax('**bold**')).toBe(true);
});
it('detects bold (double underscore)', () => {
expect(hasMarkdownSyntax('__bold__')).toBe(true);
});
it('detects strikethrough', () => {
expect(hasMarkdownSyntax('~~strikethrough~~')).toBe(true);
});
it('detects inline code', () => {
expect(hasMarkdownSyntax('use `code` here')).toBe(true);
});
it('detects links', () => {
expect(hasMarkdownSyntax('[text](url)')).toBe(true);
});
it('detects unordered list markers', () => {
expect(hasMarkdownSyntax('- item')).toBe(true);
expect(hasMarkdownSyntax('* item')).toBe(true);
expect(hasMarkdownSyntax('+ item')).toBe(true);
});
it('detects ordered list markers', () => {
expect(hasMarkdownSyntax('1. first')).toBe(true);
expect(hasMarkdownSyntax('123. item')).toBe(true);
});
it('returns false for plain text', () => {
expect(hasMarkdownSyntax('hello world')).toBe(false);
expect(hasMarkdownSyntax('no special chars here')).toBe(false);
});
it('returns false for text with single asterisks (not list marker at line start)', () => {
expect(hasMarkdownSyntax('this is *not* italic in this regex')).toBe(false);
});
it('false positive: "- temperature" triggers list pattern', () => {
expect(hasMarkdownSyntax('- temperature: 5°C')).toBe(true);
});
it('false positive: "1. first thing" at line start triggers ordered-list pattern', () => {
expect(hasMarkdownSyntax('1. first thing in sentence')).toBe(true);
});
});
describe('splitText', () => {
it('returns single-element array for short text', () => {
expect(splitText('hello')).toEqual(['hello']);
});
it('returns single-element array for exactly 2000 chars', () => {
const text = 'a'.repeat(2000);
const result = splitText(text);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2000);
});
it('splits text longer than 2000 chars into chunks', () => {
const text = 'a'.repeat(4500);
const result = splitText(text);
expect(result).toHaveLength(3);
expect(result[0]).toHaveLength(2000);
expect(result[1]).toHaveLength(2000);
expect(result[2]).toHaveLength(500);
});
it('preserves content across chunk boundaries', () => {
const text = 'x'.repeat(2000) + 'y'.repeat(500);
const result = splitText(text);
expect(result).toHaveLength(2);
expect(result[0]).toBe('x'.repeat(2000));
expect(result[1]).toBe('y'.repeat(500));
});
it('handles empty string', () => {
expect(splitText('')).toEqual(['']);
});
});
describe('sendMessage', () => {
/** Construct a QQChannel with internal state pre-configured for sendMessage. */
function makeChannel(overrides?: {
disposed?: boolean;
chatType?: 'c2c' | 'group';
replyMsgId?: string;
tokenExpiresAt?: number;
}): QQChannel {
const ch = new QQChannel(
'test-bot',
{
type: 'qq',
token: '',
senderPolicy: 'open' as const,
allowedUsers: [],
sessionScope: 'user' as const,
cwd: '/tmp',
groupPolicy: 'disabled' as const,
groups: {},
appID: 'test-app-id',
appSecret: 'test-secret',
},
{} as unknown as import('@qwen-code/channel-base').AcpBridge,
);
// Set internal state for sendMessage preconditions.
// accessToken and tokenExpiresAt bypass the fetchToken flow.
const chp = ch as unknown as Record<string, unknown>;
chp['accessToken'] = 'test-token';
chp['tokenExpiresAt'] = overrides?.tokenExpiresAt ?? Date.now() + 3600_000;
if (overrides?.disposed) chp['disposed'] = true;
if (overrides?.chatType) {
(chp['chatTypeMap'] as Map<string, string>).set(
'test-chat-id',
overrides.chatType,
);
}
if (overrides?.replyMsgId) {
(chp['replyMsgId'] as Map<string, string>).set(
'test-chat-id',
overrides.replyMsgId,
);
}
return ch;
}
beforeEach(() => {
vi.clearAllMocks();
mockSendQQMessage.mockResolvedValue(mockResponse(true));
mockFetchAccessToken.mockResolvedValue({
accessToken: 'refreshed-token',
expiresIn: 7200,
});
});
it('sends plain text to C2C chat with msg_type=0', async () => {
const ch = makeChannel({ chatType: 'c2c' });
await ch.sendMessage('test-chat-id', 'hello');
expect(mockSendQQMessage).toHaveBeenCalledTimes(1);
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ content: 'hello', msg_type: 0 },
);
});
it('sends markdown to C2C chat with msg_type=2', async () => {
const ch = makeChannel({ chatType: 'c2c' });
await ch.sendMessage('test-chat-id', '**bold text**');
expect(mockSendQQMessage).toHaveBeenCalledTimes(1);
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ msg_type: 2, markdown: { content: '**bold text**' } },
);
});
it('routes to group API path when chatType is group', async () => {
const ch = makeChannel({ chatType: 'group' });
await ch.sendMessage('test-chat-id', 'hello');
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/groups/test-chat-id/messages',
'test-token',
{ content: 'hello', msg_type: 0 },
);
});
it('falls back to plain text when markdown is rejected', async () => {
const ch = makeChannel({ chatType: 'c2c' });
mockSendQQMessage
.mockResolvedValueOnce(mockResponse(false, 400, 'markdown unsupported'))
.mockResolvedValueOnce(mockResponse(true));
await ch.sendMessage('test-chat-id', '**bold**');
expect(mockSendQQMessage).toHaveBeenCalledTimes(2);
// First attempt: markdown
expect(mockSendQQMessage).toHaveBeenNthCalledWith(
1,
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ msg_type: 2, markdown: { content: '**bold**' } },
);
// Fallback: plain text
expect(mockSendQQMessage).toHaveBeenNthCalledWith(
2,
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ content: '**bold**', msg_type: 0 },
);
});
it('stops on first chunk failure (no fallback for plain text)', async () => {
const ch = makeChannel({ chatType: 'c2c' });
mockSendQQMessage.mockResolvedValue(mockResponse(false, 500));
await ch.sendMessage('test-chat-id', 'hello');
// Only one attempt — plain text doesn't retry, and we break on failure
expect(mockSendQQMessage).toHaveBeenCalledTimes(1);
});
it('returns early when disposed', async () => {
const ch = makeChannel({ disposed: true, chatType: 'c2c' });
await ch.sendMessage('test-chat-id', 'hello');
expect(mockSendQQMessage).not.toHaveBeenCalled();
});
it('defaults to C2C path for unknown chatId', async () => {
const ch = makeChannel(); // no chatType set → not group → C2C path
await ch.sendMessage('unknown-chat', 'hello');
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/users/unknown-chat/messages',
'test-token',
{ content: 'hello', msg_type: 0 },
);
});
it('returns early when chatId fails SSRF validation', async () => {
const ch = makeChannel({ chatType: 'c2c' });
await ch.sendMessage('../traversal', 'hello');
expect(mockSendQQMessage).not.toHaveBeenCalled();
});
it('returns early when token expired and refresh fails', async () => {
const ch = makeChannel({
chatType: 'c2c',
tokenExpiresAt: Date.now() - 1000,
});
mockFetchAccessToken.mockRejectedValue(new Error('auth failed'));
await ch.sendMessage('test-chat-id', 'hello');
expect(mockSendQQMessage).not.toHaveBeenCalled();
expect(mockFetchAccessToken).toHaveBeenCalled();
});
it('catches thrown sendQQMessage errors and stops sending', async () => {
const ch = makeChannel({ chatType: 'c2c' });
mockSendQQMessage.mockRejectedValue(new Error('network down'));
await ch.sendMessage('test-chat-id', 'hello');
// No crash, and the catch+break prevents further attempts
expect(mockSendQQMessage).toHaveBeenCalledTimes(1);
});
it('includes msg_id and msg_seq when replyMsgId is set', async () => {
const ch = makeChannel({ chatType: 'c2c', replyMsgId: 'msg-456' });
await ch.sendMessage('test-chat-id', 'hello');
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ content: 'hello', msg_type: 0, msg_id: 'msg-456', msg_seq: 1 },
);
});
it('sends multi-chunk text as separate messages with incrementing msg_seq', async () => {
const ch = makeChannel({ chatType: 'c2c', replyMsgId: 'msg-789' });
const text = 'a'.repeat(2500); // 2 chunks: 2000 + 500
await ch.sendMessage('test-chat-id', text);
expect(mockSendQQMessage).toHaveBeenCalledTimes(2);
expect(mockSendQQMessage).toHaveBeenNthCalledWith(
1,
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ content: 'a'.repeat(2000), msg_type: 0, msg_id: 'msg-789', msg_seq: 1 },
);
expect(mockSendQQMessage).toHaveBeenNthCalledWith(
2,
'https://api.sgroup.qq.com',
'/v2/users/test-chat-id/messages',
'test-token',
{ content: 'a'.repeat(500), msg_type: 0, msg_id: 'msg-789', msg_seq: 2 },
);
});
});

View file

@ -0,0 +1,42 @@
/**
* QQ Bot API protocol types.
* Reference: https://bot.q.qq.com/wiki/develop/api-v2/
*/
export const OpCode = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 6,
RECONNECT: 7,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
} as const;
/** QQ Bot WebSocket intents. */
export const Intent = {
C2C_MESSAGE: 1 << 12, // C2C 消息
GROUP_AT_MESSAGE: 1 << 25, // 群聊 @ 消息事件
} as const;
export interface QQMessageEvent {
id: string;
author: {
id: string;
user_openid: string;
username?: string;
};
content: string;
}
/** Extended fields available on group message events. */
export type QQGroupMessageEvent = QQMessageEvent & {
group_openid: string;
};
export interface QQChannelConfig {
appID?: string;
appSecret?: string;
sandbox?: boolean;
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
"references": [{ "path": "../base" }]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: true,
},
});

View file

@ -47,6 +47,7 @@
"@qwen-code/acp-bridge": "file:../acp-bridge",
"@qwen-code/channel-base": "file:../channels/base",
"@qwen-code/channel-dingtalk": "file:../channels/dingtalk",
"@qwen-code/channel-qqbot": "file:../channels/qqbot",
"@qwen-code/channel-feishu": "file:../channels/feishu",
"@qwen-code/channel-telegram": "file:../channels/telegram",
"@qwen-code/channel-weixin": "file:../channels/weixin",

View file

@ -6,15 +6,25 @@ let builtinsPromise: Promise<void> | null = null;
function ensureBuiltins(): Promise<void> {
if (!builtinsPromise) {
builtinsPromise = (async () => {
const [telegram, weixin, dingtalk, feishu] = await Promise.all([
import('@qwen-code/channel-telegram'),
import('@qwen-code/channel-weixin'),
import('@qwen-code/channel-dingtalk'),
import('@qwen-code/channel-feishu'),
]);
const labelled = [
{ name: 'telegram', promise: import('@qwen-code/channel-telegram') },
{ name: 'weixin', promise: import('@qwen-code/channel-weixin') },
{ name: 'dingtalk', promise: import('@qwen-code/channel-dingtalk') },
{ name: 'feishu', promise: import('@qwen-code/channel-feishu') },
{ name: 'qqbot', promise: import('@qwen-code/channel-qqbot') },
];
for (const mod of [telegram, weixin, dingtalk, feishu]) {
registry.set(mod.plugin.channelType, mod.plugin);
const results = await Promise.allSettled(labelled.map((l) => l.promise));
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
if (result.status === 'fulfilled') {
registry.set(result.value.plugin.channelType, result.value.plugin);
} else {
process.stderr.write(
`[channel-registry] Failed to load "${labelled[i]!.name}" channel: ${result.reason}\n`,
);
}
}
})();
}

View file

@ -56,6 +56,7 @@ const buildOrder = [
'packages/channels/weixin',
'packages/channels/dingtalk',
'packages/channels/feishu',
'packages/channels/qqbot',
'packages/channels/plugin-example',
'packages/acp-bridge',
'packages/cli',

View file

@ -11,6 +11,7 @@ export default defineConfig({
'packages/channels/dingtalk',
'packages/channels/telegram',
'packages/channels/weixin',
'packages/channels/qqbot',
'integration-tests',
'scripts',
],