Add CLI setup and QQ webhook integration

This commit is contained in:
zenord
2026-08-15 16:02:05 +08:00
parent 00325efb50
commit d79264b11a
22 changed files with 1389 additions and 99 deletions
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
export type AgentInputMode = "stdin" | "arg";
export type DiscoveredAgentStatus = "ready" | "needs-config";
export type PermissionModeName = "default" | "auto" | "yolo" | "manual";
export interface PermissionModeOption {
name: PermissionModeName;
label: string;
args: string[];
warning?: string;
}
export interface DiscoveredAgent {
name: string;
label: string;
command: string;
args: string[];
inputMode: AgentInputMode;
cwd?: string;
status: DiscoveredAgentStatus;
version?: string;
reason?: string;
permissionModes?: PermissionModeOption[];
modelListCommand?: string[];
}
interface AgentProbeDefinition {
name: string;
label: string;
command: string;
args: string[];
inputMode: AgentInputMode;
statusWhenFound: DiscoveredAgentStatus;
reasonWhenFound?: string;
permissionModes?: PermissionModeOption[];
modelListCommand?: string[];
}
const PROJECT_ROOT = path.resolve(new URL("../..", import.meta.url).pathname);
const PROBE_TIMEOUT_MS = 2_000;
const MAX_PROBE_OUTPUT_BYTES = 4_096;
const KNOWN_AGENTS: AgentProbeDefinition[] = [
{
name: "kimi",
label: "Kimi",
command: "kimi",
args: ["-p"],
inputMode: "arg",
statusWhenFound: "ready",
modelListCommand: ["provider", "list", "--json"],
permissionModes: [
{ name: "default", label: "Default / implicit auto for kimi -p", args: [], warning: "Kimi -p already runs non-interactively under auto permission policy; --yolo cannot be combined with -p." }
]
},
{
name: "opencode",
label: "OpenCode",
command: "opencode",
args: ["run"],
inputMode: "arg",
statusWhenFound: "ready",
permissionModes: [
{ name: "default", label: "Default", args: [] },
{ name: "auto", label: "Auto approve", args: ["--auto"], warning: "OpenCode --auto auto-approves permissions that are not explicitly denied." }
]
},
{ name: "codex", label: "Codex", command: "codex", args: ["exec"], inputMode: "arg", statusWhenFound: "ready", permissionModes: [{ name: "default", label: "Default", args: [] }] },
{ name: "claude", label: "Claude", command: "claude", args: ["-p"], inputMode: "arg", statusWhenFound: "ready", permissionModes: [{ name: "default", label: "Default", args: [] }] },
{ name: "gemini", label: "Gemini", command: "gemini", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "qwen", label: "Qwen Code", command: "qwen", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "qwen-code", label: "Qwen Code", command: "qwen-code", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "copilot", label: "GitHub Copilot", command: "copilot", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "pi", label: "Pi", command: "pi", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." },
{ name: "hermes", label: "Hermes", command: "hermes", args: [], inputMode: "arg", statusWhenFound: "needs-config", reasonWhenFound: "Prompt mode is not known safely; configure command details before use." }
];
export function echoAgent(projectRoot = PROJECT_ROOT): DiscoveredAgent {
return {
name: "echo",
label: "Built-in echo",
command: "node",
args: ["scripts/echo-agent.js"],
inputMode: "stdin",
cwd: projectRoot,
status: "ready",
reason: "Built-in fallback agent."
};
}
export async function discoverAgents(projectRoot = PROJECT_ROOT): Promise<DiscoveredAgent[]> {
const agents: DiscoveredAgent[] = [echoAgent(projectRoot)];
for (const definition of KNOWN_AGENTS) {
const commandPath = findOnPath(definition.command);
if (!commandPath) continue;
const version = await probeVersion(commandPath);
agents.push({
name: definition.name,
label: definition.label,
command: commandPath,
args: definition.args,
inputMode: definition.inputMode,
status: definition.statusWhenFound,
version,
reason: definition.reasonWhenFound,
permissionModes: definition.permissionModes,
modelListCommand: definition.modelListCommand
});
}
return agents;
}
function findOnPath(command: string): string | undefined {
if (command.includes(path.sep)) return isExecutable(command) ? command : undefined;
const pathValue = process.env.PATH || "";
for (const entry of pathValue.split(path.delimiter)) {
if (!entry) continue;
const candidate = path.join(entry, command);
if (isExecutable(candidate)) return candidate;
}
return undefined;
}
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
async function probeVersion(command: string): Promise<string | undefined> {
const version = await safeProbe(command, ["--version"]);
if (version) return version;
return safeProbe(command, ["--help"]);
}
async function safeProbe(command: string, args: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
const child = spawn(command, args, {
shell: false,
stdio: ["ignore", "pipe", "pipe"]
});
let output = "";
let settled = false;
const append = (chunk: Buffer): void => {
if (Buffer.byteLength(output) >= MAX_PROBE_OUTPUT_BYTES) return;
const combined = Buffer.concat([Buffer.from(output), chunk]);
output = combined.subarray(0, MAX_PROBE_OUTPUT_BYTES).toString("utf8");
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGTERM");
resolve(cleanProbeOutput(output));
}, PROBE_TIMEOUT_MS);
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(undefined);
});
child.on("close", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(cleanProbeOutput(output));
});
});
}
function cleanProbeOutput(output: string): string | undefined {
const firstLine = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine;
}
+63
View File
@@ -0,0 +1,63 @@
import { spawn } from "node:child_process";
export interface KimiModelChoice {
id: string;
label: string;
source: "configured" | "fallback";
}
const FALLBACK_KIMI_MODELS: KimiModelChoice[] = [
{ id: "k3", label: "k3", source: "fallback" },
{ id: "k3-256k", label: "k3-256k", source: "fallback" },
{ id: "kimi-for-coding", label: "kimi-for-coding", source: "fallback" },
{ id: "kimi-for-coding-highspeed", label: "kimi-for-coding-highspeed", source: "fallback" }
];
export async function listConfiguredKimiModels(kimiCommand: string): Promise<KimiModelChoice[]> {
const output = await runKimiProviderList(kimiCommand);
if (!output) return FALLBACK_KIMI_MODELS;
try {
const parsed = JSON.parse(output) as { models?: Record<string, unknown> };
const modelIds = Object.keys(parsed.models || {}).sort();
if (modelIds.length === 0) return FALLBACK_KIMI_MODELS;
return modelIds.map((id) => ({ id, label: id, source: "configured" as const }));
} catch {
return FALLBACK_KIMI_MODELS;
}
}
function runKimiProviderList(kimiCommand: string): Promise<string | undefined> {
return new Promise((resolve) => {
const child = spawn(kimiCommand, ["provider", "list", "--json"], {
shell: false,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGTERM");
resolve(undefined);
}, 5_000);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(undefined);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0 && stdout.trim()) resolve(stdout);
else resolve(undefined);
});
});
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import process from "node:process";
import { discoverAgents } from "./agents/discovery.js";
import { loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { localBaseUrl } from "./cli/net.js";
import { startServer } from "./server.js";
interface ParsedArgs {
command?: string;
rest: string[];
configPath?: string;
json: boolean;
help: boolean;
}
async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv);
if (parsed.help || !parsed.command) {
printHelp();
return 0;
}
if (parsed.command === "setup") {
await runSetup(parsed.configPath);
return 0;
}
if (parsed.command === "discover-agents") {
const agents = await discoverAgents();
if (parsed.json) {
console.log(JSON.stringify(agents, null, 2));
} else {
for (const agent of agents) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`${agent.name}\t${agent.status}\t${agent.command} ${agent.args.join(" ")}${version}${reason}`.trim());
}
}
return 0;
}
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
startServer(loaded.config);
return await new Promise(() => undefined);
}
if (parsed.command === "status") {
await printStatus(parsed.configPath);
return 0;
}
if (parsed.command === "doctor") {
const loaded = loadConfigFile(parsed.configPath);
return runDoctor(loaded.config, loaded.path);
}
if (parsed.command === "print") {
const topic = parsed.rest[0];
const loaded = loadConfigFile(parsed.configPath);
if (topic === "feishu") {
await printFeishu(loaded.config);
return 0;
}
console.error(`Unknown print topic: ${topic || "(missing)"}`);
console.error("Available: feishu");
return 1;
}
console.error(`Unknown command: ${parsed.command}`);
printHelp();
return 1;
}
function parseArgs(argv: string[]): ParsedArgs {
const rest: string[] = [];
let command: string | undefined;
let configPath: string | undefined;
let json = false;
let help = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
help = true;
} else if (arg === "--json") {
json = true;
} else if (arg === "--config") {
configPath = argv[++index];
if (!configPath) throw new Error("--config requires a path");
} else if (!command) {
command = arg;
} else {
rest.push(arg);
}
}
return { command, rest, configPath, json, help };
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}${loaded.exists ? "" : " (seeded from config.example.json)"}`);
console.log(`Server: ${loaded.config.server.host}:${loaded.config.server.port}`);
console.log(`Default agent: ${loaded.config.defaultAgent}`);
console.log(`Agents: ${loaded.config.agents.map((agent) => agent.name).join(", ")}`);
console.log(`Enabled platforms: ${Object.entries(loaded.config.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_000);
const response = await fetch(`${baseUrl}/health`, { signal: controller.signal });
clearTimeout(timer);
console.log(`Health probe: HTTP ${response.status}`);
} catch {
console.log("Health probe: not reachable on local URL");
}
}
function printHelp(): void {
console.log(`gori-agent - local CLI-agent gateway setup and operations
Usage:
gori-agent setup [--config path]
gori-agent discover-agents [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
gori-agent --help
No-link fallback:
npm run gori-agent -- setup`);
}
main(process.argv.slice(2)).then((code) => {
if (Number.isInteger(code)) process.exitCode = code;
}).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import type { AppConfig } from "../config.js";
import { loadConfigFromPath, parseConfig } from "../config.js";
export interface LoadedConfigFile {
path: string;
config: AppConfig;
exists: boolean;
source: "explicit" | "env" | "local" | "example";
}
export function projectRoot(): string {
return path.resolve(new URL("../..", import.meta.url).pathname);
}
export function resolveCliConfigPath(configPath?: string): { path: string; exists: boolean; source: LoadedConfigFile["source"] } {
if (configPath) {
const resolved = path.resolve(configPath);
return { path: resolved, exists: fs.existsSync(resolved), source: "explicit" };
}
if (process.env.GORI_GATEWAY_CONFIG) {
const resolved = path.resolve(process.env.GORI_GATEWAY_CONFIG);
return { path: resolved, exists: fs.existsSync(resolved), source: "env" };
}
const local = path.resolve("config.json");
if (fs.existsSync(local)) return { path: local, exists: true, source: "local" };
return { path: path.resolve("config.json"), exists: false, source: "example" };
}
export function loadConfigFile(configPath?: string): LoadedConfigFile {
const resolved = resolveCliConfigPath(configPath);
if (resolved.exists) {
return {
path: resolved.path,
config: loadConfigFromPath(resolved.path),
exists: true,
source: resolved.source
};
}
const examplePath = path.join(projectRoot(), "config.example.json");
const raw = fs.readFileSync(examplePath, "utf8");
const config = parseConfig(JSON.parse(raw) as unknown);
return {
path: resolved.path,
config,
exists: false,
source: "example"
};
}
export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
export function readConfigJson(configPath?: string): unknown {
const loaded = loadConfigFile(configPath);
return loaded.config;
}
+62
View File
@@ -0,0 +1,62 @@
import fs from "node:fs";
import type { AppConfig } from "../config.js";
import { discoverAgents } from "../agents/discovery.js";
import { printUrlHints } from "./net.js";
export async function runDoctor(config: AppConfig, configPath: string): Promise<number> {
let problems = 0;
console.log(`Config: ${configPath}`);
console.log(`Server: ${config.server.host}:${config.server.port}`);
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
console.log(`ERROR: defaultAgent '${config.defaultAgent}' is not in agents[].`);
problems++;
} else {
console.log(`Default agent: ${config.defaultAgent}`);
}
for (const agent of config.agents) {
if (agent.cwd && !fs.existsSync(agent.cwd)) {
console.log(`WARN: agent '${agent.name}' cwd does not exist: ${agent.cwd}`);
}
}
const enabledPlatforms = Object.entries(config.platforms).filter(([, value]) => value.enabled).map(([name]) => name);
console.log(`Enabled platforms: ${enabledPlatforms.join(", ") || "none"}`);
if (enabledPlatforms.length === 0) {
console.log("WARN: no IM platform is enabled.");
}
if (config.platforms.feishu.enabled) {
if (!config.platforms.feishu.appId) problems += error("Feishu appId is empty.");
if (!config.platforms.feishu.appSecret || config.platforms.feishu.appSecret === "replace-me") warn("Feishu appSecret is missing or placeholder.");
if (!config.platforms.feishu.verificationToken || config.platforms.feishu.verificationToken === "replace-me") warn("Feishu verificationToken is missing or placeholder.");
}
if (config.platforms.qq.enabled) {
if (!config.platforms.qq.appId) problems += error("QQ appId is empty.");
if (!config.platforms.qq.clientSecret || config.platforms.qq.clientSecret === "replace-me") warn("QQ clientSecret is missing or placeholder.");
if (config.platforms.qq.verifySignature) {
const callbackSecret = config.platforms.qq.botSecret || config.platforms.qq.clientSecret;
if (!callbackSecret || callbackSecret === "replace-me") problems += error("QQ botSecret or clientSecret is required when verifySignature is true.");
}
}
const discovered = await discoverAgents();
console.log("Discovered local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.name}: ${agent.status}${version}${reason}`);
}
await printUrlHints(config);
return problems > 0 ? 1 : 0;
}
function error(message: string): 1 {
console.log(`ERROR: ${message}`);
return 1;
}
function warn(message: string): void {
console.log(`WARN: ${message}`);
}
+49
View File
@@ -0,0 +1,49 @@
import os from "node:os";
import type { AppConfig } from "../config.js";
export function localBaseUrl(config: AppConfig): string {
return `http://localhost:${config.server.port}`;
}
export function lanBaseUrls(config: AppConfig): string[] {
const urls: string[] = [];
for (const interfaces of Object.values(os.networkInterfaces())) {
for (const item of interfaces || []) {
if (item.family === "IPv4" && !item.internal) {
urls.push(`http://${item.address}:${config.server.port}`);
}
}
}
return urls;
}
export async function publicBaseUrlHint(config: AppConfig): Promise<string | undefined> {
if (config.server.publicBaseUrl) return config.server.publicBaseUrl.replace(/\/$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch("https://api.ipify.org?format=text", { signal: controller.signal });
if (!response.ok) return undefined;
const ip = (await response.text()).trim();
if (!ip) return undefined;
return `http://${ip}:${config.server.port}`;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
export async function printUrlHints(config: AppConfig): Promise<void> {
console.log(`Local: ${localBaseUrl(config)}`);
const lanUrls = lanBaseUrls(config);
if (lanUrls.length > 0) console.log(`LAN: ${lanUrls.join(", ")}`);
const publicHint = await publicBaseUrlHint(config);
if (publicHint) console.log(`Public hint: ${publicHint}`);
if (!config.server.publicBaseUrl) console.log("Set server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
}
export async function webhookBaseUrl(config: AppConfig): Promise<string> {
return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AppConfig } from "../config.js";
import { webhookBaseUrl } from "./net.js";
export async function printFeishu(config: AppConfig): Promise<void> {
const baseUrl = await webhookBaseUrl(config);
console.log("Feishu/Lark setup instructions");
console.log("");
console.log(`Webhook URL: ${baseUrl}/webhook/feishu`);
console.log("Event subscription: im.message.receive_v1");
console.log("Required config fields: platforms.feishu.appId, appSecret, verificationToken, botNames");
console.log("Do not paste appSecret into chats or logs.");
if (!config.platforms.feishu.enabled) {
console.log("Warning: platforms.feishu.enabled is false in this config.");
}
if (!config.server.publicBaseUrl) {
console.log("Warning: server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
}
}
+73
View File
@@ -0,0 +1,73 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
export interface PromptSession {
ask(question: string, defaultValue?: string): Promise<string>;
askBoolean(question: string, defaultValue?: boolean): Promise<boolean>;
askList(question: string, defaultValues?: string[]): Promise<string[]>;
choose<T>(question: string, choices: Choice<T>[], defaultIndex?: number): Promise<T>;
close(): void;
}
export interface Choice<T> {
label: string;
value: T;
hint?: string;
}
export function createPromptSession(): PromptSession {
const rl = readline.createInterface({ input, output });
return {
ask: (question, defaultValue) => ask(rl, question, defaultValue),
askBoolean: (question, defaultValue) => askBoolean(rl, question, defaultValue),
askList: (question, defaultValues) => askList(rl, question, defaultValues),
choose: (question, choices, defaultIndex) => choose(rl, question, choices, defaultIndex),
close: () => rl.close()
};
}
export async function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : "";
const answer = (await rl.question(`${question}${suffix}: `)).trim();
return answer || defaultValue || "";
}
export async function askBoolean(rl: readline.Interface, question: string, defaultValue = false): Promise<boolean> {
const suffix = defaultValue ? " [Y/n]" : " [y/N]";
for (;;) {
const answer = (await rl.question(`${question}${suffix}: `)).trim().toLowerCase();
if (!answer) return defaultValue;
if (["y", "yes"].includes(answer)) return true;
if (["n", "no"].includes(answer)) return false;
console.log("Please answer yes or no.");
}
}
export async function askList(rl: readline.Interface, question: string, defaultValues: string[] = []): Promise<string[]> {
const answer = await ask(rl, question, defaultValues.join(","));
return answer.split(",").map((value) => value.trim()).filter(Boolean);
}
export async function choose<T>(
rl: readline.Interface,
question: string,
choices: Choice<T>[],
defaultIndex = 0
): Promise<T> {
if (choices.length === 0) throw new Error("No choices available");
console.log(question);
choices.forEach((choice, index) => {
const marker = index === defaultIndex ? "*" : " ";
const hint = choice.hint ? ` - ${choice.hint}` : "";
console.log(` ${marker} ${index + 1}) ${choice.label}${hint}`);
});
for (;;) {
const answer = (await rl.question(`Choose 1-${choices.length} [${defaultIndex + 1}]: `)).trim();
if (!answer) return choices[defaultIndex]?.value ?? choices[0].value;
const index = Number(answer) - 1;
if (Number.isInteger(index) && choices[index]) return choices[index].value;
console.log(`Please enter a number from 1 to ${choices.length}.`);
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureFeishu(
prompt: PromptSession,
existing: AppConfig["platforms"]["feishu"]
): Promise<AppConfig["platforms"]["feishu"]> {
console.log("\nFeishu/Lark setup");
console.log("Create a Feishu/Lark app, enable bot messaging, and subscribe to im.message.receive_v1.");
const appId = await prompt.ask("App ID", existing.appId && existing.appId !== "cli_xxx" ? existing.appId : undefined);
const appSecretAnswer = await prompt.ask(existing.appSecret ? "App Secret (leave blank to keep existing)" : "App Secret");
const verificationToken = await prompt.ask(
"Verification token",
existing.verificationToken && existing.verificationToken !== "replace-me" ? existing.verificationToken : undefined
);
const botNames = await prompt.askList("Bot display names, comma-separated", existing.botNames);
return {
enabled: true,
appId,
appSecret: appSecretAnswer || existing.appSecret,
verificationToken,
botNames
};
}
+15
View File
@@ -0,0 +1,15 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureGenericWebhook(
prompt: PromptSession,
existing: AppConfig["platforms"]["webhook"]
): Promise<AppConfig["platforms"]["webhook"]> {
console.log("\nGeneric webhook setup");
console.log("Use POST /webhook/generic with optional X-Gori-Signature HMAC-SHA256 authentication.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Webhook secret", generated);
return { enabled: true, secret };
}
+15
View File
@@ -0,0 +1,15 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureWeixin(
prompt: PromptSession,
existing: AppConfig["platforms"]["weixin"]
): Promise<AppConfig["platforms"]["weixin"]> {
console.log("\nPersonal WeChat external webhook setup");
console.log("Native personal WeChat integration is not included; use an external bridge that POSTs to /webhook/weixin.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Bridge secret/reference", generated);
return { enabled: true, mode: "external-webhook", secret };
}
+268
View File
@@ -0,0 +1,268 @@
import path from "node:path";
import type { AppConfig, CliAgentConfig } from "../config.js";
import type { DiscoveredAgent } from "../agents/discovery.js";
import { discoverAgents, echoAgent } from "../agents/discovery.js";
import { listConfiguredKimiModels } from "../agents/kimi-models.js";
import { createPromptSession, type Choice } from "./prompt.js";
import { loadConfigFile, projectRoot, writeConfigFile } from "./config-file.js";
import { configureFeishu } from "./setup-feishu.js";
import { configureGenericWebhook } from "./setup-webhook.js";
import { configureWeixin } from "./setup-weixin.js";
import { printUrlHints } from "./net.js";
type PlatformName = keyof AppConfig["platforms"];
const PLATFORM_CHOICES: Choice<PlatformName>[] = [
{ label: "Feishu/Lark", value: "feishu", hint: "full inbound/outbound adapter" },
{ label: "WeChat external webhook", value: "weixin", hint: "personal WeChat bridge scaffold" },
{ label: "WeCom scaffold", value: "wecom", hint: "inbound is not implemented in v1" },
{ label: "QQ Bot webhook", value: "qq", hint: "official QQ Bot HTTP callback" },
{ label: "Generic webhook", value: "webhook", hint: "signed JSON webhook" }
];
export async function runSetup(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const prompt = createPromptSession();
try {
console.log("gori-agent setup");
console.log(`Config target: ${loaded.path}${loaded.exists ? "" : " (will create from config.example.json)"}`);
await printUrlHints(loaded.config);
const discovered = await discoverAgents(projectRoot());
console.log("\nDetected local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.label} [${agent.name}]: ${agent.status}${version}${reason}`);
}
const selectedAgent = await prompt.choose(
"\nChoose an agent",
discovered.map((agent) => ({
label: `${agent.label} (${agent.name})`,
value: agent,
hint: agent.status === "ready" ? agent.command : agent.reason
})),
Math.max(0, discovered.findIndex((agent) => agent.name === loaded.config.defaultAgent))
);
const agentConfig = await configureAgent(prompt, selectedAgent, loaded.config);
const selectedPlatform = await prompt.choose("\nChoose an IM platform", PLATFORM_CHOICES, 0);
const keepOtherPlatforms = await prompt.askBoolean("Preserve existing enabled settings for other platforms", false);
const nextConfig = await buildNextConfig(prompt, loaded.config, agentConfig, selectedPlatform, keepOtherPlatforms);
console.log("\nPlanned config summary:");
console.log(`- defaultAgent: ${nextConfig.defaultAgent}`);
console.log(`- agents: ${nextConfig.agents.map((agent) => agent.name).join(", ")}`);
console.log(`- enabled platforms: ${Object.entries(nextConfig.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
console.log("- secrets are not printed");
if (await prompt.askBoolean(`Write config to ${loaded.path}`, false)) {
writeConfigFile(loaded.path, nextConfig);
console.log(`Wrote ${loaded.path}`);
} else {
console.log("No changes written.");
}
} finally {
prompt.close();
}
}
async function configureAgent(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, config: AppConfig): Promise<CliAgentConfig> {
const existing = config.agents.find((agent) => agent.name === discovered.name);
const defaultCwd = existing?.cwd || discovered.cwd || projectRoot();
if (discovered.status === "needs-config") {
console.log(`\n${discovered.label} needs manual command details before it is enabled.`);
const command = await prompt.ask("Command", existing?.command || discovered.command);
const argsText = await prompt.ask("Args, separated by spaces", (existing?.args || discovered.args).join(" "));
const inputMode = await prompt.choose("Input mode", [
{ label: "Append prompt as final argv", value: "arg" as const },
{ label: "Send prompt to stdin", value: "stdin" as const }
], (existing?.inputMode || discovered.inputMode) === "stdin" ? 1 : 0);
const extraPermissionArgs = await prompt.ask("Extra auto/yolo permission args, if this agent needs them", "");
const cwd = await prompt.ask("Working directory", defaultCwd);
return agentFromParts(discovered.name, command, [...splitArgs(argsText), ...splitArgs(extraPermissionArgs)], inputMode, cwd);
}
const cwd = await prompt.ask("Working directory", defaultCwd);
const command = existing?.command || discovered.command;
const baseArgs = existing?.args || discovered.args;
const modelArgs = discovered.name === "kimi" ? await configureKimiArgs(prompt, baseArgs, command) : baseArgs;
const args = await configurePermissionArgs(prompt, discovered, modelArgs);
return agentFromParts(discovered.name, command, args, existing?.inputMode || discovered.inputMode, cwd);
}
async function configurePermissionArgs(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, args: string[]): Promise<string[]> {
if (!discovered.permissionModes || discovered.permissionModes.length === 0) return args;
const selected = await prompt.choose(
`Permission mode for ${discovered.label}`,
discovered.permissionModes.map((mode) => ({
label: mode.label,
value: mode,
hint: mode.warning
})),
0
);
if (selected.warning) console.log(`Note: ${selected.warning}`);
return mergePermissionArgs(args, selected.args);
}
function mergePermissionArgs(args: string[], permissionArgs: string[]): string[] {
const merged = [...args];
for (const arg of permissionArgs) {
if (!merged.includes(arg)) merged.push(arg);
}
return merged;
}
async function configureKimiArgs(prompt: ReturnType<typeof createPromptSession>, existingArgs: string[], kimiCommand: string): Promise<string[]> {
console.log("\nKimi Code supports temporary model selection with --model / -m.");
console.log("Setup reads your local Kimi provider/model list via `kimi provider list --json`.");
console.log("Leave it on default to use default_model from your Kimi Code config.");
const configuredModels = await listConfiguredKimiModels(kimiCommand);
const modelChoices: Choice<string>[] = [
{ label: "Use Kimi default model", value: "", hint: "respect default_model in Kimi Code config" },
...configuredModels.map((model) => ({
label: model.id,
value: model.id,
hint: model.source === "configured" ? "from local Kimi provider config" : "built-in fallback"
})),
{ label: "Custom model alias", value: "__custom__", hint: "type any provider/model alias" }
];
const existingModel = findKimiModel(existingArgs);
const defaultIndex = existingModel
? modelChoices.findIndex((choice) => choice.value === existingModel)
: 0;
const modelChoice = await prompt.choose("Choose Kimi Code model", modelChoices, defaultIndex >= 0 ? defaultIndex : modelChoices.length - 1);
const model = modelChoice === "__custom__"
? await prompt.ask("Custom Kimi model alias", existingModel || configuredModels[0]?.id || "kimi-for-coding")
: modelChoice;
return withKimiModel(existingArgs, model);
}
function findKimiModel(args: string[]): string | undefined {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if ((arg === "-m" || arg === "--model") && args[index + 1]) return args[index + 1];
if (arg.startsWith("--model=")) return arg.slice("--model=".length);
}
return undefined;
}
function withKimiModel(args: string[], model: string): string[] {
const cleaned: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "-m" || arg === "--model") {
index += 1;
continue;
}
if (arg.startsWith("--model=")) continue;
cleaned.push(arg);
}
if (!model) return ensureKimiPromptArg(cleaned);
return ["-m", model, ...ensureKimiPromptArg(cleaned)];
}
function ensureKimiPromptArg(args: string[]): string[] {
return args.includes("-p") || args.includes("--prompt") ? args : [...args, "-p"];
}
async function buildNextConfig(
prompt: ReturnType<typeof createPromptSession>,
existing: AppConfig,
selectedAgent: CliAgentConfig,
selectedPlatform: PlatformName,
keepOtherPlatforms: boolean
): Promise<AppConfig> {
const platforms: AppConfig["platforms"] = keepOtherPlatforms
? structuredClone(existing.platforms)
: disableAllPlatforms(existing.platforms);
if (selectedPlatform === "feishu") {
platforms.feishu = await configureFeishu(prompt, existing.platforms.feishu);
} else if (selectedPlatform === "webhook") {
platforms.webhook = await configureGenericWebhook(prompt, existing.platforms.webhook);
} else if (selectedPlatform === "weixin") {
platforms.weixin = await configureWeixin(prompt, existing.platforms.weixin);
} else if (selectedPlatform === "wecom") {
console.log("\nWarning: WeCom inbound webhook is a scaffold and returns 501 in v1.");
platforms.wecom = {
enabled: true,
corpId: await prompt.ask("WeCom corpId", existing.platforms.wecom.corpId),
agentId: await prompt.ask("WeCom agentId", existing.platforms.wecom.agentId),
secret: await prompt.ask(existing.platforms.wecom.secret ? "WeCom secret (leave blank to keep existing)" : "WeCom secret") || existing.platforms.wecom.secret
};
} else if (selectedPlatform === "qq") {
console.log("\nQQ Bot HTTP callback endpoint: /webhook/qq");
platforms.qq = {
enabled: true,
appId: await prompt.ask("QQ appId", existing.platforms.qq.appId),
clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret,
botSecret: await prompt.ask(existing.platforms.qq.botSecret ? "QQ botSecret for callback signing (leave blank to keep existing)" : "QQ botSecret for callback signing, blank to reuse clientSecret", "") || existing.platforms.qq.botSecret,
verifySignature: await prompt.askBoolean("Verify QQ callback signatures", existing.platforms.qq.verifySignature),
botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", ")))
};
}
const echo = echoConfig();
const agents = selectedAgent.name === "echo" ? [echo] : [selectedAgent, echo];
return {
server: existing.server,
policy: existing.policy,
defaultAgent: selectedAgent.name,
agents,
platforms
};
}
function disableAllPlatforms(platforms: AppConfig["platforms"]): AppConfig["platforms"] {
return {
feishu: { ...platforms.feishu, enabled: false },
wecom: { ...platforms.wecom, enabled: false },
qq: { ...platforms.qq, enabled: false },
webhook: { ...platforms.webhook, enabled: false },
weixin: { ...platforms.weixin, enabled: false }
};
}
function echoConfig(): CliAgentConfig {
const echo = echoAgent(projectRoot());
return {
name: echo.name,
command: echo.command,
args: echo.args,
inputMode: echo.inputMode,
cwd: echo.cwd,
timeoutMs: 30_000,
outputMaxBytes: 64_000
};
}
function agentFromParts(name: string, command: string, args: string[], inputMode: "stdin" | "arg", cwd: string): CliAgentConfig {
return {
name,
command,
args,
inputMode,
cwd: path.resolve(cwd),
timeoutMs: 120_000,
outputMaxBytes: 64_000
};
}
function splitArgs(value: string): string[] {
return value.split(" ").map((part) => part.trim()).filter(Boolean);
}
function splitCommaList(value: string): string[] {
return value.split(",").map((part) => part.trim()).filter(Boolean);
}
+23 -7
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { z } from "zod";
const cliAgentSchema = z.object({
@@ -36,7 +37,10 @@ const wecomSchema = z.object({
const qqSchema = z.object({
enabled: z.boolean().default(false),
appId: z.string().default(""),
clientSecret: z.string().default("")
clientSecret: z.string().default(""),
botSecret: z.string().default(""),
verifySignature: z.boolean().default(true),
botNames: z.array(z.string()).default([])
});
const webhookSchema = z.object({
@@ -53,7 +57,8 @@ const weixinSchema = z.object({
export const configSchema = z.object({
server: z.object({
host: z.string().default("0.0.0.0"),
port: z.number().int().positive().max(65_535).default(3000)
port: z.number().int().positive().max(65_535).default(3000),
publicBaseUrl: z.string().default("")
}).default({}),
policy: policySchema.default({}),
defaultAgent: z.string().min(1).default("echo"),
@@ -71,11 +76,12 @@ export type AppConfig = z.infer<typeof configSchema>;
export type CliAgentConfig = z.infer<typeof cliAgentSchema>;
export type GatewayPolicy = z.infer<typeof policySchema>;
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
const resolvedPath = path.resolve(configPath || "config.example.json");
const raw = fs.readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const config = configSchema.parse(parsed);
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
return path.resolve(configPath || "config.example.json");
}
export function parseConfig(rawConfig: unknown): AppConfig {
const config = configSchema.parse(rawConfig);
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`);
@@ -83,3 +89,13 @@ export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppCon
return config;
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath));
}
export function loadConfigFromPath(configPath: string): AppConfig {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return parseConfig(parsed);
}
+103 -6
View File
@@ -1,17 +1,50 @@
import type { AppConfig } from "../../config.js";
import { notImplemented } from "../../core/adapter.js";
import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import type { QqAccessTokenResponse, QqSendMessageResponse } from "./types.js";
import type { Gateway } from "../../core/gateway.js";
import { stripBotMentions } from "../../core/text.js";
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
const QQ_CALLBACK_ACK = { op: 12 };
const MESSAGE_EVENTS = new Set([
"GROUP_AT_MESSAGE_CREATE",
"C2C_MESSAGE_CREATE"
]);
export class QqAdapter implements PlatformAdapter {
readonly name = "qq";
private accessToken?: { token: string; expiresAt: number };
constructor(private readonly config: AppConfig["platforms"]["qq"]) {}
constructor(
private readonly config: AppConfig["platforms"]["qq"],
private readonly gateway: Gateway
) {}
async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> {
return notImplemented("QQ");
async handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse> {
const payload = context.body as QqWebhookPayload;
if (payload.op === 13) return this.handleValidation(payload.d);
if (this.config.verifySignature) {
const secret = this.callbackSecret();
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for signature verification" }, 400);
if (!verifyQqWebhookSignature(secret, context)) {
return jsonResponse({ ok: false, error: "invalid QQ webhook signature" }, 401);
}
}
if (payload.op !== 0 || !payload.t || !MESSAGE_EVENTS.has(payload.t)) {
return jsonResponse(QQ_CALLBACK_ACK);
}
const message = this.normalizeMessage(payload);
if (!message) return jsonResponse(QQ_CALLBACK_ACK);
void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error);
});
return jsonResponse(QQ_CALLBACK_ACK);
}
async sendMessage(message: OutgoingMessage): Promise<void> {
@@ -36,6 +69,47 @@ export class QqAdapter implements PlatformAdapter {
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim());
}
private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse {
const secret = this.callbackSecret();
if (!secret) return jsonResponse({ ok: false, error: "QQ botSecret is required for callback validation" }, 400);
if (!data?.plain_token || !data.event_ts) {
return jsonResponse({ ok: false, error: "missing QQ validation fields" }, 400);
}
return jsonResponse({
plain_token: data.plain_token,
signature: signQqValidation(secret, data.event_ts, data.plain_token)
});
}
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined {
const data = payload.d;
if (!data) return undefined;
const rawText = data.content || data.text || "";
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
if (!text) return undefined;
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
const chatId = chatIdFor(data, isGroup);
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid;
if (!chatId || !userId) return undefined;
return {
platform: this.name,
chatId,
userId,
text,
messageId: data.id || data.msg_id || data.message_id || payload.id,
isGroup,
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
raw: data
};
}
private callbackSecret(): string {
return this.config.botSecret || this.config.clientSecret;
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token;
@@ -56,3 +130,26 @@ export class QqAdapter implements PlatformAdapter {
return this.accessToken.token;
}
}
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (data.group_openid) return `group:${data.group_openid}`;
if (data.group_id) return `group:${data.group_id}`;
if (data.channel_id) return `channel:${data.channel_id}`;
if (data.guild_id) return `guild:${data.guild_id}`;
if (!isGroup && data.user_openid) return `user:${data.user_openid}`;
if (!isGroup && data.author?.id) return `user:${data.author.id}`;
return undefined;
}
function stripConfiguredBotNames(text: string, botNames: string[]): string {
let cleaned = text;
for (const name of botNames) {
const escaped = escapeRegExp(name);
cleaned = cleaned.replace(new RegExp(`@?${escaped}`, "g"), " ");
}
return cleaned.replace(/\s+/g, " ").trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+41
View File
@@ -0,0 +1,41 @@
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
import { firstHeader } from "../../core/text.js";
import type { WebhookRequestContext } from "../../core/types.js";
const ED25519_PKCS8_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
const ED25519_SEED_SIZE = 32;
const ED25519_SIGNATURE_SIZE = 64;
export function signQqValidation(botSecret: string, eventTs: string, plainToken: string): string {
const privateKey = privateKeyFromBotSecret(botSecret);
return sign(null, Buffer.from(`${eventTs}${plainToken}`, "utf8"), privateKey).toString("hex");
}
export function verifyQqWebhookSignature(botSecret: string, context: WebhookRequestContext): boolean {
const signatureHex = firstHeader(context.headers["x-signature-ed25519"]);
const timestamp = firstHeader(context.headers["x-signature-timestamp"]);
if (!signatureHex || !timestamp || !context.rawBody) return false;
const signature = Buffer.from(signatureHex, "hex");
if (signature.length !== ED25519_SIGNATURE_SIZE || (signature[63] & 224) !== 0) return false;
const privateKey = privateKeyFromBotSecret(botSecret);
const publicKey = createPublicKey(privateKey);
return verify(null, Buffer.concat([Buffer.from(timestamp, "utf8"), context.rawBody]), publicKey, signature);
}
function privateKeyFromBotSecret(botSecret: string) {
if (!botSecret) throw new Error("QQ botSecret is required for callback signature handling");
const seed = seedFromBotSecret(botSecret);
return createPrivateKey({
key: Buffer.concat([ED25519_PKCS8_SEED_PREFIX, seed]),
format: "der",
type: "pkcs8"
});
}
function seedFromBotSecret(botSecret: string): Buffer {
let seed = botSecret;
while (Buffer.byteLength(seed, "utf8") < ED25519_SEED_SIZE) seed += seed;
return Buffer.from(seed, "utf8").subarray(0, ED25519_SEED_SIZE);
}
+29
View File
@@ -11,3 +11,32 @@ export interface QqSendMessageResponse {
code?: number;
message?: string;
}
export interface QqWebhookPayload {
id?: string;
op?: number;
d?: QqWebhookEventData;
s?: number;
t?: string;
}
export interface QqWebhookEventData {
plain_token?: string;
event_ts?: string;
id?: string;
msg_id?: string;
message_id?: string;
content?: string;
text?: string;
group_openid?: string;
group_id?: string;
channel_id?: string;
guild_id?: string;
user_openid?: string;
member_openid?: string;
author?: {
id?: string;
user_openid?: string;
username?: string;
};
}
+71 -59
View File
@@ -1,5 +1,7 @@
import express from "express";
import { loadConfig } from "./config.js";
import type { Server } from "node:http";
import { fileURLToPath } from "node:url";
import { loadConfig, type AppConfig } from "./config.js";
import { AgentRegistry } from "./agents/agent-registry.js";
import { CliAgent } from "./agents/cli-agent.js";
import { Gateway } from "./core/gateway.js";
@@ -12,67 +14,77 @@ import { QqAdapter } from "./platforms/qq/adapter.js";
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
const config = loadConfig();
const sessions = new SessionStore();
const agents = new AgentRegistry(config.defaultAgent);
for (const agentConfig of config.agents) {
agents.register(new CliAgent(agentConfig));
export function createApp(config: AppConfig): express.Express {
const sessions = new SessionStore();
const agents = new AgentRegistry(config.defaultAgent);
for (const agentConfig of config.agents) {
agents.register(new CliAgent(agentConfig));
}
const gateway = new Gateway(config.policy, agents, sessions);
const platforms = new PlatformRegistry();
const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway),
new WeComAdapter(config.platforms.wecom),
new QqAdapter(config.platforms.qq, gateway),
new GenericWebhookAdapter(config.platforms.webhook, gateway),
new WeixinAdapter(config.platforms.weixin, gateway)
];
for (const adapter of adapters) platforms.register(adapter);
const app = express();
app.use(express.json({
limit: "1mb",
verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
}
}));
app.get("/health", (_req, res) => {
res.json({ ok: true, gateway: gateway.stats() });
});
app.get("/platforms", (_req, res) => {
res.json({ ok: true, platforms: platforms.list(), agents: agents.list() });
});
function mountWebhook(routeName: string, adapterName = routeName): void {
app.post(`/webhook/${routeName}`, async (req, res) => {
try {
const response = await platforms.get(adapterName).handleWebhook({
req,
body: req.body,
headers: req.headers,
query: req.query,
rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody
});
if (response.headers) {
for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value);
}
res.status(response.status || 200).json(response.body ?? { ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Webhook ${routeName} failed`, error);
res.status(500).json({ ok: false, error: message });
}
});
}
for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
mountWebhook(platformName);
}
mountWebhook("generic", "webhook");
return app;
}
const gateway = new Gateway(config.policy, agents, sessions);
const platforms = new PlatformRegistry();
const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway),
new WeComAdapter(config.platforms.wecom),
new QqAdapter(config.platforms.qq),
new GenericWebhookAdapter(config.platforms.webhook, gateway),
new WeixinAdapter(config.platforms.weixin, gateway)
];
for (const adapter of adapters) platforms.register(adapter);
const app = express();
app.use(express.json({
limit: "1mb",
verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
}
}));
app.get("/health", (_req, res) => {
res.json({ ok: true, gateway: gateway.stats() });
});
app.get("/platforms", (_req, res) => {
res.json({ ok: true, platforms: platforms.list(), agents: agents.list() });
});
function mountWebhook(routeName: string, adapterName = routeName): void {
app.post(`/webhook/${routeName}`, async (req, res) => {
try {
const response = await platforms.get(adapterName).handleWebhook({
req,
body: req.body,
headers: req.headers,
query: req.query,
rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody
});
if (response.headers) {
for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value);
}
res.status(response.status || 200).json(response.body ?? { ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Webhook ${routeName} failed`, error);
res.status(500).json({ ok: false, error: message });
}
export function startServer(config: AppConfig): Server {
const app = createApp(config);
return app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
});
}
for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
mountWebhook(platformName);
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
startServer(loadConfig());
}
mountWebhook("generic", "webhook");
app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
});