@botcord/daemon 0.1.1
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/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { resolveBootAgents } from "./agent-discovery.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build the implicit channel list for a daemon config. One channel per
|
|
4
|
+
* configured or discovered agent, keyed by agentId (matches
|
|
5
|
+
* `toGatewayConfig`). Mirrors the daemon's boot-agent resolution so
|
|
6
|
+
* `doctor` reports channels even when the config file omits `agents`.
|
|
7
|
+
*/
|
|
8
|
+
export function channelsFromDaemonConfig(cfg) {
|
|
9
|
+
let boot;
|
|
10
|
+
try {
|
|
11
|
+
boot = resolveBootAgents(cfg);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
return boot.agents.map((a) => {
|
|
17
|
+
const entry = {
|
|
18
|
+
id: a.agentId,
|
|
19
|
+
type: "botcord",
|
|
20
|
+
accountId: a.agentId,
|
|
21
|
+
};
|
|
22
|
+
if (a.credentialsFile)
|
|
23
|
+
entry.credentialsFile = a.credentialsFile;
|
|
24
|
+
return entry;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Inspect credentials + Hub reachability for one channel. Pure modulo the
|
|
29
|
+
* injected file reader and fetcher.
|
|
30
|
+
*/
|
|
31
|
+
export async function probeChannel(ch, opts) {
|
|
32
|
+
const result = {
|
|
33
|
+
id: ch.id,
|
|
34
|
+
type: ch.type,
|
|
35
|
+
accountId: ch.accountId,
|
|
36
|
+
credentialsOk: false,
|
|
37
|
+
credentialsMessage: "",
|
|
38
|
+
hubUrl: null,
|
|
39
|
+
hubOk: false,
|
|
40
|
+
hubMessage: "",
|
|
41
|
+
};
|
|
42
|
+
if (ch.type !== "botcord") {
|
|
43
|
+
result.credentialsMessage = `unsupported channel type "${ch.type}" (no credentials check)`;
|
|
44
|
+
result.hubMessage = "skipped";
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
const credFile = ch.credentialsFile ?? opts.credentialsPath(ch.accountId);
|
|
48
|
+
const raw = opts.fileReader.readFile(credFile);
|
|
49
|
+
if (raw === null) {
|
|
50
|
+
result.credentialsMessage = `missing at ${credFile}`;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
const token = parsed.token ?? parsed["token"];
|
|
56
|
+
const hubUrlRaw = parsed.hubUrl ?? parsed["hub_url"] ?? parsed["hub"];
|
|
57
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
58
|
+
result.credentialsMessage = "token missing or empty";
|
|
59
|
+
}
|
|
60
|
+
else if (typeof hubUrlRaw !== "string" || hubUrlRaw.length === 0) {
|
|
61
|
+
result.credentialsMessage = "hubUrl missing";
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
result.credentialsOk = true;
|
|
65
|
+
result.credentialsMessage = `loaded (${credFile})`;
|
|
66
|
+
result.hubUrl = hubUrlRaw;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
result.credentialsMessage = `invalid JSON: ${err.message}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!result.hubUrl) {
|
|
74
|
+
result.hubMessage = "skipped (no hub URL)";
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
// Probe `/` — the hub is ASGI and responds 2xx/3xx/404 which is fine for
|
|
78
|
+
// "reachable". We treat any response as reachable; network errors fall
|
|
79
|
+
// through to hubOk=false.
|
|
80
|
+
const probeUrl = `${result.hubUrl.replace(/\/+$/, "")}/`;
|
|
81
|
+
const http = await opts.fetcher(probeUrl, opts.timeoutMs);
|
|
82
|
+
if (http.ok) {
|
|
83
|
+
result.hubOk = true;
|
|
84
|
+
result.hubMessage = `reachable (HTTP ${http.status})`;
|
|
85
|
+
}
|
|
86
|
+
else if (http.status !== undefined) {
|
|
87
|
+
result.hubMessage = `HTTP ${http.status}`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
result.hubMessage = http.error ?? "unreachable";
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
/** Probe a list of channels sequentially. Sequential keeps output stable. */
|
|
95
|
+
export async function probeChannels(opts) {
|
|
96
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const ch of opts.channels) {
|
|
99
|
+
out.push(await probeChannel(ch, {
|
|
100
|
+
credentialsPath: opts.credentialsPath,
|
|
101
|
+
fileReader: opts.fileReader,
|
|
102
|
+
fetcher: opts.fetcher,
|
|
103
|
+
timeoutMs,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Default HTTP fetcher using `fetch` + `AbortController` timeout. */
|
|
109
|
+
export const defaultHttpFetcher = async (url, timeoutMs) => {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
112
|
+
try {
|
|
113
|
+
const resp = await fetch(url, {
|
|
114
|
+
method: "GET",
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
redirect: "manual",
|
|
117
|
+
});
|
|
118
|
+
const ok = resp.status >= 200 && resp.status < 400;
|
|
119
|
+
return { ok, status: resp.status };
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const e = err;
|
|
123
|
+
if (e.name === "AbortError")
|
|
124
|
+
return { ok: false, error: "timeout" };
|
|
125
|
+
return { ok: false, error: e.message };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
function pad(s, n) {
|
|
132
|
+
return s + " ".repeat(Math.max(0, n - s.length));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Render runtime + channel probe output. Pure — all IO happened already.
|
|
136
|
+
* Used by the CLI `doctor` command and by unit tests.
|
|
137
|
+
*/
|
|
138
|
+
export function renderDoctor(input) {
|
|
139
|
+
const lines = [];
|
|
140
|
+
// Runtimes table (matches existing doctor layout).
|
|
141
|
+
const rows = input.runtimes.map((e) => ({
|
|
142
|
+
runtime: e.id,
|
|
143
|
+
name: e.displayName,
|
|
144
|
+
status: e.result.available ? "ok" : "missing",
|
|
145
|
+
version: e.result.version ?? "—",
|
|
146
|
+
path: e.result.path ?? "—",
|
|
147
|
+
}));
|
|
148
|
+
const widths = {
|
|
149
|
+
runtime: Math.max(7, ...rows.map((r) => r.runtime.length)),
|
|
150
|
+
name: Math.max(4, ...rows.map((r) => r.name.length)),
|
|
151
|
+
status: Math.max(6, ...rows.map((r) => r.status.length)),
|
|
152
|
+
version: Math.max(7, ...rows.map((r) => r.version.length)),
|
|
153
|
+
};
|
|
154
|
+
lines.push(`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`);
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
lines.push(`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`);
|
|
157
|
+
}
|
|
158
|
+
const available = input.runtimes.filter((e) => e.result.available).length;
|
|
159
|
+
lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
|
|
160
|
+
lines.push("");
|
|
161
|
+
lines.push("Channels:");
|
|
162
|
+
if (input.channels.length === 0) {
|
|
163
|
+
lines.push(" No channels configured.");
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
const cw = {
|
|
167
|
+
id: Math.max(2, ...input.channels.map((c) => c.id.length)),
|
|
168
|
+
type: Math.max(4, ...input.channels.map((c) => c.type.length)),
|
|
169
|
+
};
|
|
170
|
+
lines.push(` ${pad("ID", cw.id)} ${pad("TYPE", cw.type)} CREDENTIALS HUB`);
|
|
171
|
+
for (const c of input.channels) {
|
|
172
|
+
const credMark = c.credentialsOk ? "✓" : "✗";
|
|
173
|
+
const hubMark = c.hubOk ? "✓" : "✗";
|
|
174
|
+
lines.push(` ${pad(c.id, cw.id)} ${pad(c.type, cw.type)} ${credMark} ${pad(c.credentialsMessage, 16)} ${hubMark} ${c.hubMessage}`);
|
|
175
|
+
}
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Thin orchestrator: runs runtime + channel probes and returns the rendered
|
|
180
|
+
* text. Keeps `index.ts` free of probe wiring.
|
|
181
|
+
*/
|
|
182
|
+
export async function runDoctor(runtimes, channels, opts) {
|
|
183
|
+
const channelResults = await probeChannels({
|
|
184
|
+
channels,
|
|
185
|
+
credentialsPath: opts.credentialsPath,
|
|
186
|
+
fileReader: opts.fileReader,
|
|
187
|
+
fetcher: opts.fetcher,
|
|
188
|
+
timeoutMs: opts.timeoutMs,
|
|
189
|
+
});
|
|
190
|
+
return { runtimes, channels: channelResults };
|
|
191
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ChannelAdapter, ChannelStatusSnapshot, GatewayConfig, GatewayInboundEnvelope } from "./types.js";
|
|
2
|
+
import type { GatewayLogger } from "./log.js";
|
|
3
|
+
/** Exponential backoff tuning for crashed-channel restarts. */
|
|
4
|
+
export interface ChannelBackoffOptions {
|
|
5
|
+
initial?: number;
|
|
6
|
+
max?: number;
|
|
7
|
+
factor?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Constructor options for `ChannelManager`. */
|
|
10
|
+
export interface ChannelManagerOptions {
|
|
11
|
+
config: GatewayConfig;
|
|
12
|
+
channels: ChannelAdapter[];
|
|
13
|
+
log: GatewayLogger;
|
|
14
|
+
emit: (env: GatewayInboundEnvelope) => Promise<void>;
|
|
15
|
+
backoffMs?: ChannelBackoffOptions;
|
|
16
|
+
}
|
|
17
|
+
/** Supervises channel adapters: lifecycle, status tracking, and crash restart with backoff. */
|
|
18
|
+
export declare class ChannelManager {
|
|
19
|
+
private readonly config;
|
|
20
|
+
private readonly log;
|
|
21
|
+
private readonly emit;
|
|
22
|
+
private readonly initialBackoff;
|
|
23
|
+
private readonly maxBackoff;
|
|
24
|
+
private readonly factor;
|
|
25
|
+
private readonly entries;
|
|
26
|
+
constructor(opts: ChannelManagerOptions);
|
|
27
|
+
private registerAdapter;
|
|
28
|
+
/** Start every configured channel; already-running channels are skipped. */
|
|
29
|
+
startAll(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Launch a single adapter that wasn't present at construction — used by
|
|
32
|
+
* `Gateway.addChannel()` to hot-plug a new agent without a full restart.
|
|
33
|
+
* Idempotent: if an entry with the same id is already running, this is a
|
|
34
|
+
* no-op (after logging a warning).
|
|
35
|
+
*/
|
|
36
|
+
addOne(adapter: ChannelAdapter): void;
|
|
37
|
+
/**
|
|
38
|
+
* Stop and forget a single channel. Cancels any pending restart timer,
|
|
39
|
+
* aborts the running turn, awaits the adapter's `stop()`, and removes
|
|
40
|
+
* the entry from the status map. Safe to call on an unknown id.
|
|
41
|
+
*/
|
|
42
|
+
removeOne(id: string, reason?: string): Promise<void>;
|
|
43
|
+
/** Abort every channel, cancel pending restarts, and await all run promises. */
|
|
44
|
+
stopAll(reason?: string): Promise<void>;
|
|
45
|
+
/** Return a shallow copy of per-channel status snapshots keyed by channel id. */
|
|
46
|
+
status(): Record<string, ChannelStatusSnapshot>;
|
|
47
|
+
/** Look up a registered channel adapter by id. */
|
|
48
|
+
getChannel(id: string): ChannelAdapter | undefined;
|
|
49
|
+
private findAccountId;
|
|
50
|
+
private launch;
|
|
51
|
+
private onExit;
|
|
52
|
+
private scheduleRestart;
|
|
53
|
+
private safeEmit;
|
|
54
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
const DEFAULT_INITIAL_BACKOFF = 1000;
|
|
2
|
+
const DEFAULT_MAX_BACKOFF = 60_000;
|
|
3
|
+
const DEFAULT_FACTOR = 2;
|
|
4
|
+
const LONG_RUN_THRESHOLD_MS = 30_000;
|
|
5
|
+
/** Supervises channel adapters: lifecycle, status tracking, and crash restart with backoff. */
|
|
6
|
+
export class ChannelManager {
|
|
7
|
+
config;
|
|
8
|
+
log;
|
|
9
|
+
emit;
|
|
10
|
+
initialBackoff;
|
|
11
|
+
maxBackoff;
|
|
12
|
+
factor;
|
|
13
|
+
entries = new Map();
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.config = opts.config;
|
|
16
|
+
this.log = opts.log;
|
|
17
|
+
this.emit = opts.emit;
|
|
18
|
+
this.initialBackoff = opts.backoffMs?.initial ?? DEFAULT_INITIAL_BACKOFF;
|
|
19
|
+
this.maxBackoff = opts.backoffMs?.max ?? DEFAULT_MAX_BACKOFF;
|
|
20
|
+
this.factor = opts.backoffMs?.factor ?? DEFAULT_FACTOR;
|
|
21
|
+
for (const adapter of opts.channels) {
|
|
22
|
+
this.registerAdapter(adapter);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
registerAdapter(adapter) {
|
|
26
|
+
const accountId = this.findAccountId(adapter.id);
|
|
27
|
+
const entry = {
|
|
28
|
+
adapter,
|
|
29
|
+
accountId,
|
|
30
|
+
state: "idle",
|
|
31
|
+
snapshot: {
|
|
32
|
+
channel: adapter.id,
|
|
33
|
+
accountId,
|
|
34
|
+
running: false,
|
|
35
|
+
reconnectAttempts: 0,
|
|
36
|
+
restartPending: false,
|
|
37
|
+
lastError: null,
|
|
38
|
+
},
|
|
39
|
+
controller: null,
|
|
40
|
+
runPromise: null,
|
|
41
|
+
restartTimer: null,
|
|
42
|
+
nextBackoff: this.initialBackoff,
|
|
43
|
+
currentStartAt: 0,
|
|
44
|
+
reconnectAttempts: 0,
|
|
45
|
+
stopRequested: false,
|
|
46
|
+
};
|
|
47
|
+
this.entries.set(adapter.id, entry);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
/** Start every configured channel; already-running channels are skipped. */
|
|
51
|
+
async startAll() {
|
|
52
|
+
for (const entry of this.entries.values()) {
|
|
53
|
+
if (entry.state === "idle" || entry.state === "crashed") {
|
|
54
|
+
this.launch(entry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Launch a single adapter that wasn't present at construction — used by
|
|
60
|
+
* `Gateway.addChannel()` to hot-plug a new agent without a full restart.
|
|
61
|
+
* Idempotent: if an entry with the same id is already running, this is a
|
|
62
|
+
* no-op (after logging a warning).
|
|
63
|
+
*/
|
|
64
|
+
addOne(adapter) {
|
|
65
|
+
const existing = this.entries.get(adapter.id);
|
|
66
|
+
if (existing) {
|
|
67
|
+
this.log.warn("channel.addOne: id already present", { channel: adapter.id });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const entry = this.registerAdapter(adapter);
|
|
71
|
+
this.launch(entry);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stop and forget a single channel. Cancels any pending restart timer,
|
|
75
|
+
* aborts the running turn, awaits the adapter's `stop()`, and removes
|
|
76
|
+
* the entry from the status map. Safe to call on an unknown id.
|
|
77
|
+
*/
|
|
78
|
+
async removeOne(id, reason) {
|
|
79
|
+
const entry = this.entries.get(id);
|
|
80
|
+
if (!entry)
|
|
81
|
+
return;
|
|
82
|
+
entry.stopRequested = true;
|
|
83
|
+
if (entry.restartTimer) {
|
|
84
|
+
clearTimeout(entry.restartTimer);
|
|
85
|
+
entry.restartTimer = null;
|
|
86
|
+
entry.snapshot = { ...entry.snapshot, restartPending: false };
|
|
87
|
+
}
|
|
88
|
+
const pending = [];
|
|
89
|
+
if (entry.state === "running" || entry.state === "starting") {
|
|
90
|
+
entry.state = "stopping";
|
|
91
|
+
entry.controller?.abort();
|
|
92
|
+
const adapter = entry.adapter;
|
|
93
|
+
if (adapter.stop) {
|
|
94
|
+
try {
|
|
95
|
+
const p = adapter.stop({ reason });
|
|
96
|
+
pending.push(Promise.resolve(p).catch((err) => {
|
|
97
|
+
this.log.warn("channel.stop failed", {
|
|
98
|
+
channel: adapter.id,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
});
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.log.warn("channel.stop threw", {
|
|
105
|
+
channel: adapter.id,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (entry.runPromise) {
|
|
112
|
+
pending.push(entry.runPromise.catch(() => undefined));
|
|
113
|
+
}
|
|
114
|
+
await Promise.all(pending);
|
|
115
|
+
this.entries.delete(id);
|
|
116
|
+
}
|
|
117
|
+
/** Abort every channel, cancel pending restarts, and await all run promises. */
|
|
118
|
+
async stopAll(reason) {
|
|
119
|
+
const pending = [];
|
|
120
|
+
for (const entry of this.entries.values()) {
|
|
121
|
+
entry.stopRequested = true;
|
|
122
|
+
if (entry.restartTimer) {
|
|
123
|
+
clearTimeout(entry.restartTimer);
|
|
124
|
+
entry.restartTimer = null;
|
|
125
|
+
entry.snapshot = { ...entry.snapshot, restartPending: false };
|
|
126
|
+
}
|
|
127
|
+
if (entry.state === "running" || entry.state === "starting") {
|
|
128
|
+
entry.state = "stopping";
|
|
129
|
+
entry.controller?.abort();
|
|
130
|
+
const adapter = entry.adapter;
|
|
131
|
+
if (adapter.stop) {
|
|
132
|
+
try {
|
|
133
|
+
const p = adapter.stop({ reason });
|
|
134
|
+
pending.push(Promise.resolve(p).catch((err) => {
|
|
135
|
+
this.log.warn("channel.stop failed", {
|
|
136
|
+
channel: adapter.id,
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
});
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.log.warn("channel.stop threw", {
|
|
143
|
+
channel: adapter.id,
|
|
144
|
+
error: err instanceof Error ? err.message : String(err),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (entry.runPromise) {
|
|
150
|
+
pending.push(entry.runPromise.catch(() => undefined));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await Promise.all(pending);
|
|
154
|
+
// Reset stop flag so startAll can re-enter.
|
|
155
|
+
for (const entry of this.entries.values()) {
|
|
156
|
+
entry.stopRequested = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Return a shallow copy of per-channel status snapshots keyed by channel id. */
|
|
160
|
+
status() {
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const [id, entry] of this.entries) {
|
|
163
|
+
out[id] = { ...entry.snapshot };
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/** Look up a registered channel adapter by id. */
|
|
168
|
+
getChannel(id) {
|
|
169
|
+
return this.entries.get(id)?.adapter;
|
|
170
|
+
}
|
|
171
|
+
findAccountId(channelId) {
|
|
172
|
+
const cfg = this.config.channels.find((c) => c.id === channelId);
|
|
173
|
+
return cfg?.accountId ?? "";
|
|
174
|
+
}
|
|
175
|
+
launch(entry) {
|
|
176
|
+
if (entry.state === "starting" || entry.state === "running")
|
|
177
|
+
return;
|
|
178
|
+
entry.stopRequested = false;
|
|
179
|
+
entry.state = "starting";
|
|
180
|
+
entry.controller = new AbortController();
|
|
181
|
+
entry.currentStartAt = Date.now();
|
|
182
|
+
entry.snapshot = {
|
|
183
|
+
...entry.snapshot,
|
|
184
|
+
running: true,
|
|
185
|
+
restartPending: false,
|
|
186
|
+
lastStartAt: entry.currentStartAt,
|
|
187
|
+
lastError: null,
|
|
188
|
+
reconnectAttempts: entry.reconnectAttempts,
|
|
189
|
+
};
|
|
190
|
+
const ctx = {
|
|
191
|
+
config: this.config,
|
|
192
|
+
accountId: entry.accountId,
|
|
193
|
+
abortSignal: entry.controller.signal,
|
|
194
|
+
log: this.log,
|
|
195
|
+
emit: (env) => this.safeEmit(entry.adapter.id, env),
|
|
196
|
+
setStatus: (patch) => {
|
|
197
|
+
entry.snapshot = { ...entry.snapshot, ...patch };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
this.log.info("channel starting", { channel: entry.adapter.id });
|
|
201
|
+
const run = (async () => {
|
|
202
|
+
try {
|
|
203
|
+
await entry.adapter.start(ctx);
|
|
204
|
+
this.onExit(entry, null);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
this.onExit(entry, err);
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
entry.runPromise = run;
|
|
211
|
+
entry.state = "running";
|
|
212
|
+
}
|
|
213
|
+
onExit(entry, err) {
|
|
214
|
+
const ranForMs = Date.now() - entry.currentStartAt;
|
|
215
|
+
const channelId = entry.adapter.id;
|
|
216
|
+
const crashed = err !== null && err !== undefined;
|
|
217
|
+
entry.snapshot = {
|
|
218
|
+
...entry.snapshot,
|
|
219
|
+
running: false,
|
|
220
|
+
lastStopAt: Date.now(),
|
|
221
|
+
lastError: crashed
|
|
222
|
+
? err instanceof Error
|
|
223
|
+
? err.message
|
|
224
|
+
: String(err)
|
|
225
|
+
: entry.snapshot.lastError ?? null,
|
|
226
|
+
};
|
|
227
|
+
if (crashed) {
|
|
228
|
+
this.log.warn("channel crashed", {
|
|
229
|
+
channel: channelId,
|
|
230
|
+
error: err instanceof Error ? err.message : String(err),
|
|
231
|
+
});
|
|
232
|
+
entry.state = "crashed";
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this.log.info("channel exited", { channel: channelId });
|
|
236
|
+
entry.state = "idle";
|
|
237
|
+
}
|
|
238
|
+
if (entry.stopRequested) {
|
|
239
|
+
entry.runPromise = null;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Long-run resets backoff to initial.
|
|
243
|
+
if (ranForMs >= LONG_RUN_THRESHOLD_MS) {
|
|
244
|
+
entry.nextBackoff = this.initialBackoff;
|
|
245
|
+
}
|
|
246
|
+
this.scheduleRestart(entry);
|
|
247
|
+
}
|
|
248
|
+
scheduleRestart(entry) {
|
|
249
|
+
const delay = entry.nextBackoff;
|
|
250
|
+
entry.snapshot = { ...entry.snapshot, restartPending: true };
|
|
251
|
+
this.log.info("channel restart scheduled", {
|
|
252
|
+
channel: entry.adapter.id,
|
|
253
|
+
delayMs: delay,
|
|
254
|
+
attempts: entry.reconnectAttempts,
|
|
255
|
+
});
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
entry.restartTimer = null;
|
|
258
|
+
entry.runPromise = null;
|
|
259
|
+
if (entry.stopRequested)
|
|
260
|
+
return;
|
|
261
|
+
entry.reconnectAttempts += 1;
|
|
262
|
+
entry.snapshot = {
|
|
263
|
+
...entry.snapshot,
|
|
264
|
+
restartPending: false,
|
|
265
|
+
reconnectAttempts: entry.reconnectAttempts,
|
|
266
|
+
};
|
|
267
|
+
entry.nextBackoff = Math.min(entry.nextBackoff * this.factor, this.maxBackoff);
|
|
268
|
+
this.launch(entry);
|
|
269
|
+
}, delay);
|
|
270
|
+
entry.restartTimer = timer;
|
|
271
|
+
}
|
|
272
|
+
async safeEmit(channelId, env) {
|
|
273
|
+
const msg = env?.message;
|
|
274
|
+
if (!msg || typeof msg.id !== "string" || !msg.id || typeof msg.channel !== "string" || !msg.channel) {
|
|
275
|
+
this.log.warn("dropping malformed inbound envelope", {
|
|
276
|
+
channel: channelId,
|
|
277
|
+
hasMessage: Boolean(msg),
|
|
278
|
+
messageId: msg && typeof msg === "object" ? msg.id : undefined,
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await this.emit(env);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
this.log.error("emit failed", {
|
|
287
|
+
channel: channelId,
|
|
288
|
+
error: err instanceof Error ? err.message : String(err),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { type InboxMessage } from "@botcord/protocol-core";
|
|
3
|
+
import type { ChannelAdapter, GatewayInboundMessage } from "../index.js";
|
|
4
|
+
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
5
|
+
export interface BotCordChannelClient {
|
|
6
|
+
ensureToken(): Promise<string>;
|
|
7
|
+
refreshToken(): Promise<string>;
|
|
8
|
+
pollInbox(options?: {
|
|
9
|
+
limit?: number;
|
|
10
|
+
ack?: boolean;
|
|
11
|
+
timeout?: number;
|
|
12
|
+
roomId?: string;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
messages: InboxMessage[];
|
|
15
|
+
count: number;
|
|
16
|
+
has_more: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
ackMessages(messageIds: string[]): Promise<void>;
|
|
19
|
+
sendMessage(to: string, text: string, options?: {
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
topic?: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
hub_msg_id?: string;
|
|
24
|
+
message_id?: string;
|
|
25
|
+
} & Record<string, unknown>>;
|
|
26
|
+
getHubUrl(): string;
|
|
27
|
+
onTokenRefresh?: (token: string, expiresAt: number) => void;
|
|
28
|
+
}
|
|
29
|
+
/** Factory that returns a ready-to-use BotCord client. Injection point for tests. */
|
|
30
|
+
export type BotCordClientFactory = (input: {
|
|
31
|
+
agentId: string;
|
|
32
|
+
hubBaseUrl?: string;
|
|
33
|
+
credentialsPath?: string;
|
|
34
|
+
}) => BotCordChannelClient;
|
|
35
|
+
/** Options accepted by `createBotCordChannel()`. */
|
|
36
|
+
export interface BotCordChannelOptions {
|
|
37
|
+
/** Channel instance id from config. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** Gateway `accountId` — matches BotCord `agentId`. */
|
|
40
|
+
accountId: string;
|
|
41
|
+
/** BotCord `agentId` (usually identical to `accountId`). */
|
|
42
|
+
agentId: string;
|
|
43
|
+
/** Override for the credentials JSON path. Defaults to `~/.botcord/credentials/<agentId>.json`. */
|
|
44
|
+
credentialsPath?: string;
|
|
45
|
+
/** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
|
|
46
|
+
hubBaseUrl?: string;
|
|
47
|
+
/** Not used by the WS-only loop today; kept for future polling fallback. */
|
|
48
|
+
pollIntervalMs?: number;
|
|
49
|
+
/** Test hook: supply a pre-built client instead of loading credentials from disk. */
|
|
50
|
+
client?: BotCordChannelClient;
|
|
51
|
+
/** Test hook: supply a client factory. Ignored when `client` is provided. */
|
|
52
|
+
clientFactory?: BotCordClientFactory;
|
|
53
|
+
/**
|
|
54
|
+
* Test hook: override the raw WebSocket constructor. Useful for tests that
|
|
55
|
+
* can't spin up a real WS server.
|
|
56
|
+
*/
|
|
57
|
+
webSocketCtor?: typeof WebSocket;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Map `InboxMessage` → `GatewayInboundMessage`. Field origins:
|
|
61
|
+
*
|
|
62
|
+
* id → msg.hub_msg_id (inbox id, what dispatcher currently keys on)
|
|
63
|
+
* channel → options.channelId (the adapter's unique instance id)
|
|
64
|
+
* accountId → options.accountId
|
|
65
|
+
* conversation.id → msg.room_id (required; we skip upstream if missing)
|
|
66
|
+
* conversation.kind → "direct" for rm_dm_ and rm_oc_ rooms, else "group"
|
|
67
|
+
* conversation.title → msg.room_name (daemon uses the same field in logs)
|
|
68
|
+
* conversation.threadId → msg.topic_id ?? msg.topic ?? null
|
|
69
|
+
* sender.id → msg.envelope.from
|
|
70
|
+
* sender.name → msg.source_user_name || undefined
|
|
71
|
+
* sender.kind → "user" when trust==owner or source_type=="dashboard_human_room",
|
|
72
|
+
* else "agent". "system" is not produced by daemon today.
|
|
73
|
+
* text → sanitized msg.text / envelope.payload.text (owner passes verbatim)
|
|
74
|
+
* raw → the full InboxMessage
|
|
75
|
+
* replyTo → msg.envelope.reply_to ?? null
|
|
76
|
+
* mentioned → msg.mentioned ?? false
|
|
77
|
+
* receivedAt → Date.now() (InboxMessage has no timestamp field today)
|
|
78
|
+
* trace.id → msg.hub_msg_id
|
|
79
|
+
* trace.streamable → true only for owner-chat rooms (matches daemon's stream-block rule)
|
|
80
|
+
*/
|
|
81
|
+
declare function normalizeInbox(msg: InboxMessage, options: {
|
|
82
|
+
channelId: string;
|
|
83
|
+
accountId: string;
|
|
84
|
+
}): GatewayInboundMessage | null;
|
|
85
|
+
/**
|
|
86
|
+
* Construct a BotCord channel adapter.
|
|
87
|
+
*
|
|
88
|
+
* `start()` connects to Hub WS, drains `/hub/inbox` on every `inbox_update`,
|
|
89
|
+
* normalizes messages, and emits envelopes with a `accept()` ack that commits
|
|
90
|
+
* to Hub. The returned promise stays pending until `abortSignal` fires.
|
|
91
|
+
*/
|
|
92
|
+
export declare function createBotCordChannel(options: BotCordChannelOptions): ChannelAdapter;
|
|
93
|
+
export { normalizeInbox as __normalizeInboxForTests };
|