Add CLI setup and QQ webhook integration

This commit is contained in:
zenord
2026-08-15 16:02:05 +08:00
parent 00325efb50
commit d79264b11a
22 changed files with 1389 additions and 99 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/
dist/
.env
config.json
*.log
.DS_Store
coverage/
+113 -23
View File
@@ -1,10 +1,82 @@
# gori-agent-gateway
Hermes-gateway-style multi-IM Agent Gateway for routing instant-message webhooks to configurable CLI agents. It is intentionally standalone and not tied to Pi.
Hermes-gateway-style multi-IM Agent Gateway for routing instant-message webhooks to configurable CLI agents. The primary product flow is `gori-agent setup`: discover local agents first, pick one, then choose the IM platform to connect.
## Hermes reference
## Quick start
This project follows the Hermes gateway pattern: platform adapters normalize inbound messages, a central gateway applies policy/session/concurrency handling, then replies are sent through the originating adapter. The Feishu adapter ports the key Hermes behavior for tenant token caching, URL challenge handling, token verification, `im.message.receive_v1` parsing, mention cleanup, and message replies.
Requires Node.js 20+.
```bash
npm install
npm run build
npx gori-agent setup
```
If the package bin has not been linked, use the no-link fallback:
```bash
npm run gori-agent -- setup
```
The setup wizard writes `config.json` only after confirmation. `config.json` is ignored by git and should hold local secrets.
Start the gateway after setup:
```bash
gori-agent start --config ./config.json
```
Fallback:
```bash
npm run gori-agent -- start --config ./config.json
```
## CLI commands
```bash
gori-agent setup
gori-agent discover-agents [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
gori-agent --help
```
Package scripts mirror common commands:
```bash
npm run setup
npm run discover-agents
npm run doctor
```
## Agent-first setup flow
`gori-agent setup` does the following:
1. Discovers local CLI agents on `PATH` and always includes the built-in `echo` fallback.
2. Lets you pick one agent.
3. Lets you choose an IM platform: Feishu/Lark, WeChat external webhook, WeCom scaffold, QQ Bot webhook, or Generic webhook.
4. Builds a focused `config.json` containing the chosen default agent, the chosen agent plus `echo`, and the chosen platform enabled.
5. Asks before writing.
Detected ready agents with known safe prompt modes:
| Agent | Command | Args | Input mode | Permission mode |
| --- | --- | --- | --- | --- |
| Kimi | `kimi` | `-p` | final argv | `kimi -p` already runs non-interactively under Kimi Code's auto permission policy. `--yolo` cannot be combined with `-p`. |
| OpenCode | `opencode` | `run` | final argv | setup can append `--auto`. |
| Codex | `codex` | `exec` | final argv | default; add manual args if needed. |
| Claude | `claude` | `-p` | final argv | default; add manual args if needed. |
| Built-in echo | `node` | `scripts/echo-agent.js` | stdin | n/a |
When you pick Kimi, setup asks which Kimi Code model to use. It reads your local Kimi model aliases at setup time with `kimi provider list --json`, so custom providers from your actual Kimi config appear in the menu. Choosing the default keeps Kimi Code's configured `default_model`; choosing a model writes `-m <model> -p` into that agent's args. You can also enter a custom model alias.
Gemini, Qwen/Qwen Code, Copilot, Pi, and Hermes are probed with `--version` then `--help` only. If found, they are shown as `needs-config` unless a safe prompt mode is known; the wizard asks you to confirm command, args, input mode, extra auto/yolo permission args, and working directory before enabling them.
Discovery never sends an actual prompt to an agent. Probes use `child_process.spawn(..., { shell: false })`, a 2s timeout, and a 4KB output cap.
## Platform status
@@ -13,28 +85,14 @@ This project follows the Hermes gateway pattern: platform adapters normalize inb
| 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 | 501 scaffold | Implemented | Uses QQ Bot access token and user/group text-message heuristics. |
| QQ | Implemented | Implemented | Supports QQ official Bot HTTP callback validation and message events. |
| Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON endpoint for local bridges and tests. |
## Install and run
## Hermes reference
Requires Node.js 20+.
This project follows the Hermes gateway pattern: platform adapters normalize inbound messages, a central gateway applies policy/session/concurrency handling, then replies are sent through the originating adapter. The Feishu adapter ports the key Hermes behavior for tenant token caching, URL challenge handling, token verification, `im.message.receive_v1` parsing, mention cleanup, and message replies.
```bash
npm install
cp config.example.json config.json
GORI_GATEWAY_CONFIG=./config.json npm run dev
```
Build and run compiled output:
```bash
npm run typecheck
npm run build
GORI_GATEWAY_CONFIG=./config.json npm start
```
Endpoints:
## Endpoints
- `GET /health`
- `GET /platforms`
@@ -46,10 +104,24 @@ Endpoints:
## Configuration
Config is loaded from `GORI_GATEWAY_CONFIG`. If the variable is not set, the gateway loads `config.example.json` from the current working directory.
Server execution still supports the existing environment variable:
```bash
GORI_GATEWAY_CONFIG=./config.json npm start
```
The CLI resolves config in this order:
1. `--config path`
2. `GORI_GATEWAY_CONFIG`
3. `./config.json`
4. seed from `config.example.json` and write to `./config.json` if confirmed
Important sections:
- `server.host`: bind address, default `0.0.0.0`.
- `server.port`: gateway port, default `3000`.
- `server.publicBaseUrl`: public HTTPS base URL used by print/setup hints, for example `https://agent.example.com`.
- `policy.allowedUsers`: allow only listed normalized user IDs when non-empty.
- `policy.allowedChats`: allow only listed normalized chat IDs when non-empty.
- `policy.requireMentionInGroup`: if true, group messages are ignored unless the adapter reports a bot mention.
@@ -64,7 +136,7 @@ CLI agent options:
- `outputMaxBytes`: caps captured stdout/stderr.
- `cwd`: optional working directory for the agent process.
## Commands
## Chat commands
The gateway handles these commands per chat before invoking an agent:
@@ -76,6 +148,10 @@ The gateway handles these commands per chat before invoking an agent:
## Feishu setup
The wizard can collect Feishu values and `gori-agent print feishu --config ./config.json` prints the webhook URL and checklist.
Manual steps:
1. Create a Feishu/Lark custom app and enable bot messaging.
2. Configure event subscription for `im.message.receive_v1`.
3. Set the request URL to `https://<host>/webhook/feishu`.
@@ -84,6 +160,20 @@ The gateway handles these commands per chat before invoking an agent:
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.
Manual steps:
1. Create a QQ official Bot and enable HTTP callback/event subscription.
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`.
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.
## Generic webhook
Payload:
+7 -3
View File
@@ -1,7 +1,8 @@
{
"server": {
"host": "0.0.0.0",
"port": 3000
"port": 3000,
"publicBaseUrl": ""
},
"policy": {
"allowedUsers": [],
@@ -21,7 +22,7 @@
{
"name": "kimi",
"command": "kimi",
"args": [],
"args": ["-p"],
"inputMode": "arg",
"timeoutMs": 120000,
"outputMaxBytes": 64000
@@ -44,7 +45,10 @@
"qq": {
"enabled": false,
"appId": "1020xxxx",
"clientSecret": "replace-me"
"clientSecret": "replace-me",
"botSecret": "replace-me",
"verifySignature": true,
"botNames": ["Gori Agent"]
},
"webhook": {
"enabled": true,
+3
View File
@@ -7,6 +7,9 @@
"": {
"name": "gori-agent-gateway",
"version": "0.1.0",
"bin": {
"gori-agent": "dist/cli.js"
},
"dependencies": {
"express": "^4.19.2",
"zod": "^3.23.8"
+8 -1
View File
@@ -4,11 +4,18 @@
"description": "Hermes-gateway-style multi-IM agent gateway for CLI agents.",
"type": "module",
"main": "dist/server.js",
"bin": {
"gori-agent": "dist/cli.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/server.js",
"dev": "tsx src/server.ts"
"dev": "tsx src/server.ts",
"gori-agent": "node dist/cli.js",
"setup": "node dist/cli.js setup",
"discover-agents": "node dist/cli.js discover-agents",
"doctor": "node dist/cli.js doctor"
},
"engines": {
"node": ">=20"
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
export type AgentInputMode = "stdin" | "arg";
export type DiscoveredAgentStatus = "ready" | "needs-config";
export type PermissionModeName = "default" | "auto" | "yolo" | "manual";
export interface PermissionModeOption {
name: PermissionModeName;
label: string;
args: string[];
warning?: string;
}
export interface DiscoveredAgent {
name: string;
label: string;
command: string;
args: string[];
inputMode: AgentInputMode;
cwd?: string;
status: DiscoveredAgentStatus;
version?: string;
reason?: string;
permissionModes?: PermissionModeOption[];
modelListCommand?: string[];
}
interface AgentProbeDefinition {
name: string;
label: string;
command: string;
args: string[];
inputMode: AgentInputMode;
statusWhenFound: DiscoveredAgentStatus;
reasonWhenFound?: string;
permissionModes?: PermissionModeOption[];
modelListCommand?: string[];
}
const PROJECT_ROOT = path.resolve(new URL("../..", import.meta.url).pathname);
const PROBE_TIMEOUT_MS = 2_000;
const MAX_PROBE_OUTPUT_BYTES = 4_096;
const KNOWN_AGENTS: AgentProbeDefinition[] = [
{
name: "kimi",
label: "Kimi",
command: "kimi",
args: ["-p"],
inputMode: "arg",
statusWhenFound: "ready",
modelListCommand: ["provider", "list", "--json"],
permissionModes: [
{ name: "default", label: "Default / implicit auto for kimi -p", args: [], warning: "Kimi -p already runs non-interactively under auto permission policy; --yolo cannot be combined with -p." }
]
},
{
name: "opencode",
label: "OpenCode",
command: "opencode",
args: ["run"],
inputMode: "arg",
statusWhenFound: "ready",
permissionModes: [
{ name: "default", label: "Default", args: [] },
{ name: "auto", label: "Auto approve", args: ["--auto"], warning: "OpenCode --auto auto-approves permissions that are not explicitly denied." }
]
},
{ name: "codex", label: "Codex", command: "codex", args: ["exec"], inputMode: "arg", statusWhenFound: "ready", permissionModes: [{ name: "default", label: "Default", args: [] }] },
{ name: "claude", label: "Claude", command: "claude", args: ["-p"], inputMode: "arg", statusWhenFound: "ready", permissionModes: [{ name: "default", label: "Default", args: [] }] },
{ name: "gemini", label: "Gemini", command: "gemini", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "qwen", label: "Qwen Code", command: "qwen", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "qwen-code", label: "Qwen Code", command: "qwen-code", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "copilot", label: "GitHub Copilot", command: "copilot", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "pi", label: "Pi", command: "pi", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "hermes", label: "Hermes", command: "hermes", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." }
];
export function echoAgent(projectRoot = PROJECT_ROOT): DiscoveredAgent {
return {
name: "echo",
label: "Built-in echo",
command: "node",
args: ["scripts/echo-agent.js"],
inputMode: "stdin",
cwd: projectRoot,
status: "ready",
reason: "Built-in fallback agent."
};
}
export async function discoverAgents(projectRoot = PROJECT_ROOT): Promise<DiscoveredAgent[]> {
const agents: DiscoveredAgent[] = [echoAgent(projectRoot)];
for (const definition of KNOWN_AGENTS) {
const commandPath = findOnPath(definition.command);
if (!commandPath) continue;
const version = await probeVersion(commandPath);
agents.push({
name: definition.name,
label: definition.label,
command: commandPath,
args: definition.args,
inputMode: definition.inputMode,
status: definition.statusWhenFound,
version,
reason: definition.reasonWhenFound,
permissionModes: definition.permissionModes,
modelListCommand: definition.modelListCommand
});
}
return agents;
}
function findOnPath(command: string): string | undefined {
if (command.includes(path.sep)) return isExecutable(command) ? command : undefined;
const pathValue = process.env.PATH || "";
for (const entry of pathValue.split(path.delimiter)) {
if (!entry) continue;
const candidate = path.join(entry, command);
if (isExecutable(candidate)) return candidate;
}
return undefined;
}
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
async function probeVersion(command: string): Promise<string | undefined> {
const version = await safeProbe(command, ["--version"]);
if (version) return version;
return safeProbe(command, ["--help"]);
}
async function safeProbe(command: string, args: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
const child = spawn(command, args, {
shell: false,
stdio: ["ignore", "pipe", "pipe"]
});
let output = "";
let settled = false;
const append = (chunk: Buffer): void => {
if (Buffer.byteLength(output) >= MAX_PROBE_OUTPUT_BYTES) return;
const combined = Buffer.concat([Buffer.from(output), chunk]);
output = combined.subarray(0, MAX_PROBE_OUTPUT_BYTES).toString("utf8");
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGTERM");
resolve(cleanProbeOutput(output));
}, PROBE_TIMEOUT_MS);
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(undefined);
});
child.on("close", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(cleanProbeOutput(output));
});
});
}
function cleanProbeOutput(output: string): string | undefined {
const firstLine = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine;
}
+63
View File
@@ -0,0 +1,63 @@
import { spawn } from "node:child_process";
export interface KimiModelChoice {
id: string;
label: string;
source: "configured" | "fallback";
}
const FALLBACK_KIMI_MODELS: KimiModelChoice[] = [
{ id: "k3", label: "k3", source: "fallback" },
{ id: "k3-256k", label: "k3-256k", source: "fallback" },
{ id: "kimi-for-coding", label: "kimi-for-coding", source: "fallback" },
{ id: "kimi-for-coding-highspeed", label: "kimi-for-coding-highspeed", source: "fallback" }
];
export async function listConfiguredKimiModels(kimiCommand: string): Promise<KimiModelChoice[]> {
const output = await runKimiProviderList(kimiCommand);
if (!output) return FALLBACK_KIMI_MODELS;
try {
const parsed = JSON.parse(output) as { models?: Record<string, unknown> };
const modelIds = Object.keys(parsed.models || {}).sort();
if (modelIds.length === 0) return FALLBACK_KIMI_MODELS;
return modelIds.map((id) => ({ id, label: id, source: "configured" as const }));
} catch {
return FALLBACK_KIMI_MODELS;
}
}
function runKimiProviderList(kimiCommand: string): Promise<string | undefined> {
return new Promise((resolve) => {
const child = spawn(kimiCommand, ["provider", "list", "--json"], {
shell: false,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGTERM");
resolve(undefined);
}, 5_000);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(undefined);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0 && stdout.trim()) resolve(stdout);
else resolve(undefined);
});
});
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import process from "node:process";
import { discoverAgents } from "./agents/discovery.js";
import { loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { localBaseUrl } from "./cli/net.js";
import { startServer } from "./server.js";
interface ParsedArgs {
command?: string;
rest: string[];
configPath?: string;
json: boolean;
help: boolean;
}
async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv);
if (parsed.help || !parsed.command) {
printHelp();
return 0;
}
if (parsed.command === "setup") {
await runSetup(parsed.configPath);
return 0;
}
if (parsed.command === "discover-agents") {
const agents = await discoverAgents();
if (parsed.json) {
console.log(JSON.stringify(agents, null, 2));
} else {
for (const agent of agents) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`${agent.name}\t${agent.status}\t${agent.command} ${agent.args.join(" ")}${version}${reason}`.trim());
}
}
return 0;
}
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
startServer(loaded.config);
return await new Promise(() => undefined);
}
if (parsed.command === "status") {
await printStatus(parsed.configPath);
return 0;
}
if (parsed.command === "doctor") {
const loaded = loadConfigFile(parsed.configPath);
return runDoctor(loaded.config, loaded.path);
}
if (parsed.command === "print") {
const topic = parsed.rest[0];
const loaded = loadConfigFile(parsed.configPath);
if (topic === "feishu") {
await printFeishu(loaded.config);
return 0;
}
console.error(`Unknown print topic: ${topic || "(missing)"}`);
console.error("Available: feishu");
return 1;
}
console.error(`Unknown command: ${parsed.command}`);
printHelp();
return 1;
}
function parseArgs(argv: string[]): ParsedArgs {
const rest: string[] = [];
let command: string | undefined;
let configPath: string | undefined;
let json = false;
let help = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
help = true;
} else if (arg === "--json") {
json = true;
} else if (arg === "--config") {
configPath = argv[++index];
if (!configPath) throw new Error("--config requires a path");
} else if (!command) {
command = arg;
} else {
rest.push(arg);
}
}
return { command, rest, configPath, json, help };
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}${loaded.exists ? "" : " (seeded from config.example.json)"}`);
console.log(`Server: ${loaded.config.server.host}:${loaded.config.server.port}`);
console.log(`Default agent: ${loaded.config.defaultAgent}`);
console.log(`Agents: ${loaded.config.agents.map((agent) => agent.name).join(", ")}`);
console.log(`Enabled platforms: ${Object.entries(loaded.config.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_000);
const response = await fetch(`${baseUrl}/health`, { signal: controller.signal });
clearTimeout(timer);
console.log(`Health probe: HTTP ${response.status}`);
} catch {
console.log("Health probe: not reachable on local URL");
}
}
function printHelp(): void {
console.log(`gori-agent - local CLI-agent gateway setup and operations
Usage:
gori-agent setup [--config path]
gori-agent discover-agents [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
gori-agent --help
No-link fallback:
npm run gori-agent -- setup`);
}
main(process.argv.slice(2)).then((code) => {
if (Number.isInteger(code)) process.exitCode = code;
}).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import type { AppConfig } from "../config.js";
import { loadConfigFromPath, parseConfig } from "../config.js";
export interface LoadedConfigFile {
path: string;
config: AppConfig;
exists: boolean;
source: "explicit" | "env" | "local" | "example";
}
export function projectRoot(): string {
return path.resolve(new URL("../..", import.meta.url).pathname);
}
export function resolveCliConfigPath(configPath?: string): { path: string; exists: boolean; source: LoadedConfigFile["source"] } {
if (configPath) {
const resolved = path.resolve(configPath);
return { path: resolved, exists: fs.existsSync(resolved), source: "explicit" };
}
if (process.env.GORI_GATEWAY_CONFIG) {
const resolved = path.resolve(process.env.GORI_GATEWAY_CONFIG);
return { path: resolved, exists: fs.existsSync(resolved), source: "env" };
}
const local = path.resolve("config.json");
if (fs.existsSync(local)) return { path: local, exists: true, source: "local" };
return { path: path.resolve("config.json"), exists: false, source: "example" };
}
export function loadConfigFile(configPath?: string): LoadedConfigFile {
const resolved = resolveCliConfigPath(configPath);
if (resolved.exists) {
return {
path: resolved.path,
config: loadConfigFromPath(resolved.path),
exists: true,
source: resolved.source
};
}
const examplePath = path.join(projectRoot(), "config.example.json");
const raw = fs.readFileSync(examplePath, "utf8");
const config = parseConfig(JSON.parse(raw) as unknown);
return {
path: resolved.path,
config,
exists: false,
source: "example"
};
}
export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
export function readConfigJson(configPath?: string): unknown {
const loaded = loadConfigFile(configPath);
return loaded.config;
}
+62
View File
@@ -0,0 +1,62 @@
import fs from "node:fs";
import type { AppConfig } from "../config.js";
import { discoverAgents } from "../agents/discovery.js";
import { printUrlHints } from "./net.js";
export async function runDoctor(config: AppConfig, configPath: string): Promise<number> {
let problems = 0;
console.log(`Config: ${configPath}`);
console.log(`Server: ${config.server.host}:${config.server.port}`);
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
console.log(`ERROR: defaultAgent '${config.defaultAgent}' is not in agents[].`);
problems++;
} else {
console.log(`Default agent: ${config.defaultAgent}`);
}
for (const agent of config.agents) {
if (agent.cwd && !fs.existsSync(agent.cwd)) {
console.log(`WARN: agent '${agent.name}' cwd does not exist: ${agent.cwd}`);
}
}
const enabledPlatforms = Object.entries(config.platforms).filter(([, value]) => value.enabled).map(([name]) => name);
console.log(`Enabled platforms: ${enabledPlatforms.join(", ") || "none"}`);
if (enabledPlatforms.length === 0) {
console.log("WARN: no IM platform is enabled.");
}
if (config.platforms.feishu.enabled) {
if (!config.platforms.feishu.appId) problems += error("Feishu appId is empty.");
if (!config.platforms.feishu.appSecret || config.platforms.feishu.appSecret === "replace-me") warn("Feishu appSecret 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.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) {
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.");
}
}
const discovered = await discoverAgents();
console.log("Discovered local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.name}: ${agent.status}${version}${reason}`);
}
await printUrlHints(config);
return problems > 0 ? 1 : 0;
}
function error(message: string): 1 {
console.log(`ERROR: ${message}`);
return 1;
}
function warn(message: string): void {
console.log(`WARN: ${message}`);
}
+49
View File
@@ -0,0 +1,49 @@
import os from "node:os";
import type { AppConfig } from "../config.js";
export function localBaseUrl(config: AppConfig): string {
return `http://localhost:${config.server.port}`;
}
export function lanBaseUrls(config: AppConfig): string[] {
const urls: string[] = [];
for (const interfaces of Object.values(os.networkInterfaces())) {
for (const item of interfaces || []) {
if (item.family === "IPv4" && !item.internal) {
urls.push(`http://${item.address}:${config.server.port}`);
}
}
}
return urls;
}
export async function publicBaseUrlHint(config: AppConfig): Promise<string | undefined> {
if (config.server.publicBaseUrl) return config.server.publicBaseUrl.replace(/\/$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch("https://api.ipify.org?format=text", { signal: controller.signal });
if (!response.ok) return undefined;
const ip = (await response.text()).trim();
if (!ip) return undefined;
return `http://${ip}:${config.server.port}`;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
export async function printUrlHints(config: AppConfig): Promise<void> {
console.log(`Local: ${localBaseUrl(config)}`);
const lanUrls = lanBaseUrls(config);
if (lanUrls.length > 0) console.log(`LAN: ${lanUrls.join(", ")}`);
const publicHint = await publicBaseUrlHint(config);
if (publicHint) console.log(`Public hint: ${publicHint}`);
if (!config.server.publicBaseUrl) console.log("Set server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
}
export async function webhookBaseUrl(config: AppConfig): Promise<string> {
return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AppConfig } from "../config.js";
import { webhookBaseUrl } from "./net.js";
export async function printFeishu(config: AppConfig): Promise<void> {
const baseUrl = await webhookBaseUrl(config);
console.log("Feishu/Lark setup instructions");
console.log("");
console.log(`Webhook URL: ${baseUrl}/webhook/feishu`);
console.log("Event subscription: im.message.receive_v1");
console.log("Required config fields: platforms.feishu.appId, appSecret, verificationToken, botNames");
console.log("Do not paste appSecret into chats or logs.");
if (!config.platforms.feishu.enabled) {
console.log("Warning: platforms.feishu.enabled is false in this config.");
}
if (!config.server.publicBaseUrl) {
console.log("Warning: server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
}
}
+73
View File
@@ -0,0 +1,73 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
export interface PromptSession {
ask(question: string, defaultValue?: string): Promise<string>;
askBoolean(question: string, defaultValue?: boolean): Promise<boolean>;
askList(question: string, defaultValues?: string[]): Promise<string[]>;
choose<T>(question: string, choices: Choice<T>[], defaultIndex?: number): Promise<T>;
close(): void;
}
export interface Choice<T> {
label: string;
value: T;
hint?: string;
}
export function createPromptSession(): PromptSession {
const rl = readline.createInterface({ input, output });
return {
ask: (question, defaultValue) => ask(rl, question, defaultValue),
askBoolean: (question, defaultValue) => askBoolean(rl, question, defaultValue),
askList: (question, defaultValues) => askList(rl, question, defaultValues),
choose: (question, choices, defaultIndex) => choose(rl, question, choices, defaultIndex),
close: () => rl.close()
};
}
export async function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : "";
const answer = (await rl.question(`${question}${suffix}: `)).trim();
return answer || defaultValue || "";
}
export async function askBoolean(rl: readline.Interface, question: string, defaultValue = false): Promise<boolean> {
const suffix = defaultValue ? " [Y/n]" : " [y/N]";
for (;;) {
const answer = (await rl.question(`${question}${suffix}: `)).trim().toLowerCase();
if (!answer) return defaultValue;
if (["y", "yes"].includes(answer)) return true;
if (["n", "no"].includes(answer)) return false;
console.log("Please answer yes or no.");
}
}
export async function askList(rl: readline.Interface, question: string, defaultValues: string[] = []): Promise<string[]> {
const answer = await ask(rl, question, defaultValues.join(","));
return answer.split(",").map((value) => value.trim()).filter(Boolean);
}
export async function choose<T>(
rl: readline.Interface,
question: string,
choices: Choice<T>[],
defaultIndex = 0
): Promise<T> {
if (choices.length === 0) throw new Error("No choices available");
console.log(question);
choices.forEach((choice, index) => {
const marker = index === defaultIndex ? "*" : " ";
const hint = choice.hint ? ` - ${choice.hint}` : "";
console.log(` ${marker} ${index + 1}) ${choice.label}${hint}`);
});
for (;;) {
const answer = (await rl.question(`Choose 1-${choices.length} [${defaultIndex + 1}]: `)).trim();
if (!answer) return choices[defaultIndex]?.value ?? choices[0].value;
const index = Number(answer) - 1;
if (Number.isInteger(index) && choices[index]) return choices[index].value;
console.log(`Please enter a number from 1 to ${choices.length}.`);
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureFeishu(
prompt: PromptSession,
existing: AppConfig["platforms"]["feishu"]
): Promise<AppConfig["platforms"]["feishu"]> {
console.log("\nFeishu/Lark setup");
console.log("Create a Feishu/Lark app, enable bot messaging, and subscribe to im.message.receive_v1.");
const appId = await prompt.ask("App ID", existing.appId && existing.appId !== "cli_xxx" ? existing.appId : undefined);
const appSecretAnswer = await prompt.ask(existing.appSecret ? "App Secret (leave blank to keep existing)" : "App Secret");
const verificationToken = await prompt.ask(
"Verification token",
existing.verificationToken && existing.verificationToken !== "replace-me" ? existing.verificationToken : undefined
);
const botNames = await prompt.askList("Bot display names, comma-separated", existing.botNames);
return {
enabled: true,
appId,
appSecret: appSecretAnswer || existing.appSecret,
verificationToken,
botNames
};
}
+15
View File
@@ -0,0 +1,15 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureGenericWebhook(
prompt: PromptSession,
existing: AppConfig["platforms"]["webhook"]
): Promise<AppConfig["platforms"]["webhook"]> {
console.log("\nGeneric webhook setup");
console.log("Use POST /webhook/generic with optional X-Gori-Signature HMAC-SHA256 authentication.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Webhook secret", generated);
return { enabled: true, secret };
}
+15
View File
@@ -0,0 +1,15 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureWeixin(
prompt: PromptSession,
existing: AppConfig["platforms"]["weixin"]
): Promise<AppConfig["platforms"]["weixin"]> {
console.log("\nPersonal WeChat external webhook setup");
console.log("Native personal WeChat integration is not included; use an external bridge that POSTs to /webhook/weixin.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Bridge secret/reference", generated);
return { enabled: true, mode: "external-webhook", secret };
}
+268
View File
@@ -0,0 +1,268 @@
import path from "node:path";
import type { AppConfig, CliAgentConfig } from "../config.js";
import type { DiscoveredAgent } from "../agents/discovery.js";
import { discoverAgents, echoAgent } from "../agents/discovery.js";
import { listConfiguredKimiModels } from "../agents/kimi-models.js";
import { createPromptSession, type Choice } from "./prompt.js";
import { loadConfigFile, projectRoot, writeConfigFile } from "./config-file.js";
import { configureFeishu } from "./setup-feishu.js";
import { configureGenericWebhook } from "./setup-webhook.js";
import { configureWeixin } from "./setup-weixin.js";
import { printUrlHints } from "./net.js";
type PlatformName = keyof AppConfig["platforms"];
const PLATFORM_CHOICES: Choice<PlatformName>[] = [
{ label: "Feishu/Lark", value: "feishu", hint: "full inbound/outbound adapter" },
{ label: "WeChat external webhook", value: "weixin", hint: "personal WeChat bridge scaffold" },
{ label: "WeCom scaffold", value: "wecom", hint: "inbound is not implemented in v1" },
{ label: "QQ Bot webhook", value: "qq", hint: "official QQ Bot HTTP callback" },
{ label: "Generic webhook", value: "webhook", hint: "signed JSON webhook" }
];
export async function runSetup(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const prompt = createPromptSession();
try {
console.log("gori-agent setup");
console.log(`Config target: ${loaded.path}${loaded.exists ? "" : " (will create from config.example.json)"}`);
await printUrlHints(loaded.config);
const discovered = await discoverAgents(projectRoot());
console.log("\nDetected local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.label} [${agent.name}]: ${agent.status}${version}${reason}`);
}
const selectedAgent = await prompt.choose(
"\nChoose an agent",
discovered.map((agent) => ({
label: `${agent.label} (${agent.name})`,
value: agent,
hint: agent.status === "ready" ? agent.command : agent.reason
})),
Math.max(0, discovered.findIndex((agent) => agent.name === loaded.config.defaultAgent))
);
const agentConfig = await configureAgent(prompt, selectedAgent, loaded.config);
const selectedPlatform = await prompt.choose("\nChoose an IM platform", PLATFORM_CHOICES, 0);
const keepOtherPlatforms = await prompt.askBoolean("Preserve existing enabled settings for other platforms", false);
const nextConfig = await buildNextConfig(prompt, loaded.config, agentConfig, selectedPlatform, keepOtherPlatforms);
console.log("\nPlanned config summary:");
console.log(`- defaultAgent: ${nextConfig.defaultAgent}`);
console.log(`- agents: ${nextConfig.agents.map((agent) => agent.name).join(", ")}`);
console.log(`- enabled platforms: ${Object.entries(nextConfig.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
console.log("- secrets are not printed");
if (await prompt.askBoolean(`Write config to ${loaded.path}`, false)) {
writeConfigFile(loaded.path, nextConfig);
console.log(`Wrote ${loaded.path}`);
} else {
console.log("No changes written.");
}
} finally {
prompt.close();
}
}
async function configureAgent(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, config: AppConfig): Promise<CliAgentConfig> {
const existing = config.agents.find((agent) => agent.name === discovered.name);
const defaultCwd = existing?.cwd || discovered.cwd || projectRoot();
if (discovered.status === "needs-config") {
console.log(`\n${discovered.label} needs manual command details before it is enabled.`);
const command = await prompt.ask("Command", existing?.command || discovered.command);
const argsText = await prompt.ask("Args, separated by spaces", (existing?.args || discovered.args).join(" "));
const inputMode = await prompt.choose("Input mode", [
{ label: "Append prompt as final argv", value: "arg" as const },
{ label: "Send prompt to stdin", value: "stdin" as const }
], (existing?.inputMode || discovered.inputMode) === "stdin" ? 1 : 0);
const extraPermissionArgs = await prompt.ask("Extra auto/yolo permission args, if this agent needs them", "");
const cwd = await prompt.ask("Working directory", defaultCwd);
return agentFromParts(discovered.name, command, [...splitArgs(argsText), ...splitArgs(extraPermissionArgs)], inputMode, cwd);
}
const cwd = await prompt.ask("Working directory", defaultCwd);
const command = existing?.command || discovered.command;
const baseArgs = existing?.args || discovered.args;
const modelArgs = discovered.name === "kimi" ? await configureKimiArgs(prompt, baseArgs, command) : baseArgs;
const args = await configurePermissionArgs(prompt, discovered, modelArgs);
return agentFromParts(discovered.name, command, args, existing?.inputMode || discovered.inputMode, cwd);
}
async function configurePermissionArgs(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, args: string[]): Promise<string[]> {
if (!discovered.permissionModes || discovered.permissionModes.length === 0) return args;
const selected = await prompt.choose(
`Permission mode for ${discovered.label}`,
discovered.permissionModes.map((mode) => ({
label: mode.label,
value: mode,
hint: mode.warning
})),
0
);
if (selected.warning) console.log(`Note: ${selected.warning}`);
return mergePermissionArgs(args, selected.args);
}
function mergePermissionArgs(args: string[], permissionArgs: string[]): string[] {
const merged = [...args];
for (const arg of permissionArgs) {
if (!merged.includes(arg)) merged.push(arg);
}
return merged;
}
async function configureKimiArgs(prompt: ReturnType<typeof createPromptSession>, existingArgs: string[], kimiCommand: string): Promise<string[]> {
console.log("\nKimi Code supports temporary model selection with --model / -m.");
console.log("Setup reads your local Kimi provider/model list via `kimi provider list --json`.");
console.log("Leave it on default to use default_model from your Kimi Code config.");
const configuredModels = await listConfiguredKimiModels(kimiCommand);
const modelChoices: Choice<string>[] = [
{ label: "Use Kimi default model", value: "", hint: "respect default_model in Kimi Code config" },
...configuredModels.map((model) => ({
label: model.id,
value: model.id,
hint: model.source === "configured" ? "from local Kimi provider config" : "built-in fallback"
})),
{ label: "Custom model alias", value: "__custom__", hint: "type any provider/model alias" }
];
const existingModel = findKimiModel(existingArgs);
const defaultIndex = existingModel
? modelChoices.findIndex((choice) => choice.value === existingModel)
: 0;
const modelChoice = await prompt.choose("Choose Kimi Code model", modelChoices, defaultIndex >= 0 ? defaultIndex : modelChoices.length - 1);
const model = modelChoice === "__custom__"
? await prompt.ask("Custom Kimi model alias", existingModel || configuredModels[0]?.id || "kimi-for-coding")
: modelChoice;
return withKimiModel(existingArgs, model);
}
function findKimiModel(args: string[]): string | undefined {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if ((arg === "-m" || arg === "--model") && args[index + 1]) return args[index + 1];
if (arg.startsWith("--model=")) return arg.slice("--model=".length);
}
return undefined;
}
function withKimiModel(args: string[], model: string): string[] {
const cleaned: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "-m" || arg === "--model") {
index += 1;
continue;
}
if (arg.startsWith("--model=")) continue;
cleaned.push(arg);
}
if (!model) return ensureKimiPromptArg(cleaned);
return ["-m", model, ...ensureKimiPromptArg(cleaned)];
}
function ensureKimiPromptArg(args: string[]): string[] {
return args.includes("-p") || args.includes("--prompt") ? args : [...args, "-p"];
}
async function buildNextConfig(
prompt: ReturnType<typeof createPromptSession>,
existing: AppConfig,
selectedAgent: CliAgentConfig,
selectedPlatform: PlatformName,
keepOtherPlatforms: boolean
): Promise<AppConfig> {
const platforms: AppConfig["platforms"] = keepOtherPlatforms
? structuredClone(existing.platforms)
: disableAllPlatforms(existing.platforms);
if (selectedPlatform === "feishu") {
platforms.feishu = await configureFeishu(prompt, existing.platforms.feishu);
} else if (selectedPlatform === "webhook") {
platforms.webhook = await configureGenericWebhook(prompt, existing.platforms.webhook);
} else if (selectedPlatform === "weixin") {
platforms.weixin = await configureWeixin(prompt, existing.platforms.weixin);
} else if (selectedPlatform === "wecom") {
console.log("\nWarning: WeCom inbound webhook is a scaffold and returns 501 in v1.");
platforms.wecom = {
enabled: true,
corpId: await prompt.ask("WeCom corpId", existing.platforms.wecom.corpId),
agentId: await prompt.ask("WeCom agentId", existing.platforms.wecom.agentId),
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 echo = echoConfig();
const agents = selectedAgent.name === "echo" ? [echo] : [selectedAgent, echo];
return {
server: existing.server,
policy: existing.policy,
defaultAgent: selectedAgent.name,
agents,
platforms
};
}
function disableAllPlatforms(platforms: AppConfig["platforms"]): AppConfig["platforms"] {
return {
feishu: { ...platforms.feishu, enabled: false },
wecom: { ...platforms.wecom, enabled: false },
qq: { ...platforms.qq, enabled: false },
webhook: { ...platforms.webhook, enabled: false },
weixin: { ...platforms.weixin, enabled: false }
};
}
function echoConfig(): CliAgentConfig {
const echo = echoAgent(projectRoot());
return {
name: echo.name,
command: echo.command,
args: echo.args,
inputMode: echo.inputMode,
cwd: echo.cwd,
timeoutMs: 30_000,
outputMaxBytes: 64_000
};
}
function agentFromParts(name: string, command: string, args: string[], inputMode: "stdin" | "arg", cwd: string): CliAgentConfig {
return {
name,
command,
args,
inputMode,
cwd: path.resolve(cwd),
timeoutMs: 120_000,
outputMaxBytes: 64_000
};
}
function splitArgs(value: string): string[] {
return value.split(" ").map((part) => part.trim()).filter(Boolean);
}
function splitCommaList(value: string): string[] {
return value.split(",").map((part) => part.trim()).filter(Boolean);
}
+23 -7
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { z } from "zod";
const cliAgentSchema = z.object({
@@ -36,7 +37,10 @@ const wecomSchema = z.object({
const qqSchema = z.object({
enabled: z.boolean().default(false),
appId: z.string().default(""),
clientSecret: z.string().default("")
clientSecret: z.string().default(""),
botSecret: z.string().default(""),
verifySignature: z.boolean().default(true),
botNames: z.array(z.string()).default([])
});
const webhookSchema = z.object({
@@ -53,7 +57,8 @@ const weixinSchema = z.object({
export const configSchema = z.object({
server: z.object({
host: z.string().default("0.0.0.0"),
port: z.number().int().positive().max(65_535).default(3000)
port: z.number().int().positive().max(65_535).default(3000),
publicBaseUrl: z.string().default("")
}).default({}),
policy: policySchema.default({}),
defaultAgent: z.string().min(1).default("echo"),
@@ -71,11 +76,12 @@ export type AppConfig = z.infer<typeof configSchema>;
export type CliAgentConfig = z.infer<typeof cliAgentSchema>;
export type GatewayPolicy = z.infer<typeof policySchema>;
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
const resolvedPath = path.resolve(configPath || "config.example.json");
const raw = fs.readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const config = configSchema.parse(parsed);
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
return path.resolve(configPath || "config.example.json");
}
export function parseConfig(rawConfig: unknown): AppConfig {
const config = configSchema.parse(rawConfig);
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`);
@@ -83,3 +89,13 @@ export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppCon
return config;
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath));
}
export function loadConfigFromPath(configPath: string): AppConfig {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return parseConfig(parsed);
}
+103 -6
View File
@@ -1,17 +1,50 @@
import type { AppConfig } from "../../config.js";
import { notImplemented } from "../../core/adapter.js";
import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import type { QqAccessTokenResponse, QqSendMessageResponse } from "./types.js";
import type { Gateway } from "../../core/gateway.js";
import { stripBotMentions } from "../../core/text.js";
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
const QQ_CALLBACK_ACK = { op: 12 };
const MESSAGE_EVENTS = new Set([
"GROUP_AT_MESSAGE_CREATE",
"C2C_MESSAGE_CREATE"
]);
export class QqAdapter implements PlatformAdapter {
readonly name = "qq";
private accessToken?: { token: string; expiresAt: number };
constructor(private readonly config: AppConfig["platforms"]["qq"]) {}
constructor(
private readonly config: AppConfig["platforms"]["qq"],
private readonly gateway: Gateway
) {}
async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> {
return notImplemented("QQ");
async handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse> {
const payload = context.body as QqWebhookPayload;
if (payload.op === 13) return this.handleValidation(payload.d);
if (this.config.verifySignature) {
const secret = this.callbackSecret();
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for signature verification" }, 400);
if (!verifyQqWebhookSignature(secret, context)) {
return jsonResponse({ ok: false, error: "invalid QQ webhook signature" }, 401);
}
}
if (payload.op !== 0 || !payload.t || !MESSAGE_EVENTS.has(payload.t)) {
return jsonResponse(QQ_CALLBACK_ACK);
}
const message = this.normalizeMessage(payload);
if (!message) return jsonResponse(QQ_CALLBACK_ACK);
void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error);
});
return jsonResponse(QQ_CALLBACK_ACK);
}
async sendMessage(message: OutgoingMessage): Promise<void> {
@@ -36,6 +69,47 @@ export class QqAdapter implements PlatformAdapter {
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim());
}
private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse {
const secret = this.callbackSecret();
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for callback validation" }, 400);
if (!data?.plain_token || !data.event_ts) {
return jsonResponse({ ok: false, error: "missing QQ validation fields" }, 400);
}
return jsonResponse({
plain_token: data.plain_token,
signature: signQqValidation(secret, data.event_ts, data.plain_token)
});
}
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined {
const data = payload.d;
if (!data) return undefined;
const rawText = data.content || data.text || "";
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
if (!text) return undefined;
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
const chatId = chatIdFor(data, isGroup);
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid;
if (!chatId || !userId) return undefined;
return {
platform: this.name,
chatId,
userId,
text,
messageId: data.id || data.msg_id || data.message_id || payload.id,
isGroup,
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
raw: data
};
}
private callbackSecret(): string {
return this.config.botSecret || this.config.clientSecret;
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
@@ -56,3 +130,26 @@ export class QqAdapter implements PlatformAdapter {
return this.accessToken.token;
}
}
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (data.group_openid) return `group:${data.group_openid}`;
if (data.group_id) return `group:${data.group_id}`;
if (data.channel_id) return `channel:${data.channel_id}`;
if (data.guild_id) return `guild:${data.guild_id}`;
if (!isGroup && data.user_openid) return `user:${data.user_openid}`;
if (!isGroup && data.author?.id) return `user:${data.author.id}`;
return undefined;
}
function stripConfiguredBotNames(text: string, botNames: string[]): string {
let cleaned = text;
for (const name of botNames) {
const escaped = escapeRegExp(name);
cleaned = cleaned.replace(new RegExp(`@?${escaped}`, "g"), " ");
}
return cleaned.replace(/\s+/g, " ").trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+41
View File
@@ -0,0 +1,41 @@
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
import { firstHeader } from "../../core/text.js";
import type { WebhookRequestContext } from "../../core/types.js";
const ED25519_PKCS8_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
const ED25519_SEED_SIZE = 32;
const ED25519_SIGNATURE_SIZE = 64;
export function signQqValidation(botSecret: string, eventTs: string, plainToken: string): string {
const privateKey = privateKeyFromBotSecret(botSecret);
return sign(null, Buffer.from(`${eventTs}${plainToken}`, "utf8"), privateKey).toString("hex");
}
export function verifyQqWebhookSignature(botSecret: string, context: WebhookRequestContext): boolean {
const signatureHex = firstHeader(context.headers["x-signature-ed25519"]);
const timestamp = firstHeader(context.headers["x-signature-timestamp"]);
if (!signatureHex || !timestamp || !context.rawBody) return false;
const signature = Buffer.from(signatureHex, "hex");
if (signature.length !== ED25519_SIGNATURE_SIZE || (signature[63] & 224) !== 0) return false;
const privateKey = privateKeyFromBotSecret(botSecret);
const publicKey = createPublicKey(privateKey);
return verify(null, Buffer.concat([Buffer.from(timestamp, "utf8"), context.rawBody]), publicKey, signature);
}
function privateKeyFromBotSecret(botSecret: string) {
if (!botSecret) throw new Error("QQ botSecret is required for callback signature handling");
const seed = seedFromBotSecret(botSecret);
return createPrivateKey({
key: Buffer.concat([ED25519_PKCS8_SEED_PREFIX, seed]),
format: "der",
type: "pkcs8"
});
}
function seedFromBotSecret(botSecret: string): Buffer {
let seed = botSecret;
while (Buffer.byteLength(seed, "utf8") < ED25519_SEED_SIZE) seed += seed;
return Buffer.from(seed, "utf8").subarray(0, ED25519_SEED_SIZE);
}
+29
View File
@@ -11,3 +11,32 @@ export interface QqSendMessageResponse {
code?: number;
message?: string;
}
export interface QqWebhookPayload {
id?: string;
op?: number;
d?: QqWebhookEventData;
s?: number;
t?: string;
}
export interface QqWebhookEventData {
plain_token?: string;
event_ts?: string;
id?: string;
msg_id?: string;
message_id?: string;
content?: string;
text?: string;
group_openid?: string;
group_id?: string;
channel_id?: string;
guild_id?: string;
user_openid?: string;
member_openid?: string;
author?: {
id?: string;
user_openid?: string;
username?: string;
};
}
+16 -4
View File
@@ -1,5 +1,7 @@
import express from "express";
import { loadConfig } from "./config.js";
import type { Server } from "node:http";
import { fileURLToPath } from "node:url";
import { loadConfig, type AppConfig } from "./config.js";
import { AgentRegistry } from "./agents/agent-registry.js";
import { CliAgent } from "./agents/cli-agent.js";
import { Gateway } from "./core/gateway.js";
@@ -12,7 +14,7 @@ import { QqAdapter } from "./platforms/qq/adapter.js";
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
const config = loadConfig();
export function createApp(config: AppConfig): express.Express {
const sessions = new SessionStore();
const agents = new AgentRegistry(config.defaultAgent);
for (const agentConfig of config.agents) {
@@ -24,7 +26,7 @@ const platforms = new PlatformRegistry();
const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway),
new WeComAdapter(config.platforms.wecom),
new QqAdapter(config.platforms.qq),
new QqAdapter(config.platforms.qq, gateway),
new GenericWebhookAdapter(config.platforms.webhook, gateway),
new WeixinAdapter(config.platforms.weixin, gateway)
];
@@ -73,6 +75,16 @@ for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
}
mountWebhook("generic", "webhook");
app.listen(config.server.port, config.server.host, () => {
return app;
}
export function startServer(config: AppConfig): Server {
const app = createApp(config);
return app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
});
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
startServer(loadConfig());
}