Add CLI setup and QQ webhook integration
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
config.json
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -1,10 +1,82 @@
|
|||||||
# gori-agent-gateway
|
# 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
|
## 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`. |
|
| Feishu/Lark | Implemented | Implemented | Replies to `im.message.receive_v1` via `/im/v1/messages/{message_id}/reply`. |
|
||||||
| WeCom | 501 scaffold | Implemented | Uses `gettoken` and `message/send`; inbound callback verification/encryption is not in v1. |
|
| WeCom | 501 scaffold | Implemented | Uses `gettoken` and `message/send`; inbound callback verification/encryption is not in v1. |
|
||||||
| personal WeChat | External webhook scaffold | Synchronous webhook response | Native iLink/personal WeChat integration is not included in v1. |
|
| personal WeChat | External webhook scaffold | Synchronous webhook response | Native iLink/personal WeChat integration is not included in v1. |
|
||||||
| QQ | 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. |
|
| 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
|
## Endpoints
|
||||||
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:
|
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /platforms`
|
- `GET /platforms`
|
||||||
@@ -46,10 +104,24 @@ Endpoints:
|
|||||||
|
|
||||||
## Configuration
|
## 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:
|
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.allowedUsers`: allow only listed normalized user IDs when non-empty.
|
||||||
- `policy.allowedChats`: allow only listed normalized chat 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.
|
- `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.
|
- `outputMaxBytes`: caps captured stdout/stderr.
|
||||||
- `cwd`: optional working directory for the agent process.
|
- `cwd`: optional working directory for the agent process.
|
||||||
|
|
||||||
## Commands
|
## Chat commands
|
||||||
|
|
||||||
The gateway handles these commands per chat before invoking an agent:
|
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
|
## 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.
|
1. Create a Feishu/Lark custom app and enable bot messaging.
|
||||||
2. Configure event subscription for `im.message.receive_v1`.
|
2. Configure event subscription for `im.message.receive_v1`.
|
||||||
3. Set the request URL to `https://<host>/webhook/feishu`.
|
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": "..." }`.
|
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
|
## Generic webhook
|
||||||
|
|
||||||
Payload:
|
Payload:
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"server": {
|
"server": {
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 3000
|
"port": 3000,
|
||||||
|
"publicBaseUrl": ""
|
||||||
},
|
},
|
||||||
"policy": {
|
"policy": {
|
||||||
"allowedUsers": [],
|
"allowedUsers": [],
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimi",
|
"name": "kimi",
|
||||||
"command": "kimi",
|
"command": "kimi",
|
||||||
"args": [],
|
"args": ["-p"],
|
||||||
"inputMode": "arg",
|
"inputMode": "arg",
|
||||||
"timeoutMs": 120000,
|
"timeoutMs": 120000,
|
||||||
"outputMaxBytes": 64000
|
"outputMaxBytes": 64000
|
||||||
@@ -44,7 +45,10 @@
|
|||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"appId": "1020xxxx",
|
"appId": "1020xxxx",
|
||||||
"clientSecret": "replace-me"
|
"clientSecret": "replace-me",
|
||||||
|
"botSecret": "replace-me",
|
||||||
|
"verifySignature": true,
|
||||||
|
"botNames": ["Gori Agent"]
|
||||||
},
|
},
|
||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
Generated
+3
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "gori-agent-gateway",
|
"name": "gori-agent-gateway",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"bin": {
|
||||||
|
"gori-agent": "dist/cli.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
+8
-1
@@ -4,11 +4,18 @@
|
|||||||
"description": "Hermes-gateway-style multi-IM agent gateway for CLI agents.",
|
"description": "Hermes-gateway-style multi-IM agent gateway for CLI agents.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
|
"bin": {
|
||||||
|
"gori-agent": "dist/cli.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node dist/server.js",
|
"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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const cliAgentSchema = z.object({
|
const cliAgentSchema = z.object({
|
||||||
@@ -36,7 +37,10 @@ const wecomSchema = z.object({
|
|||||||
const qqSchema = z.object({
|
const qqSchema = z.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
appId: z.string().default(""),
|
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({
|
const webhookSchema = z.object({
|
||||||
@@ -53,7 +57,8 @@ const weixinSchema = z.object({
|
|||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
server: z.object({
|
server: z.object({
|
||||||
host: z.string().default("0.0.0.0"),
|
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({}),
|
}).default({}),
|
||||||
policy: policySchema.default({}),
|
policy: policySchema.default({}),
|
||||||
defaultAgent: z.string().min(1).default("echo"),
|
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 CliAgentConfig = z.infer<typeof cliAgentSchema>;
|
||||||
export type GatewayPolicy = z.infer<typeof policySchema>;
|
export type GatewayPolicy = z.infer<typeof policySchema>;
|
||||||
|
|
||||||
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
|
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
|
||||||
const resolvedPath = path.resolve(configPath || "config.example.json");
|
return 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 parseConfig(rawConfig: unknown): AppConfig {
|
||||||
|
const config = configSchema.parse(rawConfig);
|
||||||
|
|
||||||
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
|
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
|
||||||
throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`);
|
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;
|
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
@@ -1,17 +1,50 @@
|
|||||||
import type { AppConfig } from "../../config.js";
|
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 { PlatformAdapter } from "../../core/adapter.js";
|
||||||
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
|
import type { Gateway } from "../../core/gateway.js";
|
||||||
import type { QqAccessTokenResponse, QqSendMessageResponse } from "./types.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 {
|
export class QqAdapter implements PlatformAdapter {
|
||||||
readonly name = "qq";
|
readonly name = "qq";
|
||||||
private accessToken?: { token: string; expiresAt: number };
|
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> {
|
async handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse> {
|
||||||
return notImplemented("QQ");
|
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> {
|
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());
|
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> {
|
private async getAccessToken(): Promise<string> {
|
||||||
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
|
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
|
||||||
|
|
||||||
@@ -56,3 +130,26 @@ export class QqAdapter implements PlatformAdapter {
|
|||||||
return this.accessToken.token;
|
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, "\\$&");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,3 +11,32 @@ export interface QqSendMessageResponse {
|
|||||||
code?: number;
|
code?: number;
|
||||||
message?: string;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+71
-59
@@ -1,5 +1,7 @@
|
|||||||
import express from "express";
|
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 { AgentRegistry } from "./agents/agent-registry.js";
|
||||||
import { CliAgent } from "./agents/cli-agent.js";
|
import { CliAgent } from "./agents/cli-agent.js";
|
||||||
import { Gateway } from "./core/gateway.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 { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
|
||||||
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
|
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
|
||||||
|
|
||||||
const config = loadConfig();
|
export function createApp(config: AppConfig): express.Express {
|
||||||
const sessions = new SessionStore();
|
const sessions = new SessionStore();
|
||||||
const agents = new AgentRegistry(config.defaultAgent);
|
const agents = new AgentRegistry(config.defaultAgent);
|
||||||
for (const agentConfig of config.agents) {
|
for (const agentConfig of config.agents) {
|
||||||
agents.register(new CliAgent(agentConfig));
|
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);
|
export function startServer(config: AppConfig): Server {
|
||||||
const platforms = new PlatformRegistry();
|
const app = createApp(config);
|
||||||
const adapters: PlatformAdapter[] = [
|
return app.listen(config.server.port, config.server.host, () => {
|
||||||
new FeishuAdapter(config.platforms.feishu, gateway),
|
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
mountWebhook(platformName);
|
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}`);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user