From d59c4a654f959361e9e49e244886cc2e9558b97d Mon Sep 17 00:00:00 2001 From: zenord Date: Sat, 15 Aug 2026 16:56:58 +0800 Subject: [PATCH] Add QQ gateway runtime support --- README.md | 49 ++++++-- bin/gori-agent | 5 + config.example.json | 5 +- gori-agent.sh | 148 +++++++++++++++++++++++ install.sh | 34 ++++++ package-lock.json | 39 +++++- package.json | 4 +- src/cli/doctor.ts | 3 +- src/cli/setup.ts | 47 ++++++-- src/config.ts | 5 +- src/platforms/qq/adapter.ts | 12 +- src/platforms/qq/gateway-client.ts | 188 +++++++++++++++++++++++++++++ src/platforms/qq/types.ts | 13 ++ src/server.ts | 31 ++++- 14 files changed, 549 insertions(+), 34 deletions(-) create mode 100755 bin/gori-agent create mode 100755 gori-agent.sh create mode 100755 install.sh create mode 100644 src/platforms/qq/gateway-client.ts diff --git a/README.md b/README.md index bb339d0..8f4988c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ Requires Node.js 20+. ```bash npm install npm run build -npx gori-agent setup +./install.sh +gori-agent setup ``` +`install.sh` installs the command wrapper under `~/.gori-agent/bin`, stores runtime PID/log files under `~/.gori-agent/state` and `~/.gori-agent/logs`, and adds the command to `~/.bashrc`. Open a new terminal or run `source ~/.bashrc` before using `gori-agent` directly. + If the package bin has not been linked, use the no-link fallback: ```bash @@ -32,6 +35,25 @@ Fallback: npm run gori-agent -- start --config ./config.json ``` +Convenience executable: + +```bash +gori-agent setup +gori-agent start +gori-agent start-daemon +gori-agent status +gori-agent logs +gori-agent stop +``` + +If the command is not linked into PATH, run the project-local wrapper: + +```bash +./gori-agent.sh status +``` + +Use `--config path` after the command to override the config path. + ## CLI commands ```bash @@ -85,7 +107,7 @@ Discovery never sends an actual prompt to an agent. Probes use `child_process.sp | Feishu/Lark | Implemented | Implemented | Replies to `im.message.receive_v1` via `/im/v1/messages/{message_id}/reply`. | | WeCom | 501 scaffold | Implemented | Uses `gettoken` and `message/send`; inbound callback verification/encryption is not in v1. | | personal WeChat | External webhook scaffold | Synchronous webhook response | Native iLink/personal WeChat integration is not included in v1. | -| QQ | Implemented | Implemented | Supports QQ official Bot HTTP callback validation and message events. | +| QQ | Implemented | Implemented | Supports QQ official Bot WebSocket gateway mode and optional HTTP callback mode. | | Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON endpoint for local bridges and tests. | ## Hermes reference @@ -162,17 +184,26 @@ The adapter accepts Feishu URL verification challenges and returns `{ "challenge ## QQ setup -The wizard can collect QQ Bot values for the official HTTP callback mode. +QQ has two connection modes: -Manual steps: +- `websocket` (default/recommended): the gateway actively connects to QQ with OAuth access token and WebSocket. No public domain or callback URL is needed. +- `webhook`: QQ posts events to a public HTTPS callback URL. -1. Create a QQ official Bot and enable HTTP callback/event subscription. +Manual WebSocket steps: + +1. Create a QQ official Bot. +2. Put `appId` and `clientSecret` into your config. +3. Keep `platforms.qq.connectionMode` as `"websocket"`. +4. Keep the default `intents` value `33554432` (`1 << 25`, `GROUP_AND_C2C_EVENT`) to receive `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE`. +5. Run `gori-agent start --config ./config.json`; the process should log `QQ websocket ready` after successful authentication. + +Manual HTTP callback steps: + +1. Set `platforms.qq.connectionMode` to `"webhook"`. 2. Configure the callback URL to `https:///webhook/qq`. QQ callback URLs must use an allowed public HTTPS port such as 443, 8443, 8080, or 80. -3. Subscribe to message events you need, commonly `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE`. -4. Put `appId`, `clientSecret`, and `botSecret` into your config. -5. Keep `verifySignature` enabled in production so `X-Signature-Ed25519` callbacks are verified. +3. Put `botSecret` into your config and keep `verifySignature` enabled so `X-Signature-Ed25519` callbacks are verified. -The adapter handles QQ `op: 13` callback URL validation and returns `{ "plain_token": "...", "signature": "..." }`. `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE` callbacks return QQ HTTP callback ACK `{ "op": 12 }` immediately, then reply through the QQ Bot group/C2C message APIs. +The webhook adapter handles QQ `op: 13` callback URL validation and returns `{ "plain_token": "...", "signature": "..." }`. `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE` callbacks return QQ HTTP callback ACK `{ "op": 12 }` immediately, then reply through the QQ Bot group/C2C message APIs. ## Generic webhook diff --git a/bin/gori-agent b/bin/gori-agent new file mode 100755 index 0000000..b994348 --- /dev/null +++ b/bin/gori-agent @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$PROJECT_ROOT/gori-agent.sh" "$@" diff --git a/config.example.json b/config.example.json index 1aedf05..c365bce 100644 --- a/config.example.json +++ b/config.example.json @@ -44,11 +44,14 @@ }, "qq": { "enabled": false, + "connectionMode": "websocket", "appId": "1020xxxx", "clientSecret": "replace-me", "botSecret": "replace-me", "verifySignature": true, - "botNames": ["Gori Agent"] + "botNames": ["Gori Agent"], + "intents": 33554432, + "shard": [0, 1] }, "webhook": { "enabled": true, diff --git a/gori-agent.sh b/gori-agent.sh new file mode 100755 index 0000000..feae508 --- /dev/null +++ b/gori-agent.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GORI_AGENT_HOME="${GORI_AGENT_HOME:-$ROOT_DIR}" +STATE_DIR="$GORI_AGENT_HOME/state" +LOG_DIR="$GORI_AGENT_HOME/logs" +CONFIG_FILE="${GORI_GATEWAY_CONFIG:-$ROOT_DIR/config.json}" +PID_FILE="$STATE_DIR/gori-agent.pid" +LOG_FILE="$LOG_DIR/gori-agent.log" + +usage() { + cat < [--config path] + +Commands: + setup Run interactive setup + start Start gateway in foreground + start-daemon Start gateway in background + stop Stop background gateway + restart Restart background gateway + status Show gateway status + doctor Check configuration and local agents + logs Follow gateway log + discover-agents List detected local CLI agents + +Environment: + GORI_GATEWAY_CONFIG Config path, default: $CONFIG_FILE +EOF +} + +ensure_build() { + if [[ ! -f "$ROOT_DIR/dist/cli.js" ]]; then + echo "dist/cli.js not found; building..." + npm run build --prefix "$ROOT_DIR" + fi +} + +ensure_state() { + mkdir -p "$STATE_DIR" "$LOG_DIR" +} + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +start_daemon() { + ensure_build + ensure_state + if is_running; then + echo "gori-agent already running, pid $(cat "$PID_FILE")" + exit 0 + fi + nohup node "$ROOT_DIR/dist/cli.js" start --config "$CONFIG_FILE" >>"$LOG_FILE" 2>&1 & + echo $! >"$PID_FILE" + echo "gori-agent started, pid $(cat "$PID_FILE")" + echo "log: $LOG_FILE" +} + +stop_daemon() { + if ! is_running; then + rm -f "$PID_FILE" + echo "gori-agent is not running" + exit 0 + fi + local pid + pid="$(cat "$PID_FILE")" + kill "$pid" + rm -f "$PID_FILE" + echo "gori-agent stopped, pid $pid" +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +COMMAND="$1" +shift + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + CONFIG_FILE="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +case "$COMMAND" in + setup) + ensure_build + exec node "$ROOT_DIR/dist/cli.js" setup --config "$CONFIG_FILE" + ;; + start) + ensure_build + exec node "$ROOT_DIR/dist/cli.js" start --config "$CONFIG_FILE" + ;; + start-daemon) + start_daemon + ;; + stop) + stop_daemon + ;; + restart) + stop_daemon + start_daemon + ;; + status) + ensure_build + if is_running; then + echo "gori-agent process: running, pid $(cat "$PID_FILE")" + else + echo "gori-agent process: not running" + fi + exec node "$ROOT_DIR/dist/cli.js" status --config "$CONFIG_FILE" + ;; + doctor) + ensure_build + exec node "$ROOT_DIR/dist/cli.js" doctor --config "$CONFIG_FILE" + ;; + discover-agents) + ensure_build + exec node "$ROOT_DIR/dist/cli.js" discover-agents --config "$CONFIG_FILE" + ;; + logs) + ensure_state + touch "$LOG_FILE" + exec tail -f "$LOG_FILE" + ;; + --help|-h|help) + usage + ;; + *) + echo "Unknown command: $COMMAND" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..da7a6ed --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GORI_AGENT_HOME="${GORI_AGENT_HOME:-$HOME/.gori-agent}" +BIN_DIR="$GORI_AGENT_HOME/bin" +PROFILE_FILE="${GORI_AGENT_PROFILE:-$HOME/.bashrc}" + +mkdir -p "$BIN_DIR" "$GORI_AGENT_HOME/state" "$GORI_AGENT_HOME/logs" + +cat >"$BIN_DIR/gori-agent" <>"$PROFILE_FILE" +fi + +echo "Installed gori-agent to $BIN_DIR/gori-agent" +echo "Project root: $PROJECT_ROOT" +echo "PATH profile: $PROFILE_FILE" +echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent --help" diff --git a/package-lock.json b/package-lock.json index f8c4fa7..e3c2af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,18 @@ "": { "name": "gori-agent-gateway", "version": "0.1.0", - "bin": { - "gori-agent": "dist/cli.js" - }, "dependencies": { "express": "^4.19.2", + "ws": "^8.21.3", "zod": "^3.23.8" }, + "bin": { + "gori-agent": "dist/cli.js" + }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.14.10", + "@types/ws": "^8.18.1", "tsx": "^4.16.2", "typescript": "^5.5.3" }, @@ -584,6 +586,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1494,6 +1506,27 @@ "node": ">= 0.8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 4f42a0e..29dc67d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/server.js", "bin": { - "gori-agent": "dist/cli.js" + "gori-agent": "bin/gori-agent" }, "scripts": { "build": "tsc -p tsconfig.json", @@ -22,11 +22,13 @@ }, "dependencies": { "express": "^4.19.2", + "ws": "^8.21.3", "zod": "^3.23.8" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.14.10", + "@types/ws": "^8.18.1", "tsx": "^4.16.2", "typescript": "^5.5.3" } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index ccc0774..ad1956b 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -32,9 +32,10 @@ export async function runDoctor(config: AppConfig, configPath: string): Promise< if (!config.platforms.feishu.verificationToken || config.platforms.feishu.verificationToken === "replace-me") warn("Feishu verificationToken is missing or placeholder."); } if (config.platforms.qq.enabled) { + console.log(`QQ connection mode: ${config.platforms.qq.connectionMode}`); if (!config.platforms.qq.appId) problems += error("QQ appId is empty."); if (!config.platforms.qq.clientSecret || config.platforms.qq.clientSecret === "replace-me") warn("QQ clientSecret is missing or placeholder."); - if (config.platforms.qq.verifySignature) { + if (config.platforms.qq.connectionMode === "webhook" && config.platforms.qq.verifySignature) { const callbackSecret = config.platforms.qq.botSecret || config.platforms.qq.clientSecret; if (!callbackSecret || callbackSecret === "replace-me") problems += error("QQ botSecret or clientSecret is required when verifySignature is true."); } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 80ab2bb..3a0adaa 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -202,15 +202,39 @@ async function buildNextConfig( secret: await prompt.ask(existing.platforms.wecom.secret ? "WeCom secret (leave blank to keep existing)" : "WeCom secret") || existing.platforms.wecom.secret }; } else if (selectedPlatform === "qq") { - console.log("\nQQ Bot HTTP callback endpoint: /webhook/qq"); - platforms.qq = { - enabled: true, - appId: await prompt.ask("QQ appId", existing.platforms.qq.appId), - clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret, - botSecret: await prompt.ask(existing.platforms.qq.botSecret ? "QQ botSecret for callback signing (leave blank to keep existing)" : "QQ botSecret for callback signing, blank to reuse clientSecret", "") || existing.platforms.qq.botSecret, - verifySignature: await prompt.askBoolean("Verify QQ callback signatures", existing.platforms.qq.verifySignature), - botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", "))) - }; + const connectionMode = await prompt.choose("QQ connection mode", [ + { label: "WebSocket gateway", value: "websocket" as const, hint: "recommended; no public callback URL needed" }, + { label: "HTTP callback webhook", value: "webhook" as const, hint: "requires public HTTPS callback URL" } + ], existing.platforms.qq.connectionMode === "webhook" ? 1 : 0); + + if (connectionMode === "websocket") { + console.log("\nQQ WebSocket gateway will actively connect to QQ; no public domain is needed."); + const intentsText = await prompt.ask("QQ gateway intents", String(existing.platforms.qq.intents)); + platforms.qq = { + enabled: true, + connectionMode, + appId: await prompt.ask("QQ appId", existing.platforms.qq.appId), + clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret, + botSecret: existing.platforms.qq.botSecret, + verifySignature: existing.platforms.qq.verifySignature, + botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", "))), + intents: parsePositiveInt(intentsText, existing.platforms.qq.intents), + shard: existing.platforms.qq.shard + }; + } else { + console.log("\nQQ Bot HTTP callback endpoint: /webhook/qq"); + platforms.qq = { + enabled: true, + connectionMode, + appId: await prompt.ask("QQ appId", existing.platforms.qq.appId), + clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret, + botSecret: await prompt.ask(existing.platforms.qq.botSecret ? "QQ botSecret for callback signing (leave blank to keep existing)" : "QQ botSecret for callback signing, blank to reuse clientSecret", "") || existing.platforms.qq.botSecret, + verifySignature: await prompt.askBoolean("Verify QQ callback signatures", existing.platforms.qq.verifySignature), + botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", "))), + intents: existing.platforms.qq.intents, + shard: existing.platforms.qq.shard + }; + } } const echo = echoConfig(); @@ -263,6 +287,11 @@ function splitArgs(value: string): string[] { return value.split(" ").map((part) => part.trim()).filter(Boolean); } +function parsePositiveInt(value: string, fallback: number): number { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + function splitCommaList(value: string): string[] { return value.split(",").map((part) => part.trim()).filter(Boolean); } diff --git a/src/config.ts b/src/config.ts index 3b6cade..0b74f70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,11 +36,14 @@ const wecomSchema = z.object({ const qqSchema = z.object({ enabled: z.boolean().default(false), + connectionMode: z.enum(["websocket", "webhook"]).default("websocket"), appId: z.string().default(""), clientSecret: z.string().default(""), botSecret: z.string().default(""), verifySignature: z.boolean().default(true), - botNames: z.array(z.string()).default([]) + botNames: z.array(z.string()).default([]), + intents: z.number().int().positive().default(1 << 25), + shard: z.tuple([z.number().int().nonnegative(), z.number().int().positive()]).default([0, 1]) }); const webhookSchema = z.object({ diff --git a/src/platforms/qq/adapter.ts b/src/platforms/qq/adapter.ts index e8f4170..ed43491 100644 --- a/src/platforms/qq/adapter.ts +++ b/src/platforms/qq/adapter.ts @@ -38,13 +38,17 @@ export class QqAdapter implements PlatformAdapter { return jsonResponse(QQ_CALLBACK_ACK); } - const message = this.normalizeMessage(payload); - if (!message) return jsonResponse(QQ_CALLBACK_ACK); + if (!this.handleDispatch(payload)) return jsonResponse(QQ_CALLBACK_ACK); + return jsonResponse(QQ_CALLBACK_ACK); + } + handleDispatch(payload: QqWebhookPayload): boolean { + const message = this.normalizeMessage(payload); + if (!message) return false; void this.gateway.receive(message, this).catch((error) => { console.error("QQ gateway error", error); }); - return jsonResponse(QQ_CALLBACK_ACK); + return true; } async sendMessage(message: OutgoingMessage): Promise { @@ -110,7 +114,7 @@ export class QqAdapter implements PlatformAdapter { return this.config.botSecret || this.config.clientSecret; } - private async getAccessToken(): Promise { + async getAccessToken(): Promise { if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token; const response = await fetch("https://bots.qq.com/app/getAppAccessToken", { diff --git a/src/platforms/qq/gateway-client.ts b/src/platforms/qq/gateway-client.ts new file mode 100644 index 0000000..93f0983 --- /dev/null +++ b/src/platforms/qq/gateway-client.ts @@ -0,0 +1,188 @@ +import os from "node:os"; +import WebSocket from "ws"; +import type { AppConfig } from "../../config.js"; +import type { QqAdapter } from "./adapter.js"; +import type { QqGatewayResponse, QqWebhookPayload } from "./types.js"; + +const QQ_GATEWAY_API = "https://api.sgroup.qq.com/gateway/bot"; +const RECONNECT_BASE_MS = 2_000; +const RECONNECT_MAX_MS = 60_000; + +export class QqGatewayClient { + private socket?: WebSocket; + private heartbeat?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private seq: number | null = null; + private sessionId?: string; + private token = ""; + private reconnectAttempts = 0; + private stopped = false; + + constructor( + private readonly config: AppConfig["platforms"]["qq"], + private readonly adapter: QqAdapter + ) {} + + start(): void { + this.stopped = false; + void this.connect().catch((error) => { + console.error("QQ websocket connect failed", error); + this.scheduleReconnect(); + }); + } + + stop(): void { + this.stopped = true; + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.heartbeat = undefined; + this.reconnectTimer = undefined; + this.socket?.close(); + this.socket = undefined; + } + + private async connect(): Promise { + if (this.stopped) return; + + const token = await this.adapter.getAccessToken(); + this.token = token; + const gateway = await this.fetchGateway(token); + if (!gateway.url) throw new Error("QQ gateway response did not include url"); + + const socket = new WebSocket(gateway.url); + this.socket = socket; + + socket.on("open", () => { + console.log(`QQ websocket connected: ${gateway.url}`); + }); + + socket.on("message", (data) => { + const payload = parsePayload(data); + if (payload) this.handlePayload(payload); + }); + + socket.on("close", (code, reason) => { + if (this.stopped) return; + this.clearHeartbeat(); + console.warn(`QQ websocket closed: ${code} ${reason.toString()}`.trim()); + this.scheduleReconnect(); + }); + + socket.on("error", (error) => { + if (!this.stopped) console.error("QQ websocket error", error); + }); + } + + handlePayload(payload: QqWebhookPayload): void { + if (typeof payload.s === "number") this.seq = payload.s; + + if (payload.op === 10) { + const heartbeatInterval = payload.d?.heartbeat_interval || 45_000; + this.sendIdentifyOrResume(); + this.startHeartbeat(heartbeatInterval); + return; + } + + if (payload.op === 0) { + if (payload.t === "READY") { + this.sessionId = payload.d?.session_id; + this.reconnectAttempts = 0; + console.log("QQ websocket ready"); + return; + } + if (payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "C2C_MESSAGE_CREATE") { + this.adapter.handleDispatch(payload); + } + return; + } + + if (payload.op === 7) { + console.warn("QQ websocket requested reconnect"); + this.reconnectNow(); + return; + } + + if (payload.op === 9) { + this.sessionId = undefined; + console.warn("QQ websocket invalid session; identifying again"); + this.sendIdentifyOrResume(); + } + } + + private async fetchGateway(token: string): Promise { + const response = await fetch(QQ_GATEWAY_API, { + headers: { Authorization: `QQBot ${token}` } + }); + if (!response.ok) throw new Error(`QQ gateway request failed: ${response.status} ${await response.text()}`); + return response.json() as Promise; + } + + private sendIdentifyOrResume(): void { + if (this.sessionId && this.seq !== null) { + this.send({ op: 6, d: { token: this.tokenHeader(), session_id: this.sessionId, seq: this.seq } }); + return; + } + + this.send({ + op: 2, + d: { + token: this.tokenHeader(), + intents: this.config.intents, + shard: this.config.shard, + properties: { + $os: process.platform, + $browser: "gori-agent-gateway", + $device: os.hostname() + } + } + }); + } + + private tokenHeader(): string { + return `QQBot ${this.token}`; + } + + private startHeartbeat(intervalMs: number): void { + this.clearHeartbeat(); + this.heartbeat = setInterval(() => { + this.send({ op: 1, d: this.seq }); + }, intervalMs); + } + + private clearHeartbeat(): void { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + + private send(payload: Record): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify(payload)); + } + + private reconnectNow(): void { + if (this.socket) this.socket.close(); + else this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS); + this.reconnectAttempts += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + void this.connect().catch((error) => { + console.error("QQ websocket reconnect failed", error); + this.scheduleReconnect(); + }); + }, delay); + } +} + +function parsePayload(data: WebSocket.RawData): QqWebhookPayload | undefined { + try { + return JSON.parse(data.toString("utf8")) as QqWebhookPayload; + } catch (error) { + console.error("QQ websocket received invalid payload", error); + return undefined; + } +} diff --git a/src/platforms/qq/types.ts b/src/platforms/qq/types.ts index 3298629..33397a3 100644 --- a/src/platforms/qq/types.ts +++ b/src/platforms/qq/types.ts @@ -20,9 +20,22 @@ export interface QqWebhookPayload { t?: string; } +export interface QqGatewayResponse { + url?: string; + shards?: number; + session_start_limit?: { + total?: number; + remaining?: number; + reset_after?: number; + max_concurrency?: number; + }; +} + export interface QqWebhookEventData { plain_token?: string; event_ts?: string; + session_id?: string; + heartbeat_interval?: number; id?: string; msg_id?: string; message_id?: string; diff --git a/src/server.ts b/src/server.ts index 910e521..392e116 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,10 +11,16 @@ import type { PlatformAdapter } from "./core/adapter.js"; import { FeishuAdapter } from "./platforms/feishu/adapter.js"; import { WeComAdapter } from "./platforms/wecom/adapter.js"; import { QqAdapter } from "./platforms/qq/adapter.js"; +import { QqGatewayClient } from "./platforms/qq/gateway-client.js"; import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js"; import { WeixinAdapter } from "./platforms/weixin/adapter.js"; -export function createApp(config: AppConfig): express.Express { +export interface GatewayRuntime { + app: express.Express; + qqGatewayClient?: QqGatewayClient; +} + +export function createGatewayRuntime(config: AppConfig): GatewayRuntime { const sessions = new SessionStore(); const agents = new AgentRegistry(config.defaultAgent); for (const agentConfig of config.agents) { @@ -23,10 +29,11 @@ export function createApp(config: AppConfig): express.Express { const gateway = new Gateway(config.policy, agents, sessions); const platforms = new PlatformRegistry(); + const qqAdapter = new QqAdapter(config.platforms.qq, gateway); const adapters: PlatformAdapter[] = [ new FeishuAdapter(config.platforms.feishu, gateway), new WeComAdapter(config.platforms.wecom), - new QqAdapter(config.platforms.qq, gateway), + qqAdapter, new GenericWebhookAdapter(config.platforms.webhook, gateway), new WeixinAdapter(config.platforms.weixin, gateway) ]; @@ -75,14 +82,28 @@ export function createApp(config: AppConfig): express.Express { } mountWebhook("generic", "webhook"); - return app; + const qqGatewayClient = config.platforms.qq.enabled && config.platforms.qq.connectionMode === "websocket" + ? new QqGatewayClient(config.platforms.qq, qqAdapter) + : undefined; + + return { app, qqGatewayClient }; +} + +export function createApp(config: AppConfig): express.Express { + return createGatewayRuntime(config).app; } export function startServer(config: AppConfig): Server { - const app = createApp(config); - return app.listen(config.server.port, config.server.host, () => { + const runtime = createGatewayRuntime(config); + const server = runtime.app.listen(config.server.port, config.server.host, () => { console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`); + if (runtime.qqGatewayClient) { + console.log("QQ websocket gateway enabled; connecting to QQ..."); + runtime.qqGatewayClient.start(); + } }); + server.on("close", () => runtime.qqGatewayClient?.stop()); + return server; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {