Add QQ gateway runtime support

This commit is contained in:
zenord
2026-08-15 16:56:58 +08:00
parent d79264b11a
commit d59c4a654f
14 changed files with 549 additions and 34 deletions
+40 -9
View File
@@ -9,9 +9,12 @@ Requires Node.js 20+.
```bash ```bash
npm install npm install
npm run build 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: If the package bin has not been linked, use the no-link fallback:
```bash ```bash
@@ -32,6 +35,25 @@ Fallback:
npm run gori-agent -- start --config ./config.json 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 ## CLI commands
```bash ```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`. | | 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. | | 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. | | 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. | | Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON endpoint for local bridges and tests. |
## Hermes reference ## Hermes reference
@@ -162,17 +184,26 @@ The adapter accepts Feishu URL verification challenges and returns `{ "challenge
## QQ setup ## 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://<host>/webhook/qq`. QQ callback URLs must use an allowed public HTTPS port such as 443, 8443, 8080, or 80. 2. Configure the callback URL to `https://<host>/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`. 3. Put `botSecret` into your config and keep `verifySignature` enabled so `X-Signature-Ed25519` callbacks are verified.
4. Put `appId`, `clientSecret`, and `botSecret` into your config.
5. Keep `verifySignature` enabled in production 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 ## Generic webhook
Executable
+5
View File
@@ -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" "$@"
+4 -1
View File
@@ -44,11 +44,14 @@
}, },
"qq": { "qq": {
"enabled": false, "enabled": false,
"connectionMode": "websocket",
"appId": "1020xxxx", "appId": "1020xxxx",
"clientSecret": "replace-me", "clientSecret": "replace-me",
"botSecret": "replace-me", "botSecret": "replace-me",
"verifySignature": true, "verifySignature": true,
"botNames": ["Gori Agent"] "botNames": ["Gori Agent"],
"intents": 33554432,
"shard": [0, 1]
}, },
"webhook": { "webhook": {
"enabled": true, "enabled": true,
Executable
+148
View File
@@ -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 <<EOF
Usage: $0 <command> [--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
Executable
+34
View File
@@ -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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export GORI_AGENT_HOME="\${GORI_AGENT_HOME:-$GORI_AGENT_HOME}"
exec "$PROJECT_ROOT/gori-agent.sh" "\$@"
EOF
chmod +x "$BIN_DIR/gori-agent"
PATH_BLOCK_START="# >>> gori-agent >>>"
PATH_BLOCK_END="# <<< gori-agent <<<"
if [[ -w "$PROFILE_FILE" ]] && ! grep -q "$PATH_BLOCK_START" "$PROFILE_FILE"; then
{
echo ""
echo "$PATH_BLOCK_START"
echo "export GORI_AGENT_HOME=\"$GORI_AGENT_HOME\""
echo "export PATH=\"$BIN_DIR:\$PATH\""
echo "$PATH_BLOCK_END"
} >>"$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"
+36 -3
View File
@@ -7,16 +7,18 @@
"": { "": {
"name": "gori-agent-gateway", "name": "gori-agent-gateway",
"version": "0.1.0", "version": "0.1.0",
"bin": {
"gori-agent": "dist/cli.js"
},
"dependencies": { "dependencies": {
"express": "^4.19.2", "express": "^4.19.2",
"ws": "^8.21.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"bin": {
"gori-agent": "dist/cli.js"
},
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/ws": "^8.18.1",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
@@ -584,6 +586,16 @@
"@types/node": "*" "@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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1494,6 +1506,27 @@
"node": ">= 0.8" "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": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+3 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"main": "dist/server.js", "main": "dist/server.js",
"bin": { "bin": {
"gori-agent": "dist/cli.js" "gori-agent": "bin/gori-agent"
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
@@ -22,11 +22,13 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.19.2", "express": "^4.19.2",
"ws": "^8.21.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/ws": "^8.18.1",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.5.3" "typescript": "^5.5.3"
} }
+2 -1
View File
@@ -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.feishu.verificationToken || config.platforms.feishu.verificationToken === "replace-me") warn("Feishu verificationToken is missing or placeholder.");
} }
if (config.platforms.qq.enabled) { 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.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.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; 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."); if (!callbackSecret || callbackSecret === "replace-me") problems += error("QQ botSecret or clientSecret is required when verifySignature is true.");
} }
+30 -1
View File
@@ -202,16 +202,40 @@ async function buildNextConfig(
secret: await prompt.ask(existing.platforms.wecom.secret ? "WeCom secret (leave blank to keep existing)" : "WeCom secret") || existing.platforms.wecom.secret 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") { } else if (selectedPlatform === "qq") {
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"); console.log("\nQQ Bot HTTP callback endpoint: /webhook/qq");
platforms.qq = { platforms.qq = {
enabled: true, enabled: true,
connectionMode,
appId: await prompt.ask("QQ appId", existing.platforms.qq.appId), 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, 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, 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), 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(", "))) 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(); const echo = echoConfig();
const agents = selectedAgent.name === "echo" ? [echo] : [selectedAgent, echo]; const agents = selectedAgent.name === "echo" ? [echo] : [selectedAgent, echo];
@@ -263,6 +287,11 @@ function splitArgs(value: string): string[] {
return value.split(" ").map((part) => part.trim()).filter(Boolean); 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[] { function splitCommaList(value: string): string[] {
return value.split(",").map((part) => part.trim()).filter(Boolean); return value.split(",").map((part) => part.trim()).filter(Boolean);
} }
+4 -1
View File
@@ -36,11 +36,14 @@ const wecomSchema = z.object({
const qqSchema = z.object({ const qqSchema = z.object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
connectionMode: z.enum(["websocket", "webhook"]).default("websocket"),
appId: z.string().default(""), appId: z.string().default(""),
clientSecret: z.string().default(""), clientSecret: z.string().default(""),
botSecret: z.string().default(""), botSecret: z.string().default(""),
verifySignature: z.boolean().default(true), 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({ const webhookSchema = z.object({
+8 -4
View File
@@ -38,13 +38,17 @@ export class QqAdapter implements PlatformAdapter {
return jsonResponse(QQ_CALLBACK_ACK); return jsonResponse(QQ_CALLBACK_ACK);
} }
const message = this.normalizeMessage(payload); if (!this.handleDispatch(payload)) return jsonResponse(QQ_CALLBACK_ACK);
if (!message) 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) => { void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error); console.error("QQ gateway error", error);
}); });
return jsonResponse(QQ_CALLBACK_ACK); return true;
} }
async sendMessage(message: OutgoingMessage): Promise<void> { async sendMessage(message: OutgoingMessage): Promise<void> {
@@ -110,7 +114,7 @@ export class QqAdapter implements PlatformAdapter {
return this.config.botSecret || this.config.clientSecret; return this.config.botSecret || this.config.clientSecret;
} }
private async getAccessToken(): Promise<string> { async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token; if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
const response = await fetch("https://bots.qq.com/app/getAppAccessToken", { const response = await fetch("https://bots.qq.com/app/getAppAccessToken", {
+188
View File
@@ -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<void> {
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<QqGatewayResponse> {
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<QqGatewayResponse>;
}
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<string, unknown>): 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;
}
}
+13
View File
@@ -20,9 +20,22 @@ export interface QqWebhookPayload {
t?: string; 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 { export interface QqWebhookEventData {
plain_token?: string; plain_token?: string;
event_ts?: string; event_ts?: string;
session_id?: string;
heartbeat_interval?: number;
id?: string; id?: string;
msg_id?: string; msg_id?: string;
message_id?: string; message_id?: string;
+26 -5
View File
@@ -11,10 +11,16 @@ import type { PlatformAdapter } from "./core/adapter.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js"; import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { WeComAdapter } from "./platforms/wecom/adapter.js"; import { WeComAdapter } from "./platforms/wecom/adapter.js";
import { QqAdapter } from "./platforms/qq/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 { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/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 sessions = new SessionStore();
const agents = new AgentRegistry(config.defaultAgent); const agents = new AgentRegistry(config.defaultAgent);
for (const agentConfig of config.agents) { 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 gateway = new Gateway(config.policy, agents, sessions);
const platforms = new PlatformRegistry(); const platforms = new PlatformRegistry();
const qqAdapter = new QqAdapter(config.platforms.qq, gateway);
const adapters: PlatformAdapter[] = [ const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway), new FeishuAdapter(config.platforms.feishu, gateway),
new WeComAdapter(config.platforms.wecom), new WeComAdapter(config.platforms.wecom),
new QqAdapter(config.platforms.qq, gateway), qqAdapter,
new GenericWebhookAdapter(config.platforms.webhook, gateway), new GenericWebhookAdapter(config.platforms.webhook, gateway),
new WeixinAdapter(config.platforms.weixin, gateway) new WeixinAdapter(config.platforms.weixin, gateway)
]; ];
@@ -75,14 +82,28 @@ export function createApp(config: AppConfig): express.Express {
} }
mountWebhook("generic", "webhook"); 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 { export function startServer(config: AppConfig): Server {
const app = createApp(config); const runtime = createGatewayRuntime(config);
return app.listen(config.server.port, config.server.host, () => { const server = runtime.app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`); 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]) { if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {