diff --git a/.gitignore b/.gitignore index 0900635..e4d15fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .env +config.json *.log .DS_Store coverage/ diff --git a/README.md b/README.md index cf01c19..bb339d0 100644 --- a/README.md +++ b/README.md @@ -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 -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:///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:///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: diff --git a/config.example.json b/config.example.json index c7c3830..1aedf05 100644 --- a/config.example.json +++ b/config.example.json @@ -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, diff --git a/package-lock.json b/package-lock.json index 39f9327..f8c4fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index ab9f316..4f42a0e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/agents/discovery.ts b/src/agents/discovery.ts new file mode 100644 index 0000000..4550738 --- /dev/null +++ b/src/agents/discovery.ts @@ -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 { + 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 { + const version = await safeProbe(command, ["--version"]); + if (version) return version; + return safeProbe(command, ["--help"]); +} + +async function safeProbe(command: string, args: string[]): Promise { + 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; +} diff --git a/src/agents/kimi-models.ts b/src/agents/kimi-models.ts new file mode 100644 index 0000000..2f49df7 --- /dev/null +++ b/src/agents/kimi-models.ts @@ -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 { + const output = await runKimiProviderList(kimiCommand); + if (!output) return FALLBACK_KIMI_MODELS; + + try { + const parsed = JSON.parse(output) as { models?: Record }; + 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 { + 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); + }); + }); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..bde7469 --- /dev/null +++ b/src/cli.ts @@ -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 { + 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 { + 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; +}); diff --git a/src/cli/config-file.ts b/src/cli/config-file.ts new file mode 100644 index 0000000..eaaf058 --- /dev/null +++ b/src/cli/config-file.ts @@ -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; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..ccc0774 --- /dev/null +++ b/src/cli/doctor.ts @@ -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 { + 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}`); +} diff --git a/src/cli/net.ts b/src/cli/net.ts new file mode 100644 index 0000000..b65a531 --- /dev/null +++ b/src/cli/net.ts @@ -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 { + 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 { + 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 { + return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config); +} diff --git a/src/cli/print.ts b/src/cli/print.ts new file mode 100644 index 0000000..57d0521 --- /dev/null +++ b/src/cli/print.ts @@ -0,0 +1,18 @@ +import type { AppConfig } from "../config.js"; +import { webhookBaseUrl } from "./net.js"; + +export async function printFeishu(config: AppConfig): Promise { + 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."); + } +} diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts new file mode 100644 index 0000000..44265c2 --- /dev/null +++ b/src/cli/prompt.ts @@ -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; + askBoolean(question: string, defaultValue?: boolean): Promise; + askList(question: string, defaultValues?: string[]): Promise; + choose(question: string, choices: Choice[], defaultIndex?: number): Promise; + close(): void; +} + +export interface Choice { + 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 { + 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 { + 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 { + const answer = await ask(rl, question, defaultValues.join(",")); + return answer.split(",").map((value) => value.trim()).filter(Boolean); +} + +export async function choose( + rl: readline.Interface, + question: string, + choices: Choice[], + defaultIndex = 0 +): Promise { + 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}.`); + } +} diff --git a/src/cli/setup-feishu.ts b/src/cli/setup-feishu.ts new file mode 100644 index 0000000..09d22b5 --- /dev/null +++ b/src/cli/setup-feishu.ts @@ -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 { + 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 + }; +} diff --git a/src/cli/setup-webhook.ts b/src/cli/setup-webhook.ts new file mode 100644 index 0000000..e24e672 --- /dev/null +++ b/src/cli/setup-webhook.ts @@ -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 { + 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 }; +} diff --git a/src/cli/setup-weixin.ts b/src/cli/setup-weixin.ts new file mode 100644 index 0000000..374a332 --- /dev/null +++ b/src/cli/setup-weixin.ts @@ -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 { + 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 }; +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..80ab2bb --- /dev/null +++ b/src/cli/setup.ts @@ -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[] = [ + { 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 { + 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, discovered: DiscoveredAgent, config: AppConfig): Promise { + 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, discovered: DiscoveredAgent, args: string[]): Promise { + 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, existingArgs: string[], kimiCommand: string): Promise { + 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[] = [ + { 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, + existing: AppConfig, + selectedAgent: CliAgentConfig, + selectedPlatform: PlatformName, + keepOtherPlatforms: boolean +): Promise { + 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); +} diff --git a/src/config.ts b/src/config.ts index 8440eb3..3b6cade 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; export type CliAgentConfig = z.infer; export type GatewayPolicy = z.infer; -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); +} diff --git a/src/platforms/qq/adapter.ts b/src/platforms/qq/adapter.ts index da84be6..e8f4170 100644 --- a/src/platforms/qq/adapter.ts +++ b/src/platforms/qq/adapter.ts @@ -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 { - return notImplemented("QQ"); + async handleWebhook(context: WebhookRequestContext): Promise { + 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 { @@ -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 { 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, "\\$&"); +} diff --git a/src/platforms/qq/crypto.ts b/src/platforms/qq/crypto.ts new file mode 100644 index 0000000..d62c836 --- /dev/null +++ b/src/platforms/qq/crypto.ts @@ -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); +} diff --git a/src/platforms/qq/types.ts b/src/platforms/qq/types.ts index cc19226..3298629 100644 --- a/src/platforms/qq/types.ts +++ b/src/platforms/qq/types.ts @@ -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; + }; +} diff --git a/src/server.ts b/src/server.ts index d3a0e63..910e521 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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,67 +14,77 @@ import { QqAdapter } from "./platforms/qq/adapter.js"; import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js"; import { WeixinAdapter } from "./platforms/weixin/adapter.js"; -const config = loadConfig(); -const sessions = new SessionStore(); -const agents = new AgentRegistry(config.defaultAgent); -for (const agentConfig of config.agents) { - agents.register(new CliAgent(agentConfig)); +export function createApp(config: AppConfig): express.Express { + const sessions = new SessionStore(); + const agents = new AgentRegistry(config.defaultAgent); + for (const agentConfig of config.agents) { + agents.register(new CliAgent(agentConfig)); + } + + const gateway = new Gateway(config.policy, agents, sessions); + const platforms = new PlatformRegistry(); + const adapters: PlatformAdapter[] = [ + new FeishuAdapter(config.platforms.feishu, gateway), + new WeComAdapter(config.platforms.wecom), + new QqAdapter(config.platforms.qq, gateway), + new GenericWebhookAdapter(config.platforms.webhook, gateway), + new WeixinAdapter(config.platforms.weixin, gateway) + ]; + for (const adapter of adapters) platforms.register(adapter); + + const app = express(); + app.use(express.json({ + limit: "1mb", + verify: (req, _res, buf) => { + (req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf); + } + })); + + app.get("/health", (_req, res) => { + res.json({ ok: true, gateway: gateway.stats() }); + }); + + app.get("/platforms", (_req, res) => { + res.json({ ok: true, platforms: platforms.list(), agents: agents.list() }); + }); + + function mountWebhook(routeName: string, adapterName = routeName): void { + app.post(`/webhook/${routeName}`, async (req, res) => { + try { + const response = await platforms.get(adapterName).handleWebhook({ + req, + body: req.body, + headers: req.headers, + query: req.query, + rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody + }); + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value); + } + res.status(response.status || 200).json(response.body ?? { ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Webhook ${routeName} failed`, error); + res.status(500).json({ ok: false, error: message }); + } + }); + } + + for (const platformName of ["feishu", "wecom", "qq", "weixin"]) { + mountWebhook(platformName); + } + mountWebhook("generic", "webhook"); + + return app; } -const gateway = new Gateway(config.policy, agents, sessions); -const platforms = new PlatformRegistry(); -const adapters: PlatformAdapter[] = [ - new FeishuAdapter(config.platforms.feishu, gateway), - new WeComAdapter(config.platforms.wecom), - new QqAdapter(config.platforms.qq), - new GenericWebhookAdapter(config.platforms.webhook, gateway), - new WeixinAdapter(config.platforms.weixin, gateway) -]; -for (const adapter of adapters) platforms.register(adapter); - -const app = express(); -app.use(express.json({ - limit: "1mb", - verify: (req, _res, buf) => { - (req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf); - } -})); - -app.get("/health", (_req, res) => { - res.json({ ok: true, gateway: gateway.stats() }); -}); - -app.get("/platforms", (_req, res) => { - res.json({ ok: true, platforms: platforms.list(), agents: agents.list() }); -}); - -function mountWebhook(routeName: string, adapterName = routeName): void { - app.post(`/webhook/${routeName}`, async (req, res) => { - try { - const response = await platforms.get(adapterName).handleWebhook({ - req, - body: req.body, - headers: req.headers, - query: req.query, - rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody - }); - if (response.headers) { - for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value); - } - res.status(response.status || 200).json(response.body ?? { ok: true }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Webhook ${routeName} failed`, error); - res.status(500).json({ ok: false, error: message }); - } +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}`); }); } -for (const platformName of ["feishu", "wecom", "qq", "weixin"]) { - mountWebhook(platformName); +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + startServer(loadConfig()); } -mountWebhook("generic", "webhook"); - -app.listen(config.server.port, config.server.host, () => { - console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`); -});