agentschat-mcp 0.30.0 → 0.32.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Connector entrypoint: wire the relay connector to one or more agentschat accounts.
3
+ *
4
+ * Single-tenant (one identity):
5
+ * AGENTCHAT_TOKEN agentschat agent key (ac_…)
6
+ * AGENTCHAT_AGENT_ID agentschat agent id
7
+ * RELAY_GATEWAY_ID the gateway id hermes uses in its upgrade token
8
+ * RELAY_GATEWAY_SECRET the per-gateway secret that token is HMAC'd with
9
+ *
10
+ * Multiplex (N identities, one per Hermes profile/agent):
11
+ * RELAY_IDENTITIES = JSON array, one entry per identity:
12
+ * [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"..."}, ...]
13
+ * Each botId is an agentschat agent_id. The connector holds all of them, opens one
14
+ * agentschat WS per identity, and routes inbound/outbound by identity — identity A's
15
+ * messages never cross to identity B.
16
+ *
17
+ * Common:
18
+ * AGENTCHAT_API_URL REST base (default https://agents-chat.com)
19
+ * AGENTCHAT_WS_URL WebSocket (default wss://agents-chat.com/ws)
20
+ * RELAY_PORT port to listen on (default 8765)
21
+ * RELAY_HOST bind host (default 127.0.0.1)
22
+ */
23
+
24
+ import WS from "ws";
25
+ import { startConnector } from "./server.ts";
26
+ import type { Identity } from "./identities.ts";
27
+
28
+ const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
29
+
30
+ function need(name: string): string {
31
+ const v = process.env[name];
32
+ if (!v) {
33
+ log(`ERROR: ${name} is required`);
34
+ process.exit(1);
35
+ }
36
+ return v;
37
+ }
38
+
39
+ const API = (process.env.AGENTCHAT_API_URL || "https://agents-chat.com").replace(/\/$/, "");
40
+ const WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws";
41
+ const PORT = Number(process.env.RELAY_PORT || 8765);
42
+ const HOST = process.env.RELAY_HOST || "127.0.0.1";
43
+
44
+ // ── Resolve identities: multiplex (RELAY_IDENTITIES) or single-tenant (legacy env) ──
45
+
46
+ function resolveIdentities(): Identity[] {
47
+ const raw = (process.env.RELAY_IDENTITIES || "").trim();
48
+ if (raw) {
49
+ let parsed: any;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ } catch (e) {
53
+ log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
54
+ process.exit(1);
55
+ }
56
+ if (!Array.isArray(parsed) || parsed.length === 0) {
57
+ log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
58
+ process.exit(1);
59
+ }
60
+ for (const it of parsed) {
61
+ if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
62
+ log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
63
+ process.exit(1);
64
+ }
65
+ }
66
+ return parsed.map((it: any) => ({
67
+ botId: String(it.botId),
68
+ agentId: String(it.agentId ?? it.botId),
69
+ token: String(it.token),
70
+ gatewayId: String(it.gatewayId),
71
+ secret: String(it.secret),
72
+ }));
73
+ }
74
+ // Single-tenant legacy env.
75
+ const agentId = need("AGENTCHAT_AGENT_ID");
76
+ const token = need("AGENTCHAT_TOKEN");
77
+ const gatewayId = need("RELAY_GATEWAY_ID");
78
+ const secret = need("RELAY_GATEWAY_SECRET");
79
+ return [{ botId: agentId, agentId, token, gatewayId, secret }];
80
+ }
81
+
82
+ const identities = resolveIdentities();
83
+ const single = identities.length === 1;
84
+
85
+ // The per-gateway secret table for the relay upgrade auth (gatewayId → secrets).
86
+ const secrets: Record<string, string[]> = {};
87
+ for (const id of identities) {
88
+ (secrets[id.gatewayId] ??= []).push(id.secret);
89
+ }
90
+
91
+ // ── agentschat connections: one WS per identity ──
92
+
93
+ let broadcast: ((msg: any) => void) | null = null;
94
+ const socketsByBot = new Map<string, WS>();
95
+ const backoffByBot = new Map<string, number>();
96
+
97
+ function connectIdentity(id: Identity) {
98
+ const ws = new WS(WS_URL);
99
+ socketsByBot.set(id.botId, ws);
100
+ ws.on("open", () => {
101
+ backoffByBot.set(id.botId, 1000);
102
+ try {
103
+ ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
104
+ } catch {}
105
+ });
106
+ ws.on("message", (raw: any) => {
107
+ let data: any;
108
+ try { data = JSON.parse(String(raw)); } catch { return; }
109
+ if (data.type === "auth_ok") {
110
+ log(`connected to agentschat as ${id.agentId}`);
111
+ return;
112
+ }
113
+ if (data.type === "message" && data.sender_id !== id.agentId) {
114
+ // Tag which identity's socket this arrived on, so the connector routes it to
115
+ // the gateway fronting that identity (and only that one).
116
+ broadcast?.({ ...data, __botId: id.botId });
117
+ }
118
+ });
119
+ ws.on("close", () => {
120
+ if ((process as any).__shutdown) return;
121
+ const delay = backoffByBot.get(id.botId) ?? 1000;
122
+ log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
123
+ setTimeout(() => connectIdentity(id), delay);
124
+ backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
125
+ });
126
+ ws.on("error", (e: any) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
127
+ }
128
+
129
+ const connector = startConnector({
130
+ port: PORT,
131
+ host: HOST,
132
+ secrets,
133
+ identities: single ? undefined : identities, // single-tenant → derived default identity
134
+ agentschat: {
135
+ async sendMessage(botId, chatId, content, replyTo) {
136
+ const id = identities.find((i) => i.botId === botId) ?? identities[0];
137
+ const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
140
+ body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...(replyTo ? { parent_id: replyTo } : {}) }),
141
+ });
142
+ if (!res.ok) throw new Error(`agentschat send failed: ${res.status}`);
143
+ const data = (await res.json()) as any;
144
+ return { id: data?.id };
145
+ },
146
+ async getChatInfo(botId, chatId) {
147
+ const id = identities.find((i) => i.botId === botId) ?? identities[0];
148
+ const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
149
+ headers: { Authorization: `Bearer ${id.token}` },
150
+ });
151
+ if (!res.ok) return { name: chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
152
+ const data = (await res.json()) as any;
153
+ return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
154
+ },
155
+ async sendTyping(botId, chatId) {
156
+ const id = identities.find((i) => i.botId === botId) ?? identities[0];
157
+ const ws = socketsByBot.get(id.botId);
158
+ if (ws && ws.readyState === WS.OPEN) {
159
+ try { ws.send(JSON.stringify({ type: "typing", channel_id: chatId, sender_id: id.agentId })); } catch {}
160
+ }
161
+ },
162
+ },
163
+ logger: log,
164
+ });
165
+
166
+ broadcast = (msg) => connector.injectAgentsChatMessage(msg);
167
+ log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
168
+
169
+ for (const id of identities) connectIdentity(id);
170
+
171
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
172
+ process.on(sig, () => {
173
+ (process as any).__shutdown = true;
174
+ for (const ws of socketsByBot.values()) {
175
+ try { ws.close(); } catch {}
176
+ }
177
+ connector.stop();
178
+ process.exit(0);
179
+ });
180
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * agentschat connector — the connector side of the Hermes relay contract.
3
+ *
4
+ * Single-tenant AND multiplex: one relay WS server fronts one OR MORE agentschat
5
+ * identities (one per Hermes profile/agent). Hermes fronts multiple identities on
6
+ * one WS by sending one `hello` per (platform, botId); here platform is always
7
+ * "agentschat" and botId is the agentschat agent_id.
8
+ *
9
+ * Frame exchange (see gateway ws_transport.py):
10
+ * gateway → hello {type:"hello", platform, botId}
11
+ * connector→ descriptor {type:"descriptor", descriptor:{...}} (one per hello)
12
+ * connector→ inbound {type:"inbound", event:{...}} (agentschat → gateway)
13
+ * gateway → outbound {type:"outbound", requestId, platform?, action}
14
+ * connector→ outbound_result {type:"outbound_result", requestId, result}
15
+ *
16
+ * Auth is fail-closed: anything wrong with the upgrade token closes 4401 before the
17
+ * socket is admitted.
18
+ *
19
+ * The single highest-correctness invariant (multiplex): identity A's messages are
20
+ * NEVER routed to or sent as identity B. Inbound routes to the socket(s) fronting the
21
+ * addressed identity; outbound uses the sending identity's own token; an identity the
22
+ * connector has no credentials for is rejected at hello (fail closed).
23
+ */
24
+
25
+ import { createServer, type Server as HttpServer, type IncomingMessage } from "node:http";
26
+ import { WebSocketServer, WebSocket } from "ws";
27
+ import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
28
+ import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
29
+ import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
30
+ import { IdentityTable, routeInbound, type Identity } from "./identities.ts";
31
+
32
+ /** What the connector needs from agentschat to fulfil outbound ops, per identity. */
33
+ export interface AgentsChatHooks {
34
+ sendMessage(botId: string, chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
35
+ getChatInfo(botId: string, chatId: string): Promise<{ name?: string; type?: string }>;
36
+ sendTyping?(botId: string, chatId: string): Promise<void>;
37
+ }
38
+
39
+ /** Legacy single-tenant hook shape (no botId first arg) — adapted to the per-identity one. */
40
+ interface LegacyHooks {
41
+ sendMessage(chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
42
+ getChatInfo(chatId: string): Promise<{ name?: string; type?: string }>;
43
+ sendTyping?(chatId: string): Promise<void>;
44
+ }
45
+
46
+ export interface ConnectorConfig {
47
+ port: number;
48
+ host?: string;
49
+ /** gatewayId → acceptable secrets (rotation window). */
50
+ secrets: Record<string, string[]>;
51
+ /**
52
+ * The identities this connector fronts. Multiplex: one per agentschat identity.
53
+ * Single-tenant (legacy): omit and provide `agentschat` — a single implicit
54
+ * identity is derived (botId "default").
55
+ */
56
+ identities?: Identity[];
57
+ /** Override the descriptor (tests/customization); defaults to the agentschat descriptor. */
58
+ descriptor?: Partial<CapabilityDescriptor>;
59
+ agentschat: AgentsChatHooks | LegacyHooks;
60
+ logger?: (msg: string) => void;
61
+ }
62
+
63
+ export interface ConnectorHandle {
64
+ port: number;
65
+ stop(): void;
66
+ /** Push an agentschat message to the gateway socket(s) fronting its addressed identity. */
67
+ injectAgentsChatMessage(msg: AgentsChatMessage): void;
68
+ connections(): number;
69
+ }
70
+
71
+ /** A connected gateway socket and the set of identities it has hello'd (fronts). */
72
+ interface GatewayConn {
73
+ ws: WebSocket;
74
+ gatewayId: string;
75
+ /** botIds this socket has declared via hello. */
76
+ fronted: Set<string>;
77
+ }
78
+
79
+ export function startConnector(config: ConnectorConfig): ConnectorHandle {
80
+ const log = config.logger ?? (() => {});
81
+ const descriptor = buildDescriptor(config.descriptor);
82
+ const single = !config.identities || config.identities.length === 0;
83
+ const table = new IdentityTable(
84
+ single ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities!,
85
+ );
86
+ // Normalize hooks to the per-identity shape. Legacy single-tenant hooks take
87
+ // (chatId, ...); wrap them to ignore the botId. Per-identity hooks take botId first.
88
+ const hooks: AgentsChatHooks = single
89
+ ? {
90
+ sendMessage: (_b, chatId, content, replyTo) => (config.agentschat as LegacyHooks).sendMessage(chatId, content, replyTo),
91
+ getChatInfo: (_b, chatId) => (config.agentschat as LegacyHooks).getChatInfo(chatId),
92
+ sendTyping: (_b, chatId) => (config.agentschat as LegacyHooks).sendTyping?.(chatId) ?? Promise.resolve(),
93
+ }
94
+ : (config.agentschat as AgentsChatHooks);
95
+ const sockets = new Set<GatewayConn>();
96
+
97
+ const http: HttpServer = createServer((_req, res) => {
98
+ res.writeHead(200, { "Content-Type": "application/json" });
99
+ res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
100
+ });
101
+
102
+ const wss = new WebSocketServer({ noServer: true });
103
+
104
+ http.on("upgrade", (req: IncomingMessage, socket, head) => {
105
+ const { pathname } = new URL(req.url ?? "/", "http://localhost");
106
+ if (pathname !== "/relay") {
107
+ socket.destroy();
108
+ return;
109
+ }
110
+ const auth = req.headers.authorization ?? "";
111
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
112
+ const payload = peekPayload(token);
113
+ const secrets = payload ? config.secrets[payload] : undefined;
114
+ const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
115
+ if (!gatewayId) {
116
+ log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
117
+ wss.handleUpgrade(req, socket, head, (ws) => {
118
+ ws.close(CLOSE_UNAUTHORIZED, "unauthorized");
119
+ });
120
+ return;
121
+ }
122
+ wss.handleUpgrade(req, socket, head, (ws) => {
123
+ onConnection(ws, gatewayId);
124
+ });
125
+ });
126
+
127
+ function onConnection(ws: WebSocket, gatewayId: string) {
128
+ const conn: GatewayConn = { ws, gatewayId, fronted: new Set() };
129
+ sockets.add(conn);
130
+ log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
131
+
132
+ ws.on("message", async (data) => {
133
+ // Newline-delimited: the gateway may batch frames; split and handle each.
134
+ const text = data.toString();
135
+ for (const line of text.split("\n")) {
136
+ if (!line.trim()) continue;
137
+ let frame: any;
138
+ try {
139
+ frame = JSON.parse(line);
140
+ } catch {
141
+ continue;
142
+ }
143
+ await handleFrame(conn, frame).catch((e) => log(`[connector] frame error: ${e}`));
144
+ }
145
+ });
146
+ ws.on("close", () => {
147
+ sockets.delete(conn);
148
+ log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
149
+ });
150
+ ws.on("error", () => sockets.delete(conn));
151
+ }
152
+
153
+ async function handleFrame(conn: GatewayConn, frame: any) {
154
+ const t = frame?.type;
155
+ if (t === "hello") {
156
+ const botId = String(frame.botId ?? "");
157
+ // Single-tenant (no identity table configured): front whatever identity the
158
+ // gateway declares — there's exactly one. Multiplex: the botId MUST be a
159
+ // registered identity (fail closed — never front an identity we can't send as).
160
+ const identity = single
161
+ ? table.all()[0]
162
+ : botId
163
+ ? table.forBot(botId)
164
+ : null;
165
+ if (!identity) {
166
+ send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
167
+ return;
168
+ }
169
+ conn.fronted.add(identity.botId);
170
+ send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
171
+ return;
172
+ }
173
+ if (t === "outbound") {
174
+ const result = await handleOutbound(conn, frame);
175
+ send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
176
+ return;
177
+ }
178
+ // Unknown / ignored frame types (interrupt, etc.) — additive contract, ignore.
179
+ }
180
+
181
+ async function handleOutbound(conn: GatewayConn, frame: any): Promise<any> {
182
+ const action = frame?.action ?? {};
183
+ const op = action?.op;
184
+ const chatId = action?.chat_id ?? "";
185
+ // Which identity sends? The frame's botId picks the egress identity for a
186
+ // multi-identity gateway (contract D-Q1.5b.1: the connector validates the
187
+ // per-frame egress target against the SET of identities THIS socket
188
+ // advertised via hello). Untagged outbound falls back to the FIRST hello'd
189
+ // identity (the session default). Fail closed when the named identity isn't
190
+ // registered or wasn't fronted by this socket — never send with another
191
+ // identity's credentials.
192
+ const firstFronted = [...conn.fronted][0];
193
+ const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
194
+ const identity = single
195
+ ? table.all()[0]
196
+ : requested && conn.fronted.has(requested)
197
+ ? table.forBot(requested)
198
+ : null;
199
+ if (!identity) {
200
+ log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
201
+ return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
202
+ }
203
+ switch (op) {
204
+ case "send": {
205
+ const r = await hooks.sendMessage(identity.botId, chatId, action.content ?? "", action.reply_to);
206
+ return { success: true, message_id: r?.id };
207
+ }
208
+ case "typing": {
209
+ await hooks.sendTyping?.(identity.botId, chatId);
210
+ return { success: true };
211
+ }
212
+ case "get_chat_info": {
213
+ const info = await hooks.getChatInfo(identity.botId, chatId);
214
+ return info ?? {};
215
+ }
216
+ default:
217
+ log(`[connector] outbound failed: unsupported op ${String(op)}`);
218
+ return { success: false, error: `unsupported op: ${String(op)}` };
219
+ }
220
+ }
221
+
222
+ function send(ws: WebSocket, obj: any) {
223
+ // Newline-delimited (gateway ws_transport.py splits on "\n"): a frame without the
224
+ // terminator never reaches the gateway's frame handler.
225
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj) + "\n");
226
+ }
227
+
228
+ http.listen(config.port, config.host ?? "127.0.0.1");
229
+ const address = http.address();
230
+ const port = typeof address === "object" && address ? address.port : config.port;
231
+
232
+ return {
233
+ port,
234
+ stop() {
235
+ for (const c of sockets) {
236
+ try { c.ws.close(1001, "connector shutdown"); } catch {}
237
+ }
238
+ wss.close();
239
+ http.close();
240
+ },
241
+ injectAgentsChatMessage(msg: AgentsChatMessage) {
242
+ // Route to the identity this message is addressed to, then only to gateway
243
+ // socket(s) fronting THAT identity — never broadcast across identities.
244
+ // The strongest signal is __botId: which identity's agentschat socket the
245
+ // message ARRIVED on (agentschat only pushes @mentions/DMs to that agent).
246
+ // Fall back to mention/DM-owner routing for hosts that don't tag.
247
+ const bySocket = (msg as any).__botId ? table.forBot(String((msg as any).__botId)) : null;
248
+ const target = bySocket ?? routeInbound(table, {
249
+ channel_id: msg.channel_id,
250
+ mentioned_ids: (msg as any).mentioned_ids,
251
+ dmOwnerBotId: (msg as any).dm_owner,
252
+ }) ?? (table.isSingle() ? table.all()[0] : null);
253
+ if (!target) {
254
+ log(`[connector] inbound unaddressed (channel=${(msg as any).channel_id ?? "?"} mentions=${JSON.stringify((msg as any).mentioned_ids ?? [])}) — dropped`);
255
+ return;
256
+ }
257
+ const event = toWireEvent(msg, "agentschat");
258
+ if (!event) return;
259
+ // Tag the fronting identity so the gateway keys the right session/profile.
260
+ (event.source as any).profile = target.botId;
261
+ for (const conn of sockets) {
262
+ if (conn.fronted.has(target.botId)) send(conn.ws, { type: "inbound", event });
263
+ }
264
+ },
265
+ connections() {
266
+ return sockets.size;
267
+ },
268
+ };
269
+ }
270
+
271
+ /** Peek the payload head of an upgrade token without verifying (to index secrets). */
272
+ function peekPayload(token: string): string | null {
273
+ try {
274
+ const decoded = Buffer.from(token, "base64url").toString("utf8");
275
+ const parts = decoded.split(":");
276
+ if (parts.length < 3) return null;
277
+ return parts.slice(0, -2).join(":");
278
+ } catch {
279
+ return null;
280
+ }
281
+ }