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/dist/connector.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// connector/run.ts
|
|
3
3
|
import WS from "ws";
|
|
4
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
4
5
|
|
|
5
6
|
// connector/server.ts
|
|
6
7
|
import { createServer } from "node:http";
|
|
@@ -104,15 +105,36 @@ function toWireEvent(msg, platform = "agentschat") {
|
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
// src/
|
|
108
|
-
function
|
|
109
|
-
if (!
|
|
108
|
+
// src/dedup.ts
|
|
109
|
+
function messageDedupKey(data) {
|
|
110
|
+
if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
|
|
111
|
+
return null;
|
|
112
|
+
return `${data.channel_id}:${data.id}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class MessageDedup {
|
|
116
|
+
max;
|
|
117
|
+
dropOnEvict;
|
|
118
|
+
seen = new Set;
|
|
119
|
+
constructor(max = 5000, dropOnEvict = 1000) {
|
|
120
|
+
this.max = max;
|
|
121
|
+
this.dropOnEvict = dropOnEvict;
|
|
122
|
+
}
|
|
123
|
+
recordOrSkip(key) {
|
|
124
|
+
if (this.seen.has(key))
|
|
125
|
+
return true;
|
|
126
|
+
this.seen.add(key);
|
|
127
|
+
if (this.seen.size > this.max) {
|
|
128
|
+
const arr = [...this.seen];
|
|
129
|
+
this.seen.clear();
|
|
130
|
+
for (const item of arr.slice(this.dropOnEvict))
|
|
131
|
+
this.seen.add(item);
|
|
132
|
+
}
|
|
110
133
|
return false;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return displayMentionRe.test(content);
|
|
134
|
+
}
|
|
135
|
+
get size() {
|
|
136
|
+
return this.seen.size;
|
|
137
|
+
}
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
// connector/identities.ts
|
|
@@ -138,25 +160,38 @@ class IdentityTable {
|
|
|
138
160
|
all() {
|
|
139
161
|
return [...this.byBot.values()];
|
|
140
162
|
}
|
|
163
|
+
replace(identities) {
|
|
164
|
+
const next = new Map;
|
|
165
|
+
for (const id of identities) {
|
|
166
|
+
if (next.has(id.botId)) {
|
|
167
|
+
throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
|
|
168
|
+
}
|
|
169
|
+
next.set(id.botId, id);
|
|
170
|
+
}
|
|
171
|
+
this.byBot.clear();
|
|
172
|
+
for (const [k, v] of next)
|
|
173
|
+
this.byBot.set(k, v);
|
|
174
|
+
}
|
|
141
175
|
}
|
|
142
|
-
function
|
|
176
|
+
function requireIdentity(identities, botId) {
|
|
177
|
+
const id = identities.find((id2) => id2.botId === botId);
|
|
178
|
+
if (!id)
|
|
179
|
+
throw new Error(`unknown identity: ${botId}`);
|
|
180
|
+
return id;
|
|
181
|
+
}
|
|
182
|
+
function matchesExactMention(content, id) {
|
|
183
|
+
if (!id)
|
|
184
|
+
return false;
|
|
185
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
186
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
|
|
187
|
+
}
|
|
188
|
+
function routeInboundTargets(table, ctx) {
|
|
143
189
|
if (ctx.channel_id?.startsWith("dm-")) {
|
|
144
|
-
|
|
190
|
+
const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
|
|
191
|
+
return owner ? [owner] : [];
|
|
145
192
|
}
|
|
146
193
|
const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
|
|
147
|
-
|
|
148
|
-
const id = table.forBot(mid);
|
|
149
|
-
if (id)
|
|
150
|
-
return id;
|
|
151
|
-
}
|
|
152
|
-
const content = ctx.content ?? "";
|
|
153
|
-
if (content) {
|
|
154
|
-
for (const id of table.all()) {
|
|
155
|
-
if (matchesMention(content, id.agentId) || matchesMention(content, id.botId))
|
|
156
|
-
return id;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
194
|
+
return table.all().filter((id) => mentioned.includes(id.botId) || matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
|
|
160
195
|
}
|
|
161
196
|
function hermesSourceProfile(id, frontedCount) {
|
|
162
197
|
const named = typeof id.profile === "string" ? id.profile.trim() : "";
|
|
@@ -168,12 +203,32 @@ function hermesSourceProfile(id, frontedCount) {
|
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
// connector/server.ts
|
|
206
|
+
class PlatformHttpError extends Error {
|
|
207
|
+
status;
|
|
208
|
+
constructor(status) {
|
|
209
|
+
super("platform HTTP failure");
|
|
210
|
+
this.status = status;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class PlatformNetworkError extends Error {
|
|
215
|
+
constructor() {
|
|
216
|
+
super("platform network failure");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
171
219
|
function startConnector(config) {
|
|
172
220
|
const log = config.logger ?? (() => {});
|
|
173
221
|
const descriptor = buildDescriptor(config.descriptor);
|
|
174
222
|
const legacy = !config.identities || config.identities.length === 0;
|
|
175
223
|
const table = new IdentityTable(legacy ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities);
|
|
176
|
-
|
|
224
|
+
function safeLabel(value) {
|
|
225
|
+
for (const id of table.all())
|
|
226
|
+
for (const secret of [id.token, id.secret]) {
|
|
227
|
+
if (secret)
|
|
228
|
+
value = value.split(secret).join("[redacted]");
|
|
229
|
+
}
|
|
230
|
+
return JSON.stringify(value.slice(0, 128));
|
|
231
|
+
}
|
|
177
232
|
const hooks = legacy ? {
|
|
178
233
|
sendMessage: (_b, chatId, content, replyTo) => config.agentschat.sendMessage(chatId, content, replyTo),
|
|
179
234
|
getChatInfo: (_b, chatId) => config.agentschat.getChatInfo(chatId),
|
|
@@ -182,6 +237,8 @@ function startConnector(config) {
|
|
|
182
237
|
} : config.agentschat;
|
|
183
238
|
const sockets = new Set;
|
|
184
239
|
const lastAddressed = new Map;
|
|
240
|
+
const delivered = new MessageDedup;
|
|
241
|
+
const inboundQueues = new Map;
|
|
185
242
|
const http = createServer((_req, res) => {
|
|
186
243
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
187
244
|
res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
|
|
@@ -197,7 +254,8 @@ function startConnector(config) {
|
|
|
197
254
|
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
198
255
|
const payload = peekPayload(token);
|
|
199
256
|
const secrets = payload ? config.secrets[payload] : undefined;
|
|
200
|
-
const
|
|
257
|
+
const secret = secrets?.find((s) => verifyUpgradeToken(token, [s]) === payload);
|
|
258
|
+
const gatewayId = secret ? payload : null;
|
|
201
259
|
if (!gatewayId) {
|
|
202
260
|
log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
|
|
203
261
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -206,13 +264,13 @@ function startConnector(config) {
|
|
|
206
264
|
return;
|
|
207
265
|
}
|
|
208
266
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
209
|
-
onConnection(ws, gatewayId);
|
|
267
|
+
onConnection(ws, gatewayId, secret);
|
|
210
268
|
});
|
|
211
269
|
});
|
|
212
|
-
function onConnection(ws, gatewayId) {
|
|
213
|
-
const conn = { ws, gatewayId, fronted: new Set };
|
|
270
|
+
function onConnection(ws, gatewayId, secret) {
|
|
271
|
+
const conn = { ws, gatewayId, secret, fronted: new Set };
|
|
214
272
|
sockets.add(conn);
|
|
215
|
-
log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
|
|
273
|
+
log(`[connector] gateway connected: ${safeLabel(gatewayId)} (${sockets.size} total)`);
|
|
216
274
|
ws.on("message", async (data) => {
|
|
217
275
|
const text = data.toString();
|
|
218
276
|
for (const line of text.split(`
|
|
@@ -230,7 +288,7 @@ function startConnector(config) {
|
|
|
230
288
|
});
|
|
231
289
|
ws.on("close", () => {
|
|
232
290
|
sockets.delete(conn);
|
|
233
|
-
log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
|
|
291
|
+
log(`[connector] gateway disconnected: ${safeLabel(gatewayId)} (${sockets.size} left)`);
|
|
234
292
|
});
|
|
235
293
|
ws.on("error", () => sockets.delete(conn));
|
|
236
294
|
}
|
|
@@ -238,17 +296,32 @@ function startConnector(config) {
|
|
|
238
296
|
const t = frame?.type;
|
|
239
297
|
if (t === "hello") {
|
|
240
298
|
const botId = String(frame.botId ?? "");
|
|
241
|
-
const identity =
|
|
242
|
-
|
|
243
|
-
|
|
299
|
+
const identity = legacy ? table.all()[0] : table.forBot(botId);
|
|
300
|
+
const reason = frame.platform !== "agentschat" ? "unsupported_platform" : !identity ? "unknown_identity" : !legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) ? "credential_mismatch" : null;
|
|
301
|
+
const labels = `gatewayId=${safeLabel(conn.gatewayId)} platform=${safeLabel(frame.platform === "agentschat" ? "agentschat" : "(unsupported)")} botId=${safeLabel(identity ? botId : "(unknown)")}`;
|
|
302
|
+
if (reason) {
|
|
303
|
+
log(`[connector] hello rejected ${labels} reason=${reason}`);
|
|
304
|
+
conn.fronted.clear();
|
|
305
|
+
send(conn.ws, { type: "error", error: `hello rejected: ${reason}` });
|
|
306
|
+
conn.ws.close(1002, `hello rejected: ${reason}`);
|
|
244
307
|
return;
|
|
245
308
|
}
|
|
246
309
|
conn.fronted.add(identity.botId);
|
|
310
|
+
if (legacy)
|
|
311
|
+
conn.legacyAlias = botId;
|
|
312
|
+
log(`[connector] hello accepted ${labels}`);
|
|
247
313
|
send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
|
|
248
314
|
return;
|
|
249
315
|
}
|
|
250
316
|
if (t === "outbound") {
|
|
251
|
-
|
|
317
|
+
let result;
|
|
318
|
+
try {
|
|
319
|
+
result = await handleOutbound(conn, frame);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const status = error instanceof PlatformHttpError && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : undefined;
|
|
322
|
+
result = status !== undefined ? { success: false, error: `agentschat HTTP ${status}`, code: "platform_http_error", status, ambiguous: status >= 500 } : error instanceof PlatformNetworkError ? { success: false, error: "agentschat network failure; outcome unknown", code: "platform_network_error", ambiguous: true } : { success: false, error: "platform operation failed; outcome unknown", code: "platform_operation_failed", ambiguous: true };
|
|
323
|
+
log(`[connector] outbound failed: ${result.code}${status ? ` status=${status}` : ""}`);
|
|
324
|
+
}
|
|
252
325
|
send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
|
|
253
326
|
return;
|
|
254
327
|
}
|
|
@@ -257,12 +330,16 @@ function startConnector(config) {
|
|
|
257
330
|
const action = frame?.action ?? {};
|
|
258
331
|
const op = action?.op;
|
|
259
332
|
const chatId = action?.chat_id ?? "";
|
|
260
|
-
const firstFronted = [...conn.fronted][0];
|
|
261
|
-
|
|
262
|
-
|
|
333
|
+
const firstFronted = conn.fronted.size === 1 ? [...conn.fronted][0] : undefined;
|
|
334
|
+
let requested = frame.botId === undefined ? firstFronted ?? null : typeof frame.botId === "string" && frame.botId ? frame.botId : null;
|
|
335
|
+
if (legacy && requested && requested === conn.legacyAlias)
|
|
336
|
+
requested = "default";
|
|
337
|
+
let identity = requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
|
|
338
|
+
if (identity && (!legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) || frame.platform !== undefined && frame.platform !== "agentschat"))
|
|
339
|
+
identity = null;
|
|
263
340
|
if (!identity) {
|
|
264
|
-
log(`[connector] outbound failed: no
|
|
265
|
-
return { success: false, error: `no
|
|
341
|
+
log(`[connector] outbound failed: no usable identity for botId=${requested ?? "?"}`);
|
|
342
|
+
return { success: false, error: `no usable identity for outbound (botId=${requested ?? "?"})` };
|
|
266
343
|
}
|
|
267
344
|
switch (op) {
|
|
268
345
|
case "send": {
|
|
@@ -287,11 +364,17 @@ function startConnector(config) {
|
|
|
287
364
|
ws.send(JSON.stringify(obj) + `
|
|
288
365
|
`);
|
|
289
366
|
}
|
|
367
|
+
const ready = new Promise((resolve, reject) => {
|
|
368
|
+
http.once("listening", resolve);
|
|
369
|
+
http.once("error", reject);
|
|
370
|
+
});
|
|
290
371
|
http.listen(config.port, config.host ?? "127.0.0.1");
|
|
291
|
-
const address = http.address();
|
|
292
|
-
const port = typeof address === "object" && address ? address.port : config.port;
|
|
293
372
|
return {
|
|
294
|
-
|
|
373
|
+
ready,
|
|
374
|
+
get port() {
|
|
375
|
+
const address = http.address();
|
|
376
|
+
return typeof address === "object" && address ? address.port : config.port;
|
|
377
|
+
},
|
|
295
378
|
stop() {
|
|
296
379
|
for (const c of sockets) {
|
|
297
380
|
try {
|
|
@@ -303,53 +386,102 @@ function startConnector(config) {
|
|
|
303
386
|
},
|
|
304
387
|
async injectAgentsChatMessage(msg) {
|
|
305
388
|
const isDm = typeof msg.channel_id === "string" && msg.channel_id.startsWith("dm-");
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
channel_id: msg.channel_id,
|
|
309
|
-
dmOwnerBotId: msg.dm_owner
|
|
310
|
-
}) ?? (table.isSingle() ? table.all()[0] : null) : routeInbound(table, {
|
|
389
|
+
const owner = msg.__botId ?? msg.dm_owner;
|
|
390
|
+
const targets = isDm ? [owner !== undefined ? typeof owner === "string" ? table.forBot(owner) : null : table.isSingle() ? table.all()[0] : null].filter((id) => !!id) : routeInboundTargets(table, {
|
|
311
391
|
channel_id: msg.channel_id,
|
|
312
392
|
mentioned_ids: msg.mentioned_ids,
|
|
313
393
|
content: msg.content
|
|
314
394
|
});
|
|
315
|
-
if (!
|
|
395
|
+
if (!targets.length) {
|
|
316
396
|
log(`[connector] inbound unaddressed (channel=${msg.channel_id ?? "?"} mentions=${JSON.stringify(msg.mentioned_ids ?? [])}) — dropped`);
|
|
317
397
|
return;
|
|
318
398
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
399
|
+
await Promise.all(targets.map((target) => {
|
|
400
|
+
const key = JSON.stringify([target.botId, msg.channel_id]);
|
|
401
|
+
const previous = inboundQueues.get(key) ?? Promise.resolve();
|
|
402
|
+
const job = previous.catch(() => {}).then(async () => {
|
|
403
|
+
if (msg.sender_id === target.agentId)
|
|
404
|
+
return;
|
|
405
|
+
const baseEvent = toWireEvent(msg, "agentschat");
|
|
406
|
+
if (!baseEvent)
|
|
407
|
+
return;
|
|
408
|
+
if (![...sockets].some((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)))
|
|
409
|
+
return;
|
|
410
|
+
const deliveryKey = typeof msg.id === "string" && msg.id ? JSON.stringify([msg.channel_id, msg.id, target.botId]) : null;
|
|
411
|
+
if (deliveryKey && delivered.recordOrSkip(deliveryKey))
|
|
412
|
+
return;
|
|
413
|
+
if (!isDm) {
|
|
414
|
+
const since = lastAddressed.get(key);
|
|
415
|
+
if (hooks.getChannelContext) {
|
|
416
|
+
const controller = new AbortController;
|
|
417
|
+
let timer;
|
|
418
|
+
try {
|
|
419
|
+
const ctx = await Promise.race([
|
|
420
|
+
hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id, controller.signal),
|
|
421
|
+
new Promise((resolve) => {
|
|
422
|
+
timer = setTimeout(() => {
|
|
423
|
+
controller.abort();
|
|
424
|
+
resolve(null);
|
|
425
|
+
}, 1000);
|
|
426
|
+
})
|
|
427
|
+
]);
|
|
428
|
+
if (ctx && ctx.length) {
|
|
429
|
+
baseEvent.context = ctx.slice(-10).map((c) => ({
|
|
430
|
+
text: String(c?.text ?? "").slice(0, 500),
|
|
431
|
+
source: { user_name: c?.user_name, user_id: c?.user_id }
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
log(`[connector] context fetch failed (delivering without it)`);
|
|
436
|
+
} finally {
|
|
437
|
+
if (timer)
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
}
|
|
333
440
|
}
|
|
334
|
-
|
|
335
|
-
|
|
441
|
+
if (msg.timestamp && Number.isFinite(Date.parse(msg.timestamp)) && (!since || Date.parse(msg.timestamp) > Date.parse(since)))
|
|
442
|
+
lastAddressed.set(key, msg.timestamp);
|
|
336
443
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
444
|
+
const deliverTo = [...sockets].filter((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)).slice(0, 1);
|
|
445
|
+
if (deliverTo.length === 0) {
|
|
446
|
+
log(`[connector] inbound dropped for botId=${target.botId}: no agentschat-fronted gateway socket`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
for (const conn of deliverTo) {
|
|
450
|
+
const event = { ...baseEvent, source: { ...baseEvent.source } };
|
|
451
|
+
const profile = hermesSourceProfile(target, conn.fronted.size);
|
|
452
|
+
if (profile)
|
|
453
|
+
event.source.profile = profile;
|
|
454
|
+
send(conn.ws, { type: "inbound", event });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
inboundQueues.set(key, job);
|
|
458
|
+
return job.finally(() => {
|
|
459
|
+
if (inboundQueues.get(key) === job)
|
|
460
|
+
inboundQueues.delete(key);
|
|
461
|
+
});
|
|
462
|
+
}));
|
|
350
463
|
},
|
|
351
464
|
connections() {
|
|
352
465
|
return sockets.size;
|
|
466
|
+
},
|
|
467
|
+
reloadIdentities(next) {
|
|
468
|
+
if (!next || next.length === 0) {
|
|
469
|
+
throw new Error("reloadIdentities requires a non-empty identity list");
|
|
470
|
+
}
|
|
471
|
+
table.replace(next);
|
|
472
|
+
for (const conn of sockets) {
|
|
473
|
+
for (const botId of conn.fronted) {
|
|
474
|
+
const id = table.forBot(botId);
|
|
475
|
+
if (!id || id.gatewayId !== conn.gatewayId || id.secret !== conn.secret)
|
|
476
|
+
conn.fronted.delete(botId);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const k of Object.keys(config.secrets))
|
|
480
|
+
delete config.secrets[k];
|
|
481
|
+
for (const id of next) {
|
|
482
|
+
(config.secrets[id.gatewayId] ??= []).push(id.secret);
|
|
483
|
+
}
|
|
484
|
+
log(`[connector] identities reloaded: ${table.size}`);
|
|
353
485
|
}
|
|
354
486
|
};
|
|
355
487
|
}
|
|
@@ -424,38 +556,6 @@ function normalizeTimestampForCursor(ts, mode) {
|
|
|
424
556
|
return ts;
|
|
425
557
|
}
|
|
426
558
|
|
|
427
|
-
// src/dedup.ts
|
|
428
|
-
function messageDedupKey(data) {
|
|
429
|
-
if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
|
|
430
|
-
return null;
|
|
431
|
-
return `${data.channel_id}:${data.id}`;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
class MessageDedup {
|
|
435
|
-
max;
|
|
436
|
-
dropOnEvict;
|
|
437
|
-
seen = new Set;
|
|
438
|
-
constructor(max = 5000, dropOnEvict = 1000) {
|
|
439
|
-
this.max = max;
|
|
440
|
-
this.dropOnEvict = dropOnEvict;
|
|
441
|
-
}
|
|
442
|
-
recordOrSkip(key) {
|
|
443
|
-
if (this.seen.has(key))
|
|
444
|
-
return true;
|
|
445
|
-
this.seen.add(key);
|
|
446
|
-
if (this.seen.size > this.max) {
|
|
447
|
-
const arr = [...this.seen];
|
|
448
|
-
this.seen.clear();
|
|
449
|
-
for (const item of arr.slice(this.dropOnEvict))
|
|
450
|
-
this.seen.add(item);
|
|
451
|
-
}
|
|
452
|
-
return false;
|
|
453
|
-
}
|
|
454
|
-
get size() {
|
|
455
|
-
return this.seen.size;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
559
|
// connector/backfill.ts
|
|
460
560
|
function planBackfill(after, msgs, agentId) {
|
|
461
561
|
const list = Array.isArray(msgs) ? msgs : [];
|
|
@@ -485,9 +585,11 @@ function ingestAgentsChatFrame(id, frame, deps) {
|
|
|
485
585
|
deps.advanceCursor(id, frame.channel_id, frame.timestamp);
|
|
486
586
|
}
|
|
487
587
|
const key = messageDedupKey(frame);
|
|
488
|
-
|
|
588
|
+
const isDm = frame.channel_id?.startsWith("dm-");
|
|
589
|
+
const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
|
|
590
|
+
if (scopedKey && deps.dedup.recordOrSkip(scopedKey))
|
|
489
591
|
return false;
|
|
490
|
-
return frame.sender_id !== id.agentId && frame.content !== "__typing__";
|
|
592
|
+
return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
|
|
491
593
|
}
|
|
492
594
|
|
|
493
595
|
// src/heartbeat.ts
|
|
@@ -579,34 +681,52 @@ var API = (process.env.AGENTCHAT_API_URL || "https://agents-chat.com").replace(/
|
|
|
579
681
|
var WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws";
|
|
580
682
|
var PORT = Number(process.env.RELAY_PORT || 8765);
|
|
581
683
|
var HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
582
|
-
function
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
684
|
+
function parseIdentitiesJson(raw, source) {
|
|
685
|
+
let parsed;
|
|
686
|
+
try {
|
|
687
|
+
parsed = JSON.parse(raw);
|
|
688
|
+
} catch {
|
|
689
|
+
throw new Error(`${source} is not valid JSON`);
|
|
690
|
+
}
|
|
691
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
692
|
+
throw new Error(`${source} must be a non-empty JSON array`);
|
|
693
|
+
}
|
|
694
|
+
const seen = new Set;
|
|
695
|
+
for (const [index, it] of parsed.entries()) {
|
|
696
|
+
const invalid = ["botId", "token", "gatewayId", "secret"].filter((k) => typeof it?.[k] !== "string" || !it[k].trim());
|
|
697
|
+
for (const k of ["agentId", "profile"]) {
|
|
698
|
+
if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim()))
|
|
699
|
+
invalid.push(k);
|
|
595
700
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
701
|
+
if (invalid.length)
|
|
702
|
+
throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
|
|
703
|
+
if (seen.has(it.botId))
|
|
704
|
+
throw new Error(`${source} entry ${index}: duplicate botId`);
|
|
705
|
+
seen.add(it.botId);
|
|
706
|
+
}
|
|
707
|
+
return parsed.map((it) => ({
|
|
708
|
+
botId: String(it.botId),
|
|
709
|
+
agentId: String(it.agentId ?? it.botId),
|
|
710
|
+
token: String(it.token),
|
|
711
|
+
gatewayId: String(it.gatewayId),
|
|
712
|
+
secret: String(it.secret),
|
|
713
|
+
...it.profile ? { profile: String(it.profile) } : {}
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
function loadIdentities() {
|
|
717
|
+
const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
|
|
718
|
+
if (file) {
|
|
719
|
+
let raw2;
|
|
720
|
+
try {
|
|
721
|
+
raw2 = readFileSync2(file, "utf8");
|
|
722
|
+
} catch {
|
|
723
|
+
throw new Error("RELAY_IDENTITIES_FILE could not be read");
|
|
601
724
|
}
|
|
602
|
-
return
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
secret: String(it.secret),
|
|
608
|
-
...it.profile ? { profile: String(it.profile) } : {}
|
|
609
|
-
}));
|
|
725
|
+
return parseIdentitiesJson(raw2, "RELAY_IDENTITIES_FILE");
|
|
726
|
+
}
|
|
727
|
+
const raw = (process.env.RELAY_IDENTITIES || "").trim();
|
|
728
|
+
if (raw) {
|
|
729
|
+
return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
|
|
610
730
|
}
|
|
611
731
|
const agentId = need("AGENTCHAT_AGENT_ID");
|
|
612
732
|
const token = need("AGENTCHAT_TOKEN");
|
|
@@ -614,14 +734,42 @@ function resolveIdentities() {
|
|
|
614
734
|
const secret = need("RELAY_GATEWAY_SECRET");
|
|
615
735
|
return [{ botId: agentId, agentId, token, gatewayId, secret }];
|
|
616
736
|
}
|
|
617
|
-
|
|
618
|
-
|
|
737
|
+
function resolveIdentitiesAtStartup() {
|
|
738
|
+
try {
|
|
739
|
+
return loadIdentities();
|
|
740
|
+
} catch (e) {
|
|
741
|
+
log(`ERROR: ${e?.message ?? e}`);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
var identities = resolveIdentitiesAtStartup();
|
|
619
746
|
var secrets = {};
|
|
620
747
|
for (const id of identities) {
|
|
621
748
|
(secrets[id.gatewayId] ??= []).push(id.secret);
|
|
622
749
|
}
|
|
623
750
|
var broadcast = null;
|
|
624
751
|
var socketsByBot = new Map;
|
|
752
|
+
var reconnectTimerByBot = new Map;
|
|
753
|
+
var currentIdentity = (id) => !process.__shutdown && identities.includes(id);
|
|
754
|
+
function cancelReconnect(botId) {
|
|
755
|
+
const timer = reconnectTimerByBot.get(botId);
|
|
756
|
+
if (timer)
|
|
757
|
+
clearTimeout(timer);
|
|
758
|
+
reconnectTimerByBot.delete(botId);
|
|
759
|
+
}
|
|
760
|
+
function scheduleReconnect(id, delay) {
|
|
761
|
+
if (!currentIdentity(id))
|
|
762
|
+
return;
|
|
763
|
+
cancelReconnect(id.botId);
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
if (reconnectTimerByBot.get(id.botId) !== timer)
|
|
766
|
+
return;
|
|
767
|
+
reconnectTimerByBot.delete(id.botId);
|
|
768
|
+
if (currentIdentity(id))
|
|
769
|
+
connectIdentity(id);
|
|
770
|
+
}, delay);
|
|
771
|
+
reconnectTimerByBot.set(id.botId, timer);
|
|
772
|
+
}
|
|
625
773
|
var backoffByBot = new Map;
|
|
626
774
|
var backfillTimerByBot = new Map;
|
|
627
775
|
var heartbeatsByBot = new Map;
|
|
@@ -693,6 +841,8 @@ async function joinMemberships(ws, id) {
|
|
|
693
841
|
return;
|
|
694
842
|
}
|
|
695
843
|
const body = await r.json();
|
|
844
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
|
|
845
|
+
return;
|
|
696
846
|
const channels = Array.isArray(body) ? body : body.channels || [];
|
|
697
847
|
const joined = [];
|
|
698
848
|
for (const ch of channels) {
|
|
@@ -707,7 +857,7 @@ async function joinMemberships(ws, id) {
|
|
|
707
857
|
clearTimeout(prev);
|
|
708
858
|
const t = setTimeout(() => {
|
|
709
859
|
backfillTimerByBot.delete(id.botId);
|
|
710
|
-
if (
|
|
860
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
|
|
711
861
|
return;
|
|
712
862
|
backfillIdentity(id, joined);
|
|
713
863
|
}, 2000);
|
|
@@ -720,6 +870,8 @@ async function joinMemberships(ws, id) {
|
|
|
720
870
|
async function backfillIdentity(id, channelIds) {
|
|
721
871
|
const s = cursorFor(id);
|
|
722
872
|
for (const channelId of channelIds) {
|
|
873
|
+
if (!currentIdentity(id))
|
|
874
|
+
return;
|
|
723
875
|
try {
|
|
724
876
|
const after = s.map.get(channelId);
|
|
725
877
|
const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
|
|
@@ -729,6 +881,8 @@ async function backfillIdentity(id, channelIds) {
|
|
|
729
881
|
if (!r.ok)
|
|
730
882
|
continue;
|
|
731
883
|
const msgs = (await r.json())?.messages ?? [];
|
|
884
|
+
if (!currentIdentity(id))
|
|
885
|
+
return;
|
|
732
886
|
const plan = planBackfill(after, msgs, id.agentId);
|
|
733
887
|
if (plan.seed) {
|
|
734
888
|
s.map.set(channelId, plan.seed);
|
|
@@ -750,6 +904,9 @@ async function backfillIdentity(id, channelIds) {
|
|
|
750
904
|
}
|
|
751
905
|
}
|
|
752
906
|
function connectIdentity(id) {
|
|
907
|
+
if (!currentIdentity(id))
|
|
908
|
+
return;
|
|
909
|
+
cancelReconnect(id.botId);
|
|
753
910
|
stopHeartbeat(id.botId);
|
|
754
911
|
const prev = socketsByBot.get(id.botId);
|
|
755
912
|
if (prev) {
|
|
@@ -771,7 +928,7 @@ function connectIdentity(id) {
|
|
|
771
928
|
} catch {}
|
|
772
929
|
},
|
|
773
930
|
reconnect: () => {
|
|
774
|
-
if (
|
|
931
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced)
|
|
775
932
|
return;
|
|
776
933
|
heartbeatForced = true;
|
|
777
934
|
log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
|
|
@@ -782,22 +939,25 @@ function connectIdentity(id) {
|
|
|
782
939
|
} catch {}
|
|
783
940
|
socketsByBot.delete(id.botId);
|
|
784
941
|
stopHeartbeat(id.botId);
|
|
785
|
-
|
|
786
|
-
if (!process.__shutdown)
|
|
787
|
-
connectIdentity(id);
|
|
788
|
-
}, 500);
|
|
942
|
+
scheduleReconnect(id, 500);
|
|
789
943
|
},
|
|
790
944
|
getReadyState: () => ws.readyState ?? WS_CLOSED
|
|
791
945
|
}, HB_PING_MS, HB_PONG_MS, HB_CONNECT_MS);
|
|
792
946
|
heartbeatsByBot.set(id.botId, hb);
|
|
793
947
|
hb.start();
|
|
794
948
|
ws.on("open", () => {
|
|
949
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) {
|
|
950
|
+
ws.close();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
795
953
|
backoffByBot.set(id.botId, 1000);
|
|
796
954
|
try {
|
|
797
955
|
ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
|
|
798
956
|
} catch {}
|
|
799
957
|
});
|
|
800
958
|
ws.on("message", (raw) => {
|
|
959
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
|
|
960
|
+
return;
|
|
801
961
|
let data;
|
|
802
962
|
try {
|
|
803
963
|
data = JSON.parse(String(raw));
|
|
@@ -829,6 +989,8 @@ function connectIdentity(id) {
|
|
|
829
989
|
}
|
|
830
990
|
});
|
|
831
991
|
ws.on("close", () => {
|
|
992
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
|
|
993
|
+
return;
|
|
832
994
|
stopHeartbeat(id.botId);
|
|
833
995
|
if (process.__shutdown)
|
|
834
996
|
return;
|
|
@@ -836,7 +998,7 @@ function connectIdentity(id) {
|
|
|
836
998
|
return;
|
|
837
999
|
const delay = backoffByBot.get(id.botId) ?? 1000;
|
|
838
1000
|
log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
|
|
839
|
-
|
|
1001
|
+
scheduleReconnect(id, delay);
|
|
840
1002
|
backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
|
|
841
1003
|
});
|
|
842
1004
|
ws.on("error", (e) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
|
|
@@ -848,19 +1010,21 @@ var connector = startConnector({
|
|
|
848
1010
|
identities,
|
|
849
1011
|
agentschat: {
|
|
850
1012
|
async sendMessage(botId, chatId, content, replyTo) {
|
|
851
|
-
const id = identities
|
|
1013
|
+
const id = requireIdentity(identities, botId);
|
|
852
1014
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
|
|
853
1015
|
method: "POST",
|
|
854
1016
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
|
|
855
1017
|
body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...replyTo ? { parent_id: replyTo } : {} })
|
|
1018
|
+
}).catch(() => {
|
|
1019
|
+
throw new PlatformNetworkError;
|
|
856
1020
|
});
|
|
857
1021
|
if (!res.ok)
|
|
858
|
-
throw new
|
|
1022
|
+
throw new PlatformHttpError(res.status);
|
|
859
1023
|
const data = await res.json();
|
|
860
1024
|
return { id: data?.id };
|
|
861
1025
|
},
|
|
862
1026
|
async getChatInfo(botId, chatId) {
|
|
863
|
-
const id = identities
|
|
1027
|
+
const id = requireIdentity(identities, botId);
|
|
864
1028
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
|
|
865
1029
|
headers: { Authorization: `Bearer ${id.token}` }
|
|
866
1030
|
});
|
|
@@ -870,7 +1034,7 @@ var connector = startConnector({
|
|
|
870
1034
|
return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
|
|
871
1035
|
},
|
|
872
1036
|
async sendTyping(botId, chatId) {
|
|
873
|
-
const id = identities
|
|
1037
|
+
const id = requireIdentity(identities, botId);
|
|
874
1038
|
const ws = socketsByBot.get(id.botId);
|
|
875
1039
|
if (ws && ws.readyState === WS.OPEN) {
|
|
876
1040
|
try {
|
|
@@ -878,9 +1042,10 @@ var connector = startConnector({
|
|
|
878
1042
|
} catch {}
|
|
879
1043
|
}
|
|
880
1044
|
},
|
|
881
|
-
async getChannelContext(botId, chatId, sinceTs, excludeId) {
|
|
882
|
-
const id = identities
|
|
1045
|
+
async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
|
|
1046
|
+
const id = requireIdentity(identities, botId);
|
|
883
1047
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
|
|
1048
|
+
signal,
|
|
884
1049
|
headers: { Authorization: `Bearer ${id.token}` }
|
|
885
1050
|
});
|
|
886
1051
|
if (!res.ok)
|
|
@@ -900,12 +1065,68 @@ var connector = startConnector({
|
|
|
900
1065
|
logger: log
|
|
901
1066
|
});
|
|
902
1067
|
broadcast = (msg) => connector.injectAgentsChatMessage(msg);
|
|
1068
|
+
await connector.ready;
|
|
903
1069
|
log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
|
|
904
1070
|
for (const id of identities)
|
|
905
1071
|
connectIdentity(id);
|
|
1072
|
+
function reloadIdentitiesFromConfig() {
|
|
1073
|
+
const next = loadIdentities().map((id) => identities.find((prev) => prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token && prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
|
|
1074
|
+
const prevByBot = new Map(identities.map((i) => [i.botId, i]));
|
|
1075
|
+
const nextByBot = new Map(next.map((i) => [i.botId, i]));
|
|
1076
|
+
const added = next.filter((i) => !prevByBot.has(i.botId));
|
|
1077
|
+
const removed = identities.filter((i) => !nextByBot.has(i.botId));
|
|
1078
|
+
const kept = next.filter((i) => prevByBot.has(i.botId));
|
|
1079
|
+
connector.reloadIdentities(next);
|
|
1080
|
+
identities = next;
|
|
1081
|
+
for (const id of removed) {
|
|
1082
|
+
cancelReconnect(id.botId);
|
|
1083
|
+
log(`hot-reload: disconnecting removed identity ${id.botId}`);
|
|
1084
|
+
stopHeartbeat(id.botId);
|
|
1085
|
+
const t = backfillTimerByBot.get(id.botId);
|
|
1086
|
+
if (t) {
|
|
1087
|
+
clearTimeout(t);
|
|
1088
|
+
backfillTimerByBot.delete(id.botId);
|
|
1089
|
+
}
|
|
1090
|
+
const ws = socketsByBot.get(id.botId);
|
|
1091
|
+
if (ws) {
|
|
1092
|
+
try {
|
|
1093
|
+
ws.removeAllListeners("close");
|
|
1094
|
+
ws.close();
|
|
1095
|
+
} catch {}
|
|
1096
|
+
socketsByBot.delete(id.botId);
|
|
1097
|
+
}
|
|
1098
|
+
backoffByBot.delete(id.botId);
|
|
1099
|
+
}
|
|
1100
|
+
for (const id of kept) {
|
|
1101
|
+
const prev = prevByBot.get(id.botId);
|
|
1102
|
+
if (prev !== id) {
|
|
1103
|
+
cancelReconnect(id.botId);
|
|
1104
|
+
const timer = backfillTimerByBot.get(id.botId);
|
|
1105
|
+
if (timer)
|
|
1106
|
+
clearTimeout(timer);
|
|
1107
|
+
backfillTimerByBot.delete(id.botId);
|
|
1108
|
+
log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
|
|
1109
|
+
connectIdentity(id);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
for (const id of added) {
|
|
1113
|
+
log(`hot-reload: connecting new identity ${id.botId}`);
|
|
1114
|
+
connectIdentity(id);
|
|
1115
|
+
}
|
|
1116
|
+
log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
|
|
1117
|
+
}
|
|
1118
|
+
process.on("SIGHUP", () => {
|
|
1119
|
+
try {
|
|
1120
|
+
reloadIdentitiesFromConfig();
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
906
1125
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
907
1126
|
process.on(sig, () => {
|
|
908
1127
|
process.__shutdown = true;
|
|
1128
|
+
for (const botId of reconnectTimerByBot.keys())
|
|
1129
|
+
cancelReconnect(botId);
|
|
909
1130
|
for (const botId of [...heartbeatsByBot.keys()])
|
|
910
1131
|
stopHeartbeat(botId);
|
|
911
1132
|
for (const ws of socketsByBot.values()) {
|