Refactor runtime around isolated bot instances

This commit is contained in:
zenord
2026-08-16 23:45:09 +08:00
parent 914579a94c
commit f9fb4ef776
51 changed files with 2355 additions and 1459 deletions
+98 -36
View File
@@ -7,60 +7,122 @@ import { loadConfigFromPath, parseConfig } from "../config.js";
export interface LoadedConfigFile {
path: string;
config: AppConfig;
exists: boolean;
source: "explicit" | "env" | "local" | "example";
source: "explicit" | "env" | "local";
}
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 resolveCliConfigPath(configPath?: string): { path: string; source: LoadedConfigFile["source"] } {
if (configPath) return { path: path.resolve(configPath), source: "explicit" };
if (process.env.GORI_GATEWAY_CONFIG) return { path: path.resolve(process.env.GORI_GATEWAY_CONFIG), source: "env" };
return { path: path.resolve("config.json"), source: "local" };
}
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
};
if (!fs.existsSync(resolved.path)) {
throw new Error(`Runtime config does not exist: ${resolved.path}; config.example.json is documentation only`);
}
return { path: resolved.path, config: loadConfigFromPath(resolved.path), source: resolved.source };
}
export function loadExampleConfig(): AppConfig {
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"
};
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
}
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");
const directory = path.dirname(configPath);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const temp = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", 0o600);
fs.writeFileSync(fd, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temp, configPath);
fs.chmodSync(configPath, 0o600);
const dirFd = fs.openSync(directory, "r");
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
} catch (error) {
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore cleanup failure */ }
try { fs.unlinkSync(temp); } catch { /* ignore cleanup failure */ }
throw error;
}
}
export function operationalConfigProblems(config: AppConfig): string[] {
const problems: string[] = [];
if (config.bot.id === "BOT_ID") problems.push("bot.id is still the example placeholder");
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder");
if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder");
else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable");
try {
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
} catch { problems.push("bot.workspace must be an existing directory"); }
const platform = config.gateway.platform;
switch (platform.type) {
case "qq":
if (missingOrPlaceholder(platform.appId)) problems.push("QQ appId is missing or placeholder");
if (missingOrPlaceholder(platform.clientSecret)) problems.push("QQ clientSecret is missing or placeholder");
if (platform.botNames.some(isPlaceholder)) problems.push("QQ botNames contains a placeholder");
break;
case "feishu":
if (missingOrPlaceholder(platform.appId)) problems.push("Feishu appId is missing or placeholder");
if (missingOrPlaceholder(platform.appSecret)) problems.push("Feishu appSecret is missing or placeholder");
break;
case "wecom":
if (missingOrPlaceholder(platform.corpId)) problems.push("WeCom corpId is missing or placeholder");
if (missingOrPlaceholder(platform.agentId)) problems.push("WeCom agentId is missing or placeholder");
if (missingOrPlaceholder(platform.secret)) problems.push("WeCom secret is missing or placeholder");
break;
case "webhook":
if (missingOrPlaceholder(platform.secret)) problems.push("Generic webhook secret is missing or placeholder");
break;
case "weixin":
if (missingOrPlaceholder(platform.secret)) problems.push("Weixin bridge secret is missing or placeholder");
break;
}
return problems;
}
export function assertOperationalConfig(config: AppConfig, configFile?: string): void {
const problems = operationalConfigProblems(config);
if (configFile) {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) problems.push("config must be a regular file");
const mode = stat.mode & 0o777;
if (mode !== 0o600) problems.push(`config file mode must be 0600, got 0${mode.toString(8)}`);
}
if (problems.length > 0) throw new Error(`Refusing to start: ${problems.join("; ")}`);
}
function missingOrPlaceholder(value: string): boolean { return !value || isPlaceholder(value); }
function isExecutable(command: string): boolean {
try {
if (command.includes(path.sep)) {
fs.accessSync(command, fs.constants.X_OK);
return fs.statSync(command).isFile();
}
return (process.env.PATH || "").split(path.delimiter).some((directory) => {
try { fs.accessSync(path.join(directory, command), fs.constants.X_OK); return fs.statSync(path.join(directory, command)).isFile(); }
catch { return false; }
});
} catch { return false; }
}
function isPlaceholder(value: string): boolean {
return /^(?:BOT_ID|QQ_(?:APP_ID|CLIENT_SECRET|BOT_NAME)|(?:FEISHU|WECOM|WEBHOOK|WEIXIN)_[A-Z0-9_]+)$/.test(value)
|| value.includes("<") || value.includes(">");
}
export function readConfigJson(configPath?: string): unknown {
const loaded = loadConfigFile(configPath);
return loaded.config;
return loadConfigFile(configPath).config;
}