@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
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import {
|
|
3
|
+
BotCordClient,
|
|
4
|
+
buildHubWebSocketUrl,
|
|
5
|
+
defaultCredentialsFile,
|
|
6
|
+
loadStoredCredentials,
|
|
7
|
+
updateCredentialsToken,
|
|
8
|
+
type InboxMessage,
|
|
9
|
+
} from "@botcord/protocol-core";
|
|
10
|
+
import type {
|
|
11
|
+
ChannelAdapter,
|
|
12
|
+
ChannelSendContext,
|
|
13
|
+
ChannelSendResult,
|
|
14
|
+
ChannelStartContext,
|
|
15
|
+
ChannelStatusSnapshot,
|
|
16
|
+
ChannelStopContext,
|
|
17
|
+
ChannelStreamBlockContext,
|
|
18
|
+
GatewayInboundEnvelope,
|
|
19
|
+
GatewayInboundMessage,
|
|
20
|
+
GatewayLogger,
|
|
21
|
+
} from "../index.js";
|
|
22
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
23
|
+
|
|
24
|
+
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
25
|
+
const KEEPALIVE_INTERVAL = 20_000;
|
|
26
|
+
const MAX_AUTH_FAILURES = 5;
|
|
27
|
+
const SEEN_MESSAGES_CAP = 500;
|
|
28
|
+
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
29
|
+
const DM_ROOM_PREFIX = "rm_dm_";
|
|
30
|
+
|
|
31
|
+
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
32
|
+
export interface BotCordChannelClient {
|
|
33
|
+
ensureToken(): Promise<string>;
|
|
34
|
+
refreshToken(): Promise<string>;
|
|
35
|
+
pollInbox(options?: {
|
|
36
|
+
limit?: number;
|
|
37
|
+
ack?: boolean;
|
|
38
|
+
timeout?: number;
|
|
39
|
+
roomId?: string;
|
|
40
|
+
}): Promise<{ messages: InboxMessage[]; count: number; has_more: boolean }>;
|
|
41
|
+
ackMessages(messageIds: string[]): Promise<void>;
|
|
42
|
+
sendMessage(
|
|
43
|
+
to: string,
|
|
44
|
+
text: string,
|
|
45
|
+
options?: { replyTo?: string; topic?: string },
|
|
46
|
+
): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
|
|
47
|
+
getHubUrl(): string;
|
|
48
|
+
onTokenRefresh?: (token: string, expiresAt: number) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Factory that returns a ready-to-use BotCord client. Injection point for tests. */
|
|
52
|
+
export type BotCordClientFactory = (input: {
|
|
53
|
+
agentId: string;
|
|
54
|
+
hubBaseUrl?: string;
|
|
55
|
+
credentialsPath?: string;
|
|
56
|
+
}) => BotCordChannelClient;
|
|
57
|
+
|
|
58
|
+
/** Options accepted by `createBotCordChannel()`. */
|
|
59
|
+
export interface BotCordChannelOptions {
|
|
60
|
+
/** Channel instance id from config. */
|
|
61
|
+
id: string;
|
|
62
|
+
/** Gateway `accountId` — matches BotCord `agentId`. */
|
|
63
|
+
accountId: string;
|
|
64
|
+
/** BotCord `agentId` (usually identical to `accountId`). */
|
|
65
|
+
agentId: string;
|
|
66
|
+
/** Override for the credentials JSON path. Defaults to `~/.botcord/credentials/<agentId>.json`. */
|
|
67
|
+
credentialsPath?: string;
|
|
68
|
+
/** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
|
|
69
|
+
hubBaseUrl?: string;
|
|
70
|
+
/** Not used by the WS-only loop today; kept for future polling fallback. */
|
|
71
|
+
pollIntervalMs?: number;
|
|
72
|
+
/** Test hook: supply a pre-built client instead of loading credentials from disk. */
|
|
73
|
+
client?: BotCordChannelClient;
|
|
74
|
+
/** Test hook: supply a client factory. Ignored when `client` is provided. */
|
|
75
|
+
clientFactory?: BotCordClientFactory;
|
|
76
|
+
/**
|
|
77
|
+
* Test hook: override the raw WebSocket constructor. Useful for tests that
|
|
78
|
+
* can't spin up a real WS server.
|
|
79
|
+
*/
|
|
80
|
+
webSocketCtor?: typeof WebSocket;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
84
|
+
function defaultClientFactory(input: {
|
|
85
|
+
agentId: string;
|
|
86
|
+
hubBaseUrl?: string;
|
|
87
|
+
credentialsPath?: string;
|
|
88
|
+
}): BotCordChannelClient {
|
|
89
|
+
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
90
|
+
const creds = loadStoredCredentials(credFile);
|
|
91
|
+
const client = new BotCordClient({
|
|
92
|
+
hubUrl: input.hubBaseUrl ?? creds.hubUrl,
|
|
93
|
+
agentId: creds.agentId,
|
|
94
|
+
keyId: creds.keyId,
|
|
95
|
+
privateKey: creds.privateKey,
|
|
96
|
+
token: creds.token,
|
|
97
|
+
tokenExpiresAt: creds.tokenExpiresAt,
|
|
98
|
+
});
|
|
99
|
+
client.onTokenRefresh = (token, expiresAt) => {
|
|
100
|
+
try {
|
|
101
|
+
updateCredentialsToken(credFile, token, expiresAt);
|
|
102
|
+
} catch {
|
|
103
|
+
// persistence failures are non-fatal — next refresh will retry.
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return client as unknown as BotCordChannelClient;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Classify inbound trust tier to decide whether to sanitize text.
|
|
111
|
+
*
|
|
112
|
+
* Mirrors `daemon/src/dispatcher.ts#classifyTrust`: owner-chat rooms
|
|
113
|
+
* (`rm_oc_` prefix) and `dashboard_user_chat` come from the operator and
|
|
114
|
+
* pass through verbatim; everything else gets sanitized before emit.
|
|
115
|
+
*/
|
|
116
|
+
function isOwnerTrust(msg: InboxMessage): boolean {
|
|
117
|
+
if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX)) return true;
|
|
118
|
+
if (msg.source_type === "dashboard_user_chat") return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Map `InboxMessage` → `GatewayInboundMessage`. Field origins:
|
|
124
|
+
*
|
|
125
|
+
* id → msg.hub_msg_id (inbox id, what dispatcher currently keys on)
|
|
126
|
+
* channel → options.channelId (the adapter's unique instance id)
|
|
127
|
+
* accountId → options.accountId
|
|
128
|
+
* conversation.id → msg.room_id (required; we skip upstream if missing)
|
|
129
|
+
* conversation.kind → "direct" for rm_dm_ and rm_oc_ rooms, else "group"
|
|
130
|
+
* conversation.title → msg.room_name (daemon uses the same field in logs)
|
|
131
|
+
* conversation.threadId → msg.topic_id ?? msg.topic ?? null
|
|
132
|
+
* sender.id → msg.envelope.from
|
|
133
|
+
* sender.name → msg.source_user_name || undefined
|
|
134
|
+
* sender.kind → "user" when trust==owner or source_type=="dashboard_human_room",
|
|
135
|
+
* else "agent". "system" is not produced by daemon today.
|
|
136
|
+
* text → sanitized msg.text / envelope.payload.text (owner passes verbatim)
|
|
137
|
+
* raw → the full InboxMessage
|
|
138
|
+
* replyTo → msg.envelope.reply_to ?? null
|
|
139
|
+
* mentioned → msg.mentioned ?? false
|
|
140
|
+
* receivedAt → Date.now() (InboxMessage has no timestamp field today)
|
|
141
|
+
* trace.id → msg.hub_msg_id
|
|
142
|
+
* trace.streamable → true only for owner-chat rooms (matches daemon's stream-block rule)
|
|
143
|
+
*/
|
|
144
|
+
function normalizeInbox(
|
|
145
|
+
msg: InboxMessage,
|
|
146
|
+
options: { channelId: string; accountId: string },
|
|
147
|
+
): GatewayInboundMessage | null {
|
|
148
|
+
const env = msg.envelope;
|
|
149
|
+
if (!env) return null;
|
|
150
|
+
if (env.type !== "message") return null;
|
|
151
|
+
if (!msg.room_id) return null;
|
|
152
|
+
|
|
153
|
+
const rawText =
|
|
154
|
+
msg.text ?? (typeof env.payload?.text === "string" ? (env.payload.text as string) : "");
|
|
155
|
+
if (typeof rawText !== "string") return null;
|
|
156
|
+
|
|
157
|
+
const ownerTrust = isOwnerTrust(msg);
|
|
158
|
+
const text = ownerTrust ? rawText : sanitizeUntrustedContent(rawText);
|
|
159
|
+
|
|
160
|
+
const isDm = msg.room_id.startsWith(DM_ROOM_PREFIX);
|
|
161
|
+
const isOwnerChat = msg.room_id.startsWith(OWNER_CHAT_PREFIX);
|
|
162
|
+
const senderKind: "user" | "agent" =
|
|
163
|
+
ownerTrust || msg.source_type === "dashboard_human_room" ? "user" : "agent";
|
|
164
|
+
|
|
165
|
+
const senderName = msg.source_user_name ?? undefined;
|
|
166
|
+
const threadId = msg.topic_id ?? msg.topic ?? null;
|
|
167
|
+
const streamable = isOwnerChat;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: msg.hub_msg_id,
|
|
171
|
+
channel: options.channelId,
|
|
172
|
+
accountId: options.accountId,
|
|
173
|
+
conversation: {
|
|
174
|
+
id: msg.room_id,
|
|
175
|
+
kind: isDm || isOwnerChat ? "direct" : "group",
|
|
176
|
+
...(msg.room_name ? { title: msg.room_name } : {}),
|
|
177
|
+
threadId,
|
|
178
|
+
},
|
|
179
|
+
sender: {
|
|
180
|
+
id: env.from,
|
|
181
|
+
...(senderName ? { name: senderName } : {}),
|
|
182
|
+
kind: senderKind,
|
|
183
|
+
},
|
|
184
|
+
text,
|
|
185
|
+
raw: msg,
|
|
186
|
+
replyTo: env.reply_to ?? null,
|
|
187
|
+
mentioned: msg.mentioned ?? false,
|
|
188
|
+
receivedAt: Date.now(),
|
|
189
|
+
trace: { id: msg.hub_msg_id, streamable },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Construct a BotCord channel adapter.
|
|
195
|
+
*
|
|
196
|
+
* `start()` connects to Hub WS, drains `/hub/inbox` on every `inbox_update`,
|
|
197
|
+
* normalizes messages, and emits envelopes with a `accept()` ack that commits
|
|
198
|
+
* to Hub. The returned promise stays pending until `abortSignal` fires.
|
|
199
|
+
*/
|
|
200
|
+
export function createBotCordChannel(options: BotCordChannelOptions): ChannelAdapter {
|
|
201
|
+
const channelType = "botcord";
|
|
202
|
+
const factory = options.clientFactory ?? defaultClientFactory;
|
|
203
|
+
let clientRef: BotCordChannelClient | null = options.client ?? null;
|
|
204
|
+
const seenMessages = new Set<string>();
|
|
205
|
+
let stopCallback: (() => void) | null = null;
|
|
206
|
+
|
|
207
|
+
let statusSnapshot: ChannelStatusSnapshot = {
|
|
208
|
+
channel: options.id,
|
|
209
|
+
accountId: options.accountId,
|
|
210
|
+
running: false,
|
|
211
|
+
connected: false,
|
|
212
|
+
reconnectAttempts: 0,
|
|
213
|
+
lastError: null,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function rememberSeen(hubMsgId: string): boolean {
|
|
217
|
+
if (seenMessages.has(hubMsgId)) return false;
|
|
218
|
+
seenMessages.add(hubMsgId);
|
|
219
|
+
if (seenMessages.size > SEEN_MESSAGES_CAP) {
|
|
220
|
+
const first = seenMessages.values().next().value;
|
|
221
|
+
if (first) seenMessages.delete(first);
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function ensureClient(): BotCordChannelClient {
|
|
227
|
+
if (!clientRef) {
|
|
228
|
+
clientRef = factory({
|
|
229
|
+
agentId: options.agentId,
|
|
230
|
+
hubBaseUrl: options.hubBaseUrl,
|
|
231
|
+
credentialsPath: options.credentialsPath,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return clientRef;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function drainInbox(
|
|
238
|
+
client: BotCordChannelClient,
|
|
239
|
+
emit: (env: GatewayInboundEnvelope) => Promise<void>,
|
|
240
|
+
log: GatewayLogger,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
const resp = await client.pollInbox({ limit: 50, ack: false });
|
|
243
|
+
const msgs = resp.messages ?? [];
|
|
244
|
+
log.info("botcord inbox drained", { count: msgs.length });
|
|
245
|
+
if (msgs.length === 0) return;
|
|
246
|
+
|
|
247
|
+
for (const msg of msgs) {
|
|
248
|
+
if (!rememberSeen(msg.hub_msg_id)) {
|
|
249
|
+
// Already emitted; ack again so Hub stops requeueing.
|
|
250
|
+
try {
|
|
251
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log.warn("botcord duplicate ack failed", { err: String(err) });
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const normalized = normalizeInbox(msg, {
|
|
258
|
+
channelId: options.id,
|
|
259
|
+
accountId: options.accountId,
|
|
260
|
+
});
|
|
261
|
+
if (!normalized) {
|
|
262
|
+
// Not eligible (wrong type, missing room, etc.) — ack so it drops.
|
|
263
|
+
try {
|
|
264
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
log.warn("botcord skip ack failed", { err: String(err) });
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const envelope: GatewayInboundEnvelope = {
|
|
271
|
+
message: normalized,
|
|
272
|
+
ack: {
|
|
273
|
+
accept: async () => {
|
|
274
|
+
try {
|
|
275
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
log.warn("botcord ack failed — relying on seen-cache dedup", {
|
|
278
|
+
hubMsgId: msg.hub_msg_id,
|
|
279
|
+
err: String(err),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
await emit(envelope);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
log.error("botcord emit threw", {
|
|
289
|
+
hubMsgId: msg.hub_msg_id,
|
|
290
|
+
err: String(err),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function startWsLoop(
|
|
297
|
+
client: BotCordChannelClient,
|
|
298
|
+
ctx: ChannelStartContext,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
const { abortSignal, log, emit, setStatus } = ctx;
|
|
301
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
302
|
+
const wsCtor = options.webSocketCtor ?? WebSocket;
|
|
303
|
+
|
|
304
|
+
let ws: WebSocket | null = null;
|
|
305
|
+
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
306
|
+
let keepaliveTimer: NodeJS.Timeout | null = null;
|
|
307
|
+
let reconnectAttempt = 0;
|
|
308
|
+
let consecutiveAuthFailures = 0;
|
|
309
|
+
let running = true;
|
|
310
|
+
let processing = false;
|
|
311
|
+
let pendingUpdate = false;
|
|
312
|
+
let pendingRefresh: Promise<unknown> | null = null;
|
|
313
|
+
let resolveLoop: (() => void) | null = null;
|
|
314
|
+
|
|
315
|
+
const done = new Promise<void>((resolve) => {
|
|
316
|
+
resolveLoop = resolve;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
function clearTimers() {
|
|
320
|
+
if (reconnectTimer) {
|
|
321
|
+
clearTimeout(reconnectTimer);
|
|
322
|
+
reconnectTimer = null;
|
|
323
|
+
}
|
|
324
|
+
if (keepaliveTimer) {
|
|
325
|
+
clearInterval(keepaliveTimer);
|
|
326
|
+
keepaliveTimer = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function markStatus(patch: Partial<ChannelStatusSnapshot>) {
|
|
331
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
332
|
+
setStatus(patch);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function fireInbox() {
|
|
336
|
+
if (processing) {
|
|
337
|
+
pendingUpdate = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
processing = true;
|
|
341
|
+
try {
|
|
342
|
+
do {
|
|
343
|
+
pendingUpdate = false;
|
|
344
|
+
await drainInbox(client, emit, log);
|
|
345
|
+
} while (pendingUpdate && running);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
log.error("botcord inbox drain failed", { err: String(err) });
|
|
348
|
+
} finally {
|
|
349
|
+
processing = false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function scheduleReconnect() {
|
|
354
|
+
if (!running) return;
|
|
355
|
+
const delay =
|
|
356
|
+
RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
|
|
357
|
+
reconnectAttempt += 1;
|
|
358
|
+
markStatus({
|
|
359
|
+
connected: false,
|
|
360
|
+
restartPending: true,
|
|
361
|
+
reconnectAttempts: reconnectAttempt,
|
|
362
|
+
});
|
|
363
|
+
log.info("botcord ws reconnect scheduled", { delayMs: delay, attempt: reconnectAttempt });
|
|
364
|
+
reconnectTimer = setTimeout(() => {
|
|
365
|
+
reconnectTimer = null;
|
|
366
|
+
void connect();
|
|
367
|
+
}, delay);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function connect() {
|
|
371
|
+
if (!running) return;
|
|
372
|
+
markStatus({ connected: false, restartPending: false });
|
|
373
|
+
if (pendingRefresh) {
|
|
374
|
+
try {
|
|
375
|
+
await pendingRefresh;
|
|
376
|
+
} catch {
|
|
377
|
+
// already logged by scheduler
|
|
378
|
+
} finally {
|
|
379
|
+
pendingRefresh = null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
let token: string;
|
|
383
|
+
try {
|
|
384
|
+
token = await client.ensureToken();
|
|
385
|
+
} catch (err) {
|
|
386
|
+
log.error("botcord ws token refresh failed", { err: String(err) });
|
|
387
|
+
markStatus({ lastError: String(err) });
|
|
388
|
+
scheduleReconnect();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const url = buildHubWebSocketUrl(hubUrl);
|
|
393
|
+
log.info("botcord ws connecting", { url, agentId: options.agentId });
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
ws = new wsCtor(url);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
log.error("botcord ws construct failed", { err: String(err) });
|
|
399
|
+
markStatus({ lastError: String(err) });
|
|
400
|
+
scheduleReconnect();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
ws.on("open", () => {
|
|
405
|
+
ws!.send(JSON.stringify({ type: "auth", token }));
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
ws.on("message", (data: WebSocket.RawData) => {
|
|
409
|
+
let msg: { type?: string; agent_id?: string } | null = null;
|
|
410
|
+
try {
|
|
411
|
+
msg = JSON.parse(String(data));
|
|
412
|
+
} catch {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!msg || typeof msg.type !== "string") return;
|
|
416
|
+
if (msg.type === "auth_ok") {
|
|
417
|
+
reconnectAttempt = 0;
|
|
418
|
+
consecutiveAuthFailures = 0;
|
|
419
|
+
markStatus({
|
|
420
|
+
running: true,
|
|
421
|
+
connected: true,
|
|
422
|
+
reconnectAttempts: 0,
|
|
423
|
+
lastStartAt: Date.now(),
|
|
424
|
+
lastError: null,
|
|
425
|
+
});
|
|
426
|
+
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
427
|
+
void fireInbox();
|
|
428
|
+
keepaliveTimer = setInterval(() => {
|
|
429
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
430
|
+
try {
|
|
431
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
432
|
+
} catch {
|
|
433
|
+
// ignore
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}, KEEPALIVE_INTERVAL);
|
|
437
|
+
} else if (msg.type === "inbox_update") {
|
|
438
|
+
log.info("botcord ws inbox_update received");
|
|
439
|
+
void fireInbox();
|
|
440
|
+
} else if (msg.type === "heartbeat" || msg.type === "pong") {
|
|
441
|
+
// no-op
|
|
442
|
+
} else if (msg.type === "error" || msg.type === "auth_failed") {
|
|
443
|
+
log.warn("botcord ws server error", { msg });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
ws.on("close", (code: number, reason: Buffer) => {
|
|
448
|
+
const reasonStr = reason?.toString() || "";
|
|
449
|
+
log.info("botcord ws closed", { code, reason: reasonStr });
|
|
450
|
+
clearTimers();
|
|
451
|
+
markStatus({ connected: false });
|
|
452
|
+
if (!running) {
|
|
453
|
+
if (resolveLoop) {
|
|
454
|
+
const r = resolveLoop;
|
|
455
|
+
resolveLoop = null;
|
|
456
|
+
r();
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (code === 4001) {
|
|
461
|
+
consecutiveAuthFailures += 1;
|
|
462
|
+
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
|
463
|
+
log.error("botcord ws auth failing persistently — giving up reconnects", {
|
|
464
|
+
failures: consecutiveAuthFailures,
|
|
465
|
+
});
|
|
466
|
+
running = false;
|
|
467
|
+
markStatus({
|
|
468
|
+
running: false,
|
|
469
|
+
connected: false,
|
|
470
|
+
lastStopAt: Date.now(),
|
|
471
|
+
lastError: "auth failed repeatedly",
|
|
472
|
+
});
|
|
473
|
+
if (resolveLoop) {
|
|
474
|
+
const r = resolveLoop;
|
|
475
|
+
resolveLoop = null;
|
|
476
|
+
r();
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
pendingRefresh = client
|
|
481
|
+
.refreshToken()
|
|
482
|
+
.catch((err) => log.error("botcord ws forced refresh failed", { err: String(err) }));
|
|
483
|
+
}
|
|
484
|
+
scheduleReconnect();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
ws.on("error", (err: Error) => {
|
|
488
|
+
log.warn("botcord ws error", { err: String(err) });
|
|
489
|
+
markStatus({ lastError: String(err) });
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function stopLoop() {
|
|
494
|
+
if (!running) return;
|
|
495
|
+
running = false;
|
|
496
|
+
clearTimers();
|
|
497
|
+
markStatus({
|
|
498
|
+
running: false,
|
|
499
|
+
connected: false,
|
|
500
|
+
lastStopAt: Date.now(),
|
|
501
|
+
});
|
|
502
|
+
if (ws) {
|
|
503
|
+
try {
|
|
504
|
+
ws.close();
|
|
505
|
+
} catch {
|
|
506
|
+
// ignore
|
|
507
|
+
}
|
|
508
|
+
ws = null;
|
|
509
|
+
}
|
|
510
|
+
if (resolveLoop) {
|
|
511
|
+
const r = resolveLoop;
|
|
512
|
+
resolveLoop = null;
|
|
513
|
+
r();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
stopCallback = stopLoop;
|
|
518
|
+
abortSignal.addEventListener("abort", stopLoop, { once: true });
|
|
519
|
+
void connect();
|
|
520
|
+
return done;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const adapter: ChannelAdapter = {
|
|
524
|
+
id: options.id,
|
|
525
|
+
type: channelType,
|
|
526
|
+
|
|
527
|
+
async start(ctx: ChannelStartContext): Promise<void> {
|
|
528
|
+
const client = ensureClient();
|
|
529
|
+
// Only patch fields owned by the adapter; the manager is the single
|
|
530
|
+
// writer for `channel` (== adapter.id) and `accountId`.
|
|
531
|
+
const patch: Partial<ChannelStatusSnapshot> = {
|
|
532
|
+
running: true,
|
|
533
|
+
connected: false,
|
|
534
|
+
reconnectAttempts: 0,
|
|
535
|
+
lastStartAt: Date.now(),
|
|
536
|
+
lastError: null,
|
|
537
|
+
};
|
|
538
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
539
|
+
ctx.setStatus(patch);
|
|
540
|
+
await startWsLoop(client, ctx);
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
async stop(_ctx: ChannelStopContext): Promise<void> {
|
|
544
|
+
if (stopCallback) {
|
|
545
|
+
stopCallback();
|
|
546
|
+
stopCallback = null;
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
|
551
|
+
const client = ensureClient();
|
|
552
|
+
const { message } = ctx;
|
|
553
|
+
const options: { replyTo?: string; topic?: string } = {};
|
|
554
|
+
if (message.replyTo) options.replyTo = message.replyTo;
|
|
555
|
+
if (message.threadId) options.topic = message.threadId;
|
|
556
|
+
const resp = await client.sendMessage(message.conversationId, message.text, options);
|
|
557
|
+
const providerMessageId =
|
|
558
|
+
(resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
|
|
559
|
+
(resp && typeof (resp as { message_id?: unknown }).message_id === "string"
|
|
560
|
+
? (resp as { message_id: string }).message_id
|
|
561
|
+
: null);
|
|
562
|
+
return { providerMessageId: providerMessageId ?? null };
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
async streamBlock(ctx: ChannelStreamBlockContext): Promise<void> {
|
|
566
|
+
const client = ensureClient();
|
|
567
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
568
|
+
try {
|
|
569
|
+
const token = await client.ensureToken();
|
|
570
|
+
const block = ctx.block as { raw?: unknown; kind?: string; seq?: number } | undefined;
|
|
571
|
+
const resp = await fetch(`${hubUrl}/hub/stream-block`, {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: {
|
|
574
|
+
"Content-Type": "application/json",
|
|
575
|
+
Authorization: `Bearer ${token}`,
|
|
576
|
+
},
|
|
577
|
+
body: JSON.stringify({
|
|
578
|
+
trace_id: ctx.traceId,
|
|
579
|
+
seq: typeof block?.seq === "number" ? block.seq : 0,
|
|
580
|
+
block: ctx.block,
|
|
581
|
+
}),
|
|
582
|
+
signal: AbortSignal.timeout(10_000),
|
|
583
|
+
});
|
|
584
|
+
if (!resp.ok && resp.status !== 204) {
|
|
585
|
+
const body = await resp.text().catch(() => "");
|
|
586
|
+
ctx.log.warn("botcord stream-block non-ok", {
|
|
587
|
+
status: resp.status,
|
|
588
|
+
body: body.slice(0, 200),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
ctx.log.warn("botcord stream-block failed", { err: String(err) });
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
status(): ChannelStatusSnapshot {
|
|
597
|
+
return { ...statusSnapshot };
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
return adapter;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Re-export the normalizer for tests that want to exercise it directly.
|
|
605
|
+
export { normalizeInbox as __normalizeInboxForTests };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize untrusted inbound content before handing it off to a local runtime.
|
|
3
|
+
*
|
|
4
|
+
* Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
|
|
5
|
+
* does not depend back on the daemon package. Keep these two files in sync —
|
|
6
|
+
* any new structural marker added in one place should be mirrored in the other.
|
|
7
|
+
*
|
|
8
|
+
* Neutralizes:
|
|
9
|
+
* - BotCord structural markers the channel itself emits (so peers can't forge them).
|
|
10
|
+
* - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
|
|
11
|
+
* - Wrapper XML tags the channel uses to frame inbound content
|
|
12
|
+
* (<agent-message>, <human-message>, <room-rule>).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function sanitizeUntrustedContent(text: string): string {
|
|
16
|
+
let s = text;
|
|
17
|
+
s = s.replace(
|
|
18
|
+
/<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
|
|
19
|
+
"[⚠ stripped: agent-message tag]",
|
|
20
|
+
);
|
|
21
|
+
s = s.replace(
|
|
22
|
+
/<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
|
|
23
|
+
"[⚠ stripped: human-message tag]",
|
|
24
|
+
);
|
|
25
|
+
s = s.replace(
|
|
26
|
+
/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi,
|
|
27
|
+
"[⚠ stripped: room-rule tag]",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return s
|
|
31
|
+
.split(/\r?\n/)
|
|
32
|
+
.map((line) => {
|
|
33
|
+
let l = line;
|
|
34
|
+
l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
|
|
35
|
+
l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
|
|
36
|
+
l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
|
|
37
|
+
l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
|
|
38
|
+
l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
|
|
39
|
+
const head = String(label).split(":")[0].trim() || String(label).trim();
|
|
40
|
+
return `[⚠ fake: BotCord ${head}]`;
|
|
41
|
+
});
|
|
42
|
+
l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
|
|
43
|
+
l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
|
|
44
|
+
l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
|
|
45
|
+
l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
|
|
46
|
+
l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
|
|
47
|
+
l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
|
|
48
|
+
l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
|
|
49
|
+
return l;
|
|
50
|
+
})
|
|
51
|
+
.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize a sender label so it's safe to embed inside
|
|
56
|
+
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
57
|
+
* markers, or characters that could break the XML attribute boundary.
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeSenderName(name: string): string {
|
|
60
|
+
return name
|
|
61
|
+
.replace(/[\n\r]/g, " ")
|
|
62
|
+
.replace(/\[/g, "⟦")
|
|
63
|
+
.replace(/\]/g, "⟧")
|
|
64
|
+
.replace(/"/g, "'")
|
|
65
|
+
.replace(/</g, "<")
|
|
66
|
+
.replace(/>/g, ">")
|
|
67
|
+
.slice(0, 100);
|
|
68
|
+
}
|