@hasna/bridge 0.1.1 → 0.2.0
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/README.md +158 -1
- package/dist/cli/index.js +1602 -92
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1352 -66
- package/dist/lib/agents.d.ts +14 -1
- package/dist/lib/agents.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/lib/imessage.d.ts +36 -0
- package/dist/lib/imessage.d.ts.map +1 -0
- package/dist/lib/router.d.ts.map +1 -1
- package/dist/lib/sessions.d.ts +48 -0
- package/dist/lib/sessions.d.ts.map +1 -0
- package/dist/lib/state.d.ts +8 -1
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/telegram.d.ts +7 -0
- package/dist/lib/telegram.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +855 -40
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/architecture.md +100 -15
- package/docs/session-bridge-plan.md +204 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,6 +32,11 @@ function mergeEnv(profile, agent) {
|
|
|
32
32
|
env["HOME"] = profile.home;
|
|
33
33
|
return Object.keys(env).length ? env : undefined;
|
|
34
34
|
}
|
|
35
|
+
function compatibilityDetail(kind) {
|
|
36
|
+
if (kind === "shell")
|
|
37
|
+
return "shell command session; local bridge state is durable";
|
|
38
|
+
return "compatibility mode: this adapter invokes the current CLI one message at a time until a stable create/send/resume API is wired";
|
|
39
|
+
}
|
|
35
40
|
function resolveAgent(config, agentId) {
|
|
36
41
|
const agent = config.agents[agentId];
|
|
37
42
|
if (!agent)
|
|
@@ -50,7 +55,7 @@ function buildAgentCommand(config, agentId, input) {
|
|
|
50
55
|
const kind = agent.kind;
|
|
51
56
|
const command = agent.command || profile?.command;
|
|
52
57
|
const args = agent.args || profile?.args;
|
|
53
|
-
const cwd = agent.cwd || profile?.cwd;
|
|
58
|
+
const cwd = input.session?.cwd || agent.cwd || profile?.cwd;
|
|
54
59
|
const env = mergeEnv(profile, agent);
|
|
55
60
|
if (command) {
|
|
56
61
|
return { command: [command, ...renderCustomArgs(args, prompt)], cwd, env };
|
|
@@ -72,6 +77,46 @@ function buildAgentCommand(config, agentId, input) {
|
|
|
72
77
|
}
|
|
73
78
|
return { command: ["sh", "-lc", prompt], cwd, env };
|
|
74
79
|
}
|
|
80
|
+
function createAgentSessionRef(config, agentId) {
|
|
81
|
+
const { agent } = resolveAgent(config, agentId);
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
return {
|
|
84
|
+
kind: agent.kind,
|
|
85
|
+
mode: "compatibility",
|
|
86
|
+
createdAt: timestamp,
|
|
87
|
+
updatedAt: timestamp,
|
|
88
|
+
detail: compatibilityDetail(agent.kind)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function resumeAgentSessionRef(session) {
|
|
92
|
+
return {
|
|
93
|
+
supported: session.agentSession?.mode === "durable",
|
|
94
|
+
ref: session.agentSession,
|
|
95
|
+
detail: session.agentSession?.mode === "durable" ? "durable agent session ref is available" : "compatibility sessions do not expose agent-side resume; bridge binding state is still durable"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function cancelAgentSession(session) {
|
|
99
|
+
return {
|
|
100
|
+
supported: false,
|
|
101
|
+
ref: session.agentSession,
|
|
102
|
+
detail: `cancel is not implemented for ${session.agentSession?.kind || "unknown"} ${session.agentSession?.mode || "compatibility"} sessions`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function closeAgentSession(session) {
|
|
106
|
+
return {
|
|
107
|
+
supported: session.agentSession?.mode === "durable",
|
|
108
|
+
ref: session.agentSession,
|
|
109
|
+
detail: session.agentSession?.mode === "durable" ? "durable close is adapter-owned" : "compatibility close only updates bridge session state"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function sendAgentSessionMessage(config, session, message, options = {}) {
|
|
113
|
+
const run = options.run || runAgent;
|
|
114
|
+
return run(config, session.agentId, {
|
|
115
|
+
message,
|
|
116
|
+
route: { id: `session:${session.id}`, fromChannel: message.channelId, toAgent: session.agentId },
|
|
117
|
+
session
|
|
118
|
+
});
|
|
119
|
+
}
|
|
75
120
|
async function runAgent(config, agentId, input) {
|
|
76
121
|
const { agent } = resolveAgent(config, agentId);
|
|
77
122
|
const built = buildAgentCommand(config, agentId, input);
|
|
@@ -4130,7 +4175,14 @@ var channelSchema = exports_external.discriminatedUnion("kind", [
|
|
|
4130
4175
|
kind: exports_external.literal("imessage"),
|
|
4131
4176
|
label: exports_external.string().optional(),
|
|
4132
4177
|
enabled: exports_external.boolean().optional(),
|
|
4133
|
-
account: exports_external.string().optional()
|
|
4178
|
+
account: exports_external.string().optional(),
|
|
4179
|
+
serviceName: exports_external.string().optional(),
|
|
4180
|
+
defaultHandle: exports_external.string().optional(),
|
|
4181
|
+
allowedHandles: exports_external.array(exports_external.string()).optional(),
|
|
4182
|
+
allowAllHandles: exports_external.boolean().optional(),
|
|
4183
|
+
receiveMode: exports_external.enum(["disabled", "chat-db"]).optional(),
|
|
4184
|
+
chatDbPath: exports_external.string().optional(),
|
|
4185
|
+
pollLimit: exports_external.number().int().positive().max(500).optional()
|
|
4134
4186
|
})
|
|
4135
4187
|
]);
|
|
4136
4188
|
var envSchema = exports_external.record(exports_external.string(), exports_external.string());
|
|
@@ -4259,25 +4311,44 @@ async function upsertRoute(route, configPath = defaultConfigPath()) {
|
|
|
4259
4311
|
await saveConfig(config, configPath);
|
|
4260
4312
|
return config;
|
|
4261
4313
|
}
|
|
4262
|
-
// src/lib/
|
|
4263
|
-
import {
|
|
4314
|
+
// src/lib/daemon.ts
|
|
4315
|
+
import { spawn } from "child_process";
|
|
4316
|
+
import { closeSync, openSync } from "fs";
|
|
4317
|
+
import { chmod as chmod3, mkdir as mkdir3, readFile as readFile3, rename, rm, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
|
|
4318
|
+
import { dirname as dirname3, join as join3, resolve } from "path";
|
|
4264
4319
|
|
|
4265
4320
|
// src/lib/state.ts
|
|
4266
4321
|
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
4267
4322
|
import { dirname as dirname2, join as join2 } from "path";
|
|
4323
|
+
var STATE_SCHEMA_VERSION = 2;
|
|
4268
4324
|
function defaultStatePath() {
|
|
4269
4325
|
return process.env["BRIDGE_STATE"] || join2(bridgeHome(), "state.json");
|
|
4270
4326
|
}
|
|
4271
4327
|
function emptyState() {
|
|
4272
|
-
return {
|
|
4328
|
+
return {
|
|
4329
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
4330
|
+
telegramOffsets: {},
|
|
4331
|
+
sessions: {},
|
|
4332
|
+
bindings: {},
|
|
4333
|
+
messageLedger: {},
|
|
4334
|
+
cursors: {}
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
function normalizeState(value) {
|
|
4338
|
+
return {
|
|
4339
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
4340
|
+
telegramOffsets: value.telegramOffsets && typeof value.telegramOffsets === "object" ? value.telegramOffsets : {},
|
|
4341
|
+
sessions: value.sessions && typeof value.sessions === "object" ? value.sessions : {},
|
|
4342
|
+
bindings: value.bindings && typeof value.bindings === "object" ? value.bindings : {},
|
|
4343
|
+
messageLedger: value.messageLedger && typeof value.messageLedger === "object" ? value.messageLedger : {},
|
|
4344
|
+
cursors: value.cursors && typeof value.cursors === "object" ? value.cursors : {}
|
|
4345
|
+
};
|
|
4273
4346
|
}
|
|
4274
4347
|
async function loadState(statePath = defaultStatePath()) {
|
|
4275
4348
|
try {
|
|
4276
4349
|
const raw = await readFile2(statePath, "utf-8");
|
|
4277
4350
|
const parsed = JSON.parse(raw);
|
|
4278
|
-
return
|
|
4279
|
-
telegramOffsets: parsed.telegramOffsets && typeof parsed.telegramOffsets === "object" ? parsed.telegramOffsets : {}
|
|
4280
|
-
};
|
|
4351
|
+
return normalizeState(parsed);
|
|
4281
4352
|
} catch (err) {
|
|
4282
4353
|
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
4283
4354
|
return emptyState();
|
|
@@ -4286,71 +4357,44 @@ async function loadState(statePath = defaultStatePath()) {
|
|
|
4286
4357
|
}
|
|
4287
4358
|
}
|
|
4288
4359
|
async function saveState(state, statePath = defaultStatePath()) {
|
|
4360
|
+
const normalized = normalizeState(state);
|
|
4289
4361
|
await mkdir2(dirname2(statePath), { recursive: true, mode: 448 });
|
|
4290
|
-
await writeFile2(statePath, `${JSON.stringify(
|
|
4362
|
+
await writeFile2(statePath, `${JSON.stringify(normalized, null, 2)}
|
|
4291
4363
|
`, { encoding: "utf-8", mode: 384 });
|
|
4292
4364
|
await chmod2(statePath, 384);
|
|
4293
4365
|
}
|
|
4294
4366
|
|
|
4295
|
-
// src/lib/
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
const mode = info.mode & 511;
|
|
4303
|
-
const ok = (mode & 63) === 0;
|
|
4304
|
-
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
4305
|
-
} catch (err) {
|
|
4306
|
-
if (isNotFound(err))
|
|
4307
|
-
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
4308
|
-
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
4309
|
-
}
|
|
4310
|
-
}
|
|
4311
|
-
async function commandExists(command) {
|
|
4312
|
-
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
4313
|
-
stdout: "ignore",
|
|
4314
|
-
stderr: "ignore"
|
|
4315
|
-
});
|
|
4316
|
-
return await proc.exited === 0;
|
|
4317
|
-
}
|
|
4318
|
-
async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
|
|
4319
|
-
const checks = [];
|
|
4320
|
-
let config = await loadConfig(configPath);
|
|
4321
|
-
checks.push(await privateFileCheck("config", configPath));
|
|
4322
|
-
checks.push(await privateFileCheck("state", statePath));
|
|
4323
|
-
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
4324
|
-
checks.push({
|
|
4325
|
-
name: `command:${command}`,
|
|
4326
|
-
ok: command === "bridge" ? true : await commandExists(command),
|
|
4327
|
-
detail: command === "bridge" ? "current package" : undefined
|
|
4328
|
-
});
|
|
4367
|
+
// src/lib/telegram.ts
|
|
4368
|
+
var DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
4369
|
+
function telegramApiBase() {
|
|
4370
|
+
const raw = process.env["BRIDGE_TELEGRAM_API_BASE"] || DEFAULT_TELEGRAM_API_BASE;
|
|
4371
|
+
const parsed = new URL(raw);
|
|
4372
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
4373
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must use http or https");
|
|
4329
4374
|
}
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
4333
|
-
checks.push({
|
|
4334
|
-
name: `telegram-token:${channel.id}`,
|
|
4335
|
-
ok: Boolean(process.env[envName]),
|
|
4336
|
-
detail: envName
|
|
4337
|
-
});
|
|
4338
|
-
checks.push({
|
|
4339
|
-
name: `telegram-allowlist:${channel.id}`,
|
|
4340
|
-
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
4341
|
-
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
4342
|
-
});
|
|
4375
|
+
if (parsed.username || parsed.password) {
|
|
4376
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain credentials");
|
|
4343
4377
|
}
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
name: `route:${route.id}`,
|
|
4347
|
-
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
4348
|
-
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
4349
|
-
});
|
|
4378
|
+
if (parsed.search || parsed.hash) {
|
|
4379
|
+
throw new Error("BRIDGE_TELEGRAM_API_BASE must not contain query strings or fragments");
|
|
4350
4380
|
}
|
|
4351
|
-
return
|
|
4381
|
+
return parsed;
|
|
4382
|
+
}
|
|
4383
|
+
function telegramApiBaseInfo() {
|
|
4384
|
+
const parsed = telegramApiBase();
|
|
4385
|
+
return {
|
|
4386
|
+
overridden: parsed.href.replace(/\/$/, "") !== DEFAULT_TELEGRAM_API_BASE,
|
|
4387
|
+
origin: parsed.origin,
|
|
4388
|
+
pathname: parsed.pathname
|
|
4389
|
+
};
|
|
4390
|
+
}
|
|
4391
|
+
function telegramMethodUrl(token, method) {
|
|
4392
|
+
const base = telegramApiBase();
|
|
4393
|
+
const prefix = base.pathname.replace(/\/$/, "");
|
|
4394
|
+
base.pathname = `${prefix}/bot${token}/${method}`;
|
|
4395
|
+
base.search = "";
|
|
4396
|
+
return base.toString();
|
|
4352
4397
|
}
|
|
4353
|
-
// src/lib/telegram.ts
|
|
4354
4398
|
function telegramToken(channel) {
|
|
4355
4399
|
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
4356
4400
|
const token = process.env[envName];
|
|
@@ -4366,7 +4410,7 @@ function telegramChatAllowed(channel, chatId) {
|
|
|
4366
4410
|
return Boolean(chatId && channel.allowedChatIds.includes(chatId));
|
|
4367
4411
|
}
|
|
4368
4412
|
async function sendTelegramMessage(token, chatId, text) {
|
|
4369
|
-
const response = await fetch(
|
|
4413
|
+
const response = await fetch(telegramMethodUrl(token, "sendMessage"), {
|
|
4370
4414
|
method: "POST",
|
|
4371
4415
|
headers: { "content-type": "application/json" },
|
|
4372
4416
|
body: JSON.stringify({ chat_id: chatId, text })
|
|
@@ -4383,7 +4427,7 @@ async function getTelegramUpdates(token, options = {}) {
|
|
|
4383
4427
|
if (options.offset !== undefined)
|
|
4384
4428
|
params.set("offset", String(options.offset));
|
|
4385
4429
|
params.set("timeout", String(options.timeoutSeconds ?? 20));
|
|
4386
|
-
const response = await fetch(
|
|
4430
|
+
const response = await fetch(`${telegramMethodUrl(token, "getUpdates")}?${params.toString()}`);
|
|
4387
4431
|
const body = await response.json().catch(() => {
|
|
4388
4432
|
return;
|
|
4389
4433
|
});
|
|
@@ -4402,12 +4446,854 @@ function telegramUpdateToMessage(channelId, update) {
|
|
|
4402
4446
|
channelId,
|
|
4403
4447
|
text,
|
|
4404
4448
|
chatId: String(chatId),
|
|
4449
|
+
threadId: update.message?.message_thread_id !== undefined ? String(update.message.message_thread_id) : undefined,
|
|
4405
4450
|
from: update.message?.from?.username || (update.message?.from?.id !== undefined ? String(update.message.from.id) : undefined),
|
|
4406
4451
|
receivedAt: update.message?.date ? new Date(update.message.date * 1000).toISOString() : new Date().toISOString(),
|
|
4407
4452
|
raw: update
|
|
4408
4453
|
};
|
|
4409
4454
|
}
|
|
4410
4455
|
|
|
4456
|
+
// src/lib/daemon.ts
|
|
4457
|
+
function isNotFound(err) {
|
|
4458
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
4459
|
+
}
|
|
4460
|
+
function currentPlatformSupervisor() {
|
|
4461
|
+
if (process.platform === "darwin")
|
|
4462
|
+
return "launchd";
|
|
4463
|
+
if (process.platform === "linux")
|
|
4464
|
+
return "systemd";
|
|
4465
|
+
return "process";
|
|
4466
|
+
}
|
|
4467
|
+
function resolveSupervisor(supervisor = "process") {
|
|
4468
|
+
return supervisor === "auto" ? currentPlatformSupervisor() : supervisor;
|
|
4469
|
+
}
|
|
4470
|
+
function defaultDaemonDir() {
|
|
4471
|
+
return join3(bridgeHome(), "daemon");
|
|
4472
|
+
}
|
|
4473
|
+
function daemonPaths(daemonDir = defaultDaemonDir()) {
|
|
4474
|
+
const dir = resolve(daemonDir);
|
|
4475
|
+
return {
|
|
4476
|
+
dir,
|
|
4477
|
+
lockDir: join3(dir, "lock"),
|
|
4478
|
+
metadataFile: join3(dir, "bridge-daemon.json"),
|
|
4479
|
+
stdoutLog: join3(dir, "bridge.out.log"),
|
|
4480
|
+
stderrLog: join3(dir, "bridge.err.log"),
|
|
4481
|
+
launchdPlist: join3(process.env["HOME"] || process.cwd(), "Library", "LaunchAgents", "com.hasna.bridge.plist"),
|
|
4482
|
+
systemdUnit: join3(process.env["HOME"] || process.cwd(), ".config", "systemd", "user", "hasna-bridge.service")
|
|
4483
|
+
};
|
|
4484
|
+
}
|
|
4485
|
+
async function ensureDaemonDir(dir = defaultDaemonDir()) {
|
|
4486
|
+
const paths = daemonPaths(dir);
|
|
4487
|
+
await mkdir3(paths.dir, { recursive: true, mode: 448 });
|
|
4488
|
+
await chmod3(paths.dir, 448);
|
|
4489
|
+
return paths;
|
|
4490
|
+
}
|
|
4491
|
+
async function fileExists(path) {
|
|
4492
|
+
try {
|
|
4493
|
+
await stat(path);
|
|
4494
|
+
return true;
|
|
4495
|
+
} catch (err) {
|
|
4496
|
+
if (isNotFound(err))
|
|
4497
|
+
return false;
|
|
4498
|
+
throw err;
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
async function readMetadata(paths) {
|
|
4502
|
+
try {
|
|
4503
|
+
return JSON.parse(await readFile3(paths.metadataFile, "utf-8"));
|
|
4504
|
+
} catch (err) {
|
|
4505
|
+
if (isNotFound(err))
|
|
4506
|
+
return;
|
|
4507
|
+
throw err;
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
async function writeMetadata(paths, metadata) {
|
|
4511
|
+
const tmp = `${paths.metadataFile}.${process.pid}.${Date.now()}.tmp`;
|
|
4512
|
+
await writeFile3(tmp, `${JSON.stringify(metadata, null, 2)}
|
|
4513
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
4514
|
+
await chmod3(tmp, 384);
|
|
4515
|
+
await rename(tmp, paths.metadataFile);
|
|
4516
|
+
await chmod3(paths.metadataFile, 384);
|
|
4517
|
+
}
|
|
4518
|
+
async function withDaemonLock(paths, fn) {
|
|
4519
|
+
try {
|
|
4520
|
+
await mkdir3(paths.lockDir, { mode: 448 });
|
|
4521
|
+
} catch (err) {
|
|
4522
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EEXIST") {
|
|
4523
|
+
throw new Error(`Another bridge daemon operation is already running: ${paths.lockDir}`);
|
|
4524
|
+
}
|
|
4525
|
+
throw err;
|
|
4526
|
+
}
|
|
4527
|
+
try {
|
|
4528
|
+
return await fn();
|
|
4529
|
+
} finally {
|
|
4530
|
+
await rmdir(paths.lockDir).catch(() => {
|
|
4531
|
+
return;
|
|
4532
|
+
});
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
function pidAlive(pid) {
|
|
4536
|
+
try {
|
|
4537
|
+
process.kill(pid, 0);
|
|
4538
|
+
return true;
|
|
4539
|
+
} catch {
|
|
4540
|
+
return false;
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
async function processCommand(pid) {
|
|
4544
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], {
|
|
4545
|
+
stdout: "pipe",
|
|
4546
|
+
stderr: "ignore"
|
|
4547
|
+
});
|
|
4548
|
+
if (await proc.exited !== 0)
|
|
4549
|
+
return;
|
|
4550
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
4551
|
+
}
|
|
4552
|
+
async function processPgid(pid) {
|
|
4553
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "pgid="], {
|
|
4554
|
+
stdout: "pipe",
|
|
4555
|
+
stderr: "ignore"
|
|
4556
|
+
});
|
|
4557
|
+
if (await proc.exited !== 0)
|
|
4558
|
+
return;
|
|
4559
|
+
const parsed = Number.parseInt((await new Response(proc.stdout).text()).trim(), 10);
|
|
4560
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
4561
|
+
}
|
|
4562
|
+
function shellQuote(value) {
|
|
4563
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
4564
|
+
}
|
|
4565
|
+
function commandPattern(command) {
|
|
4566
|
+
return command.map(shellQuote).join(" ");
|
|
4567
|
+
}
|
|
4568
|
+
async function processMatches(metadata) {
|
|
4569
|
+
if (!pidAlive(metadata.pid))
|
|
4570
|
+
return false;
|
|
4571
|
+
const command = await processCommand(metadata.pid);
|
|
4572
|
+
if (!command)
|
|
4573
|
+
return false;
|
|
4574
|
+
if (!metadata.pgid)
|
|
4575
|
+
return false;
|
|
4576
|
+
const pgid = await processPgid(metadata.pid);
|
|
4577
|
+
if (pgid !== metadata.pgid)
|
|
4578
|
+
return false;
|
|
4579
|
+
const requiredArgs = [
|
|
4580
|
+
metadata.command[1],
|
|
4581
|
+
"serve",
|
|
4582
|
+
"--config",
|
|
4583
|
+
metadata.configPath,
|
|
4584
|
+
"--state",
|
|
4585
|
+
metadata.statePath,
|
|
4586
|
+
"--interval",
|
|
4587
|
+
String(metadata.intervalMs)
|
|
4588
|
+
].filter((arg) => Boolean(arg));
|
|
4589
|
+
if (metadata.serveJson)
|
|
4590
|
+
requiredArgs.push("--json");
|
|
4591
|
+
return requiredArgs.every((arg) => command.includes(arg));
|
|
4592
|
+
}
|
|
4593
|
+
async function removeMetadata(paths) {
|
|
4594
|
+
await rm(paths.metadataFile, { force: true });
|
|
4595
|
+
}
|
|
4596
|
+
function safeTelegramApiBaseInfo() {
|
|
4597
|
+
try {
|
|
4598
|
+
return telegramApiBaseInfo();
|
|
4599
|
+
} catch (err) {
|
|
4600
|
+
return {
|
|
4601
|
+
overridden: true,
|
|
4602
|
+
origin: "",
|
|
4603
|
+
pathname: "",
|
|
4604
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4605
|
+
};
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
function startCommand(options) {
|
|
4609
|
+
const scriptPath = process.argv[1];
|
|
4610
|
+
const base = scriptPath ? [process.execPath, scriptPath] : ["bridge"];
|
|
4611
|
+
const command = [
|
|
4612
|
+
...base,
|
|
4613
|
+
"serve",
|
|
4614
|
+
"--config",
|
|
4615
|
+
options.configPath,
|
|
4616
|
+
"--state",
|
|
4617
|
+
options.statePath,
|
|
4618
|
+
"--interval",
|
|
4619
|
+
String(options.intervalMs)
|
|
4620
|
+
];
|
|
4621
|
+
if (options.serveJson)
|
|
4622
|
+
command.push("--json");
|
|
4623
|
+
return command;
|
|
4624
|
+
}
|
|
4625
|
+
function telegramChannels(config) {
|
|
4626
|
+
return Object.values(config.channels).filter((channel) => channel.kind === "telegram" && channel.enabled !== false);
|
|
4627
|
+
}
|
|
4628
|
+
function imessagePollChannels(config) {
|
|
4629
|
+
return Object.values(config.channels).filter((channel) => channel.kind === "imessage" && channel.enabled !== false && channel.receiveMode === "chat-db");
|
|
4630
|
+
}
|
|
4631
|
+
function requiredTelegramEnvVars(config) {
|
|
4632
|
+
return [...new Set(telegramChannels(config).map((channel) => channel.botTokenEnv || "TELEGRAM_BOT_TOKEN"))];
|
|
4633
|
+
}
|
|
4634
|
+
async function validateStartConfig(configPath) {
|
|
4635
|
+
const config = await loadConfig(configPath);
|
|
4636
|
+
const channels = [...telegramChannels(config), ...imessagePollChannels(config)];
|
|
4637
|
+
if (!channels.length)
|
|
4638
|
+
throw new Error("No enabled pollable channels configured; add Telegram or iMessage receive before starting the daemon");
|
|
4639
|
+
for (const envName of requiredTelegramEnvVars(config)) {
|
|
4640
|
+
if (!process.env[envName])
|
|
4641
|
+
throw new Error(`Missing Telegram bot token env var for daemon start: ${envName}`);
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
function openPrivateLog(path) {
|
|
4645
|
+
const fd = openSync(path, "a", 384);
|
|
4646
|
+
return fd;
|
|
4647
|
+
}
|
|
4648
|
+
async function ensurePrivateLogFiles(paths) {
|
|
4649
|
+
for (const path of [paths.stdoutLog, paths.stderrLog]) {
|
|
4650
|
+
const fd = openPrivateLog(path);
|
|
4651
|
+
closeSync(fd);
|
|
4652
|
+
await chmod3(path, 384);
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
async function runCapture(command) {
|
|
4656
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
4657
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
4658
|
+
proc.exited,
|
|
4659
|
+
new Response(proc.stdout).text(),
|
|
4660
|
+
new Response(proc.stderr).text()
|
|
4661
|
+
]);
|
|
4662
|
+
return { exitCode, stdout, stderr };
|
|
4663
|
+
}
|
|
4664
|
+
async function installedSupervisorStatus(supervisor, paths) {
|
|
4665
|
+
if (supervisor === "launchd") {
|
|
4666
|
+
if (!await fileExists(paths.launchdPlist))
|
|
4667
|
+
return { running: false, detail: "launchd plist not installed" };
|
|
4668
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4669
|
+
if (uid === undefined)
|
|
4670
|
+
return { running: false, detail: "launchd status requires a numeric uid" };
|
|
4671
|
+
const result = await runCapture(["launchctl", "print", `gui/${uid}/com.hasna.bridge`]);
|
|
4672
|
+
if (result.exitCode !== 0)
|
|
4673
|
+
return { running: false, detail: result.stderr.trim() || result.stdout.trim() || "launchd service not loaded" };
|
|
4674
|
+
const running = /state\s*=\s*running/.test(result.stdout);
|
|
4675
|
+
return { running, detail: running ? "launchd running" : "launchd loaded but not running" };
|
|
4676
|
+
}
|
|
4677
|
+
if (supervisor === "systemd") {
|
|
4678
|
+
if (!await fileExists(paths.systemdUnit))
|
|
4679
|
+
return { running: false, detail: "systemd unit not installed" };
|
|
4680
|
+
const result = await runCapture(["systemctl", "--user", "is-active", "hasna-bridge.service"]);
|
|
4681
|
+
const state = result.stdout.trim() || result.stderr.trim() || "unknown";
|
|
4682
|
+
return { running: result.exitCode === 0 && state === "active", detail: `systemd ${state}` };
|
|
4683
|
+
}
|
|
4684
|
+
return { running: false, detail: "process supervisor has no installed status" };
|
|
4685
|
+
}
|
|
4686
|
+
async function daemonStatus(options = {}) {
|
|
4687
|
+
const supervisor = resolveSupervisor(options.supervisor);
|
|
4688
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4689
|
+
const metadata = await readMetadata(paths);
|
|
4690
|
+
const live = metadata ? await processMatches(metadata) : false;
|
|
4691
|
+
const stale = Boolean(metadata && !live);
|
|
4692
|
+
const startedAt = metadata?.startedAt;
|
|
4693
|
+
const uptimeSeconds = live && startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)) : undefined;
|
|
4694
|
+
const installed = {
|
|
4695
|
+
launchd: await fileExists(paths.launchdPlist),
|
|
4696
|
+
systemd: await fileExists(paths.systemdUnit)
|
|
4697
|
+
};
|
|
4698
|
+
const installedRuntime = supervisor === "process" ? undefined : await installedSupervisorStatus(supervisor, paths);
|
|
4699
|
+
return {
|
|
4700
|
+
running: installedRuntime ? installedRuntime.running : live,
|
|
4701
|
+
stale: installedRuntime ? false : stale,
|
|
4702
|
+
supervisor,
|
|
4703
|
+
pid: metadata?.pid,
|
|
4704
|
+
startedAt,
|
|
4705
|
+
uptimeSeconds,
|
|
4706
|
+
detail: installedRuntime?.detail || (stale ? "stale process metadata" : live ? "running" : "not running"),
|
|
4707
|
+
installedDetail: installedRuntime?.detail,
|
|
4708
|
+
metadata,
|
|
4709
|
+
paths,
|
|
4710
|
+
installed,
|
|
4711
|
+
telegramApiBase: safeTelegramApiBaseInfo()
|
|
4712
|
+
};
|
|
4713
|
+
}
|
|
4714
|
+
async function startProcessDaemon(options = {}) {
|
|
4715
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4716
|
+
return withDaemonLock(paths, async () => {
|
|
4717
|
+
const existing = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4718
|
+
if (existing.running)
|
|
4719
|
+
return existing;
|
|
4720
|
+
if (existing.stale)
|
|
4721
|
+
await removeMetadata(paths);
|
|
4722
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
4723
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
4724
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
4725
|
+
const serveJson = Boolean(options.serveJson);
|
|
4726
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 0)
|
|
4727
|
+
throw new Error("--interval must be a non-negative integer");
|
|
4728
|
+
await validateStartConfig(configPath);
|
|
4729
|
+
const stdoutFd = openPrivateLog(paths.stdoutLog);
|
|
4730
|
+
const stderrFd = openPrivateLog(paths.stderrLog);
|
|
4731
|
+
try {
|
|
4732
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
4733
|
+
const child = spawn(command[0], command.slice(1), {
|
|
4734
|
+
cwd: process.cwd(),
|
|
4735
|
+
detached: true,
|
|
4736
|
+
env: process.env,
|
|
4737
|
+
stdio: ["ignore", stdoutFd, stderrFd]
|
|
4738
|
+
});
|
|
4739
|
+
child.unref();
|
|
4740
|
+
const metadata = {
|
|
4741
|
+
version: 1,
|
|
4742
|
+
supervisor: "process",
|
|
4743
|
+
pid: child.pid || 0,
|
|
4744
|
+
pgid: child.pid || undefined,
|
|
4745
|
+
startedAt: new Date().toISOString(),
|
|
4746
|
+
identity: {
|
|
4747
|
+
command: commandPattern(command),
|
|
4748
|
+
cwd: process.cwd(),
|
|
4749
|
+
configPath,
|
|
4750
|
+
statePath,
|
|
4751
|
+
daemonDir: paths.dir,
|
|
4752
|
+
bridgeHome: bridgeHome()
|
|
4753
|
+
},
|
|
4754
|
+
command,
|
|
4755
|
+
cwd: process.cwd(),
|
|
4756
|
+
configPath,
|
|
4757
|
+
statePath,
|
|
4758
|
+
intervalMs,
|
|
4759
|
+
serveJson,
|
|
4760
|
+
daemonDir: paths.dir,
|
|
4761
|
+
bridgeHome: bridgeHome(),
|
|
4762
|
+
stdoutLog: paths.stdoutLog,
|
|
4763
|
+
stderrLog: paths.stderrLog
|
|
4764
|
+
};
|
|
4765
|
+
if (!metadata.pid)
|
|
4766
|
+
throw new Error("Failed to start bridge daemon process");
|
|
4767
|
+
await writeMetadata(paths, metadata);
|
|
4768
|
+
await Bun.sleep(200);
|
|
4769
|
+
const status = await daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4770
|
+
if (!status.running) {
|
|
4771
|
+
await removeMetadata(paths);
|
|
4772
|
+
throw new Error(`Bridge daemon failed to stay running; inspect ${paths.stderrLog}`);
|
|
4773
|
+
}
|
|
4774
|
+
return status;
|
|
4775
|
+
} finally {
|
|
4776
|
+
closeSync(stdoutFd);
|
|
4777
|
+
closeSync(stderrFd);
|
|
4778
|
+
await chmod3(paths.stdoutLog, 384).catch(() => {
|
|
4779
|
+
return;
|
|
4780
|
+
});
|
|
4781
|
+
await chmod3(paths.stderrLog, 384).catch(() => {
|
|
4782
|
+
return;
|
|
4783
|
+
});
|
|
4784
|
+
}
|
|
4785
|
+
});
|
|
4786
|
+
}
|
|
4787
|
+
async function stopPid(pid, force) {
|
|
4788
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
4789
|
+
}
|
|
4790
|
+
async function waitForExit(pid, timeoutMs) {
|
|
4791
|
+
const started = Date.now();
|
|
4792
|
+
while (Date.now() - started < timeoutMs) {
|
|
4793
|
+
if (!pidAlive(pid))
|
|
4794
|
+
return true;
|
|
4795
|
+
await Bun.sleep(100);
|
|
4796
|
+
}
|
|
4797
|
+
return !pidAlive(pid);
|
|
4798
|
+
}
|
|
4799
|
+
async function stopProcessDaemon(options = {}) {
|
|
4800
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4801
|
+
return withDaemonLock(paths, async () => {
|
|
4802
|
+
const metadata = await readMetadata(paths);
|
|
4803
|
+
if (!metadata)
|
|
4804
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4805
|
+
if (!await processMatches(metadata)) {
|
|
4806
|
+
await removeMetadata(paths);
|
|
4807
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4808
|
+
}
|
|
4809
|
+
await stopPid(metadata.pid, false);
|
|
4810
|
+
let exited = await waitForExit(metadata.pid, options.timeoutMs ?? 5000);
|
|
4811
|
+
if (!exited && options.force) {
|
|
4812
|
+
await stopPid(metadata.pid, true);
|
|
4813
|
+
exited = await waitForExit(metadata.pid, 2000);
|
|
4814
|
+
}
|
|
4815
|
+
if (!exited)
|
|
4816
|
+
throw new Error(`Bridge daemon did not stop within ${options.timeoutMs ?? 5000}ms`);
|
|
4817
|
+
await removeMetadata(paths);
|
|
4818
|
+
return daemonStatus({ daemonDir: paths.dir, supervisor: "process" });
|
|
4819
|
+
});
|
|
4820
|
+
}
|
|
4821
|
+
async function restartProcessDaemon(options = {}) {
|
|
4822
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4823
|
+
const metadata = await readMetadata(paths);
|
|
4824
|
+
await stopProcessDaemon(options);
|
|
4825
|
+
return startProcessDaemon({
|
|
4826
|
+
...options,
|
|
4827
|
+
configPath: options.configPath || metadata?.configPath,
|
|
4828
|
+
statePath: options.statePath || metadata?.statePath,
|
|
4829
|
+
intervalMs: options.intervalMs ?? metadata?.intervalMs,
|
|
4830
|
+
serveJson: options.serveJson ?? metadata?.serveJson
|
|
4831
|
+
});
|
|
4832
|
+
}
|
|
4833
|
+
function xmlEscape(value) {
|
|
4834
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
4835
|
+
}
|
|
4836
|
+
function plistArray(values) {
|
|
4837
|
+
return values.map((value) => ` <string>${xmlEscape(value)}</string>`).join(`
|
|
4838
|
+
`);
|
|
4839
|
+
}
|
|
4840
|
+
function renderLaunchdPlist(command, paths) {
|
|
4841
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
4842
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4843
|
+
<plist version="1.0">
|
|
4844
|
+
<dict>
|
|
4845
|
+
<key>Label</key>
|
|
4846
|
+
<string>com.hasna.bridge</string>
|
|
4847
|
+
<key>ProgramArguments</key>
|
|
4848
|
+
<array>
|
|
4849
|
+
${plistArray(command)}
|
|
4850
|
+
</array>
|
|
4851
|
+
<key>RunAtLoad</key>
|
|
4852
|
+
<true/>
|
|
4853
|
+
<key>KeepAlive</key>
|
|
4854
|
+
<true/>
|
|
4855
|
+
<key>StandardOutPath</key>
|
|
4856
|
+
<string>${xmlEscape(paths.stdoutLog)}</string>
|
|
4857
|
+
<key>StandardErrorPath</key>
|
|
4858
|
+
<string>${xmlEscape(paths.stderrLog)}</string>
|
|
4859
|
+
<key>WorkingDirectory</key>
|
|
4860
|
+
<string>${xmlEscape(process.cwd())}</string>
|
|
4861
|
+
</dict>
|
|
4862
|
+
</plist>
|
|
4863
|
+
`;
|
|
4864
|
+
}
|
|
4865
|
+
function systemdEscape(value) {
|
|
4866
|
+
return value.replaceAll("%", "%%").replaceAll(`
|
|
4867
|
+
`, " ");
|
|
4868
|
+
}
|
|
4869
|
+
function systemdQuote(value) {
|
|
4870
|
+
return `"${systemdEscape(value).replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
4871
|
+
}
|
|
4872
|
+
function renderSystemdUnit(command, paths) {
|
|
4873
|
+
return `[Unit]
|
|
4874
|
+
Description=Hasna Bridge daemon
|
|
4875
|
+
After=network-online.target
|
|
4876
|
+
|
|
4877
|
+
[Service]
|
|
4878
|
+
Type=simple
|
|
4879
|
+
ExecStart=${command.map(systemdQuote).join(" ")}
|
|
4880
|
+
Restart=always
|
|
4881
|
+
RestartSec=5
|
|
4882
|
+
WorkingDirectory=${systemdEscape(process.cwd())}
|
|
4883
|
+
StandardOutput=append:${systemdEscape(paths.stdoutLog)}
|
|
4884
|
+
StandardError=append:${systemdEscape(paths.stderrLog)}
|
|
4885
|
+
|
|
4886
|
+
[Install]
|
|
4887
|
+
WantedBy=default.target
|
|
4888
|
+
`;
|
|
4889
|
+
}
|
|
4890
|
+
async function installFile(path, content) {
|
|
4891
|
+
await mkdir3(dirname3(path), { recursive: true, mode: 448 });
|
|
4892
|
+
await writeFile3(path, content, { encoding: "utf-8", mode: 384 });
|
|
4893
|
+
await chmod3(path, 384);
|
|
4894
|
+
}
|
|
4895
|
+
async function installDaemon(options = {}) {
|
|
4896
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4897
|
+
if (supervisor === "process") {
|
|
4898
|
+
throw new Error("The process supervisor does not need install; use `bridge daemon start`");
|
|
4899
|
+
}
|
|
4900
|
+
const paths = await ensureDaemonDir(options.daemonDir);
|
|
4901
|
+
await ensurePrivateLogFiles(paths);
|
|
4902
|
+
const configPath = resolve(options.configPath || defaultConfigPath());
|
|
4903
|
+
const statePath = resolve(options.statePath || defaultStatePath());
|
|
4904
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
4905
|
+
const serveJson = Boolean(options.serveJson);
|
|
4906
|
+
const command = startCommand({ configPath, statePath, intervalMs, serveJson });
|
|
4907
|
+
const config = await loadConfig(configPath);
|
|
4908
|
+
const requiredEnv = requiredTelegramEnvVars(config);
|
|
4909
|
+
if (supervisor === "launchd") {
|
|
4910
|
+
await installFile(paths.launchdPlist, renderLaunchdPlist(command, paths));
|
|
4911
|
+
return {
|
|
4912
|
+
supervisor,
|
|
4913
|
+
path: paths.launchdPlist,
|
|
4914
|
+
command,
|
|
4915
|
+
requiredEnv,
|
|
4916
|
+
warning: "Telegram token values are not written to launchd files. Set them in the launchd environment before starting."
|
|
4917
|
+
};
|
|
4918
|
+
}
|
|
4919
|
+
await installFile(paths.systemdUnit, renderSystemdUnit(command, paths));
|
|
4920
|
+
return {
|
|
4921
|
+
supervisor,
|
|
4922
|
+
path: paths.systemdUnit,
|
|
4923
|
+
command,
|
|
4924
|
+
requiredEnv,
|
|
4925
|
+
warning: "Telegram token values are not written to systemd files. Import them into the user manager environment before starting."
|
|
4926
|
+
};
|
|
4927
|
+
}
|
|
4928
|
+
async function runCommand(command) {
|
|
4929
|
+
const { exitCode, stdout, stderr } = await runCapture(command);
|
|
4930
|
+
if (exitCode !== 0)
|
|
4931
|
+
throw new Error(`${command.join(" ")} failed (${exitCode}): ${stderr || stdout}`);
|
|
4932
|
+
}
|
|
4933
|
+
async function waitForInstalledRunning(supervisor, paths, timeoutMs = 5000) {
|
|
4934
|
+
const started = Date.now();
|
|
4935
|
+
let last = "";
|
|
4936
|
+
while (Date.now() - started < timeoutMs) {
|
|
4937
|
+
const status = await installedSupervisorStatus(supervisor, paths);
|
|
4938
|
+
last = status.detail;
|
|
4939
|
+
if (status.running)
|
|
4940
|
+
return;
|
|
4941
|
+
await Bun.sleep(250);
|
|
4942
|
+
}
|
|
4943
|
+
throw new Error(`${supervisor} service did not report running: ${last}`);
|
|
4944
|
+
}
|
|
4945
|
+
async function startInstalledDaemon(options = {}) {
|
|
4946
|
+
const result = await installDaemon(options);
|
|
4947
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4948
|
+
if (result.supervisor === "launchd") {
|
|
4949
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4950
|
+
if (uid === undefined)
|
|
4951
|
+
throw new Error("launchd start requires a numeric uid");
|
|
4952
|
+
await runCommand(["launchctl", "bootstrap", `gui/${uid}`, result.path]).catch(async (err) => {
|
|
4953
|
+
if (!String(err).includes("Input/output error"))
|
|
4954
|
+
throw err;
|
|
4955
|
+
await runCommand(["launchctl", "kickstart", "-k", `gui/${uid}/com.hasna.bridge`]);
|
|
4956
|
+
});
|
|
4957
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
4958
|
+
return result;
|
|
4959
|
+
}
|
|
4960
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]);
|
|
4961
|
+
await runCommand(["systemctl", "--user", "enable", "--now", "hasna-bridge.service"]);
|
|
4962
|
+
await waitForInstalledRunning(result.supervisor, paths);
|
|
4963
|
+
return result;
|
|
4964
|
+
}
|
|
4965
|
+
async function stopInstalledDaemon(options = {}) {
|
|
4966
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4967
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4968
|
+
if (supervisor === "launchd") {
|
|
4969
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
4970
|
+
if (uid === undefined)
|
|
4971
|
+
throw new Error("launchd stop requires a numeric uid");
|
|
4972
|
+
await runCommand(["launchctl", "bootout", `gui/${uid}`, paths.launchdPlist]);
|
|
4973
|
+
return;
|
|
4974
|
+
}
|
|
4975
|
+
if (supervisor === "systemd") {
|
|
4976
|
+
await runCommand(["systemctl", "--user", "disable", "--now", "hasna-bridge.service"]);
|
|
4977
|
+
return;
|
|
4978
|
+
}
|
|
4979
|
+
await stopProcessDaemon(options);
|
|
4980
|
+
}
|
|
4981
|
+
async function restartInstalledDaemon(options = {}) {
|
|
4982
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4983
|
+
if (supervisor === "process")
|
|
4984
|
+
return restartProcessDaemon(options);
|
|
4985
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
4986
|
+
return;
|
|
4987
|
+
});
|
|
4988
|
+
return startInstalledDaemon({ ...options, supervisor });
|
|
4989
|
+
}
|
|
4990
|
+
async function uninstallDaemon(options = {}) {
|
|
4991
|
+
const supervisor = resolveSupervisor(options.supervisor || "auto");
|
|
4992
|
+
const paths = daemonPaths(options.daemonDir);
|
|
4993
|
+
const removed = [];
|
|
4994
|
+
if (supervisor === "launchd") {
|
|
4995
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
4996
|
+
return;
|
|
4997
|
+
});
|
|
4998
|
+
await rm(paths.launchdPlist, { force: true });
|
|
4999
|
+
removed.push(paths.launchdPlist);
|
|
5000
|
+
} else if (supervisor === "systemd") {
|
|
5001
|
+
await stopInstalledDaemon({ ...options, supervisor }).catch(() => {
|
|
5002
|
+
return;
|
|
5003
|
+
});
|
|
5004
|
+
await rm(paths.systemdUnit, { force: true });
|
|
5005
|
+
await runCommand(["systemctl", "--user", "daemon-reload"]).catch(() => {
|
|
5006
|
+
return;
|
|
5007
|
+
});
|
|
5008
|
+
removed.push(paths.systemdUnit);
|
|
5009
|
+
} else {
|
|
5010
|
+
await stopProcessDaemon({ ...options, supervisor }).catch(() => {
|
|
5011
|
+
return;
|
|
5012
|
+
});
|
|
5013
|
+
await removeMetadata(paths);
|
|
5014
|
+
removed.push(paths.metadataFile);
|
|
5015
|
+
}
|
|
5016
|
+
return { supervisor, removed };
|
|
5017
|
+
}
|
|
5018
|
+
async function tailFile(path, lines) {
|
|
5019
|
+
try {
|
|
5020
|
+
const raw = await readFile3(path, "utf-8");
|
|
5021
|
+
return raw.split(/\r?\n/).slice(-Math.max(1, lines)).join(`
|
|
5022
|
+
`);
|
|
5023
|
+
} catch (err) {
|
|
5024
|
+
if (isNotFound(err))
|
|
5025
|
+
return "";
|
|
5026
|
+
throw err;
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
async function daemonLogs(options = {}) {
|
|
5030
|
+
const paths = daemonPaths(options.daemonDir);
|
|
5031
|
+
const lines = options.lines ?? 100;
|
|
5032
|
+
return {
|
|
5033
|
+
stdout: await tailFile(paths.stdoutLog, lines),
|
|
5034
|
+
stderr: await tailFile(paths.stderrLog, lines),
|
|
5035
|
+
paths
|
|
5036
|
+
};
|
|
5037
|
+
}
|
|
5038
|
+
// src/lib/doctor.ts
|
|
5039
|
+
import { stat as stat2 } from "fs/promises";
|
|
5040
|
+
|
|
5041
|
+
// src/lib/imessage.ts
|
|
5042
|
+
import { access } from "fs/promises";
|
|
5043
|
+
import { join as join4 } from "path";
|
|
5044
|
+
import { Database } from "bun:sqlite";
|
|
5045
|
+
function defaultMessagesDbPath() {
|
|
5046
|
+
return join4(homeDir(), "Library", "Messages", "chat.db");
|
|
5047
|
+
}
|
|
5048
|
+
function imessageHandleAllowed(channel, handle) {
|
|
5049
|
+
if (channel.allowAllHandles)
|
|
5050
|
+
return true;
|
|
5051
|
+
if (!channel.allowedHandles?.length)
|
|
5052
|
+
return false;
|
|
5053
|
+
return Boolean(handle && channel.allowedHandles.includes(handle));
|
|
5054
|
+
}
|
|
5055
|
+
function appleScriptString(value) {
|
|
5056
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
5057
|
+
}
|
|
5058
|
+
function renderSendIMessageScript(channel, handle, text) {
|
|
5059
|
+
const service = channel.serviceName || "iMessage";
|
|
5060
|
+
const serviceSelector = channel.account ? `1st service whose name = ${appleScriptString(service)} and account = ${appleScriptString(channel.account)}` : `1st service whose name = ${appleScriptString(service)}`;
|
|
5061
|
+
const targetLines = handle.startsWith("chat:") ? [
|
|
5062
|
+
`set targetChat to 1st chat whose id = ${appleScriptString(handle.slice("chat:".length))}`,
|
|
5063
|
+
`send ${appleScriptString(text)} to targetChat`
|
|
5064
|
+
] : [
|
|
5065
|
+
`set targetBuddy to buddy ${appleScriptString(handle)} of targetService`,
|
|
5066
|
+
`send ${appleScriptString(text)} to targetBuddy`
|
|
5067
|
+
];
|
|
5068
|
+
return [
|
|
5069
|
+
'tell application "Messages"',
|
|
5070
|
+
`set targetService to ${serviceSelector}`,
|
|
5071
|
+
...targetLines,
|
|
5072
|
+
"end tell"
|
|
5073
|
+
].join(`
|
|
5074
|
+
`);
|
|
5075
|
+
}
|
|
5076
|
+
async function defaultRun(command) {
|
|
5077
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
5078
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
5079
|
+
proc.exited,
|
|
5080
|
+
new Response(proc.stdout).text(),
|
|
5081
|
+
new Response(proc.stderr).text()
|
|
5082
|
+
]);
|
|
5083
|
+
return { exitCode, stdout, stderr };
|
|
5084
|
+
}
|
|
5085
|
+
async function sendIMessage(channel, handle, text, options = {}) {
|
|
5086
|
+
if (!(options.allowChatTarget && handle.startsWith("chat:")) && !imessageHandleAllowed(channel, handle)) {
|
|
5087
|
+
throw new Error(`iMessage handle is not allowed for channel ${channel.id}: ${handle}`);
|
|
5088
|
+
}
|
|
5089
|
+
const script = renderSendIMessageScript(channel, handle, text);
|
|
5090
|
+
const result = await (options.run || defaultRun)(["osascript", "-e", script]);
|
|
5091
|
+
if (result.exitCode !== 0) {
|
|
5092
|
+
throw new Error(`iMessage send failed: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
|
|
5093
|
+
}
|
|
5094
|
+
return { ok: true };
|
|
5095
|
+
}
|
|
5096
|
+
function imessageDateToIso(value) {
|
|
5097
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
5098
|
+
return new Date().toISOString();
|
|
5099
|
+
const appleEpochMs = Date.UTC(2001, 0, 1);
|
|
5100
|
+
if (value > 1000000000000000)
|
|
5101
|
+
return new Date(appleEpochMs + Math.floor(value / 1e6)).toISOString();
|
|
5102
|
+
if (value > 1e9)
|
|
5103
|
+
return new Date(appleEpochMs + value * 1000).toISOString();
|
|
5104
|
+
return new Date(appleEpochMs + value).toISOString();
|
|
5105
|
+
}
|
|
5106
|
+
function getIMessageDbPath(channel) {
|
|
5107
|
+
return channel.chatDbPath || defaultMessagesDbPath();
|
|
5108
|
+
}
|
|
5109
|
+
function getIMessageMessages(channel, options = {}) {
|
|
5110
|
+
if ((channel.receiveMode || "disabled") !== "chat-db")
|
|
5111
|
+
return [];
|
|
5112
|
+
const db = new Database(getIMessageDbPath(channel), { readonly: true });
|
|
5113
|
+
try {
|
|
5114
|
+
const limit = options.limit || channel.pollLimit || 50;
|
|
5115
|
+
const scanLimit = Math.max(limit * 10, limit);
|
|
5116
|
+
const rows = db.query(`
|
|
5117
|
+
select
|
|
5118
|
+
message.ROWID as rowId,
|
|
5119
|
+
handle.id as handle,
|
|
5120
|
+
chat.guid as chatGuid,
|
|
5121
|
+
chat.display_name as displayName,
|
|
5122
|
+
message.text as text,
|
|
5123
|
+
message.date as date
|
|
5124
|
+
from message
|
|
5125
|
+
left join handle on message.handle_id = handle.ROWID
|
|
5126
|
+
left join chat_message_join on chat_message_join.message_id = message.ROWID
|
|
5127
|
+
left join chat on chat.ROWID = chat_message_join.chat_id
|
|
5128
|
+
where message.ROWID > ?
|
|
5129
|
+
and message.is_from_me = 0
|
|
5130
|
+
and message.text is not null
|
|
5131
|
+
order by message.ROWID asc
|
|
5132
|
+
limit ?
|
|
5133
|
+
`).all(options.afterRowId || 0, scanLimit);
|
|
5134
|
+
return rows.filter((row) => row.handle && row.text && imessageHandleAllowed(channel, row.handle)).slice(0, limit).map((row) => {
|
|
5135
|
+
const item = { rowId: row.rowId, handle: row.handle, text: row.text, date: row.date };
|
|
5136
|
+
if (row.chatGuid)
|
|
5137
|
+
item.chatGuid = row.chatGuid;
|
|
5138
|
+
if (row.displayName)
|
|
5139
|
+
item.displayName = row.displayName;
|
|
5140
|
+
return item;
|
|
5141
|
+
});
|
|
5142
|
+
} finally {
|
|
5143
|
+
db.close();
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
function imessageRowToMessage(channelId, row) {
|
|
5147
|
+
return {
|
|
5148
|
+
id: `imessage:${row.rowId}`,
|
|
5149
|
+
channelId,
|
|
5150
|
+
chatId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
|
|
5151
|
+
responseTargetId: row.chatGuid ? `chat:${row.chatGuid}` : row.handle,
|
|
5152
|
+
from: row.handle,
|
|
5153
|
+
text: row.text,
|
|
5154
|
+
receivedAt: imessageDateToIso(row.date),
|
|
5155
|
+
raw: row
|
|
5156
|
+
};
|
|
5157
|
+
}
|
|
5158
|
+
async function commandExists(command) {
|
|
5159
|
+
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
5160
|
+
stdout: "ignore",
|
|
5161
|
+
stderr: "ignore"
|
|
5162
|
+
});
|
|
5163
|
+
return await proc.exited === 0;
|
|
5164
|
+
}
|
|
5165
|
+
async function diagnoseIMessage(channel) {
|
|
5166
|
+
const checks = [];
|
|
5167
|
+
checks.push({
|
|
5168
|
+
name: `imessage-platform:${channel.id}`,
|
|
5169
|
+
ok: process.platform === "darwin",
|
|
5170
|
+
detail: process.platform === "darwin" ? "macOS" : `unsupported platform: ${process.platform}`
|
|
5171
|
+
});
|
|
5172
|
+
checks.push({
|
|
5173
|
+
name: `imessage-osascript:${channel.id}`,
|
|
5174
|
+
ok: await commandExists("osascript"),
|
|
5175
|
+
detail: "required for Messages send automation"
|
|
5176
|
+
});
|
|
5177
|
+
checks.push({
|
|
5178
|
+
name: `imessage-allowlist:${channel.id}`,
|
|
5179
|
+
ok: Boolean(channel.allowAllHandles || channel.allowedHandles?.length),
|
|
5180
|
+
detail: channel.allowAllHandles ? "allowAllHandles=true" : `${channel.allowedHandles?.length || 0} handle(s)`
|
|
5181
|
+
});
|
|
5182
|
+
if ((channel.receiveMode || "disabled") === "chat-db") {
|
|
5183
|
+
const path = getIMessageDbPath(channel);
|
|
5184
|
+
try {
|
|
5185
|
+
await access(path);
|
|
5186
|
+
checks.push({ name: `imessage-chat-db:${channel.id}`, ok: true, detail: path });
|
|
5187
|
+
} catch (err) {
|
|
5188
|
+
checks.push({
|
|
5189
|
+
name: `imessage-chat-db:${channel.id}`,
|
|
5190
|
+
ok: false,
|
|
5191
|
+
detail: `${path}: ${err instanceof Error ? err.message : String(err)}. Grant Full Disk Access to the terminal/daemon host or disable receive mode.`
|
|
5192
|
+
});
|
|
5193
|
+
}
|
|
5194
|
+
} else {
|
|
5195
|
+
checks.push({ name: `imessage-receive:${channel.id}`, ok: true, detail: "receiveMode=disabled" });
|
|
5196
|
+
}
|
|
5197
|
+
return checks;
|
|
5198
|
+
}
|
|
5199
|
+
|
|
5200
|
+
// src/lib/doctor.ts
|
|
5201
|
+
function isNotFound2(err) {
|
|
5202
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
5203
|
+
}
|
|
5204
|
+
async function privateFileCheck(name, path) {
|
|
5205
|
+
try {
|
|
5206
|
+
const info = await stat2(path);
|
|
5207
|
+
const mode = info.mode & 511;
|
|
5208
|
+
const ok = (mode & 63) === 0;
|
|
5209
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
5210
|
+
} catch (err) {
|
|
5211
|
+
if (isNotFound2(err))
|
|
5212
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
5213
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
5214
|
+
}
|
|
5215
|
+
}
|
|
5216
|
+
async function privateDirCheck(name, path) {
|
|
5217
|
+
try {
|
|
5218
|
+
const info = await stat2(path);
|
|
5219
|
+
const mode = info.mode & 511;
|
|
5220
|
+
const ok = (mode & 63) === 0;
|
|
5221
|
+
return { name, ok, detail: `${path} mode=${mode.toString(8)}` };
|
|
5222
|
+
} catch (err) {
|
|
5223
|
+
if (isNotFound2(err))
|
|
5224
|
+
return { name, ok: true, detail: `not created yet: ${path}` };
|
|
5225
|
+
return { name, ok: false, detail: `${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
5228
|
+
async function commandExists2(command) {
|
|
5229
|
+
const proc = Bun.spawn(["sh", "-lc", `command -v ${command} >/dev/null 2>&1`], {
|
|
5230
|
+
stdout: "ignore",
|
|
5231
|
+
stderr: "ignore"
|
|
5232
|
+
});
|
|
5233
|
+
return await proc.exited === 0;
|
|
5234
|
+
}
|
|
5235
|
+
async function doctor(configPath = defaultConfigPath(), statePath = defaultStatePath()) {
|
|
5236
|
+
const checks = [];
|
|
5237
|
+
const config = await loadConfig(configPath);
|
|
5238
|
+
const daemon = await daemonStatus();
|
|
5239
|
+
const paths = daemonPaths();
|
|
5240
|
+
checks.push(await privateFileCheck("config", configPath));
|
|
5241
|
+
checks.push(await privateFileCheck("state", statePath));
|
|
5242
|
+
checks.push(await privateDirCheck("daemon-dir", paths.dir));
|
|
5243
|
+
checks.push(await privateFileCheck("daemon-metadata", paths.metadataFile));
|
|
5244
|
+
checks.push({
|
|
5245
|
+
name: "daemon-status",
|
|
5246
|
+
ok: !daemon.stale,
|
|
5247
|
+
detail: daemon.running ? `running pid=${daemon.pid}` : daemon.stale ? `stale pid=${daemon.pid}` : "not running"
|
|
5248
|
+
});
|
|
5249
|
+
try {
|
|
5250
|
+
const apiBase = telegramApiBaseInfo();
|
|
5251
|
+
checks.push({
|
|
5252
|
+
name: "telegram-api-base",
|
|
5253
|
+
ok: true,
|
|
5254
|
+
detail: apiBase.overridden ? `overridden: ${apiBase.origin}${apiBase.pathname}` : apiBase.origin
|
|
5255
|
+
});
|
|
5256
|
+
} catch (err) {
|
|
5257
|
+
checks.push({
|
|
5258
|
+
name: "telegram-api-base",
|
|
5259
|
+
ok: false,
|
|
5260
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
5261
|
+
});
|
|
5262
|
+
}
|
|
5263
|
+
for (const command of ["bridge", "codewith", "claude", "aicopilot"]) {
|
|
5264
|
+
checks.push({
|
|
5265
|
+
name: `command:${command}`,
|
|
5266
|
+
ok: command === "bridge" ? true : await commandExists2(command),
|
|
5267
|
+
detail: command === "bridge" ? "current package" : undefined
|
|
5268
|
+
});
|
|
5269
|
+
}
|
|
5270
|
+
const telegramChannels2 = Object.values(config.channels).filter((channel) => channel.kind === "telegram");
|
|
5271
|
+
for (const channel of telegramChannels2) {
|
|
5272
|
+
const envName = channel.botTokenEnv || "TELEGRAM_BOT_TOKEN";
|
|
5273
|
+
checks.push({
|
|
5274
|
+
name: `telegram-token:${channel.id}`,
|
|
5275
|
+
ok: Boolean(process.env[envName]),
|
|
5276
|
+
detail: envName
|
|
5277
|
+
});
|
|
5278
|
+
checks.push({
|
|
5279
|
+
name: `telegram-allowlist:${channel.id}`,
|
|
5280
|
+
ok: Boolean(channel.allowAllChats || channel.allowedChatIds?.length),
|
|
5281
|
+
detail: channel.allowAllChats ? "allowAllChats=true" : `${channel.allowedChatIds?.length || 0} chat id(s)`
|
|
5282
|
+
});
|
|
5283
|
+
}
|
|
5284
|
+
for (const route of config.routes) {
|
|
5285
|
+
checks.push({
|
|
5286
|
+
name: `route:${route.id}`,
|
|
5287
|
+
ok: Boolean(config.channels[route.fromChannel] && config.agents[route.toAgent]),
|
|
5288
|
+
detail: `${route.fromChannel} -> ${route.toAgent}`
|
|
5289
|
+
});
|
|
5290
|
+
}
|
|
5291
|
+
const imessageChannels = Object.values(config.channels).filter((channel) => channel.kind === "imessage");
|
|
5292
|
+
for (const channel of imessageChannels) {
|
|
5293
|
+
checks.push(...await diagnoseIMessage(channel));
|
|
5294
|
+
}
|
|
5295
|
+
return { ok: checks.every((check) => check.ok), configPath, checks };
|
|
5296
|
+
}
|
|
4411
5297
|
// src/lib/router.ts
|
|
4412
5298
|
function matchingRoutes(config, message) {
|
|
4413
5299
|
const channel = config.channels[message.channelId];
|
|
@@ -4416,6 +5302,9 @@ function matchingRoutes(config, message) {
|
|
|
4416
5302
|
if (channel?.kind === "telegram" && !telegramChatAllowed(channel, message.chatId)) {
|
|
4417
5303
|
return [];
|
|
4418
5304
|
}
|
|
5305
|
+
if (channel?.kind === "imessage" && !imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId))) {
|
|
5306
|
+
return [];
|
|
5307
|
+
}
|
|
4419
5308
|
return config.routes.filter((route) => {
|
|
4420
5309
|
if (route.enabled === false)
|
|
4421
5310
|
return false;
|
|
@@ -4455,40 +5344,437 @@ async function routeMessage(config, message, options = {}) {
|
|
|
4455
5344
|
if (options.writeConsole !== false)
|
|
4456
5345
|
(options.writeConsole || console.log)(responseText);
|
|
4457
5346
|
deliveredResponse = true;
|
|
5347
|
+
} else if (responseText && channel?.kind === "imessage") {
|
|
5348
|
+
const handle = message.responseTargetId || message.chatId || message.from;
|
|
5349
|
+
const allowedIdentity = message.from || (handle?.startsWith("chat:") ? undefined : handle);
|
|
5350
|
+
if (handle && imessageHandleAllowed(channel, allowedIdentity)) {
|
|
5351
|
+
await sendIMessage(channel, handle, responseText, { allowChatTarget: handle.startsWith("chat:") });
|
|
5352
|
+
deliveredResponse = true;
|
|
5353
|
+
}
|
|
4458
5354
|
}
|
|
4459
5355
|
results.push({ route, agent, deliveredResponse });
|
|
4460
5356
|
}
|
|
4461
5357
|
return results;
|
|
4462
5358
|
}
|
|
5359
|
+
// src/lib/sessions.ts
|
|
5360
|
+
import { randomUUID } from "crypto";
|
|
5361
|
+
function nowIso() {
|
|
5362
|
+
return new Date().toISOString();
|
|
5363
|
+
}
|
|
5364
|
+
function newSessionId() {
|
|
5365
|
+
return `ses_${randomUUID()}`;
|
|
5366
|
+
}
|
|
5367
|
+
function normalizeConversationId(channel, conversation) {
|
|
5368
|
+
if (conversation.includes(":") && conversation.startsWith(`${channel.kind}:`))
|
|
5369
|
+
return conversation;
|
|
5370
|
+
if (channel.kind === "telegram")
|
|
5371
|
+
return `telegram:${channel.id}:${conversation}`;
|
|
5372
|
+
if (channel.kind === "imessage")
|
|
5373
|
+
return `imessage:${channel.id}:${conversation}`;
|
|
5374
|
+
return `${channel.kind}:${channel.id}:${conversation || "default"}`;
|
|
5375
|
+
}
|
|
5376
|
+
function messageConversationId(config, message) {
|
|
5377
|
+
const channel = config.channels[message.channelId];
|
|
5378
|
+
if (!channel)
|
|
5379
|
+
return;
|
|
5380
|
+
if (channel.kind === "telegram") {
|
|
5381
|
+
if (!message.chatId)
|
|
5382
|
+
return;
|
|
5383
|
+
return normalizeConversationId(channel, message.threadId ? `${message.chatId}:${message.threadId}` : message.chatId);
|
|
5384
|
+
}
|
|
5385
|
+
if (channel.kind === "imessage") {
|
|
5386
|
+
const conversation = message.chatId || message.from;
|
|
5387
|
+
return conversation ? normalizeConversationId(channel, conversation) : undefined;
|
|
5388
|
+
}
|
|
5389
|
+
return normalizeConversationId(channel, message.chatId || message.from || "default");
|
|
5390
|
+
}
|
|
5391
|
+
function bindingId(channelId, conversationId) {
|
|
5392
|
+
return `${channelId}::${conversationId}`;
|
|
5393
|
+
}
|
|
5394
|
+
function ledgerId(message) {
|
|
5395
|
+
return `${message.channelId}::${message.id}`;
|
|
5396
|
+
}
|
|
5397
|
+
function createBridgeSession(config, state, input) {
|
|
5398
|
+
const { agent, profile } = resolveAgent(config, input.agentId);
|
|
5399
|
+
const timestamp = nowIso();
|
|
5400
|
+
const session = {
|
|
5401
|
+
id: input.id || newSessionId(),
|
|
5402
|
+
agentId: agent.id,
|
|
5403
|
+
profileId: agent.profileId,
|
|
5404
|
+
cwd: input.cwd || agent.cwd || profile?.cwd,
|
|
5405
|
+
title: input.title,
|
|
5406
|
+
status: "active",
|
|
5407
|
+
createdAt: timestamp,
|
|
5408
|
+
updatedAt: timestamp,
|
|
5409
|
+
agentSession: createAgentSessionRef(config, agent.id)
|
|
5410
|
+
};
|
|
5411
|
+
state.sessions[session.id] = session;
|
|
5412
|
+
return session;
|
|
5413
|
+
}
|
|
5414
|
+
function getBridgeSession(state, sessionId) {
|
|
5415
|
+
const session = state.sessions[sessionId];
|
|
5416
|
+
if (!session)
|
|
5417
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
5418
|
+
return session;
|
|
5419
|
+
}
|
|
5420
|
+
function listBridgeSessions(state) {
|
|
5421
|
+
return Object.values(state.sessions).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
5422
|
+
}
|
|
5423
|
+
function updateBridgeSessionStatus(state, sessionId, status) {
|
|
5424
|
+
const session = getBridgeSession(state, sessionId);
|
|
5425
|
+
if (status === "closed")
|
|
5426
|
+
closeAgentSession(session);
|
|
5427
|
+
session.status = status;
|
|
5428
|
+
session.updatedAt = nowIso();
|
|
5429
|
+
return session;
|
|
5430
|
+
}
|
|
5431
|
+
function attachBridgeSession(config, state, input) {
|
|
5432
|
+
const channel = config.channels[input.channelId];
|
|
5433
|
+
if (!channel)
|
|
5434
|
+
throw new Error(`Channel not found: ${input.channelId}`);
|
|
5435
|
+
const session = getBridgeSession(state, input.sessionId);
|
|
5436
|
+
if (session.status === "closed")
|
|
5437
|
+
throw new Error(`Cannot attach closed session: ${session.id}`);
|
|
5438
|
+
const conversationId = normalizeConversationId(channel, input.conversation);
|
|
5439
|
+
const id = bindingId(channel.id, conversationId);
|
|
5440
|
+
const existing = state.bindings[id];
|
|
5441
|
+
const timestamp = nowIso();
|
|
5442
|
+
const binding = {
|
|
5443
|
+
id,
|
|
5444
|
+
channelId: channel.id,
|
|
5445
|
+
conversationId,
|
|
5446
|
+
activeSessionId: session.id,
|
|
5447
|
+
defaultSessionId: input.makeDefault ? session.id : existing?.defaultSessionId,
|
|
5448
|
+
createdAt: existing?.createdAt || timestamp,
|
|
5449
|
+
updatedAt: timestamp,
|
|
5450
|
+
authorization: input.authorization || existing?.authorization || (channel.kind === "telegram" ? { chatId: input.conversation.split(":")[0] } : undefined)
|
|
5451
|
+
};
|
|
5452
|
+
state.bindings[id] = binding;
|
|
5453
|
+
return binding;
|
|
5454
|
+
}
|
|
5455
|
+
function detachBridgeBinding(config, state, channelId, conversation) {
|
|
5456
|
+
const channel = config.channels[channelId];
|
|
5457
|
+
if (!channel)
|
|
5458
|
+
throw new Error(`Channel not found: ${channelId}`);
|
|
5459
|
+
const conversationId = normalizeConversationId(channel, conversation);
|
|
5460
|
+
const id = bindingId(channel.id, conversationId);
|
|
5461
|
+
const existing = state.bindings[id];
|
|
5462
|
+
delete state.bindings[id];
|
|
5463
|
+
return existing;
|
|
5464
|
+
}
|
|
5465
|
+
function findBridgeBinding(config, state, message) {
|
|
5466
|
+
const conversationId = messageConversationId(config, message);
|
|
5467
|
+
if (!conversationId)
|
|
5468
|
+
return;
|
|
5469
|
+
return state.bindings[bindingId(message.channelId, conversationId)];
|
|
5470
|
+
}
|
|
5471
|
+
function noSessionText(channelId, conversationId) {
|
|
5472
|
+
return [
|
|
5473
|
+
"No bridge session is attached to this conversation.",
|
|
5474
|
+
"Create and attach one locally:",
|
|
5475
|
+
"bridge sessions create --agent <agent-id>",
|
|
5476
|
+
`bridge sessions attach <session-id> --channel ${channelId}${conversationId ? ` --conversation ${conversationId}` : " --conversation <conversation-id>"}`
|
|
5477
|
+
].join(`
|
|
5478
|
+
`);
|
|
5479
|
+
}
|
|
5480
|
+
async function deliverResponse(config, message, text, options) {
|
|
5481
|
+
const channel = config.channels[message.channelId];
|
|
5482
|
+
if (!text || !channel || channel.enabled === false)
|
|
5483
|
+
return false;
|
|
5484
|
+
if (channel.kind === "telegram" && message.chatId) {
|
|
5485
|
+
if (!telegramChatAllowed(channel, message.chatId))
|
|
5486
|
+
return false;
|
|
5487
|
+
await (options.sendTelegram || sendTelegramMessage)(telegramToken(channel), message.chatId, text);
|
|
5488
|
+
return true;
|
|
5489
|
+
}
|
|
5490
|
+
if (channel.kind === "console") {
|
|
5491
|
+
if (options.writeConsole !== false)
|
|
5492
|
+
(options.writeConsole || console.log)(text);
|
|
5493
|
+
return true;
|
|
5494
|
+
}
|
|
5495
|
+
if (channel.kind === "imessage" && message.chatId) {
|
|
5496
|
+
const allowedIdentity = message.from || (message.chatId.startsWith("chat:") ? undefined : message.chatId);
|
|
5497
|
+
if (!imessageHandleAllowed(channel, allowedIdentity))
|
|
5498
|
+
return false;
|
|
5499
|
+
await sendIMessage(channel, message.responseTargetId || message.chatId, text, { allowChatTarget: Boolean(message.responseTargetId?.startsWith("chat:") || message.chatId.startsWith("chat:")) });
|
|
5500
|
+
return true;
|
|
5501
|
+
}
|
|
5502
|
+
return false;
|
|
5503
|
+
}
|
|
5504
|
+
async function deliverStoredResponse(config, state, binding, message, entry, options) {
|
|
5505
|
+
const session = getBridgeSession(state, binding.activeSessionId);
|
|
5506
|
+
const responseText = entry.responseText || "";
|
|
5507
|
+
const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
|
|
5508
|
+
completeLedger(entry, "delivered", session.id);
|
|
5509
|
+
entry.deliveredResponse = deliveredResponse;
|
|
5510
|
+
return {
|
|
5511
|
+
kind: "session",
|
|
5512
|
+
session,
|
|
5513
|
+
binding,
|
|
5514
|
+
conversationId: binding.conversationId,
|
|
5515
|
+
deliveredResponse,
|
|
5516
|
+
status: responseText ? "delivered" : "no_output"
|
|
5517
|
+
};
|
|
5518
|
+
}
|
|
5519
|
+
function channelAuthorized(config, message) {
|
|
5520
|
+
const channel = config.channels[message.channelId];
|
|
5521
|
+
if (!channel || channel.enabled === false)
|
|
5522
|
+
return false;
|
|
5523
|
+
if (channel.kind === "telegram")
|
|
5524
|
+
return telegramChatAllowed(channel, message.chatId);
|
|
5525
|
+
if (channel.kind === "imessage")
|
|
5526
|
+
return imessageHandleAllowed(channel, message.from || (message.chatId?.startsWith("chat:") ? undefined : message.chatId));
|
|
5527
|
+
return true;
|
|
5528
|
+
}
|
|
5529
|
+
function bindingAuthorized(binding, message) {
|
|
5530
|
+
if (binding.authorization?.chatId && binding.authorization.chatId !== message.chatId)
|
|
5531
|
+
return false;
|
|
5532
|
+
if (binding.authorization?.from && binding.authorization.from !== message.from)
|
|
5533
|
+
return false;
|
|
5534
|
+
return true;
|
|
5535
|
+
}
|
|
5536
|
+
async function sendBridgeSessionMessage(config, state, sessionId, message, options = {}) {
|
|
5537
|
+
const session = getBridgeSession(state, sessionId);
|
|
5538
|
+
if (session.status === "paused")
|
|
5539
|
+
return { kind: "session", session, status: "paused", message: "Session is paused" };
|
|
5540
|
+
if (session.status === "closed")
|
|
5541
|
+
return { kind: "session", session, status: "closed", message: "Session is closed" };
|
|
5542
|
+
const agent = await sendAgentSessionMessage(config, session, message, { run: options.run });
|
|
5543
|
+
const timestamp = nowIso();
|
|
5544
|
+
session.lastMessageAt = timestamp;
|
|
5545
|
+
session.updatedAt = timestamp;
|
|
5546
|
+
if (session.agentSession)
|
|
5547
|
+
session.agentSession.updatedAt = timestamp;
|
|
5548
|
+
if (agent.timedOut || agent.exitCode !== null && agent.exitCode !== 0) {
|
|
5549
|
+
return {
|
|
5550
|
+
kind: "session",
|
|
5551
|
+
session,
|
|
5552
|
+
agent,
|
|
5553
|
+
deliveredResponse: false,
|
|
5554
|
+
status: "failed",
|
|
5555
|
+
message: agent.stderr.trim() || agent.stdout.trim() || (agent.timedOut ? "Agent timed out" : `Agent exited ${agent.exitCode}`)
|
|
5556
|
+
};
|
|
5557
|
+
}
|
|
5558
|
+
const responseText = agent.stdout.trim();
|
|
5559
|
+
await options.beforeDeliver?.(agent, responseText);
|
|
5560
|
+
const deliveredResponse = responseText ? await deliverResponse(config, message, responseText, options) : false;
|
|
5561
|
+
return {
|
|
5562
|
+
kind: "session",
|
|
5563
|
+
session,
|
|
5564
|
+
agent,
|
|
5565
|
+
deliveredResponse,
|
|
5566
|
+
status: responseText ? "delivered" : "no_output"
|
|
5567
|
+
};
|
|
5568
|
+
}
|
|
5569
|
+
async function routeSessionMessage(config, state, message, options = {}) {
|
|
5570
|
+
const channel = config.channels[message.channelId];
|
|
5571
|
+
if (!channel || channel.enabled === false) {
|
|
5572
|
+
return { kind: "session", status: "unauthorized", message: `Channel not enabled: ${message.channelId}` };
|
|
5573
|
+
}
|
|
5574
|
+
if (!channelAuthorized(config, message)) {
|
|
5575
|
+
return { kind: "session", status: "unauthorized", message: "Message is not authorized for this channel" };
|
|
5576
|
+
}
|
|
5577
|
+
const conversationId = messageConversationId(config, message);
|
|
5578
|
+
const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
|
|
5579
|
+
if (!binding) {
|
|
5580
|
+
const text = noSessionText(message.channelId, conversationId);
|
|
5581
|
+
if (options.respondOnNoSession !== false)
|
|
5582
|
+
await deliverResponse(config, message, text, options);
|
|
5583
|
+
return { kind: "session", conversationId, status: "no_session", message: text };
|
|
5584
|
+
}
|
|
5585
|
+
if (!bindingAuthorized(binding, message)) {
|
|
5586
|
+
return { kind: "session", binding, conversationId, status: "unauthorized", message: "Message does not match binding authorization" };
|
|
5587
|
+
}
|
|
5588
|
+
const result = await sendBridgeSessionMessage(config, state, binding.activeSessionId, message, options);
|
|
5589
|
+
return { ...result, binding, conversationId };
|
|
5590
|
+
}
|
|
5591
|
+
function beginLedger(state, message, conversationId) {
|
|
5592
|
+
const id = ledgerId(message);
|
|
5593
|
+
const existing = state.messageLedger[id];
|
|
5594
|
+
if (existing && ["delivered", "skipped", "unauthorized"].includes(existing.status)) {
|
|
5595
|
+
return { entry: existing, shouldProcess: false };
|
|
5596
|
+
}
|
|
5597
|
+
const timestamp = nowIso();
|
|
5598
|
+
const entry = existing || {
|
|
5599
|
+
id,
|
|
5600
|
+
channelId: message.channelId,
|
|
5601
|
+
messageId: message.id,
|
|
5602
|
+
conversationId,
|
|
5603
|
+
status: "processing",
|
|
5604
|
+
attempts: 0,
|
|
5605
|
+
firstSeenAt: timestamp,
|
|
5606
|
+
updatedAt: timestamp
|
|
5607
|
+
};
|
|
5608
|
+
if (entry.status !== "agent_completed")
|
|
5609
|
+
entry.status = "processing";
|
|
5610
|
+
entry.attempts += 1;
|
|
5611
|
+
entry.conversationId = conversationId || entry.conversationId;
|
|
5612
|
+
entry.updatedAt = timestamp;
|
|
5613
|
+
delete entry.error;
|
|
5614
|
+
state.messageLedger[id] = entry;
|
|
5615
|
+
return { entry, shouldProcess: true };
|
|
5616
|
+
}
|
|
5617
|
+
function completeLedger(entry, status, sessionId, error) {
|
|
5618
|
+
const timestamp = nowIso();
|
|
5619
|
+
entry.status = status;
|
|
5620
|
+
entry.sessionId = sessionId || entry.sessionId;
|
|
5621
|
+
entry.updatedAt = timestamp;
|
|
5622
|
+
if (["delivered", "skipped", "unauthorized"].includes(status))
|
|
5623
|
+
entry.terminalAt = timestamp;
|
|
5624
|
+
if (error)
|
|
5625
|
+
entry.error = error;
|
|
5626
|
+
return entry;
|
|
5627
|
+
}
|
|
5628
|
+
function recordAgentCompleted(entry, sessionId, agent, responseText) {
|
|
5629
|
+
const timestamp = nowIso();
|
|
5630
|
+
entry.status = "agent_completed";
|
|
5631
|
+
entry.sessionId = sessionId || entry.sessionId;
|
|
5632
|
+
entry.responseText = responseText;
|
|
5633
|
+
entry.agentExitCode = agent.exitCode;
|
|
5634
|
+
entry.agentTimedOut = agent.timedOut;
|
|
5635
|
+
entry.updatedAt = timestamp;
|
|
5636
|
+
delete entry.error;
|
|
5637
|
+
return entry;
|
|
5638
|
+
}
|
|
5639
|
+
async function dispatchMessageWithSessions(config, state, message, options = {}) {
|
|
5640
|
+
const conversationId = messageConversationId(config, message);
|
|
5641
|
+
const { entry, shouldProcess } = beginLedger(state, message, conversationId);
|
|
5642
|
+
if (!shouldProcess)
|
|
5643
|
+
return { message, ledger: entry };
|
|
5644
|
+
await options.persistState?.(state);
|
|
5645
|
+
try {
|
|
5646
|
+
const binding = conversationId ? state.bindings[bindingId(message.channelId, conversationId)] : undefined;
|
|
5647
|
+
if (binding) {
|
|
5648
|
+
if (!bindingAuthorized(binding, message)) {
|
|
5649
|
+
const session3 = {
|
|
5650
|
+
kind: "session",
|
|
5651
|
+
binding,
|
|
5652
|
+
conversationId,
|
|
5653
|
+
status: "unauthorized",
|
|
5654
|
+
message: "Message does not match binding authorization"
|
|
5655
|
+
};
|
|
5656
|
+
completeLedger(entry, "unauthorized");
|
|
5657
|
+
return { message, session: session3, ledger: entry };
|
|
5658
|
+
}
|
|
5659
|
+
if (entry.status === "agent_completed") {
|
|
5660
|
+
const session3 = await deliverStoredResponse(config, state, binding, message, entry, options);
|
|
5661
|
+
return { message, session: session3, ledger: entry };
|
|
5662
|
+
}
|
|
5663
|
+
const session2 = await routeSessionMessage(config, state, message, {
|
|
5664
|
+
...options,
|
|
5665
|
+
beforeDeliver: async (agent, responseText) => {
|
|
5666
|
+
recordAgentCompleted(entry, binding.activeSessionId, agent, responseText);
|
|
5667
|
+
await options.persistState?.(state);
|
|
5668
|
+
await options.beforeDeliver?.(agent, responseText);
|
|
5669
|
+
}
|
|
5670
|
+
});
|
|
5671
|
+
if (session2.status === "failed") {
|
|
5672
|
+
completeLedger(entry, "failed", session2.session?.id, session2.message);
|
|
5673
|
+
throw new Error(session2.message || "Agent session failed");
|
|
5674
|
+
}
|
|
5675
|
+
const terminal = session2.status === "unauthorized" ? "unauthorized" : session2.status === "delivered" || session2.status === "no_output" ? "delivered" : "skipped";
|
|
5676
|
+
completeLedger(entry, terminal, session2.session?.id);
|
|
5677
|
+
entry.deliveredResponse = session2.deliveredResponse;
|
|
5678
|
+
return { message, session: session2, ledger: entry };
|
|
5679
|
+
}
|
|
5680
|
+
if (options.fallbackToRoutes) {
|
|
5681
|
+
const routes = await routeMessage(config, message, options);
|
|
5682
|
+
if (routes.length) {
|
|
5683
|
+
completeLedger(entry, "delivered");
|
|
5684
|
+
return { message, routes, ledger: entry };
|
|
5685
|
+
}
|
|
5686
|
+
}
|
|
5687
|
+
const session = await routeSessionMessage(config, state, message, options);
|
|
5688
|
+
const status = session.status === "unauthorized" ? "unauthorized" : "skipped";
|
|
5689
|
+
completeLedger(entry, status, session.session?.id);
|
|
5690
|
+
return { message, session, ledger: entry };
|
|
5691
|
+
} catch (err) {
|
|
5692
|
+
const messageText = err instanceof Error ? err.message : String(err);
|
|
5693
|
+
if (entry.status === "agent_completed") {
|
|
5694
|
+
entry.error = messageText;
|
|
5695
|
+
entry.updatedAt = nowIso();
|
|
5696
|
+
} else {
|
|
5697
|
+
completeLedger(entry, "failed", undefined, messageText);
|
|
5698
|
+
}
|
|
5699
|
+
throw err;
|
|
5700
|
+
}
|
|
5701
|
+
}
|
|
4463
5702
|
export {
|
|
4464
5703
|
upsertRoute,
|
|
4465
5704
|
upsertProfile,
|
|
4466
5705
|
upsertChannel,
|
|
4467
5706
|
upsertAgent,
|
|
5707
|
+
updateBridgeSessionStatus,
|
|
5708
|
+
uninstallDaemon,
|
|
4468
5709
|
telegramUpdateToMessage,
|
|
4469
5710
|
telegramToken,
|
|
4470
5711
|
telegramChatAllowed,
|
|
5712
|
+
telegramApiBaseInfo,
|
|
5713
|
+
tailFile,
|
|
5714
|
+
stopProcessDaemon,
|
|
5715
|
+
stopInstalledDaemon,
|
|
5716
|
+
startProcessDaemon,
|
|
5717
|
+
startInstalledDaemon,
|
|
4471
5718
|
sendTelegramMessage,
|
|
5719
|
+
sendIMessage,
|
|
5720
|
+
sendBridgeSessionMessage,
|
|
5721
|
+
sendAgentSessionMessage,
|
|
4472
5722
|
saveState,
|
|
4473
5723
|
saveConfig,
|
|
4474
5724
|
runAgent,
|
|
5725
|
+
routeSessionMessage,
|
|
4475
5726
|
routeMessage,
|
|
5727
|
+
resumeAgentSessionRef,
|
|
5728
|
+
restartProcessDaemon,
|
|
5729
|
+
restartInstalledDaemon,
|
|
5730
|
+
resolveSupervisor,
|
|
4476
5731
|
resolveAgent,
|
|
5732
|
+
requiredTelegramEnvVars,
|
|
5733
|
+
renderSystemdUnit,
|
|
5734
|
+
renderSendIMessageScript,
|
|
5735
|
+
renderLaunchdPlist,
|
|
4477
5736
|
redactConfig,
|
|
4478
5737
|
parseConfig,
|
|
5738
|
+
normalizeConversationId,
|
|
5739
|
+
messageConversationId,
|
|
4479
5740
|
matchingRoutes,
|
|
4480
5741
|
loadState,
|
|
4481
5742
|
loadConfig,
|
|
5743
|
+
listBridgeSessions,
|
|
5744
|
+
ledgerId,
|
|
5745
|
+
installDaemon,
|
|
5746
|
+
imessageRowToMessage,
|
|
5747
|
+
imessageHandleAllowed,
|
|
4482
5748
|
homeDir,
|
|
4483
5749
|
getTelegramUpdates,
|
|
5750
|
+
getIMessageMessages,
|
|
5751
|
+
getIMessageDbPath,
|
|
5752
|
+
getBridgeSession,
|
|
5753
|
+
findBridgeBinding,
|
|
5754
|
+
ensureDaemonDir,
|
|
4484
5755
|
ensureConfig,
|
|
4485
5756
|
emptyState,
|
|
4486
5757
|
emptyConfig,
|
|
4487
5758
|
doctor,
|
|
5759
|
+
dispatchMessageWithSessions,
|
|
5760
|
+
diagnoseIMessage,
|
|
5761
|
+
detachBridgeBinding,
|
|
4488
5762
|
defaultStatePath,
|
|
5763
|
+
defaultMessagesDbPath,
|
|
5764
|
+
defaultDaemonDir,
|
|
4489
5765
|
defaultConfigPath,
|
|
5766
|
+
daemonStatus,
|
|
5767
|
+
daemonPaths,
|
|
5768
|
+
daemonLogs,
|
|
5769
|
+
createBridgeSession,
|
|
5770
|
+
createAgentSessionRef,
|
|
5771
|
+
closeAgentSession,
|
|
5772
|
+
cancelAgentSession,
|
|
4490
5773
|
buildAgentCommand,
|
|
4491
5774
|
bridgeHome,
|
|
5775
|
+
bindingId,
|
|
5776
|
+
attachBridgeSession,
|
|
5777
|
+
STATE_SCHEMA_VERSION,
|
|
4492
5778
|
CONFIG_VERSION,
|
|
4493
5779
|
CHANNEL_KINDS,
|
|
4494
5780
|
AGENT_KINDS
|