From 549ee22f987c3d66df5ab46ddb0b345b42222b21 Mon Sep 17 00:00:00 2001 From: cheatthegod <2057337990@qq.com> Date: Tue, 24 Mar 2026 09:43:45 +0000 Subject: [PATCH] Add QQ official bot support on latest main --- .env.example | 8 + README.md | 2 +- README.zh-CN.md | 2 +- docs/CHANNELS.md | 31 +++ docs/CHANNELS.zh-CN.md | 49 ++-- docs/QQ_SETUP.zh-CN.md | 171 ++++++++++++++ package-lock.json | 1 + package.json | 1 + src/channels/qq.ts | 503 +++++++++++++++++++++++++++++++++++++++++ src/config.ts | 3 + src/index.ts | 15 ++ 11 files changed, 766 insertions(+), 20 deletions(-) create mode 100644 docs/QQ_SETUP.zh-CN.md create mode 100644 src/channels/qq.ts diff --git a/.env.example b/.env.example index 114527a..17ac1f7 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,14 @@ WECOM_SECRET=your-secret # WECOM_AGENT_ID=your-agent-id # WECOM_CORP_SECRET=your-corp-secret +# ─── QQ Official Bot — Optional ─────────── + +# Current QQ support covers official QQ Bot text receive/reply over WebSocket. +# Supported inbound events: private chat messages and group @bot messages. +# QQ_APP_ID=your-app-id +# QQ_CLIENT_SECRET=your-client-secret +# QQ_SANDBOX=false + # ─── Feishu (Lark) — Optional ─────────────── # Current Feishu support covers text receive/reply. diff --git a/README.md b/README.md index b35f7e5..1db81a6 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ In any connected chat, simply message: ## Messaging channels -Supported platforms include **WhatsApp** (default), **Feishu (Lark)**, **WeCom**, **Discord**, **Slack** (Socket Mode), **WeChat Personal** (experimental), and optional **local web** (browser) chat. Full setup steps, env vars, and disabling channels are in **[docs/CHANNELS.md](docs/CHANNELS.md)** (简体中文:[docs/CHANNELS.zh-CN.md](docs/CHANNELS.zh-CN.md)). +Supported platforms include **WhatsApp** (default), **QQ Official Bot**, **Feishu (Lark)**, **WeCom**, **Discord**, **Slack** (Socket Mode), **WeChat Personal** (experimental), and optional **local web** (browser) chat. Full setup steps, env vars, and disabling channels are in **[docs/CHANNELS.md](docs/CHANNELS.md)** (简体中文:[docs/CHANNELS.zh-CN.md](docs/CHANNELS.zh-CN.md)). **Lab trace** (SSE timeline, workspace tree) is built into the local web UI — no extra config needed. See **[docs/DASHBOARD.md](docs/DASHBOARD.md)**. diff --git a/README.zh-CN.md b/README.zh-CN.md index 45531bf..ddfbdfe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ BioClaw 将常见的生物信息学任务带到聊天界面中。研究者可以 - 差异分析可视化(火山图等) - 文献检索与摘要 -默认通道为 WhatsApp;飞书、企业微信、Discord、Slack、本地网页等配置见 **[docs/CHANNELS.zh-CN.md](docs/CHANNELS.zh-CN.md)**。飞书的完整配置、OpenRouter 设置、群聊限制与排障见 **[docs/FEISHU_SETUP.zh-CN.md](docs/FEISHU_SETUP.zh-CN.md)**。QQ 相关截图仍为路线图示意,详见该文档。 +默认通道为 WhatsApp;QQ 官方 Bot、飞书、企业微信、Discord、Slack、本地网页等配置见 **[docs/CHANNELS.zh-CN.md](docs/CHANNELS.zh-CN.md)**。飞书的完整配置、OpenRouter 设置、群聊限制与排障见 **[docs/FEISHU_SETUP.zh-CN.md](docs/FEISHU_SETUP.zh-CN.md)**;QQ 的完整步骤与排障见 **[docs/QQ_SETUP.zh-CN.md](docs/QQ_SETUP.zh-CN.md)**。 ## 快速开始 diff --git a/docs/CHANNELS.md b/docs/CHANNELS.md index fb7cebc..85a42f3 100644 --- a/docs/CHANNELS.md +++ b/docs/CHANNELS.md @@ -50,6 +50,37 @@ The server IP must be on that app’s trusted IP whitelist. --- +## QQ Official Bot + +BioClaw currently supports **official QQ Bot** text receive/reply over **WebSocket**. + +Supported inbound events in the current MVP: +- `C2C_MESSAGE_CREATE` for private chat messages +- `GROUP_AT_MESSAGE_CREATE` for group `@bot` messages + +1. Create a bot app in the QQ Open Platform (`q.qq.com`). +2. Enable **WebSocket** event delivery for the bot. +3. Subscribe the private-chat and group-@bot message events (the current implementation listens with `1 << 25`, the combined group/C2C intent documented by QQ Bot). +4. Add these variables to `.env`: + + ```bash + QQ_APP_ID=your-app-id + QQ_CLIENT_SECRET=your-client-secret + # Optional: use QQ sandbox gateway/API endpoints + # QQ_SANDBOX=true + ``` + +5. Start BioClaw. The first private message or group `@bot` message auto-registers that conversation. + +Current limits: +- Official QQ Bot only; no personal QQ automation +- Text-only send/receive in this first version +- Group replies are intended for `@bot` message flows + +Chinese setup notes: [QQ_SETUP.zh-CN.md](QQ_SETUP.zh-CN.md) + +--- + ## Feishu (Lark) BioClaw currently supports **text receive/reply** for Feishu bots. Start with **WebSocket mode** if possible; use webhook mode only when your deployment requires inbound HTTP callbacks. diff --git a/docs/CHANNELS.zh-CN.md b/docs/CHANNELS.zh-CN.md index 1b34c0d..76fde35 100644 --- a/docs/CHANNELS.zh-CN.md +++ b/docs/CHANNELS.zh-CN.md @@ -51,6 +51,37 @@ WECOM_CORP_SECRET=应用 Secret --- +## QQ 官方 Bot + +BioClaw 当前已支持 **QQ 官方 Bot** 的**文本消息收发**,接入方式为 **WebSocket**。 + +当前 MVP 支持的入站事件: +- `C2C_MESSAGE_CREATE`:QQ 私聊机器人 +- `GROUP_AT_MESSAGE_CREATE`:群里 `@` 机器人 + +1. 在 QQ 开放平台创建机器人应用。 +2. 在机器人配置里启用 **WebSocket** 接收事件。 +3. 订阅私聊消息和群聊 `@机器人` 消息事件。 +4. 在 `.env` 中至少配置: + + ```bash + QQ_APP_ID=your-app-id + QQ_CLIENT_SECRET=your-client-secret + # 可选:若使用 QQ 沙盒环境 + # QQ_SANDBOX=true + ``` + +5. 启动 BioClaw 后,第一条私聊消息或群里 `@机器人` 的消息会自动注册该会话。 + +当前限制: +- 仅支持 **官方 QQ Bot**,不支持个人 QQ 自动化 +- 第一版只做文本消息 +- 群聊场景按 `@机器人` 触发路径实现 + +详细中文步骤与排障见 [QQ 接入实操与排障指南](QQ_SETUP.zh-CN.md)。 + +--- + ## 飞书(Lark) BioClaw 当前已支持**飞书文本消息收发**。优先建议使用 **WebSocket / 长连接**;只有在部署环境必须走公网回调时,再切到 webhook。 @@ -189,21 +220,3 @@ BioClaw 使用 **[Socket Mode](https://api.slack.com/apis/socket-mode)** 长连 - 只关 WhatsApp:`DISABLE_WHATSAPP=1` 或 `ENABLE_WHATSAPP=false`。 - 不用的通道:对应 token 留空或不配置即可(飞书、企业微信、Discord、Slack 都是如此;Slack 需同时配置 `SLACK_BOT_TOKEN` 与 `SLACK_APP_TOKEN`)。 ---- - -## QQ 说明(路线图) - -以下截图用于展示**QQ 通道**这一产品扩展方向;当前仓库内**仍没有**可直接启用的 QQ 通道实现,请勿按 QQ 文档期待开箱即用。 - -### QQ + DeepSeek(示意) - -
-QQ DeepSeek demo 1 -
- -
-QQ DeepSeek demo 2 -
- - -任务类演示仍见 [ExampleTask/ExampleTask.md](../ExampleTask/ExampleTask.md)。 diff --git a/docs/QQ_SETUP.zh-CN.md b/docs/QQ_SETUP.zh-CN.md new file mode 100644 index 0000000..4daaecf --- /dev/null +++ b/docs/QQ_SETUP.zh-CN.md @@ -0,0 +1,171 @@ +# QQ 接入实操与排障指南 + +本文档对应当前 BioClaw 中已经落地的 **QQ 官方 Bot 文本版**。 + +当前支持范围: +- QQ 官方 Bot +- WebSocket 长连接接收事件 +- 私聊消息:`C2C_MESSAGE_CREATE` +- 群里 `@机器人` 消息:`GROUP_AT_MESSAGE_CREATE` +- 文本回复 + +当前不支持: +- 个人 QQ 自动化 +- 图片 / 文件发送 +- webhook 模式 +- 频道场景的完整适配 + +## 1. 前提 + +1. 你使用的是 **QQ 开放平台 / 官方 Bot**,不是个人 QQ 号自动化。 +2. 服务器可以访问: + - `https://bots.qq.com` + - `https://api.sgroup.qq.com` +3. BioClaw 已能正常启动,模型提供商配置正确。 + +## 2. 在 QQ 开放平台里做什么 + +1. 创建机器人应用。 +2. 记录: + - `App ID` + - `Client Secret` +3. 在机器人配置里启用 **WebSocket** 接收事件。 +4. 订阅这两个事件: + - 私聊消息 + - 群聊 `@机器人` 消息 +5. 如果平台区分沙盒与正式环境,先确认你当前使用的是哪一个。 + +说明:当前 BioClaw 的 QQ 通道监听的是官方文档中的 group/C2C 组合 intent(`1 << 25`),对应私聊和群里 `@机器人` 两类消息事件。 + +## 3. 写入服务器 `.env` + +在服务器执行: + +```bash +cd /home/ubuntu/cqr_files/Bioclaw_new/BioClaw + +[ -f .env ] || cp .env.example .env +cp .env ".env.bak.$(date +%F-%H%M%S)" + +upsert_env() { + key="$1" + value="$2" + if grep -q "^${key}=" .env; then + sed -i "s|^${key}=.*|${key}=${value}|" .env + else + printf '%s=%s\n' "$key" "$value" >> .env + fi +} + +read -rp 'QQ App ID: ' QQ_APP_ID_INPUT +read -rsp 'QQ Client Secret: ' QQ_CLIENT_SECRET_INPUT +echo + +upsert_env QQ_APP_ID "$QQ_APP_ID_INPUT" +upsert_env QQ_CLIENT_SECRET "$QQ_CLIENT_SECRET_INPUT" +# 如使用沙盒环境,再打开这一行 +# upsert_env QQ_SANDBOX true + +unset QQ_APP_ID_INPUT QQ_CLIENT_SECRET_INPUT + +grep -E '^(QQ_APP_ID|QQ_SANDBOX)=' .env || true +grep '^QQ_CLIENT_SECRET=' .env | sed 's/=.*/=***hidden***/' +``` + +## 4. 启动 + +```bash +cd /home/ubuntu/cqr_files/Bioclaw_new/BioClaw +source /home/ubuntu/.nvm/nvm.sh +nvm use 22 +npm run build +npm start +``` + +如果 QQ 长连接成功,终端里应出现: + +```text +Connected to QQ (websocket) +``` + +## 5. 如何测试 + +### 5.1 私聊测试 + +直接给机器人发: + +```text +你好 +``` + +期望日志: + +```text +QQ direct message received +QQ message sent +``` + +### 5.2 群聊测试 + +把机器人加入群后,在群里发送: + +```text +@机器人 你好 +``` + +期望日志: + +```text +QQ group message received +QQ message sent +``` + +## 6. 常见问题 + +### 6.1 没有任何 QQ 日志 + +重点检查: +- 机器人应用是否真的启用了 WebSocket +- 私聊 / 群里 `@机器人` 事件是否已订阅 +- 服务器是否能访问 `bots.qq.com` 与 `api.sgroup.qq.com` +- `.env` 中 `QQ_APP_ID` / `QQ_CLIENT_SECRET` 是否对应同一个应用 + +### 6.2 提示模型 API key 无效 + +这不是 QQ 问题,而是模型提供商配置问题。优先检查: +- `MODEL_PROVIDER` +- `OPENROUTER_API_KEY` +- 或 `ANTHROPIC_API_KEY` + +### 6.3 群里发普通消息机器人不回 + +当前实现按官方 `GROUP_AT_MESSAGE_CREATE` 路径工作。第一版默认只保证: +- 私聊消息 +- 群里 `@机器人` 的消息 + +所以群里测试时请先显式 `@机器人`。 + +### 6.4 这是个人 QQ 机器人吗 + +不是。当前实现只支持 **官方 QQ Bot**。 + +## 7. 当前实现边界 + +当前版本为了稳妥,只做了最小可用集: +- 自动注册 QQ 会话 +- 文本收发 +- 私聊与群 `@机器人` + +后续如果需要,再扩展: +- 图片 / 文件 +- webhook 模式 +- 更完整的富消息 +- 更细的群资料同步 + +## 8. 参考 + +- QQ 机器人官方文档: +- WebSocket 接入说明: +- 事件订阅与通知: +- 消息事件: +- 发送消息: diff --git a/package-lock.json b/package-lock.json index 684117d..dbeef6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "pino-pretty": "^13.0.0", "qrcode-terminal": "^0.12.0", "weixin-agent-sdk": "^0.1.0", + "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/package.json b/package.json index 0d10164..b9a14bd 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "pino": "^9.6.0", "pino-pretty": "^13.0.0", "qrcode-terminal": "^0.12.0", + "ws": "^8.19.0", "weixin-agent-sdk": "^0.1.0", "zod": "^4.3.6" }, diff --git a/src/channels/qq.ts b/src/channels/qq.ts new file mode 100644 index 0000000..b174f03 --- /dev/null +++ b/src/channels/qq.ts @@ -0,0 +1,503 @@ +import WebSocket, { RawData } from 'ws'; + +import { logger } from '../logger.js'; +import { Channel, NewMessage, OnChatMetadata, OnInboundMessage, RegisteredGroup } from '../types.js'; + +const QQ_JID_SUFFIX_GROUP = '@qq.group'; +const QQ_JID_SUFFIX_USER = '@qq.user'; +const QQ_ACCESS_TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'; +const QQ_API_BASE = 'https://api.sgroup.qq.com'; +const QQ_SANDBOX_API_BASE = 'https://sandbox.api.sgroup.qq.com'; +const QQ_GROUP_AND_C2C_INTENT = 1 << 25; +const ACCESS_TOKEN_SKEW_MS = 60_000; +const RECONNECT_DELAY_MS = 5_000; +const SEEN_EVENT_TTL_MS = 10 * 60 * 1000; + +type QQDispatchType = 'READY' | 'C2C_MESSAGE_CREATE' | 'GROUP_AT_MESSAGE_CREATE'; + +interface QQAccessTokenResponse { + access_token?: string; + expires_in?: number | string; +} + +interface QQGatewayBotResponse { + url: string; + shards?: number; +} + +interface QQAttachment { + content_type?: string; + filename?: string; +} + +interface QQC2CEvent { + id: string; + content?: string; + timestamp?: string; + author?: { + user_openid?: string; + }; + attachments?: QQAttachment[]; +} + +interface QQGroupAtEvent { + id: string; + content?: string; + timestamp?: string; + group_openid?: string; + author?: { + member_openid?: string; + }; + attachments?: QQAttachment[]; +} + +interface QQReadyEvent { + session_id?: string; +} + +interface QQGatewayPayload { + id?: string; + op: number; + d?: T; + s?: number; + t?: QQDispatchType; +} + +export interface QQChannelOpts { + appId: string; + clientSecret: string; + sandbox?: boolean; + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; + autoRegister?: (jid: string, name: string, channelName: string) => void; +} + +export function buildQqChatJid(openId: string, kind: 'group' | 'user'): string { + return `${openId}${kind === 'group' ? QQ_JID_SUFFIX_GROUP : QQ_JID_SUFFIX_USER}`; +} + +export function parseQqMessageContent(content?: string, attachments?: QQAttachment[]): string | null { + const text = content?.trim(); + if (text) return text; + + const firstAttachment = attachments?.[0]; + if (!firstAttachment) return null; + + const contentType = firstAttachment.content_type || ''; + if (contentType.startsWith('image/')) return '[image]'; + if (contentType.startsWith('video/')) return '[video]'; + if (contentType === 'voice') return '[voice]'; + if (contentType === 'file') return firstAttachment.filename ? `[file: ${firstAttachment.filename}]` : '[file]'; + return '[attachment]'; +} + +function apiBase(sandbox: boolean | undefined): string { + return sandbox ? QQ_SANDBOX_API_BASE : QQ_API_BASE; +} + +function toIsoTimestamp(value?: string): string { + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return new Date().toISOString(); + return parsed.toISOString(); +} + +function buildDirectChatName(userOpenId: string): string { + return `QQ DM ${userOpenId}`; +} + +function buildGroupChatName(groupOpenId: string): string { + return `QQ Group ${groupOpenId.slice(-8)}`; +} + +function textPreview(text: string): string { + return text.replace(/\s+/g, ' ').trim().slice(0, 80); +} + +export class QQChannel implements Channel { + name = 'qq'; + prefixAssistantName = false; + + private readonly opts: QQChannelOpts; + private ws?: WebSocket; + private connected = false; + private shuttingDown = false; + private heartbeatTimer?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private connectPromise?: Promise; + private lastSequence: number | null = null; + private sessionId?: string; + private accessToken?: string; + private accessTokenExpiresAt = 0; + private readonly seenEventIds = new Map(); + private readonly seenEventGcTimer: NodeJS.Timeout; + + constructor(opts: QQChannelOpts) { + this.opts = opts; + this.seenEventGcTimer = setInterval(() => this.pruneSeenEvents(), 60_000); + } + + async connect(): Promise { + if (this.connected) return; + if (this.connectPromise) return this.connectPromise; + + this.shuttingDown = false; + this.connectPromise = this.connectOnce().finally(() => { + this.connectPromise = undefined; + }); + return this.connectPromise; + } + + isConnected(): boolean { + return this.connected; + } + + ownsJid(jid: string): boolean { + return jid.endsWith(QQ_JID_SUFFIX_GROUP) || jid.endsWith(QQ_JID_SUFFIX_USER); + } + + async disconnect(): Promise { + this.shuttingDown = true; + this.connected = false; + clearInterval(this.seenEventGcTimer); + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.heartbeatTimer = undefined; + this.reconnectTimer = undefined; + this.lastSequence = null; + this.sessionId = undefined; + + if (!this.ws) return; + const ws = this.ws; + this.ws = undefined; + await new Promise((resolve) => { + ws.once('close', () => resolve()); + ws.close(); + setTimeout(() => resolve(), 2_000); + }); + } + + async sendMessage(jid: string, text: string): Promise { + const payload = { content: text, msg_type: 0 }; + + if (jid.endsWith(QQ_JID_SUFFIX_USER)) { + const openId = jid.slice(0, -QQ_JID_SUFFIX_USER.length); + await this.apiRequest(`/v2/users/${openId}/messages`, { + method: 'POST', + body: JSON.stringify(payload), + }); + logger.info({ jid, length: text.length }, 'QQ message sent'); + return; + } + + if (jid.endsWith(QQ_JID_SUFFIX_GROUP)) { + const groupOpenId = jid.slice(0, -QQ_JID_SUFFIX_GROUP.length); + await this.apiRequest(`/v2/groups/${groupOpenId}/messages`, { + method: 'POST', + body: JSON.stringify(payload), + }); + logger.info({ jid, length: text.length }, 'QQ message sent'); + return; + } + + logger.warn({ jid }, 'Invalid QQ JID, message not sent'); + } + + private async connectOnce(): Promise { + const gateway = await this.getGateway(); + logger.info({ url: gateway.url, sandbox: !!this.opts.sandbox }, 'Connecting to QQ gateway'); + + await new Promise((resolve, reject) => { + let settled = false; + const ws = new WebSocket(gateway.url); + this.ws = ws; + this.lastSequence = null; + this.sessionId = undefined; + + const settleResolve = () => { + if (settled) return; + settled = true; + resolve(); + }; + + const settleReject = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + + ws.on('message', (data) => { + void this.handleSocketMessage(data, settleResolve, settleReject); + }); + + ws.once('error', (err) => { + logger.error({ err }, 'QQ WebSocket error'); + this.connected = false; + if (!settled) { + settleReject(err instanceof Error ? err : new Error(String(err))); + return; + } + if (!this.shuttingDown) this.scheduleReconnect('error'); + }); + + ws.once('close', (code, reason) => { + this.connected = false; + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + this.ws = undefined; + + const reasonText = reason.toString() || 'no reason'; + if (!settled) { + settleReject(new Error(`QQ WebSocket closed before ready (code=${code}, reason=${reasonText})`)); + return; + } + if (!this.shuttingDown) this.scheduleReconnect(`close code=${code} reason=${reasonText}`); + }); + }); + } + + private async handleSocketMessage( + raw: RawData, + onReady: () => void, + onFatal: (error: Error) => void, + ): Promise { + let payload: QQGatewayPayload; + try { + payload = JSON.parse(raw.toString()) as QQGatewayPayload; + } catch (err) { + logger.warn({ err }, 'Failed to parse QQ gateway payload'); + return; + } + + if (typeof payload.s === 'number') this.lastSequence = payload.s; + + switch (payload.op) { + case 10: { + const heartbeatInterval = Number((payload.d as { heartbeat_interval?: number } | undefined)?.heartbeat_interval || 45_000); + this.startHeartbeat(heartbeatInterval); + try { + await this.sendIdentify(); + } catch (err) { + onFatal(err instanceof Error ? err : new Error(String(err))); + } + return; + } + case 11: + return; + case 7: + logger.warn('QQ gateway requested reconnect'); + this.ws?.close(); + return; + case 9: + logger.warn('QQ gateway reported invalid session'); + this.ws?.close(); + return; + case 0: + break; + default: + return; + } + + if (payload.t === 'READY') { + this.sessionId = (payload.d as QQReadyEvent | undefined)?.session_id; + this.connected = true; + logger.info('Connected to QQ (websocket)'); + onReady(); + return; + } + + if (payload.t === 'C2C_MESSAGE_CREATE') { + await this.handleDirectMessage(payload.d as QQC2CEvent); + return; + } + + if (payload.t === 'GROUP_AT_MESSAGE_CREATE') { + await this.handleGroupAtMessage(payload.d as QQGroupAtEvent); + } + } + + private startHeartbeat(intervalMs: number): void { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = setInterval(() => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ op: 1, d: this.lastSequence })); + }, intervalMs); + } + + private async sendIdentify(): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('QQ gateway socket is not open'); + } + const accessToken = await this.getAccessToken(); + this.ws.send(JSON.stringify({ + op: 2, + d: { + token: `QQBot ${accessToken}`, + intents: QQ_GROUP_AND_C2C_INTENT, + shard: [0, 1], + properties: { + $os: 'linux', + $browser: 'bioclaw', + $device: 'bioclaw', + }, + }, + })); + } + + private async handleDirectMessage(event: QQC2CEvent): Promise { + if (!event.id || this.isDuplicateEvent(event.id)) return; + + const sender = event.author?.user_openid; + if (!sender) return; + + const content = parseQqMessageContent(event.content, event.attachments); + if (!content) return; + + const chatJid = buildQqChatJid(sender, 'user'); + const timestamp = toIsoTimestamp(event.timestamp); + const chatName = buildDirectChatName(sender); + + this.opts.onChatMetadata(chatJid, timestamp, chatName); + this.ensureRegistered(chatJid, chatName); + if (!this.opts.registeredGroups()[chatJid]) { + logger.info({ chatJid }, 'QQ direct message from unregistered conversation, ignored'); + return; + } + + const message: NewMessage = { + id: event.id, + chat_jid: chatJid, + sender, + sender_name: sender, + content, + timestamp, + is_from_me: false, + }; + this.opts.onMessage(chatJid, message); + logger.info({ chatJid, sender, preview: textPreview(content) }, 'QQ direct message received'); + } + + private async handleGroupAtMessage(event: QQGroupAtEvent): Promise { + if (!event.id || this.isDuplicateEvent(event.id)) return; + + const groupOpenId = event.group_openid; + const sender = event.author?.member_openid; + if (!groupOpenId || !sender) return; + + const content = parseQqMessageContent(event.content, event.attachments); + if (!content) return; + + const chatJid = buildQqChatJid(groupOpenId, 'group'); + const timestamp = toIsoTimestamp(event.timestamp); + const chatName = buildGroupChatName(groupOpenId); + + this.opts.onChatMetadata(chatJid, timestamp, chatName); + this.ensureRegistered(chatJid, chatName); + if (!this.opts.registeredGroups()[chatJid]) { + logger.info({ chatJid }, 'QQ group message from unregistered conversation, ignored'); + return; + } + + const message: NewMessage = { + id: event.id, + chat_jid: chatJid, + sender, + sender_name: sender, + content, + timestamp, + is_from_me: false, + }; + this.opts.onMessage(chatJid, message); + logger.info({ chatJid, sender, preview: textPreview(content) }, 'QQ group message received'); + } + + private ensureRegistered(chatJid: string, chatName: string): void { + const groups = this.opts.registeredGroups(); + if (groups[chatJid] || !this.opts.autoRegister) return; + this.opts.autoRegister(chatJid, chatName, 'qq'); + } + + private async getGateway(): Promise { + return this.apiRequest('/gateway/bot', { method: 'GET' }); + } + + private async getAccessToken(forceRefresh = false): Promise { + if (!forceRefresh && this.accessToken && Date.now() < this.accessTokenExpiresAt) { + return this.accessToken; + } + + const response = await fetch(QQ_ACCESS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId: this.opts.appId, clientSecret: this.opts.clientSecret }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to get QQ access token (${response.status}): ${body.slice(0, 200)}`); + } + + const data = await response.json() as QQAccessTokenResponse; + if (!data.access_token) { + throw new Error('QQ access token response did not include access_token'); + } + + const expiresInSeconds = Number(data.expires_in || 7200); + this.accessToken = data.access_token; + this.accessTokenExpiresAt = Date.now() + Math.max(1_000, expiresInSeconds * 1000 - ACCESS_TOKEN_SKEW_MS); + return this.accessToken; + } + + private async apiRequest(path: string, init: RequestInit, retry = true): Promise { + const token = await this.getAccessToken(); + const headers = new Headers(init.headers); + headers.set('Authorization', `QQBot ${token}`); + headers.set('Accept', 'application/json'); + if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + + const response = await fetch(`${apiBase(this.opts.sandbox)}${path}`, { + ...init, + headers, + }); + + if (response.status === 401 && retry) { + await this.getAccessToken(true); + return this.apiRequest(path, init, false); + } + + if (!response.ok) { + const body = await response.text(); + throw new Error(`QQ API ${init.method || 'GET'} ${path} failed (${response.status}): ${body.slice(0, 200)}`); + } + + if (response.status === 204) return undefined as T; + return await response.json() as T; + } + + private scheduleReconnect(reason: string): void { + if (this.shuttingDown || this.reconnectTimer) return; + logger.warn({ reason }, 'Scheduling QQ reconnect'); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + void this.connect().catch((err) => { + logger.error({ err }, 'QQ reconnect failed'); + this.scheduleReconnect('retry-after-failure'); + }); + }, RECONNECT_DELAY_MS); + } + + private isDuplicateEvent(eventId: string): boolean { + const now = Date.now(); + const seenAt = this.seenEventIds.get(eventId); + this.seenEventIds.set(eventId, now); + return seenAt !== undefined && now - seenAt < SEEN_EVENT_TTL_MS; + } + + private pruneSeenEvents(): void { + const cutoff = Date.now() - SEEN_EVENT_TTL_MS; + for (const [eventId, seenAt] of this.seenEventIds.entries()) { + if (seenAt < cutoff) this.seenEventIds.delete(eventId); + } + } +} diff --git a/src/config.ts b/src/config.ts index 6517a07..ab9da7f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,9 @@ export const LOCAL_WEB_GROUP_NAME = export const LOCAL_WEB_GROUP_FOLDER = process.env.LOCAL_WEB_GROUP_FOLDER || 'local-web'; export const LOCAL_WEB_SECRET = process.env.LOCAL_WEB_SECRET || ''; +export const QQ_APP_ID = process.env.QQ_APP_ID || ''; +export const QQ_CLIENT_SECRET = process.env.QQ_CLIENT_SECRET || ''; +export const QQ_SANDBOX = process.env.QQ_SANDBOX === 'true'; export const FEISHU_APP_ID = process.env.FEISHU_APP_ID || ''; export const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET || ''; export const FEISHU_CONNECTION_MODE = diff --git a/src/index.ts b/src/index.ts index 9a1f269..233767c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,9 @@ import { FEISHU_PATH, FEISHU_PORT, FEISHU_VERIFICATION_TOKEN, + QQ_APP_ID, + QQ_CLIENT_SECRET, + QQ_SANDBOX, IDLE_TIMEOUT, LOCAL_WEB_GROUP_FOLDER, LOCAL_WEB_GROUP_JID, @@ -53,6 +56,7 @@ import { } from './session-manager.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { LocalWebChannel } from './channels/local-web/channel.js'; +import { QQChannel } from './channels/qq.js'; import { FeishuChannel } from './channels/feishu.js'; import { WhatsAppChannel } from './channels/whatsapp/channel.js'; import { WeComChannel } from './channels/wecom.js'; @@ -263,6 +267,17 @@ async function main(): Promise { await localWeb.connect(); } + if (QQ_APP_ID && QQ_CLIENT_SECRET) { + const qq = new QQChannel({ + appId: QQ_APP_ID, + clientSecret: QQ_CLIENT_SECRET, + sandbox: QQ_SANDBOX, + ...channelCallbacks, + }); + channels.push(qq); + try { await qq.connect(); } catch (err) { logger.error({ err }, 'QQ connection failed'); } + } + if (FEISHU_APP_ID && FEISHU_APP_SECRET) { const feishu = new FeishuChannel({ appId: FEISHU_APP_ID,