@hasna/bridge 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +168 -1
- package/README.md +85 -3
- package/dist/cli/index.js +865 -95
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +787 -73
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/daemon.d.ts +123 -0
- package/dist/lib/daemon.d.ts.map +1 -0
- package/dist/lib/doctor.d.ts +1 -1
- package/dist/lib/doctor.d.ts.map +1 -1
- package/dist/lib/router.d.ts.map +1 -1
- package/dist/lib/telegram.d.ts +6 -0
- package/dist/lib/telegram.d.ts.map +1 -1
- package/dist/mcp/index.js +327 -38
- package/docs/architecture.md +35 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -4099,6 +4099,7 @@ function defaultConfigPath() {
|
|
|
4099
4099
|
}
|
|
4100
4100
|
|
|
4101
4101
|
// src/lib/config.ts
|
|
4102
|
+
var REDACTED_VALUE = "[redacted]";
|
|
4102
4103
|
var channelSchema = exports_external.discriminatedUnion("kind", [
|
|
4103
4104
|
exports_external.object({
|
|
4104
4105
|
id: exports_external.string().min(1),
|
|
@@ -4186,6 +4187,31 @@ function parseConfig(value) {
|
|
|
4186
4187
|
const parsed = configSchema.parse(value);
|
|
4187
4188
|
return parsed;
|
|
4188
4189
|
}
|
|
4190
|
+
function redactEnv(env) {
|
|
4191
|
+
if (!env)
|
|
4192
|
+
return;
|
|
4193
|
+
return Object.fromEntries(Object.keys(env).map((key) => [key, REDACTED_VALUE]));
|
|
4194
|
+
}
|
|
4195
|
+
function redactEnvRecord(items) {
|
|
4196
|
+
return Object.fromEntries(Object.entries(items).map(([id, item]) => {
|
|
4197
|
+
const clone = { ...item };
|
|
4198
|
+
if (item.env)
|
|
4199
|
+
clone.env = redactEnv(item.env);
|
|
4200
|
+
return [id, clone];
|
|
4201
|
+
}));
|
|
4202
|
+
}
|
|
4203
|
+
function redactConfig(config) {
|
|
4204
|
+
return {
|
|
4205
|
+
...config,
|
|
4206
|
+
channels: Object.fromEntries(Object.entries(config.channels).map(([id, channel]) => [id, { ...channel }])),
|
|
4207
|
+
profiles: redactEnvRecord(config.profiles),
|
|
4208
|
+
agents: redactEnvRecord(config.agents),
|
|
4209
|
+
routes: config.routes.map((route) => ({
|
|
4210
|
+
...route,
|
|
4211
|
+
match: route.match ? { ...route.match, chatIds: route.match.chatIds ? [...route.match.chatIds] : undefined } : undefined
|
|
4212
|
+
}))
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4189
4215
|
async function loadConfig(configPath = defaultConfigPath()) {
|
|
4190
4216
|
try {
|
|
4191
4217
|
const raw = await readFile(configPath, "utf-8");
|
|
@@ -4233,55 +4259,73 @@ async function upsertRoute(route, configPath = defaultConfigPath()) {
|
|
|
4233
4259
|
await saveConfig(config, configPath);
|
|
4234
4260
|
return config;
|
|
4235
4261
|
}
|
|
4236
|
-
// src/lib/
|
|
4237
|
-
import {
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4262
|
+
// src/lib/daemon.ts
|
|
4263
|
+
import { spawn } from "child_process";
|
|
4264
|
+
import { closeSync, openSync } from "fs";
|
|
4265
|
+
import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
|
|
4266
|
+
import { dirname as dirname3, join as join3, resolve } from "path";
|
|
4267
|
+
|
|
4268
|
+
// src/lib/state.ts
|
|
4269
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
4270
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
4271
|
+
function defaultStatePath() {
|
|
4272
|
+
return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
|
|
4244
4273
|
}
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4274
|
+
function emptyState() {
|
|
4275
|
+
return { telegramOffsets: {} };
|
|
4276
|
+
}
|
|
4277
|
+
async function loadState(statePath = defaultStatePath()) {
|
|
4248
4278
|
try {
|
|
4249
|
-
await
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4279
|
+
const raw = await readFile2(statePath, "utf-8");
|
|
4280
|
+
const parsed = JSON.parse(raw);
|
|
4281
|
+
return {
|
|
4282
|
+
telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
|
|
4283
|
+
};
|
|
4284
|
+
} catch (err) {
|
|
4285
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
4286
|
+
return emptyState();
|
|
4287
|
+
}
|
|
4288
|
+
throw err;
|
|
4253
4289
|
}
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4290
|
+
}
|
|
4291
|
+
async function saveState(state, statePath = defaultStatePath()) {
|
|
4292
|
+
await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
|
|
4293
|
+
await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
|
|
4294
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
4295
|
+
await chmod2(statePath, 384);
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
// src/lib/telegram.ts
|
|
4299
|
+
var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
4300
|
+
function telegramApiBase() {
|
|
4301
|
+
const raw = process.env["BRIDGE_TELEGRAM_API_BASE"] || DEFAULT_TELEGRAM_API_BASE;
|
|
4302
|
+
const parsed = new URL(raw);
|
|
4303
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
4304
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must use http or https");
|
|
4260
4305
|
}
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
4264
|
-
checks.push({
|
|
4265
|
-
name: `telegram-token:${channel.id}`,
|
|
4266
|
-
ok: Boolean(process.env[envName]),
|
|
4267
|
-
detail: envName
|
|
4268
|
-
});
|
|
4269
|
-
checks.push({
|
|
4270
|
-
name: `telegram-allowlist:${channel.id}`,
|
|
4271
|
-
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
4272
|
-
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
4273
|
-
});
|
|
4306
|
+
if (parsed.username || parsed.password) {
|
|
4307
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain credentials");
|
|
4274
4308
|
}
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
name: `route:${route.id}`,
|
|
4278
|
-
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
4279
|
-
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
4280
|
-
});
|
|
4309
|
+
if (parsed.search || parsed.hash) {
|
|
4310
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain query strings or fragments");
|
|
4281
4311
|
}
|
|
4282
|
-
return
|
|
4312
|
+
return parsed;
|
|
4313
|
+
}
|
|
4314
|
+
function telegramApiBaseInfo() {
|
|
4315
|
+
const parsed = telegramApiBase();
|
|
4316
|
+
return {
|
|
4317
|
+
overridden: parsed.href.replace(/\/$/, "") !== DEFAULT_TELEGRAM_API_BASE,
|
|
4318
|
+
origin: parsed.origin,
|
|
4319
|
+
pathname: parsed.pathname
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
function telegramMethodUrl(token, method) {
|
|
4323
|
+
const base = telegramApiBase();
|
|
4324
|
+
const prefix = base.pathname.replace(/\/$/, "");
|
|
4325
|
+
base.pathname = `${prefix}/bot${token}/${method}`;
|
|
4326
|
+
base.search = "";
|
|
4327
|
+
return base.toString();
|
|
4283
4328
|
}
|
|
4284
|
-
// src/lib/telegram.ts
|
|
4285
4329
|
function telegramToken(channel) {
|
|
4286
4330
|
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
4287
4331
|
const token = process.env[envName];
|
|
@@ -4297,7 +4341,7 @@ function telegramChatAllowed(channel, chatId) {
|
|
|
4297
4341
|
return Boolean(chatId && channel.allowedChatIds.includes(chatId));
|
|
4298
4342
|
}
|
|
4299
4343
|
async function sendTelegramMessage(token, chatId, text) {
|
|
4300
|
-
const response = await fetch(
|
|
4344
|
+
const response = await fetch(telegramMethodUrl(token, "sendMessage"), {
|
|
4301
4345
|
method: "POST",
|
|
4302
4346
|
headers: { "content-type": "application/json" },
|
|
4303
4347
|
body: JSON.stringify({ chat_id: chatId, text })
|
|
@@ -4314,7 +4358,7 @@ async function getTelegramUpdates(token, options = {}) {
|
|
|
4314
4358
|
if (options.offset !== undefined)
|
|
4315
4359
|
params.set("offset", String(options.offset));
|
|
4316
4360
|
params.set("timeout", String(options.timeoutSeconds ?? 20));
|
|
4317
|
-
const response = await fetch(
|
|
4361
|
+
const response = await fetch(`${telegramMethodUrl(token, "getUpdates")}?${params.toString()}`);
|
|
4318
4362
|
const body = await response.json().catch(() => {
|
|
4319
4363
|
return;
|
|
4320
4364
|
});
|
|
@@ -4339,9 +4383,684 @@ function telegramUpdateToMessage(channelId, update) {
|
|
|
4339
4383
|
};
|
|
4340
4384
|
}
|
|
4341
4385
|
|
|
4386
|
+
// src/lib/daemon.ts
|
|
4387
|
+
function isNotFound(err) {
|
|
4388
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
4389
|
+
}
|
|
4390
|
+
function currentPlatformSupervisor() {
|
|
4391
|
+
if (process.platform === "darwin")
|
|
4392
|
+
return "launchd";
|
|
4393
|
+
if (process.platform === "linux")
|
|
4394
|
+
return "systemd";
|
|
4395
|
+
return "process";
|
|
4396
|
+
}
|
|
4397
|
+
function resolveSupervisor(supervisor = "process") {
|
|
4398
|
+
return supervisor === "auto" ? currentPlatformSupervisor() : supervisor;
|
|
4399
|
+
}
|
|
4400
|
+
function defaultDaemonDir() {
|
|
4401
|
+
return join3(bridgeHome(), "daemon");
|
|
4402
|
+
}
|
|
4403
|
+
function daemonPaths(daemonDir = defaultDaemonDir()) {
|
|
4404
|
+
const dir = resolve(daemonDir);
|
|
4405
|
+
return {
|
|
4406
|
+
dir,
|
|
4407
|
+
lockDir: join3(dir, "lock"),
|
|
4408
|
+
metadataFile: join3(dir, "bridge-daemon.json"),
|
|
4409
|
+
stdoutLog: join3(dir, "bridge.out.log"),
|
|
4410
|
+
stderrLog: join3(dir, "bridge.err.log"),
|
|
4411
|
+
launchdPlist: join3(process.env["HOME"] || process.cwd(), "Library", "LaunchAgents", "com.hasna.bridge.plist"),
|
|
4412
|
+
systemdUnit: join3(process.env["HOME"] || process.cwd(), ".config", "systemd", "user", "hasna-bridge.service")
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
async function ensureDaemonDir(dir = defaultDaemonDir()) {
|
|
4416
|
+
const paths = daemonPaths(dir);
|
|
4417
|
+
await mkdir3(paths.dir, { recursive: true, mode: 448 });
|
|
4418
|
+
await chmod3(paths.dir, 448);
|
|
4419
|
+
return paths;
|
|
4420
|
+
}
|
|
4421
|
+
async function fileExists(path) {
|
|
4422
|
+
try {
|
|
4423
|
+
await stat(path);
|
|
4424
|
+
return true;
|
|
4425
|
+
} catch (err) {
|
|
4426
|
+
if (isNotFound(err))
|
|
4427
|
+
return false;
|
|
4428
|
+
throw err;
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
async function readMetadata(paths) {
|
|
4432
|
+
try {
|
|
4433
|
+
return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
|
|
4434
|
+
} catch (err) {
|
|
4435
|
+
if (isNotFound(err))
|
|
4436
|
+
return;
|
|
4437
|
+
throw err;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
async function writeMetadata(paths, metadata) {
|
|
4441
|
+
const tmp = `${paths.metadataFile}.${process.pid}.${Date.now()}.tmp`;
|
|
4442
|
+
await writeFile3(tmp, `${JSON.stringify(metadata, null, 2)}
|
|
4443
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
4444
|
+
await chmod3(tmp, 384);
|
|
4445
|
+
await rename(tmp, paths.metadataFile);
|
|
4446
|
+
await chmod3(paths.metadataFile, 384);
|
|
4447
|
+
}
|
|
4448
|
+
async function withDaemonLock(paths, fn) {
|
|
4449
|
+
try {
|
|
4450
|
+
await mkdir3(paths.lockDir, { mode: 448 });
|
|
4451
|
+
} catch (err) {
|
|
4452
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EEXIST") {
|
|
4453
|
+
throw new Error(`Another bridge daemon operation is already running: ${paths.lockDir}`);
|
|
4454
|
+
}
|
|
4455
|
+
throw err;
|
|
4456
|
+
}
|
|
4457
|
+
try {
|
|
4458
|
+
return await fn();
|
|
4459
|
+
} finally {
|
|
4460
|
+
await rmdir(paths.lockDir).catch(() => {
|
|
4461
|
+
return;
|
|
4462
|
+
});
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
function pidAlive(pid) {
|
|
4466
|
+
try {
|
|
4467
|
+
process.kill(pid, 0);
|
|
4468
|
+
return true;
|
|
4469
|
+
} catch {
|
|
4470
|
+
return false;
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
async function processCommand(pid) {
|
|
4474
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], {
|
|
4475
|
+
stdout: "pipe",
|
|
4476
|
+
stderr: "ignore"
|
|
4477
|
+
});
|
|
4478
|
+
if (await proc.exited !== 0)
|
|
4479
|
+
return;
|
|
4480
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
4481
|
+
}
|
|
4482
|
+
async function processPgid(pid) {
|
|
4483
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pgid="], {
|
|
4484
|
+
stdout: "pipe",
|
|
4485
|
+
stderr: "ignore"
|
|
4486
|
+
});
|
|
4487
|
+
if (await proc.exited !== 0)
|
|
4488
|
+
return;
|
|
4489
|
+
const parsed = Number.parseInt((await new Response(proc.stdout).text()).trim(), 10);
|
|
4490
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
4491
|
+
}
|
|
4492
|
+
function shellQuote(value) {
|
|
4493
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
4494
|
+
}
|
|
4495
|
+
function commandPattern(command) {
|
|
4496
|
+
return command.map(shellQuote).join(" ");
|
|
4497
|
+
}
|
|
4498
|
+
async function processMatches(metadata) {
|
|
4499
|
+
if (!pidAlive(metadata.pid))
|
|
4500
|
+
return false;
|
|
4501
|
+
const command = await processCommand(metadata.pid);
|
|
4502
|
+
if (!command)
|
|
4503
|
+
return false;
|
|
4504
|
+
if (!metadata.pgid)
|
|
4505
|
+
return false;
|
|
4506
|
+
const pgid = await processPgid(metadata.pid);
|
|
4507
|
+
if (pgid !== metadata.pgid)
|
|
4508
|
+
return false;
|
|
4509
|
+
const requiredArgs = [
|
|
4510
|
+
metadata.command[1],
|
|
4511
|
+
"serve",
|
|
4512
|
+
"--config",
|
|
4513
|
+
metadata.configPath,
|
|
4514
|
+
"--state",
|
|
4515
|
+
metadata.statePath,
|
|
4516
|
+
"--interval",
|
|
4517
|
+
String(metadata.intervalMs)
|
|
4518
|
+
].filter((arg) => Boolean(arg));
|
|
4519
|
+
if (metadata.serveJson)
|
|
4520
|
+
requiredArgs.push("--json");
|
|
4521
|
+
return requiredArgs.every((arg) => command.includes(arg));
|
|
4522
|
+
}
|
|
4523
|
+
async function removeMetadata(paths) {
|
|
4524
|
+
await rm(paths.metadataFile, { force: true });
|
|
4525
|
+
}
|
|
4526
|
+
function safeTelegramApiBaseInfo() {
|
|
4527
|
+
try {
|
|
4528
|
+
return telegramApiBaseInfo();
|
|
4529
|
+
} catch (err) {
|
|
4530
|
+
return {
|
|
4531
|
+
overridden: true,
|
|
4532
|
+
origin: "",
|
|
4533
|
+
pathname: "",
|
|
4534
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
function startCommand(options) {
|
|
4539
|
+
const scriptPath = process.argv[1];
|
|
4540
|
+
const base = scriptPath ? [process.execPath, scriptPath] : ["bridge"];
|
|
4541
|
+
const command = [
|
|
4542
|
+
...base,
|
|
4543
|
+
"serve",
|
|
4544
|
+
"--config",
|
|
4545
|
+
options.configPath,
|
|
4546
|
+
"--state",
|
|
4547
|
+
options.statePath,
|
|
4548
|
+
"--interval",
|
|
4549
|
+
String(options.intervalMs)
|
|
4550
|
+
];
|
|
4551
|
+
if (options.serveJson)
|
|
4552
|
+
command.push("--json");
|
|
4553
|
+
return command;
|
|
4554
|
+
}
|
|
4555
|
+
function telegramChannels(config) {
|
|
4556
|
+
return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
|
|
4557
|
+
}
|
|
4558
|
+
function requiredTelegramEnvVars(config) {
|
|
4559
|
+
return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
|
|
4560
|
+
}
|
|
4561
|
+
async function validateStartConfig(configPath) {
|
|
4562
|
+
const config = await loadConfig(configPath);
|
|
4563
|
+
const channels = telegramChannels(config);
|
|
4564
|
+
if (!channels.length)
|
|
4565
|
+
throw new Error("No enabled Telegram channels configured; add one before starting the daemon");
|
|
4566
|
+
for (const envName of requiredTelegramEnvVars(config)) {
|
|
4567
|
+
if (!process.env[envName])
|
|
4568
|
+
throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
function openPrivateLog(path) {
|
|
4572
|
+
const fd = openSync(path, "a", 384);
|
|
4573
|
+
return fd;
|
|
4574
|
+
}
|
|
4575
|
+
async function ensurePrivateLogFiles(paths) {
|
|
4576
|
+
for (const path of [paths.stdoutLog, paths.stderrLog]) {
|
|
4577
|
+
const fd = openPrivateLog(path);
|
|
4578
|
+
closeSync(fd);
|
|
4579
|
+
await chmod3(path, 384);
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
async function runCapture(command) {
|
|
4583
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
4584
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
4585
|
+
proc.exited,
|
|
4586
|
+
new Response(proc.stdout).text(),
|
|
4587
|
+
new Response(proc.stderr).text()
|
|
4588
|
+
]);
|
|
4589
|
+
return { exitCode, stdout, stderr };
|
|
4590
|
+
}
|
|
4591
|
+
async function installedSupervisorStatus(supervisor, paths) {
|
|
4592
|
+
if (supervisor === "launchd") {
|
|
4593
|
+
if (!await fileExists(paths.launchdPlist))
|
|
4594
|
+
return { running: false, detail: "launchd plist not installed" };
|
|
4595
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4596
|
+
if (uid === undefined)
|
|
4597
|
+
return { running: false, detail: "launchd status requires a numeric uid" };
|
|
4598
|
+
const result = await runCapture(["launchctl", "print", `gui/${uid}/com.hasna.bridge`]);
|
|
4599
|
+
if (result.exitCode !== 0)
|
|
4600
|
+
return { running: false, detail: result.stderr.trim() || result.stdout.trim() || "launchd service not loaded" };
|
|
4601
|
+
const running = /state\s*=\s*running/.test(result.stdout);
|
|
4602
|
+
return { running, detail: running ? "launchd running" : "launchd loaded but not running" };
|
|
4603
|
+
}
|
|
4604
|
+
if (supervisor === "systemd") {
|
|
4605
|
+
if (!await fileExists(paths.systemdUnit))
|
|
4606
|
+
return { running: false, detail: "systemd unit not installed" };
|
|
4607
|
+
const result = await runCapture(["systemctl", "--user", "is-active", "hasna-bridge.service"]);
|
|
4608
|
+
const state = result.stdout.trim() || result.stderr.trim() || "unknown";
|
|
4609
|
+
return { running: result.exitCode === 0 && state === "active", detail: `systemd ${state}` };
|
|
4610
|
+
}
|
|
4611
|
+
return { running: false, detail: "process supervisor has no installed status" };
|
|
4612
|
+
}
|
|
4613
|
+
async function daemonStatus(options = {}) {
|
|
4614
|
+
const supervisor = resolveSupervisor(options.supervisor);
|
|
4615
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4616
|
+
const metadata = await readMetadata(paths);
|
|
4617
|
+
const live = metadata ? await processMatches(metadata) : false;
|
|
4618
|
+
const stale = Boolean(metadata && !live);
|
|
4619
|
+
const startedAt = metadata?.startedAt;
|
|
4620
|
+
const uptimeSeconds = live && startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)) : undefined;
|
|
4621
|
+
const installed = {
|
|
4622
|
+
launchd: await fileExists(paths.launchdPlist),
|
|
4623
|
+
systemd: await fileExists(paths.systemdUnit)
|
|
4624
|
+
};
|
|
4625
|
+
const installedRuntime = supervisor === "process" ? undefined : await installedSupervisorStatus(supervisor, paths);
|
|
4626
|
+
return {
|
|
4627
|
+
running: installedRuntime ? installedRuntime.running : live,
|
|
4628
|
+
stale: installedRuntime ? false : stale,
|
|
4629
|
+
supervisor,
|
|
4630
|
+
pid: metadata?.pid,
|
|
4631
|
+
startedAt,
|
|
4632
|
+
uptimeSeconds,
|
|
4633
|
+
detail: installedRuntime?.detail || (stale ? "stale process metadata" : live ? "running" : "not running"),
|
|
4634
|
+
installedDetail: installedRuntime?.detail,
|
|
4635
|
+
metadata,
|
|
4636
|
+
paths,
|
|
4637
|
+
installed,
|
|
4638
|
+
telegramApiBase: safeTelegramApiBaseInfo()
|
|
4639
|
+
};
|
|
4640
|
+
}
|
|
4641
|
+
async function startProcessDaemon(options = {}) {
|
|
4642
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4643
|
+
return withDaemonLock(paths, async () => {
|
|
4644
|
+
const existing = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4645
|
+
if (existing.running)
|
|
4646
|
+
return existing;
|
|
4647
|
+
if (existing.stale)
|
|
4648
|
+
await removeMetadata(paths);
|
|
4649
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
4650
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
4651
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
4652
|
+
const serveJson = Boolean(options.serveJson);
|
|
4653
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 0)
|
|
4654
|
+
throw new Error("--interval must be a non-negative integer");
|
|
4655
|
+
await validateStartConfig(configPath);
|
|
4656
|
+
const stdoutFd = openPrivateLog(paths.stdoutLog);
|
|
4657
|
+
const stderrFd = openPrivateLog(paths.stderrLog);
|
|
4658
|
+
try {
|
|
4659
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
4660
|
+
const child = spawn(command[0], command.slice(1), {
|
|
4661
|
+
cwd: process.cwd(),
|
|
4662
|
+
detached: true,
|
|
4663
|
+
env: process.env,
|
|
4664
|
+
stdio: ["ignore", stdoutFd, stderrFd]
|
|
4665
|
+
});
|
|
4666
|
+
child.unref();
|
|
4667
|
+
const metadata = {
|
|
4668
|
+
version: 1,
|
|
4669
|
+
supervisor: "process",
|
|
4670
|
+
pid: child.pid || 0,
|
|
4671
|
+
pgid: child.pid || undefined,
|
|
4672
|
+
startedAt: new Date().toISOString(),
|
|
4673
|
+
identity: {
|
|
4674
|
+
command: commandPattern(command),
|
|
4675
|
+
cwd: process.cwd(),
|
|
4676
|
+
configPath,
|
|
4677
|
+
statePath,
|
|
4678
|
+
daemonDir: paths.dir,
|
|
4679
|
+
bridgeHome: bridgeHome()
|
|
4680
|
+
},
|
|
4681
|
+
command,
|
|
4682
|
+
cwd: process.cwd(),
|
|
4683
|
+
configPath,
|
|
4684
|
+
statePath,
|
|
4685
|
+
intervalMs,
|
|
4686
|
+
serveJson,
|
|
4687
|
+
daemonDir: paths.dir,
|
|
4688
|
+
bridgeHome: bridgeHome(),
|
|
4689
|
+
stdoutLog: paths.stdoutLog,
|
|
4690
|
+
stderrLog: paths.stderrLog
|
|
4691
|
+
};
|
|
4692
|
+
if (!metadata.pid)
|
|
4693
|
+
throw new Error("Failed to start bridge daemon process");
|
|
4694
|
+
await writeMetadata(paths, metadata);
|
|
4695
|
+
await Bun.sleep(200);
|
|
4696
|
+
const status = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4697
|
+
if (!status.running) {
|
|
4698
|
+
await removeMetadata(paths);
|
|
4699
|
+
throw new Error(`Bridge daemon failed to stay running; inspect ${paths.stderrLog}`);
|
|
4700
|
+
}
|
|
4701
|
+
return status;
|
|
4702
|
+
} finally {
|
|
4703
|
+
closeSync(stdoutFd);
|
|
4704
|
+
closeSync(stderrFd);
|
|
4705
|
+
await chmod3(paths.stdoutLog, 384).catch(() => {
|
|
4706
|
+
return;
|
|
4707
|
+
});
|
|
4708
|
+
await chmod3(paths.stderrLog, 384).catch(() => {
|
|
4709
|
+
return;
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
4712
|
+
});
|
|
4713
|
+
}
|
|
4714
|
+
async function stopPid(pid, force) {
|
|
4715
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
4716
|
+
}
|
|
4717
|
+
async function waitForExit(pid, timeoutMs) {
|
|
4718
|
+
const started = Date.now();
|
|
4719
|
+
while (Date.now() - started < timeoutMs) {
|
|
4720
|
+
if (!pidAlive(pid))
|
|
4721
|
+
return true;
|
|
4722
|
+
await Bun.sleep(100);
|
|
4723
|
+
}
|
|
4724
|
+
return !pidAlive(pid);
|
|
4725
|
+
}
|
|
4726
|
+
async function stopProcessDaemon(options = {}) {
|
|
4727
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4728
|
+
return withDaemonLock(paths, async () => {
|
|
4729
|
+
const metadata = await readMetadata(paths);
|
|
4730
|
+
if (!metadata)
|
|
4731
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4732
|
+
if (!await processMatches(metadata)) {
|
|
4733
|
+
await removeMetadata(paths);
|
|
4734
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4735
|
+
}
|
|
4736
|
+
await stopPid(metadata.pid, false);
|
|
4737
|
+
let exited = await waitForExit(metadata.pid, options.timeoutMs ?? 5000);
|
|
4738
|
+
if (!exited && options.force) {
|
|
4739
|
+
await stopPid(metadata.pid, true);
|
|
4740
|
+
exited = await waitForExit(metadata.pid, 2000);
|
|
4741
|
+
}
|
|
4742
|
+
if (!exited)
|
|
4743
|
+
throw new Error(`Bridge daemon did not stop within ${options.timeoutMs ?? 5000}ms`);
|
|
4744
|
+
await removeMetadata(paths);
|
|
4745
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4746
|
+
});
|
|
4747
|
+
}
|
|
4748
|
+
async function restartProcessDaemon(options = {}) {
|
|
4749
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4750
|
+
const metadata = await readMetadata(paths);
|
|
4751
|
+
await stopProcessDaemon(options);
|
|
4752
|
+
return startProcessDaemon({
|
|
4753
|
+
...options,
|
|
4754
|
+
configPath: options.configPath || metadata?.configPath,
|
|
4755
|
+
statePath: options.statePath || metadata?.statePath,
|
|
4756
|
+
intervalMs: options.intervalMs ?? metadata?.intervalMs,
|
|
4757
|
+
serveJson: options.serveJson ?? metadata?.serveJson
|
|
4758
|
+
});
|
|
4759
|
+
}
|
|
4760
|
+
function xmlEscape(value) {
|
|
4761
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
4762
|
+
}
|
|
4763
|
+
function plistArray(values) {
|
|
4764
|
+
return values.map((value) => ` <string>${xmlEscape(value)}</string>`).join(`
|
|
4765
|
+
`);
|
|
4766
|
+
}
|
|
4767
|
+
function renderLaunchdPlist(command, paths) {
|
|
4768
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
4769
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4770
|
+
<plist version="1.0">
|
|
4771
|
+
<dict>
|
|
4772
|
+
<key>Label</key>
|
|
4773
|
+
<string>com.hasna.bridge</string>
|
|
4774
|
+
<key>ProgramArguments</key>
|
|
4775
|
+
<array>
|
|
4776
|
+
${plistArray(command)}
|
|
4777
|
+
</array>
|
|
4778
|
+
<key>RunAtLoad</key>
|
|
4779
|
+
<true/>
|
|
4780
|
+
<key>KeepAlive</key>
|
|
4781
|
+
<true/>
|
|
4782
|
+
<key>StandardOutPath</key>
|
|
4783
|
+
<string>${xmlEscape(paths.stdoutLog)}</string>
|
|
4784
|
+
<key>StandardErrorPath</key>
|
|
4785
|
+
<string>${xmlEscape(paths.stderrLog)}</string>
|
|
4786
|
+
<key>WorkingDirectory</key>
|
|
4787
|
+
<string>${xmlEscape(process.cwd())}</string>
|
|
4788
|
+
</dict>
|
|
4789
|
+
</plist>
|
|
4790
|
+
`;
|
|
4791
|
+
}
|
|
4792
|
+
function systemdEscape(value) {
|
|
4793
|
+
return value.replaceAll("%", "%%").replaceAll(`
|
|
4794
|
+
`, " ");
|
|
4795
|
+
}
|
|
4796
|
+
function systemdQuote(value) {
|
|
4797
|
+
return `"${systemdEscape(value).replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
4798
|
+
}
|
|
4799
|
+
function renderSystemdUnit(command, paths) {
|
|
4800
|
+
return `[Unit]
|
|
4801
|
+
Description=Hasna Bridge daemon
|
|
4802
|
+
After=network-online.target
|
|
4803
|
+
|
|
4804
|
+
[Service]
|
|
4805
|
+
Type=simple
|
|
4806
|
+
ExecStart=${command.map(systemdQuote).join(" ")}
|
|
4807
|
+
Restart=always
|
|
4808
|
+
RestartSec=5
|
|
4809
|
+
WorkingDirectory=${systemdEscape(process.cwd())}
|
|
4810
|
+
StandardOutput=append:${systemdEscape(paths.stdoutLog)}
|
|
4811
|
+
StandardError=append:${systemdEscape(paths.stderrLog)}
|
|
4812
|
+
|
|
4813
|
+
[Install]
|
|
4814
|
+
WantedBy=default.target
|
|
4815
|
+
`;
|
|
4816
|
+
}
|
|
4817
|
+
async function installFile(path, content) {
|
|
4818
|
+
await mkdir3(dirname3(path), { recursive: true, mode: 448 });
|
|
4819
|
+
await writeFile3(path, content, { encoding: "utf-8", mode: 384 });
|
|
4820
|
+
await chmod3(path, 384);
|
|
4821
|
+
}
|
|
4822
|
+
async function installDaemon(options = {}) {
|
|
4823
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4824
|
+
if (supervisor === "process") {
|
|
4825
|
+
throw new Error("The process supervisor does not need install; use `bridge daemon start`");
|
|
4826
|
+
}
|
|
4827
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4828
|
+
await ensurePrivateLogFiles(paths);
|
|
4829
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
4830
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
4831
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
4832
|
+
const serveJson = Boolean(options.serveJson);
|
|
4833
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
4834
|
+
const config = await loadConfig(configPath);
|
|
4835
|
+
const requiredEnv = requiredTelegramEnvVars(config);
|
|
4836
|
+
if (supervisor === "launchd") {
|
|
4837
|
+
await installFile(paths.launchdPlist, renderLaunchdPlist(command, paths));
|
|
4838
|
+
return {
|
|
4839
|
+
supervisor,
|
|
4840
|
+
path: paths.launchdPlist,
|
|
4841
|
+
command,
|
|
4842
|
+
requiredEnv,
|
|
4843
|
+
warning: "Telegram token values are not written to launchd files. Set them in the launchd environment before starting."
|
|
4844
|
+
};
|
|
4845
|
+
}
|
|
4846
|
+
await installFile(paths.systemdUnit, renderSystemdUnit(command, paths));
|
|
4847
|
+
return {
|
|
4848
|
+
supervisor,
|
|
4849
|
+
path: paths.systemdUnit,
|
|
4850
|
+
command,
|
|
4851
|
+
requiredEnv,
|
|
4852
|
+
warning: "Telegram token values are not written to systemd files. Import them into the user manager environment before starting."
|
|
4853
|
+
};
|
|
4854
|
+
}
|
|
4855
|
+
async function runCommand(command) {
|
|
4856
|
+
const { exitCode, stdout, stderr } = await runCapture(command);
|
|
4857
|
+
if (exitCode !== 0)
|
|
4858
|
+
throw new Error(`${command.join(" ")} failed (${exitCode}): ${stderr || stdout}`);
|
|
4859
|
+
}
|
|
4860
|
+
async function waitForInstalledRunning(supervisor, paths, timeoutMs = 5000) {
|
|
4861
|
+
const started = Date.now();
|
|
4862
|
+
let last = "";
|
|
4863
|
+
while (Date.now() - started < timeoutMs) {
|
|
4864
|
+
const status = await installedSupervisorStatus(supervisor, paths);
|
|
4865
|
+
last = status.detail;
|
|
4866
|
+
if (status.running)
|
|
4867
|
+
return;
|
|
4868
|
+
await Bun.sleep(250);
|
|
4869
|
+
}
|
|
4870
|
+
throw new Error(`${supervisor} service did not report running: ${last}`);
|
|
4871
|
+
}
|
|
4872
|
+
async function startInstalledDaemon(options = {}) {
|
|
4873
|
+
const result = await installDaemon(options);
|
|
4874
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4875
|
+
if (result.supervisor === "launchd") {
|
|
4876
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4877
|
+
if (uid === undefined)
|
|
4878
|
+
throw new Error("launchd start requires a numeric uid");
|
|
4879
|
+
await runCommand(["launchctl", "bootstrap", `gui/${uid}`, result.path]).catch(async (err) => {
|
|
4880
|
+
if (!String(err).includes("Input/output error"))
|
|
4881
|
+
throw err;
|
|
4882
|
+
await runCommand(["launchctl", "kickstart", "-k", `gui/${uid}/com.hasna.bridge`]);
|
|
4883
|
+
});
|
|
4884
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
4885
|
+
return result;
|
|
4886
|
+
}
|
|
4887
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]);
|
|
4888
|
+
await runCommand(["systemctl", "--user", "enable", "--now", "hasna-bridge.service"]);
|
|
4889
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
4890
|
+
return result;
|
|
4891
|
+
}
|
|
4892
|
+
async function stopInstalledDaemon(options = {}) {
|
|
4893
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4894
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4895
|
+
if (supervisor === "launchd") {
|
|
4896
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4897
|
+
if (uid === undefined)
|
|
4898
|
+
throw new Error("launchd stop requires a numeric uid");
|
|
4899
|
+
await runCommand(["launchctl", "bootout", `gui/${uid}`, paths.launchdPlist]);
|
|
4900
|
+
return;
|
|
4901
|
+
}
|
|
4902
|
+
if (supervisor === "systemd") {
|
|
4903
|
+
await runCommand(["systemctl", "--user", "disable", "--now", "hasna-bridge.service"]);
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4906
|
+
await stopProcessDaemon(options);
|
|
4907
|
+
}
|
|
4908
|
+
async function restartInstalledDaemon(options = {}) {
|
|
4909
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4910
|
+
if (supervisor === "process")
|
|
4911
|
+
return restartProcessDaemon(options);
|
|
4912
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
4913
|
+
return;
|
|
4914
|
+
});
|
|
4915
|
+
return startInstalledDaemon({ ...options, supervisor });
|
|
4916
|
+
}
|
|
4917
|
+
async function uninstallDaemon(options = {}) {
|
|
4918
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4919
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4920
|
+
const removed = [];
|
|
4921
|
+
if (supervisor === "launchd") {
|
|
4922
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
4923
|
+
return;
|
|
4924
|
+
});
|
|
4925
|
+
await rm(paths.launchdPlist, { force: true });
|
|
4926
|
+
removed.push(paths.launchdPlist);
|
|
4927
|
+
} else if (supervisor === "systemd") {
|
|
4928
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
4929
|
+
return;
|
|
4930
|
+
});
|
|
4931
|
+
await rm(paths.systemdUnit, { force: true });
|
|
4932
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]).catch(() => {
|
|
4933
|
+
return;
|
|
4934
|
+
});
|
|
4935
|
+
removed.push(paths.systemdUnit);
|
|
4936
|
+
} else {
|
|
4937
|
+
await stopProcessDaemon({ ...options, supervisor }).catch(() => {
|
|
4938
|
+
return;
|
|
4939
|
+
});
|
|
4940
|
+
await removeMetadata(paths);
|
|
4941
|
+
removed.push(paths.metadataFile);
|
|
4942
|
+
}
|
|
4943
|
+
return { supervisor, removed };
|
|
4944
|
+
}
|
|
4945
|
+
async function tailFile(path, lines) {
|
|
4946
|
+
try {
|
|
4947
|
+
const raw = await readFile3(path, "utf-8");
|
|
4948
|
+
return raw.split(/\r?\n/).slice(-Math.max(1, lines)).join(`
|
|
4949
|
+
`);
|
|
4950
|
+
} catch (err) {
|
|
4951
|
+
if (isNotFound(err))
|
|
4952
|
+
return "";
|
|
4953
|
+
throw err;
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
async function daemonLogs(options = {}) {
|
|
4957
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4958
|
+
const lines = options.lines ?? 100;
|
|
4959
|
+
return {
|
|
4960
|
+
stdout: await tailFile(paths.stdoutLog, lines),
|
|
4961
|
+
stderr: await tailFile(paths.stderrLog, lines),
|
|
4962
|
+
paths
|
|
4963
|
+
};
|
|
4964
|
+
}
|
|
4965
|
+
// src/lib/doctor.ts
|
|
4966
|
+
import { stat as stat2 } from "fs/promises";
|
|
4967
|
+
function isNotFound2(err) {
|
|
4968
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
4969
|
+
}
|
|
4970
|
+
async function privateFileCheck(name, path) {
|
|
4971
|
+
try {
|
|
4972
|
+
const info = await stat2(path);
|
|
4973
|
+
const mode = info.mode & 511;
|
|
4974
|
+
const ok = (mode & 63) === 0;
|
|
4975
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
4976
|
+
} catch (err) {
|
|
4977
|
+
if (isNotFound2(err))
|
|
4978
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
4979
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4982
|
+
async function privateDirCheck(name, path) {
|
|
4983
|
+
try {
|
|
4984
|
+
const info = await stat2(path);
|
|
4985
|
+
const mode = info.mode & 511;
|
|
4986
|
+
const ok = (mode & 63) === 0;
|
|
4987
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
4988
|
+
} catch (err) {
|
|
4989
|
+
if (isNotFound2(err))
|
|
4990
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
4991
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
async function commandExists(command) {
|
|
4995
|
+
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
4996
|
+
stdout: "ignore",
|
|
4997
|
+
stderr: "ignore"
|
|
4998
|
+
});
|
|
4999
|
+
return await proc.exited === 0;
|
|
5000
|
+
}
|
|
5001
|
+
async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
|
|
5002
|
+
const checks = [];
|
|
5003
|
+
const config = await loadConfig(configPath);
|
|
5004
|
+
const daemon = await daemonStatus();
|
|
5005
|
+
const paths = daemonPaths();
|
|
5006
|
+
checks.push(await privateFileCheck("config", configPath));
|
|
5007
|
+
checks.push(await privateFileCheck("state", statePath));
|
|
5008
|
+
checks.push(await privateDirCheck("daemon-dir", paths.dir));
|
|
5009
|
+
checks.push(await privateFileCheck("daemon-metadata", paths.metadataFile));
|
|
5010
|
+
checks.push({
|
|
5011
|
+
name: "daemon-status",
|
|
5012
|
+
ok: !daemon.stale,
|
|
5013
|
+
detail: daemon.running ? `running pid=${daemon.pid}` : daemon.stale ? `stale pid=${daemon.pid}` : "not running"
|
|
5014
|
+
});
|
|
5015
|
+
try {
|
|
5016
|
+
const apiBase = telegramApiBaseInfo();
|
|
5017
|
+
checks.push({
|
|
5018
|
+
name: "telegram-api-base",
|
|
5019
|
+
ok: true,
|
|
5020
|
+
detail: apiBase.overridden ? `overridden: ${apiBase.origin}${apiBase.pathname}` : apiBase.origin
|
|
5021
|
+
});
|
|
5022
|
+
} catch (err) {
|
|
5023
|
+
checks.push({
|
|
5024
|
+
name: "telegram-api-base",
|
|
5025
|
+
ok: false,
|
|
5026
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
5027
|
+
});
|
|
5028
|
+
}
|
|
5029
|
+
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
5030
|
+
checks.push({
|
|
5031
|
+
name: `command:${command}`,
|
|
5032
|
+
ok: command === "bridge" ? true : await commandExists(command),
|
|
5033
|
+
detail: command === "bridge" ? "current package" : undefined
|
|
5034
|
+
});
|
|
5035
|
+
}
|
|
5036
|
+
const telegramChannels2 = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
|
|
5037
|
+
for (const channel of telegramChannels2) {
|
|
5038
|
+
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
5039
|
+
checks.push({
|
|
5040
|
+
name: `telegram-token:${channel.id}`,
|
|
5041
|
+
ok: Boolean(process.env[envName]),
|
|
5042
|
+
detail: envName
|
|
5043
|
+
});
|
|
5044
|
+
checks.push({
|
|
5045
|
+
name: `telegram-allowlist:${channel.id}`,
|
|
5046
|
+
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
5047
|
+
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
5048
|
+
});
|
|
5049
|
+
}
|
|
5050
|
+
for (const route of config.routes) {
|
|
5051
|
+
checks.push({
|
|
5052
|
+
name: `route:${route.id}`,
|
|
5053
|
+
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
5054
|
+
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
5055
|
+
});
|
|
5056
|
+
}
|
|
5057
|
+
return { ok: checks.every((check) => check.ok), configPath, checks };
|
|
5058
|
+
}
|
|
4342
5059
|
// src/lib/router.ts
|
|
4343
5060
|
function matchingRoutes(config, message) {
|
|
4344
5061
|
const channel = config.channels[message.channelId];
|
|
5062
|
+
if (!channel || channel.enabled === false)
|
|
5063
|
+
return [];
|
|
4345
5064
|
if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
|
|
4346
5065
|
return [];
|
|
4347
5066
|
}
|
|
@@ -4369,6 +5088,10 @@ async function routeMessage(config, message, options = {}) {
|
|
|
4369
5088
|
let deliveredResponse = false;
|
|
4370
5089
|
const channel = responseChannel(config, route, message);
|
|
4371
5090
|
const responseText = agent.stdout.trim();
|
|
5091
|
+
if (channel?.enabled === false) {
|
|
5092
|
+
results.push({ route, agent, deliveredResponse });
|
|
5093
|
+
continue;
|
|
5094
|
+
}
|
|
4372
5095
|
if (responseText && channel?.kind === "telegram" && message.chatId) {
|
|
4373
5096
|
if (!telegramChatAllowed(channel, message.chatId)) {
|
|
4374
5097
|
results.push({ route, agent, deliveredResponse });
|
|
@@ -4385,61 +5108,52 @@ async function routeMessage(config, message, options = {}) {
|
|
|
4385
5108
|
}
|
|
4386
5109
|
return results;
|
|
4387
5110
|
}
|
|
4388
|
-
// src/lib/state.ts
|
|
4389
|
-
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
4390
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
4391
|
-
function defaultStatePath() {
|
|
4392
|
-
return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
|
|
4393
|
-
}
|
|
4394
|
-
function emptyState() {
|
|
4395
|
-
return { telegramOffsets: {} };
|
|
4396
|
-
}
|
|
4397
|
-
async function loadState(statePath = defaultStatePath()) {
|
|
4398
|
-
try {
|
|
4399
|
-
const raw = await readFile2(statePath, "utf-8");
|
|
4400
|
-
const parsed = JSON.parse(raw);
|
|
4401
|
-
return {
|
|
4402
|
-
telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
|
|
4403
|
-
};
|
|
4404
|
-
} catch (err) {
|
|
4405
|
-
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
4406
|
-
return emptyState();
|
|
4407
|
-
}
|
|
4408
|
-
throw err;
|
|
4409
|
-
}
|
|
4410
|
-
}
|
|
4411
|
-
async function saveState(state, statePath = defaultStatePath()) {
|
|
4412
|
-
await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
|
|
4413
|
-
await writeFile2(statePath, `${JSON.stringify(state, null, 2)}
|
|
4414
|
-
`, { encoding: "utf-8", mode: 384 });
|
|
4415
|
-
await chmod2(statePath, 384);
|
|
4416
|
-
}
|
|
4417
5111
|
export {
|
|
4418
5112
|
upsertRoute,
|
|
4419
5113
|
upsertProfile,
|
|
4420
5114
|
upsertChannel,
|
|
4421
5115
|
upsertAgent,
|
|
5116
|
+
uninstallDaemon,
|
|
4422
5117
|
telegramUpdateToMessage,
|
|
4423
5118
|
telegramToken,
|
|
4424
5119
|
telegramChatAllowed,
|
|
5120
|
+
telegramApiBaseInfo,
|
|
5121
|
+
tailFile,
|
|
5122
|
+
stopProcessDaemon,
|
|
5123
|
+
stopInstalledDaemon,
|
|
5124
|
+
startProcessDaemon,
|
|
5125
|
+
startInstalledDaemon,
|
|
4425
5126
|
sendTelegramMessage,
|
|
4426
5127
|
saveState,
|
|
4427
5128
|
saveConfig,
|
|
4428
5129
|
runAgent,
|
|
4429
5130
|
routeMessage,
|
|
5131
|
+
restartProcessDaemon,
|
|
5132
|
+
restartInstalledDaemon,
|
|
5133
|
+
resolveSupervisor,
|
|
4430
5134
|
resolveAgent,
|
|
5135
|
+
requiredTelegramEnvVars,
|
|
5136
|
+
renderSystemdUnit,
|
|
5137
|
+
renderLaunchdPlist,
|
|
5138
|
+
redactConfig,
|
|
4431
5139
|
parseConfig,
|
|
4432
5140
|
matchingRoutes,
|
|
4433
5141
|
loadState,
|
|
4434
5142
|
loadConfig,
|
|
5143
|
+
installDaemon,
|
|
4435
5144
|
homeDir,
|
|
4436
5145
|
getTelegramUpdates,
|
|
5146
|
+
ensureDaemonDir,
|
|
4437
5147
|
ensureConfig,
|
|
4438
5148
|
emptyState,
|
|
4439
5149
|
emptyConfig,
|
|
4440
5150
|
doctor,
|
|
4441
5151
|
defaultStatePath,
|
|
5152
|
+
defaultDaemonDir,
|
|
4442
5153
|
defaultConfigPath,
|
|
5154
|
+
daemonStatus,
|
|
5155
|
+
daemonPaths,
|
|
5156
|
+
daemonLogs,
|
|
4443
5157
|
buildAgentCommand,
|
|
4444
5158
|
bridgeHome,
|
|
4445
5159
|
CONFIG_VERSION,
|