agentschat-mcp 0.33.7 → 0.34.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/CHANGELOG.md +81 -0
- package/README.md +93 -52
- package/connector/README.md +181 -121
- package/connector/identities.ts +40 -0
- package/connector/ingest.ts +6 -2
- package/connector/run.ts +171 -45
- package/connector/server.ts +174 -104
- package/dist/connector.js +373 -152
- package/dist/server.js +91 -35
- package/package.json +3 -2
- package/skills/onboarding.md +236 -39
- package/src/cli.mjs +67 -23
- package/src/identity.ts +12 -0
- package/src/server.ts +75 -31
package/connector/identities.ts
CHANGED
|
@@ -63,6 +63,29 @@ export class IdentityTable {
|
|
|
63
63
|
all(): Identity[] {
|
|
64
64
|
return [...this.byBot.values()];
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hot-replace the identity set (RELAY_IDENTITIES reload). Fails closed on
|
|
69
|
+
* duplicate botIds the same way the constructor does.
|
|
70
|
+
*/
|
|
71
|
+
replace(identities: Identity[]): void {
|
|
72
|
+
const next = new Map<string, Identity>();
|
|
73
|
+
for (const id of identities) {
|
|
74
|
+
if (next.has(id.botId)) {
|
|
75
|
+
throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
|
|
76
|
+
}
|
|
77
|
+
next.set(id.botId, id);
|
|
78
|
+
}
|
|
79
|
+
this.byBot.clear();
|
|
80
|
+
for (const [k, v] of next) this.byBot.set(k, v);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Platform hooks must never substitute another identity's credentials. */
|
|
85
|
+
export function requireIdentity(identities: readonly Identity[], botId: string): Identity {
|
|
86
|
+
const id = identities.find(id => id.botId === botId);
|
|
87
|
+
if (!id) throw new Error(`unknown identity: ${botId}`);
|
|
88
|
+
return id;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
export interface InboundContext {
|
|
@@ -90,6 +113,23 @@ export interface InboundContext {
|
|
|
90
113
|
* the agent's session and burn its tokens on chatter not addressed to it.
|
|
91
114
|
* `mentioned_ids` is still honored when a host provides it (explicit signal wins).
|
|
92
115
|
*/
|
|
116
|
+
function matchesExactMention(content: string, id: string): boolean {
|
|
117
|
+
if (!id) return false;
|
|
118
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** All exact addressed targets, once each, in identity-table order. */
|
|
123
|
+
export function routeInboundTargets(table: IdentityTable, ctx: InboundContext): Identity[] {
|
|
124
|
+
if (ctx.channel_id?.startsWith("dm-")) {
|
|
125
|
+
const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
|
|
126
|
+
return owner ? [owner] : [];
|
|
127
|
+
}
|
|
128
|
+
const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
|
|
129
|
+
return table.all().filter(id => mentioned.includes(id.botId) ||
|
|
130
|
+
matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
export function routeInbound(table: IdentityTable, ctx: InboundContext): Identity | null {
|
|
94
134
|
// DM: route to the identity that owns the DM channel.
|
|
95
135
|
if (ctx.channel_id?.startsWith("dm-")) {
|
package/connector/ingest.ts
CHANGED
|
@@ -33,6 +33,10 @@ export function ingestAgentsChatFrame(
|
|
|
33
33
|
deps.advanceCursor(id, frame.channel_id, frame.timestamp);
|
|
34
34
|
}
|
|
35
35
|
const key = messageDedupKey(frame as { id?: string; channel_id?: string });
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const isDm = frame.channel_id?.startsWith("dm-");
|
|
37
|
+
const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
|
|
38
|
+
if (scopedKey && deps.dedup.recordOrSkip(scopedKey)) return false;
|
|
39
|
+
// Group fanout must filter self per TARGET, not per arrival socket: A can
|
|
40
|
+
// mention B, and A's self echo may be the first copy of the shared message.
|
|
41
|
+
return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
|
|
38
42
|
}
|
package/connector/run.ts
CHANGED
|
@@ -10,22 +10,31 @@
|
|
|
10
10
|
* Multiplex (N identities, one per Hermes profile/agent):
|
|
11
11
|
* RELAY_IDENTITIES = JSON array, one entry per identity:
|
|
12
12
|
* [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"...","profile":"<hermes profile>"}, ...]
|
|
13
|
+
* OR RELAY_IDENTITIES_FILE = path to that JSON array (preferred for hot-reload).
|
|
13
14
|
* Each botId is an agentschat agent_id. Optional `profile` is the Hermes profile
|
|
14
15
|
* name (for gateway.multiplex_profiles). Do NOT put the AgentsChat agent_id in
|
|
15
16
|
* profile — that splits Hermes session keys and breaks clarify. The connector
|
|
16
17
|
* holds all identities, opens one agentschat WS per identity, and routes by
|
|
17
|
-
* identity
|
|
18
|
+
* identity. Each gateway must hello its own authorized botId. Prefer distinct
|
|
19
|
+
* gatewayId/secret pairs per bot with separate single-identity gateways.
|
|
20
|
+
* Shared multi-hello connections require explicit, correct outbound botIds.
|
|
21
|
+
*
|
|
22
|
+
* Hot-reload (no process rewrite):
|
|
23
|
+
* SIGHUP re-reads RELAY_IDENTITIES_FILE (or RELAY_IDENTITIES env), opens WS for
|
|
24
|
+
* new botIds, disconnects removed ones. New identities need a gateway hello.
|
|
18
25
|
*
|
|
19
26
|
* Common:
|
|
20
27
|
* AGENTCHAT_API_URL REST base (default https://agents-chat.com)
|
|
21
28
|
* AGENTCHAT_WS_URL WebSocket (default wss://agents-chat.com/ws)
|
|
22
29
|
* RELAY_PORT port to listen on (default 8765)
|
|
23
30
|
* RELAY_HOST bind host (default 127.0.0.1)
|
|
31
|
+
* RELAY_IDENTITIES_FILE path to JSON identities array (SIGHUP reloads it)
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
34
|
import WS from "ws";
|
|
27
|
-
import {
|
|
28
|
-
import
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { startConnector, PlatformHttpError, PlatformNetworkError } from "./server.ts";
|
|
37
|
+
import { requireIdentity, type Identity } from "./identities.ts";
|
|
29
38
|
import { join } from "node:path";
|
|
30
39
|
import { redactSecrets } from "../src/redact.ts";
|
|
31
40
|
import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
|
|
@@ -51,36 +60,50 @@ const WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws
|
|
|
51
60
|
const PORT = Number(process.env.RELAY_PORT || 8765);
|
|
52
61
|
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
53
62
|
|
|
54
|
-
// ── Resolve identities: multiplex (RELAY_IDENTITIES) or single-tenant (legacy env) ──
|
|
63
|
+
// ── Resolve identities: multiplex (RELAY_IDENTITIES[_FILE]) or single-tenant (legacy env) ──
|
|
55
64
|
|
|
56
|
-
function
|
|
65
|
+
function parseIdentitiesJson(raw: string, source: string): Identity[] {
|
|
66
|
+
let parsed: any;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`${source} is not valid JSON`);
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
73
|
+
throw new Error(`${source} must be a non-empty JSON array`);
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
for (const [index, it] of parsed.entries()) {
|
|
77
|
+
const invalid = ["botId", "token", "gatewayId", "secret"].filter(k => typeof it?.[k] !== "string" || !it[k].trim());
|
|
78
|
+
for (const k of ["agentId", "profile"]) {
|
|
79
|
+
if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim())) invalid.push(k);
|
|
80
|
+
}
|
|
81
|
+
if (invalid.length) throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
|
|
82
|
+
if (seen.has(it.botId)) throw new Error(`${source} entry ${index}: duplicate botId`);
|
|
83
|
+
seen.add(it.botId);
|
|
84
|
+
}
|
|
85
|
+
return parsed.map((it: any) => ({
|
|
86
|
+
botId: String(it.botId),
|
|
87
|
+
agentId: String(it.agentId ?? it.botId),
|
|
88
|
+
token: String(it.token),
|
|
89
|
+
gatewayId: String(it.gatewayId),
|
|
90
|
+
secret: String(it.secret),
|
|
91
|
+
...(it.profile ? { profile: String(it.profile) } : {}),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Load identities; throws on bad JSON/shape (SIGHUP keeps prior set). */
|
|
96
|
+
function loadIdentities(): Identity[] {
|
|
97
|
+
const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
|
|
98
|
+
if (file) {
|
|
99
|
+
let raw: string;
|
|
100
|
+
try { raw = readFileSync(file, "utf8"); }
|
|
101
|
+
catch { throw new Error("RELAY_IDENTITIES_FILE could not be read"); }
|
|
102
|
+
return parseIdentitiesJson(raw, "RELAY_IDENTITIES_FILE");
|
|
103
|
+
}
|
|
57
104
|
const raw = (process.env.RELAY_IDENTITIES || "").trim();
|
|
58
105
|
if (raw) {
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
parsed = JSON.parse(raw);
|
|
62
|
-
} catch (e) {
|
|
63
|
-
log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
|
|
64
|
-
process.exit(1);
|
|
65
|
-
}
|
|
66
|
-
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
67
|
-
log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|
|
70
|
-
for (const it of parsed) {
|
|
71
|
-
if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
|
|
72
|
-
log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return parsed.map((it: any) => ({
|
|
77
|
-
botId: String(it.botId),
|
|
78
|
-
agentId: String(it.agentId ?? it.botId),
|
|
79
|
-
token: String(it.token),
|
|
80
|
-
gatewayId: String(it.gatewayId),
|
|
81
|
-
secret: String(it.secret),
|
|
82
|
-
...(it.profile ? { profile: String(it.profile) } : {}),
|
|
83
|
-
}));
|
|
106
|
+
return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
|
|
84
107
|
}
|
|
85
108
|
// Single-tenant legacy env.
|
|
86
109
|
const agentId = need("AGENTCHAT_AGENT_ID");
|
|
@@ -90,10 +113,20 @@ function resolveIdentities(): Identity[] {
|
|
|
90
113
|
return [{ botId: agentId, agentId, token, gatewayId, secret }];
|
|
91
114
|
}
|
|
92
115
|
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
function resolveIdentitiesAtStartup(): Identity[] {
|
|
117
|
+
try {
|
|
118
|
+
return loadIdentities();
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
log(`ERROR: ${e?.message ?? e}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Mutable identity list — hooks and reload close over this binding. */
|
|
126
|
+
let identities = resolveIdentitiesAtStartup();
|
|
95
127
|
|
|
96
128
|
// The per-gateway secret table for the relay upgrade auth (gatewayId → secrets).
|
|
129
|
+
// Mutated in place on hot-reload so startConnector's closed-over object stays valid.
|
|
97
130
|
const secrets: Record<string, string[]> = {};
|
|
98
131
|
for (const id of identities) {
|
|
99
132
|
(secrets[id.gatewayId] ??= []).push(id.secret);
|
|
@@ -103,6 +136,24 @@ for (const id of identities) {
|
|
|
103
136
|
|
|
104
137
|
let broadcast: ((msg: any) => void) | null = null;
|
|
105
138
|
const socketsByBot = new Map<string, WS>();
|
|
139
|
+
const reconnectTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
|
|
140
|
+
// Identity object is the generation token; unchanged reload rows retain it.
|
|
141
|
+
const currentIdentity = (id: Identity) => !(process as any).__shutdown && identities.includes(id);
|
|
142
|
+
function cancelReconnect(botId: string) {
|
|
143
|
+
const timer = reconnectTimerByBot.get(botId);
|
|
144
|
+
if (timer) clearTimeout(timer);
|
|
145
|
+
reconnectTimerByBot.delete(botId);
|
|
146
|
+
}
|
|
147
|
+
function scheduleReconnect(id: Identity, delay: number) {
|
|
148
|
+
if (!currentIdentity(id)) return;
|
|
149
|
+
cancelReconnect(id.botId);
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
if (reconnectTimerByBot.get(id.botId) !== timer) return;
|
|
152
|
+
reconnectTimerByBot.delete(id.botId);
|
|
153
|
+
if (currentIdentity(id)) connectIdentity(id);
|
|
154
|
+
}, delay);
|
|
155
|
+
reconnectTimerByBot.set(id.botId, timer);
|
|
156
|
+
}
|
|
106
157
|
const backoffByBot = new Map<string, number>();
|
|
107
158
|
const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
|
|
108
159
|
const heartbeatsByBot = new Map<string, HeartbeatMonitor>();
|
|
@@ -187,6 +238,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
187
238
|
return;
|
|
188
239
|
}
|
|
189
240
|
const body = await r.json() as any;
|
|
241
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
190
242
|
const channels = Array.isArray(body) ? body : (body.channels || []);
|
|
191
243
|
const joined: string[] = [];
|
|
192
244
|
for (const ch of channels) {
|
|
@@ -201,7 +253,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
201
253
|
if (prev) clearTimeout(prev);
|
|
202
254
|
const t = setTimeout(() => {
|
|
203
255
|
backfillTimerByBot.delete(id.botId);
|
|
204
|
-
if ((
|
|
256
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
205
257
|
void backfillIdentity(id, joined);
|
|
206
258
|
}, 2000);
|
|
207
259
|
(t as any).unref?.();
|
|
@@ -214,6 +266,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
214
266
|
async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
215
267
|
const s = cursorFor(id);
|
|
216
268
|
for (const channelId of channelIds) {
|
|
269
|
+
if (!currentIdentity(id)) return;
|
|
217
270
|
try {
|
|
218
271
|
const after = s.map.get(channelId);
|
|
219
272
|
const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
|
|
@@ -222,6 +275,7 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
|
222
275
|
});
|
|
223
276
|
if (!r.ok) continue;
|
|
224
277
|
const msgs = (((await r.json()) as any)?.messages ?? []) as any[];
|
|
278
|
+
if (!currentIdentity(id)) return;
|
|
225
279
|
const plan = planBackfill(after, msgs, id.agentId);
|
|
226
280
|
if (plan.seed) {
|
|
227
281
|
s.map.set(channelId, plan.seed);
|
|
@@ -243,6 +297,8 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
|
243
297
|
}
|
|
244
298
|
|
|
245
299
|
function connectIdentity(id: Identity) {
|
|
300
|
+
if (!currentIdentity(id)) return;
|
|
301
|
+
cancelReconnect(id.botId);
|
|
246
302
|
// Replace any prior socket/heartbeat for this identity (heartbeat-forced
|
|
247
303
|
// reconnect calls us again after closing the old socket).
|
|
248
304
|
stopHeartbeat(id.botId);
|
|
@@ -269,7 +325,7 @@ function connectIdentity(id: Identity) {
|
|
|
269
325
|
} catch {}
|
|
270
326
|
},
|
|
271
327
|
reconnect: () => {
|
|
272
|
-
if ((
|
|
328
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced) return;
|
|
273
329
|
heartbeatForced = true;
|
|
274
330
|
log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
|
|
275
331
|
backoffByBot.set(id.botId, 1000);
|
|
@@ -279,9 +335,7 @@ function connectIdentity(id: Identity) {
|
|
|
279
335
|
} catch {}
|
|
280
336
|
socketsByBot.delete(id.botId);
|
|
281
337
|
stopHeartbeat(id.botId);
|
|
282
|
-
|
|
283
|
-
if (!(process as any).__shutdown) connectIdentity(id);
|
|
284
|
-
}, 500);
|
|
338
|
+
scheduleReconnect(id, 500);
|
|
285
339
|
},
|
|
286
340
|
getReadyState: () => ws.readyState ?? WS_CLOSED,
|
|
287
341
|
},
|
|
@@ -293,12 +347,14 @@ function connectIdentity(id: Identity) {
|
|
|
293
347
|
hb.start();
|
|
294
348
|
|
|
295
349
|
ws.on("open", () => {
|
|
350
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) { ws.close(); return; }
|
|
296
351
|
backoffByBot.set(id.botId, 1000);
|
|
297
352
|
try {
|
|
298
353
|
ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
|
|
299
354
|
} catch {}
|
|
300
355
|
});
|
|
301
356
|
ws.on("message", (raw: any) => {
|
|
357
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
302
358
|
let data: any;
|
|
303
359
|
try { data = JSON.parse(String(raw)); } catch { return; }
|
|
304
360
|
if (data.type === "pong") {
|
|
@@ -326,12 +382,13 @@ function connectIdentity(id: Identity) {
|
|
|
326
382
|
}
|
|
327
383
|
});
|
|
328
384
|
ws.on("close", () => {
|
|
385
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
329
386
|
stopHeartbeat(id.botId);
|
|
330
387
|
if ((process as any).__shutdown) return;
|
|
331
388
|
if (heartbeatForced) return; // reconnect already scheduled by HeartbeatMonitor
|
|
332
389
|
const delay = backoffByBot.get(id.botId) ?? 1000;
|
|
333
390
|
log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
|
|
334
|
-
|
|
391
|
+
scheduleReconnect(id, delay);
|
|
335
392
|
backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
|
|
336
393
|
});
|
|
337
394
|
ws.on("error", (e: any) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
|
|
@@ -346,18 +403,18 @@ const connector = startConnector({
|
|
|
346
403
|
identities,
|
|
347
404
|
agentschat: {
|
|
348
405
|
async sendMessage(botId, chatId, content, replyTo) {
|
|
349
|
-
const id = identities
|
|
406
|
+
const id = requireIdentity(identities, botId);
|
|
350
407
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
|
|
351
408
|
method: "POST",
|
|
352
409
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
|
|
353
410
|
body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...(replyTo ? { parent_id: replyTo } : {}) }),
|
|
354
|
-
});
|
|
355
|
-
if (!res.ok) throw new
|
|
411
|
+
}).catch(() => { throw new PlatformNetworkError(); });
|
|
412
|
+
if (!res.ok) throw new PlatformHttpError(res.status);
|
|
356
413
|
const data = (await res.json()) as any;
|
|
357
414
|
return { id: data?.id };
|
|
358
415
|
},
|
|
359
416
|
async getChatInfo(botId, chatId) {
|
|
360
|
-
const id = identities
|
|
417
|
+
const id = requireIdentity(identities, botId);
|
|
361
418
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
|
|
362
419
|
headers: { Authorization: `Bearer ${id.token}` },
|
|
363
420
|
});
|
|
@@ -366,7 +423,7 @@ const connector = startConnector({
|
|
|
366
423
|
return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
|
|
367
424
|
},
|
|
368
425
|
async sendTyping(botId, chatId) {
|
|
369
|
-
const id = identities
|
|
426
|
+
const id = requireIdentity(identities, botId);
|
|
370
427
|
const ws = socketsByBot.get(id.botId);
|
|
371
428
|
if (ws && ws.readyState === WS.OPEN) {
|
|
372
429
|
try { ws.send(JSON.stringify({ type: "typing", channel_id: chatId, sender_id: id.agentId })); } catch {}
|
|
@@ -376,9 +433,10 @@ const connector = startConnector({
|
|
|
376
433
|
// oldest→newest, trigger message excluded, secrets redacted (a group channel is
|
|
377
434
|
// untrusted content — never forward a leaked key downstream). Best-effort: any
|
|
378
435
|
// failure returns null and the addressed message still delivers without it.
|
|
379
|
-
async getChannelContext(botId, chatId, sinceTs, excludeId) {
|
|
380
|
-
const id = identities
|
|
436
|
+
async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
|
|
437
|
+
const id = requireIdentity(identities, botId);
|
|
381
438
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
|
|
439
|
+
signal,
|
|
382
440
|
headers: { Authorization: `Bearer ${id.token}` },
|
|
383
441
|
});
|
|
384
442
|
if (!res.ok) return null;
|
|
@@ -399,13 +457,81 @@ const connector = startConnector({
|
|
|
399
457
|
});
|
|
400
458
|
|
|
401
459
|
broadcast = (msg) => connector.injectAgentsChatMessage(msg);
|
|
460
|
+
await connector.ready;
|
|
402
461
|
log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
|
|
403
462
|
|
|
404
463
|
for (const id of identities) connectIdentity(id);
|
|
405
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Hot-reload RELAY_IDENTITIES without rewriting the connector process.
|
|
467
|
+
* Prefer RELAY_IDENTITIES_FILE so SIGHUP re-reads disk; env-only deployments
|
|
468
|
+
* re-parse RELAY_IDENTITIES (unchanged unless the supervisor rewrote the env).
|
|
469
|
+
* Opens AgentsChat WS for new botIds, disconnects removed ones. Gateway hello
|
|
470
|
+
* must include each new identity before it receives any inbound traffic.
|
|
471
|
+
*/
|
|
472
|
+
function reloadIdentitiesFromConfig() {
|
|
473
|
+
const next = loadIdentities().map(id => identities.find(prev =>
|
|
474
|
+
prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token &&
|
|
475
|
+
prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
|
|
476
|
+
const prevByBot = new Map(identities.map((i) => [i.botId, i]));
|
|
477
|
+
const nextByBot = new Map(next.map((i) => [i.botId, i]));
|
|
478
|
+
const added = next.filter((i) => !prevByBot.has(i.botId));
|
|
479
|
+
const removed = identities.filter((i) => !nextByBot.has(i.botId));
|
|
480
|
+
const kept = next.filter((i) => prevByBot.has(i.botId));
|
|
481
|
+
|
|
482
|
+
// Table first — if replace throws, leave AgentsChat sockets / `identities` untouched.
|
|
483
|
+
connector.reloadIdentities(next);
|
|
484
|
+
identities = next;
|
|
485
|
+
|
|
486
|
+
for (const id of removed) {
|
|
487
|
+
cancelReconnect(id.botId);
|
|
488
|
+
log(`hot-reload: disconnecting removed identity ${id.botId}`);
|
|
489
|
+
stopHeartbeat(id.botId);
|
|
490
|
+
const t = backfillTimerByBot.get(id.botId);
|
|
491
|
+
if (t) { clearTimeout(t); backfillTimerByBot.delete(id.botId); }
|
|
492
|
+
const ws = socketsByBot.get(id.botId);
|
|
493
|
+
if (ws) {
|
|
494
|
+
try {
|
|
495
|
+
ws.removeAllListeners("close");
|
|
496
|
+
ws.close();
|
|
497
|
+
} catch {}
|
|
498
|
+
socketsByBot.delete(id.botId);
|
|
499
|
+
}
|
|
500
|
+
backoffByBot.delete(id.botId);
|
|
501
|
+
// Keep cursor files on disk (reconnect watermark survives a temporary remove).
|
|
502
|
+
}
|
|
503
|
+
for (const id of kept) {
|
|
504
|
+
// Token/gateway rotation: update in-memory row already done via `identities = next`.
|
|
505
|
+
// If the agentschat token changed, force reconnect so auth uses the new key.
|
|
506
|
+
const prev = prevByBot.get(id.botId)!;
|
|
507
|
+
if (prev !== id) {
|
|
508
|
+
cancelReconnect(id.botId);
|
|
509
|
+
const timer = backfillTimerByBot.get(id.botId);
|
|
510
|
+
if (timer) clearTimeout(timer);
|
|
511
|
+
backfillTimerByBot.delete(id.botId);
|
|
512
|
+
log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
|
|
513
|
+
connectIdentity(id);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const id of added) {
|
|
517
|
+
log(`hot-reload: connecting new identity ${id.botId}`);
|
|
518
|
+
connectIdentity(id);
|
|
519
|
+
}
|
|
520
|
+
log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
process.on("SIGHUP", () => {
|
|
524
|
+
try {
|
|
525
|
+
reloadIdentitiesFromConfig();
|
|
526
|
+
} catch (e: any) {
|
|
527
|
+
log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
406
531
|
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
407
532
|
process.on(sig, () => {
|
|
408
533
|
(process as any).__shutdown = true;
|
|
534
|
+
for (const botId of reconnectTimerByBot.keys()) cancelReconnect(botId);
|
|
409
535
|
for (const botId of [...heartbeatsByBot.keys()]) stopHeartbeat(botId);
|
|
410
536
|
for (const ws of socketsByBot.values()) {
|
|
411
537
|
try { ws.close(); } catch {}
|