Add CLI setup and QQ webhook integration
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
config.json
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
|
||||
@@ -1,10 +1,82 @@
|
||||
# gori-agent-gateway
|
||||
|
||||
Hermes-gateway-style multi-IM Agent Gateway for routing instant-message webhooks to configurable CLI agents. It is intentionally standalone and not tied to Pi.
|
||||
Hermes-gateway-style multi-IM Agent Gateway for routing instant-message webhooks to configurable CLI agents. The primary product flow is `gori-agent setup`: discover local agents first, pick one, then choose the IM platform to connect.
|
||||
|
||||
## Hermes reference
|
||||
## Quick start
|
||||
|
||||
This project follows the Hermes gateway pattern: platform adapters normalize inbound messages, a central gateway applies policy/session/concurrency handling, then replies are sent through the originating adapter. The Feishu adapter ports the key Hermes behavior for tenant token caching, URL challenge handling, token verification, `im.message.receive_v1` parsing, mention cleanup, and message replies.
|
||||
Requires Node.js 20+.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx gori-agent setup
|
||||
```
|
||||
|
||||
If the package bin has not been linked, use the no-link fallback:
|
||||
|
||||
```bash
|
||||
npm run gori-agent -- setup
|
||||
```
|
||||
|
||||
The setup wizard writes `config.json` only after confirmation. `config.json` is ignored by git and should hold local secrets.
|
||||
|
||||
Start the gateway after setup:
|
||||
|
||||
```bash
|
||||
gori-agent start --config ./config.json
|
||||
```
|
||||
|
||||
Fallback:
|
||||
|
||||
```bash
|
||||
npm run gori-agent -- start --config ./config.json
|
||||
```
|
||||
|
||||
## CLI commands
|
||||
|
||||
```bash
|
||||
gori-agent setup
|
||||
gori-agent discover-agents [--json]
|
||||
gori-agent start [--config path]
|
||||
gori-agent status [--config path]
|
||||
gori-agent doctor [--config path]
|
||||
gori-agent print feishu [--config path]
|
||||
gori-agent --help
|
||||
```
|
||||
|
||||
Package scripts mirror common commands:
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
npm run discover-agents
|
||||
npm run doctor
|
||||
```
|
||||
|
||||
## Agent-first setup flow
|
||||
|
||||
`gori-agent setup` does the following:
|
||||
|
||||
1. Discovers local CLI agents on `PATH` and always includes the built-in `echo` fallback.
|
||||
2. Lets you pick one agent.
|
||||
3. Lets you choose an IM platform: Feishu/Lark, WeChat external webhook, WeCom scaffold, QQ Bot webhook, or Generic webhook.
|
||||
4. Builds a focused `config.json` containing the chosen default agent, the chosen agent plus `echo`, and the chosen platform enabled.
|
||||
5. Asks before writing.
|
||||
|
||||
Detected ready agents with known safe prompt modes:
|
||||
|
||||
| Agent | Command | Args | Input mode | Permission mode |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Kimi | `kimi` | `-p` | final argv | `kimi -p` already runs non-interactively under Kimi Code's auto permission policy. `--yolo` cannot be combined with `-p`. |
|
||||
| OpenCode | `opencode` | `run` | final argv | setup can append `--auto`. |
|
||||
| Codex | `codex` | `exec` | final argv | default; add manual args if needed. |
|
||||
| Claude | `claude` | `-p` | final argv | default; add manual args if needed. |
|
||||
| Built-in echo | `node` | `scripts/echo-agent.js` | stdin | n/a |
|
||||
|
||||
When you pick Kimi, setup asks which Kimi Code model to use. It reads your local Kimi model aliases at setup time with `kimi provider list --json`, so custom providers from your actual Kimi config appear in the menu. Choosing the default keeps Kimi Code's configured `default_model`; choosing a model writes `-m <model> -p` into that agent's args. You can also enter a custom model alias.
|
||||
|
||||
Gemini, Qwen/Qwen Code, Copilot, Pi, and Hermes are probed with `--version` then `--help` only. If found, they are shown as `needs-config` unless a safe prompt mode is known; the wizard asks you to confirm command, args, input mode, extra auto/yolo permission args, and working directory before enabling them.
|
||||
|
||||
Discovery never sends an actual prompt to an agent. Probes use `child_process.spawn(..., { shell: false })`, a 2s timeout, and a 4KB output cap.
|
||||
|
||||
## Platform status
|
||||
|
||||
@@ -13,28 +85,14 @@ This project follows the Hermes gateway pattern: platform adapters normalize inb
|
||||
| Feishu/Lark | Implemented | Implemented | Replies to `im.message.receive_v1` via `/im/v1/messages/{message_id}/reply`. |
|
||||
| WeCom | 501 scaffold | Implemented | Uses `gettoken` and `message/send`; inbound callback verification/encryption is not in v1. |
|
||||
| personal WeChat | External webhook scaffold | Synchronous webhook response | Native iLink/personal WeChat integration is not included in v1. |
|
||||
| QQ | 501 scaffold | Implemented | Uses QQ Bot access token and user/group text-message heuristics. |
|
||||
| QQ | Implemented | Implemented | Supports QQ official Bot HTTP callback validation and message events. |
|
||||
| Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON endpoint for local bridges and tests. |
|
||||
|
||||
## Install and run
|
||||
## Hermes reference
|
||||
|
||||
Requires Node.js 20+.
|
||||
This project follows the Hermes gateway pattern: platform adapters normalize inbound messages, a central gateway applies policy/session/concurrency handling, then replies are sent through the originating adapter. The Feishu adapter ports the key Hermes behavior for tenant token caching, URL challenge handling, token verification, `im.message.receive_v1` parsing, mention cleanup, and message replies.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp config.example.json config.json
|
||||
GORI_GATEWAY_CONFIG=./config.json npm run dev
|
||||
```
|
||||
|
||||
Build and run compiled output:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build
|
||||
GORI_GATEWAY_CONFIG=./config.json npm start
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
## Endpoints
|
||||
|
||||
- `GET /health`
|
||||
- `GET /platforms`
|
||||
@@ -46,10 +104,24 @@ Endpoints:
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is loaded from `GORI_GATEWAY_CONFIG`. If the variable is not set, the gateway loads `config.example.json` from the current working directory.
|
||||
Server execution still supports the existing environment variable:
|
||||
|
||||
```bash
|
||||
GORI_GATEWAY_CONFIG=./config.json npm start
|
||||
```
|
||||
|
||||
The CLI resolves config in this order:
|
||||
|
||||
1. `--config path`
|
||||
2. `GORI_GATEWAY_CONFIG`
|
||||
3. `./config.json`
|
||||
4. seed from `config.example.json` and write to `./config.json` if confirmed
|
||||
|
||||
Important sections:
|
||||
|
||||
- `server.host`: bind address, default `0.0.0.0`.
|
||||
- `server.port`: gateway port, default `3000`.
|
||||
- `server.publicBaseUrl`: public HTTPS base URL used by print/setup hints, for example `https://agent.example.com`.
|
||||
- `policy.allowedUsers`: allow only listed normalized user IDs when non-empty.
|
||||
- `policy.allowedChats`: allow only listed normalized chat IDs when non-empty.
|
||||
- `policy.requireMentionInGroup`: if true, group messages are ignored unless the adapter reports a bot mention.
|
||||
@@ -64,7 +136,7 @@ CLI agent options:
|
||||
- `outputMaxBytes`: caps captured stdout/stderr.
|
||||
- `cwd`: optional working directory for the agent process.
|
||||
|
||||
## Commands
|
||||
## Chat commands
|
||||
|
||||
The gateway handles these commands per chat before invoking an agent:
|
||||
|
||||
@@ -76,6 +148,10 @@ The gateway handles these commands per chat before invoking an agent:
|
||||
|
||||
## Feishu setup
|
||||
|
||||
The wizard can collect Feishu values and `gori-agent print feishu --config ./config.json` prints the webhook URL and checklist.
|
||||
|
||||
Manual steps:
|
||||
|
||||
1. Create a Feishu/Lark custom app and enable bot messaging.
|
||||
2. Configure event subscription for `im.message.receive_v1`.
|
||||
3. Set the request URL to `https://<host>/webhook/feishu`.
|
||||
@@ -84,6 +160,20 @@ The gateway handles these commands per chat before invoking an agent:
|
||||
|
||||
The adapter accepts Feishu URL verification challenges and returns `{ "challenge": "..." }`.
|
||||
|
||||
## QQ setup
|
||||
|
||||
The wizard can collect QQ Bot values for the official HTTP callback mode.
|
||||
|
||||
Manual steps:
|
||||
|
||||
1. Create a QQ official Bot and enable HTTP callback/event subscription.
|
||||
2. Configure the callback URL to `https://<host>/webhook/qq`. QQ callback URLs must use an allowed public HTTPS port such as 443, 8443, 8080, or 80.
|
||||
3. Subscribe to message events you need, commonly `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE`.
|
||||
4. Put `appId`, `clientSecret`, and `botSecret` into your config.
|
||||
5. Keep `verifySignature` enabled in production so `X-Signature-Ed25519` callbacks are verified.
|
||||
|
||||
The adapter handles QQ `op: 13` callback URL validation and returns `{ "plain_token": "...", "signature": "..." }`. `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE` callbacks return QQ HTTP callback ACK `{ "op": 12 }` immediately, then reply through the QQ Bot group/C2C message APIs.
|
||||
|
||||
## Generic webhook
|
||||
|
||||
Payload:
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
Generated
+3
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "gori-agent-gateway",
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"gori-agent": "dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
@@ -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 path from "node:path";
|
||||
import process from "node:process";
|
||||
import { z } from "zod";
|
||||
|
||||
const cliAgentSchema = z.object({
|
||||
@@ -36,7 +37,10 @@ const wecomSchema = z.object({
|
||||
const qqSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
appId: z.string().default(""),
|
||||
clientSecret: z.string().default("")
|
||||
clientSecret: z.string().default(""),
|
||||
botSecret: z.string().default(""),
|
||||
verifySignature: z.boolean().default(true),
|
||||
botNames: z.array(z.string()).default([])
|
||||
});
|
||||
|
||||
const webhookSchema = z.object({
|
||||
@@ -53,7 +57,8 @@ const weixinSchema = z.object({
|
||||
export const configSchema = z.object({
|
||||
server: z.object({
|
||||
host: z.string().default("0.0.0.0"),
|
||||
port: z.number().int().positive().max(65_535).default(3000)
|
||||
port: z.number().int().positive().max(65_535).default(3000),
|
||||
publicBaseUrl: z.string().default("")
|
||||
}).default({}),
|
||||
policy: policySchema.default({}),
|
||||
defaultAgent: z.string().min(1).default("echo"),
|
||||
@@ -71,11 +76,12 @@ export type AppConfig = z.infer<typeof configSchema>;
|
||||
export type CliAgentConfig = z.infer<typeof cliAgentSchema>;
|
||||
export type GatewayPolicy = z.infer<typeof policySchema>;
|
||||
|
||||
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
|
||||
const resolvedPath = path.resolve(configPath || "config.example.json");
|
||||
const raw = fs.readFileSync(resolvedPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const config = configSchema.parse(parsed);
|
||||
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
|
||||
return path.resolve(configPath || "config.example.json");
|
||||
}
|
||||
|
||||
export function parseConfig(rawConfig: unknown): AppConfig {
|
||||
const config = configSchema.parse(rawConfig);
|
||||
|
||||
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
|
||||
throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`);
|
||||
@@ -83,3 +89,13 @@ export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppCon
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
|
||||
return loadConfigFromPath(resolveConfigPath(configPath));
|
||||
}
|
||||
|
||||
export function loadConfigFromPath(configPath: string): AppConfig {
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parseConfig(parsed);
|
||||
}
|
||||
|
||||
+103
-6
@@ -1,17 +1,50 @@
|
||||
import type { AppConfig } from "../../config.js";
|
||||
import { notImplemented } from "../../core/adapter.js";
|
||||
import { jsonResponse } from "../../core/adapter.js";
|
||||
import type { PlatformAdapter } from "../../core/adapter.js";
|
||||
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
|
||||
import type { QqAccessTokenResponse, QqSendMessageResponse } from "./types.js";
|
||||
import type { Gateway } from "../../core/gateway.js";
|
||||
import { stripBotMentions } from "../../core/text.js";
|
||||
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
|
||||
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
|
||||
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
|
||||
|
||||
const QQ_CALLBACK_ACK = { op: 12 };
|
||||
const MESSAGE_EVENTS = new Set([
|
||||
"GROUP_AT_MESSAGE_CREATE",
|
||||
"C2C_MESSAGE_CREATE"
|
||||
]);
|
||||
|
||||
export class QqAdapter implements PlatformAdapter {
|
||||
readonly name = "qq";
|
||||
private accessToken?: { token: string; expiresAt: number };
|
||||
|
||||
constructor(private readonly config: AppConfig["platforms"]["qq"]) {}
|
||||
constructor(
|
||||
private readonly config: AppConfig["platforms"]["qq"],
|
||||
private readonly gateway: Gateway
|
||||
) {}
|
||||
|
||||
async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> {
|
||||
return notImplemented("QQ");
|
||||
async handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse> {
|
||||
const payload = context.body as QqWebhookPayload;
|
||||
if (payload.op === 13) return this.handleValidation(payload.d);
|
||||
|
||||
if (this.config.verifySignature) {
|
||||
const secret = this.callbackSecret();
|
||||
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for signature verification" }, 400);
|
||||
if (!verifyQqWebhookSignature(secret, context)) {
|
||||
return jsonResponse({ ok: false, error: "invalid QQ webhook signature" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.op !== 0 || !payload.t || !MESSAGE_EVENTS.has(payload.t)) {
|
||||
return jsonResponse(QQ_CALLBACK_ACK);
|
||||
}
|
||||
|
||||
const message = this.normalizeMessage(payload);
|
||||
if (!message) return jsonResponse(QQ_CALLBACK_ACK);
|
||||
|
||||
void this.gateway.receive(message, this).catch((error) => {
|
||||
console.error("QQ gateway error", error);
|
||||
});
|
||||
return jsonResponse(QQ_CALLBACK_ACK);
|
||||
}
|
||||
|
||||
async sendMessage(message: OutgoingMessage): Promise<void> {
|
||||
@@ -36,6 +69,47 @@ export class QqAdapter implements PlatformAdapter {
|
||||
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim());
|
||||
}
|
||||
|
||||
private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse {
|
||||
const secret = this.callbackSecret();
|
||||
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for callback validation" }, 400);
|
||||
if (!data?.plain_token || !data.event_ts) {
|
||||
return jsonResponse({ ok: false, error: "missing QQ validation fields" }, 400);
|
||||
}
|
||||
return jsonResponse({
|
||||
plain_token: data.plain_token,
|
||||
signature: signQqValidation(secret, data.event_ts, data.plain_token)
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined {
|
||||
const data = payload.d;
|
||||
if (!data) return undefined;
|
||||
|
||||
const rawText = data.content || data.text || "";
|
||||
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
|
||||
if (!text) return undefined;
|
||||
|
||||
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
|
||||
const chatId = chatIdFor(data, isGroup);
|
||||
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid;
|
||||
if (!chatId || !userId) return undefined;
|
||||
|
||||
return {
|
||||
platform: this.name,
|
||||
chatId,
|
||||
userId,
|
||||
text,
|
||||
messageId: data.id || data.msg_id || data.message_id || payload.id,
|
||||
isGroup,
|
||||
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
|
||||
private callbackSecret(): string {
|
||||
return this.config.botSecret || this.config.clientSecret;
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
|
||||
|
||||
@@ -56,3 +130,26 @@ export class QqAdapter implements PlatformAdapter {
|
||||
return this.accessToken.token;
|
||||
}
|
||||
}
|
||||
|
||||
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
|
||||
if (data.group_openid) return `group:${data.group_openid}`;
|
||||
if (data.group_id) return `group:${data.group_id}`;
|
||||
if (data.channel_id) return `channel:${data.channel_id}`;
|
||||
if (data.guild_id) return `guild:${data.guild_id}`;
|
||||
if (!isGroup && data.user_openid) return `user:${data.user_openid}`;
|
||||
if (!isGroup && data.author?.id) return `user:${data.author.id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stripConfiguredBotNames(text: string, botNames: string[]): string {
|
||||
let cleaned = text;
|
||||
for (const name of botNames) {
|
||||
const escaped = escapeRegExp(name);
|
||||
cleaned = cleaned.replace(new RegExp(`@?${escaped}`, "g"), " ");
|
||||
}
|
||||
return cleaned.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface QqWebhookPayload {
|
||||
id?: string;
|
||||
op?: number;
|
||||
d?: QqWebhookEventData;
|
||||
s?: number;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
export interface QqWebhookEventData {
|
||||
plain_token?: string;
|
||||
event_ts?: string;
|
||||
id?: string;
|
||||
msg_id?: string;
|
||||
message_id?: string;
|
||||
content?: string;
|
||||
text?: string;
|
||||
group_openid?: string;
|
||||
group_id?: string;
|
||||
channel_id?: string;
|
||||
guild_id?: string;
|
||||
user_openid?: string;
|
||||
member_openid?: string;
|
||||
author?: {
|
||||
id?: string;
|
||||
user_openid?: string;
|
||||
username?: string;
|
||||
};
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,5 +1,7 @@
|
||||
import express from "express";
|
||||
import { loadConfig } from "./config.js";
|
||||
import type { Server } from "node:http";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig, type AppConfig } from "./config.js";
|
||||
import { AgentRegistry } from "./agents/agent-registry.js";
|
||||
import { CliAgent } from "./agents/cli-agent.js";
|
||||
import { Gateway } from "./core/gateway.js";
|
||||
@@ -12,7 +14,7 @@ import { QqAdapter } from "./platforms/qq/adapter.js";
|
||||
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
|
||||
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
|
||||
|
||||
const config = loadConfig();
|
||||
export function createApp(config: AppConfig): express.Express {
|
||||
const sessions = new SessionStore();
|
||||
const agents = new AgentRegistry(config.defaultAgent);
|
||||
for (const agentConfig of config.agents) {
|
||||
@@ -24,7 +26,7 @@ const platforms = new PlatformRegistry();
|
||||
const adapters: PlatformAdapter[] = [
|
||||
new FeishuAdapter(config.platforms.feishu, gateway),
|
||||
new WeComAdapter(config.platforms.wecom),
|
||||
new QqAdapter(config.platforms.qq),
|
||||
new QqAdapter(config.platforms.qq, gateway),
|
||||
new GenericWebhookAdapter(config.platforms.webhook, gateway),
|
||||
new WeixinAdapter(config.platforms.weixin, gateway)
|
||||
];
|
||||
@@ -73,6 +75,16 @@ for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
|
||||
}
|
||||
mountWebhook("generic", "webhook");
|
||||
|
||||
app.listen(config.server.port, config.server.host, () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
export function startServer(config: AppConfig): Server {
|
||||
const app = createApp(config);
|
||||
return app.listen(config.server.port, config.server.host, () => {
|
||||
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
startServer(loadConfig());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user