Add QQ gateway runtime support
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
Generated
+36
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user