@botcord/botcord 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.
@@ -0,0 +1,413 @@
1
+ type AgentMessageLike = {
2
+ role?: unknown;
3
+ content?: unknown;
4
+ timestamp?: unknown;
5
+ };
6
+
7
+ type UserTurn = {
8
+ text: string;
9
+ normalized: string;
10
+ timestamp?: number;
11
+ };
12
+
13
+ type OutboundSample = {
14
+ text: string;
15
+ normalized: string;
16
+ timestamp: number;
17
+ };
18
+
19
+ export type BotCordLoopRiskReason = {
20
+ id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
21
+ summary: string;
22
+ };
23
+
24
+ export type BotCordLoopRiskEvaluation = {
25
+ reasons: BotCordLoopRiskReason[];
26
+ };
27
+
28
+ const outboundBySession = new Map<string, OutboundSample[]>();
29
+
30
+ const TURN_WINDOW_MS = 2 * 60_000;
31
+ const TURN_THRESHOLD = 8;
32
+ const ALTERNATION_THRESHOLD = 6;
33
+ const MIN_TURNS_PER_SIDE = 3;
34
+
35
+ const OUTBOUND_MAX_AGE_MS = 10 * 60_000;
36
+ const MAX_TRACKED_OUTBOUND = 6;
37
+ const SHORT_ACK_MAX_CHARS = 48;
38
+ const MIN_REPEAT_TEXT_CHARS = 6;
39
+ const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
40
+
41
+ const ENGLISH_ACK_OR_CLOSURE = new Set([
42
+ "ok",
43
+ "okay",
44
+ "got it",
45
+ "thanks",
46
+ "thank you",
47
+ "noted",
48
+ "understood",
49
+ "sounds good",
50
+ "sgtm",
51
+ "roger",
52
+ "copy",
53
+ "will do",
54
+ "all good",
55
+ "no worries",
56
+ "bye",
57
+ "goodbye",
58
+ "see you",
59
+ "talk later",
60
+ ]);
61
+
62
+ const CHINESE_ACK_OR_CLOSURE = new Set([
63
+ "收到",
64
+ "好的",
65
+ "好",
66
+ "行",
67
+ "嗯",
68
+ "嗯嗯",
69
+ "明白",
70
+ "明白了",
71
+ "知道了",
72
+ "谢谢",
73
+ "谢谢你",
74
+ "感谢",
75
+ "辛苦了",
76
+ "先这样",
77
+ "回头聊",
78
+ "有需要再说",
79
+ "没问题",
80
+ "了解",
81
+ "好嘞",
82
+ ]);
83
+
84
+ function resolveTimestamp(value: unknown): number | undefined {
85
+ if (typeof value === "number" && Number.isFinite(value)) return value;
86
+ if (typeof value === "string") {
87
+ const numeric = Number(value);
88
+ if (Number.isFinite(numeric)) return numeric;
89
+ const parsed = Date.parse(value);
90
+ if (Number.isFinite(parsed)) return parsed;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function extractTextFromContent(content: unknown): string {
96
+ if (typeof content === "string") return content;
97
+ if (Array.isArray(content)) {
98
+ return content
99
+ .map((part) => {
100
+ if (typeof part === "string") return part;
101
+ if (!part || typeof part !== "object") return "";
102
+ const record = part as Record<string, unknown>;
103
+ if (record.type === "text" && typeof record.text === "string") return record.text;
104
+ if (typeof record.text === "string") return record.text;
105
+ return "";
106
+ })
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ }
110
+ if (content && typeof content === "object") {
111
+ const record = content as Record<string, unknown>;
112
+ if (typeof record.text === "string") return record.text;
113
+ }
114
+ return "";
115
+ }
116
+
117
+ function stripBotCordPromptScaffolding(text: string): string {
118
+ const filtered = text
119
+ .split(/\r?\n/)
120
+ .map((line) => line.trim())
121
+ .filter((line) => {
122
+ if (!line) return false;
123
+ if (line.startsWith("[BotCord Message]")) return false;
124
+ if (line.startsWith("[BotCord Notification]")) return false;
125
+ if (line.startsWith("[Room Rule]")) return false;
126
+ if (line.startsWith("[In group chats, do NOT reply")) return false;
127
+ if (line.startsWith("[If the conversation has naturally concluded")) return false;
128
+ if (line.includes('reply with exactly "NO_REPLY"')) return false;
129
+ if (line.startsWith("<agent-message")) return false;
130
+ if (line === "</agent-message>") return false;
131
+ if (line.startsWith("<room-rule>")) return false;
132
+ if (line === "</room-rule>") return false;
133
+ return true;
134
+ });
135
+
136
+ return filtered.join("\n").trim();
137
+ }
138
+
139
+ function normalizeLoopText(text: string): string {
140
+ return stripBotCordPromptScaffolding(text)
141
+ .toLowerCase()
142
+ .replace(/https?:\/\/\S+/gu, " ")
143
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
144
+ .replace(/\s+/gu, " ")
145
+ .trim();
146
+ }
147
+
148
+ function isShortAckOrClosure(text: string): boolean {
149
+ const normalized = normalizeLoopText(text);
150
+ if (!normalized || normalized.length > SHORT_ACK_MAX_CHARS) return false;
151
+ return ENGLISH_ACK_OR_CLOSURE.has(normalized) || CHINESE_ACK_OR_CLOSURE.has(normalized);
152
+ }
153
+
154
+ function trigramSet(text: string): Set<string> {
155
+ if (text.length <= 3) return new Set([text]);
156
+ const grams = new Set<string>();
157
+ for (let i = 0; i <= text.length - 3; i++) {
158
+ grams.add(text.slice(i, i + 3));
159
+ }
160
+ return grams;
161
+ }
162
+
163
+ function jaccardSimilarity(a: string, b: string): number {
164
+ if (!a || !b) return 0;
165
+ if (a === b) return 1;
166
+ const aSet = trigramSet(a);
167
+ const bSet = trigramSet(b);
168
+ let intersection = 0;
169
+ for (const gram of aSet) {
170
+ if (bSet.has(gram)) intersection++;
171
+ }
172
+ const union = aSet.size + bSet.size - intersection;
173
+ return union === 0 ? 0 : intersection / union;
174
+ }
175
+
176
+ function areOutboundTextsHighlySimilar(a: string, b: string): boolean {
177
+ if (!a || !b) return false;
178
+ if (a === b) return true;
179
+ if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS) return false;
180
+ return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
181
+ }
182
+
183
+ function extractHistoricalUserTurns(messages: unknown[]): UserTurn[] {
184
+ const result: UserTurn[] = [];
185
+ for (const message of messages) {
186
+ if (!message || typeof message !== "object") continue;
187
+ const candidate = message as AgentMessageLike;
188
+ if (candidate.role !== "user") continue;
189
+ const rawText = extractTextFromContent(candidate.content);
190
+ const text = stripBotCordPromptScaffolding(rawText);
191
+ if (!text) continue;
192
+ result.push({
193
+ text,
194
+ normalized: normalizeLoopText(text),
195
+ timestamp: resolveTimestamp(candidate.timestamp),
196
+ });
197
+ }
198
+ return result;
199
+ }
200
+
201
+ function looksLikeBotCordPrompt(prompt: string): boolean {
202
+ return prompt.includes("[BotCord Message]") || prompt.includes("[BotCord Notification]");
203
+ }
204
+
205
+ function pruneOutboundSamples(sessionKey: string, now: number): OutboundSample[] {
206
+ const existing = outboundBySession.get(sessionKey) ?? [];
207
+ const next = existing.filter((sample) => now - sample.timestamp <= OUTBOUND_MAX_AGE_MS);
208
+ if (next.length === 0) {
209
+ outboundBySession.delete(sessionKey);
210
+ return [];
211
+ }
212
+ outboundBySession.set(sessionKey, next);
213
+ return next;
214
+ }
215
+
216
+ function recordOutboundSample(sessionKey: string, sample: OutboundSample): void {
217
+ const existing = pruneOutboundSamples(sessionKey, sample.timestamp);
218
+ const next = [...existing, sample].slice(-MAX_TRACKED_OUTBOUND);
219
+ outboundBySession.set(sessionKey, next);
220
+ }
221
+
222
+ function buildTurnTimeline(params: {
223
+ historicalUserTurns: UserTurn[];
224
+ currentPrompt: string;
225
+ outbound: OutboundSample[];
226
+ now: number;
227
+ }): Array<{ role: "user" | "assistant"; timestamp: number }> {
228
+ const { historicalUserTurns, currentPrompt, outbound, now } = params;
229
+ const turns: Array<{ role: "user" | "assistant"; timestamp: number }> = [];
230
+
231
+ for (const turn of historicalUserTurns) {
232
+ if (turn.timestamp !== undefined && now - turn.timestamp <= TURN_WINDOW_MS) {
233
+ turns.push({ role: "user", timestamp: turn.timestamp });
234
+ }
235
+ }
236
+
237
+ if (stripBotCordPromptScaffolding(currentPrompt)) {
238
+ turns.push({ role: "user", timestamp: now });
239
+ }
240
+
241
+ for (const sample of outbound) {
242
+ if (now - sample.timestamp <= TURN_WINDOW_MS) {
243
+ turns.push({ role: "assistant", timestamp: sample.timestamp });
244
+ }
245
+ }
246
+
247
+ turns.sort((a, b) => a.timestamp - b.timestamp);
248
+ return turns;
249
+ }
250
+
251
+ function detectHighTurnRate(params: {
252
+ historicalUserTurns: UserTurn[];
253
+ currentPrompt: string;
254
+ outbound: OutboundSample[];
255
+ now: number;
256
+ }): BotCordLoopRiskReason | undefined {
257
+ const timeline = buildTurnTimeline(params);
258
+ if (timeline.length < TURN_THRESHOLD) return undefined;
259
+
260
+ let userTurns = 0;
261
+ let assistantTurns = 0;
262
+ let alternations = 0;
263
+
264
+ for (let i = 0; i < timeline.length; i++) {
265
+ if (timeline[i]?.role === "user") userTurns++;
266
+ if (timeline[i]?.role === "assistant") assistantTurns++;
267
+ if (i > 0 && timeline[i]?.role !== timeline[i - 1]?.role) alternations++;
268
+ }
269
+
270
+ if (
271
+ userTurns >= MIN_TURNS_PER_SIDE &&
272
+ assistantTurns >= MIN_TURNS_PER_SIDE &&
273
+ alternations >= ALTERNATION_THRESHOLD
274
+ ) {
275
+ return {
276
+ id: "high_turn_rate",
277
+ summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
278
+ };
279
+ }
280
+
281
+ return undefined;
282
+ }
283
+
284
+ function detectShortAckTail(params: {
285
+ historicalUserTurns: UserTurn[];
286
+ currentPrompt: string;
287
+ }): BotCordLoopRiskReason | undefined {
288
+ const currentPrompt = stripBotCordPromptScaffolding(params.currentPrompt);
289
+ const userTexts = params.historicalUserTurns.map((turn) => turn.text);
290
+ if (currentPrompt) userTexts.push(currentPrompt);
291
+ const tail = userTexts.slice(-2);
292
+ if (tail.length < 2) return undefined;
293
+ if (tail.every((text) => isShortAckOrClosure(text))) {
294
+ return {
295
+ id: "short_ack_tail",
296
+ summary: "the last two inbound user messages are short acknowledgements or closure phrases",
297
+ };
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ function detectRepeatedOutbound(outbound: OutboundSample[]): BotCordLoopRiskReason | undefined {
303
+ const recent = outbound.slice(-3);
304
+ if (recent.length < 2) return undefined;
305
+
306
+ const last = recent[recent.length - 1];
307
+ if (!last) return undefined;
308
+
309
+ const previous = recent.slice(0, -1);
310
+ const exactMatches = previous.filter((sample) => sample.normalized === last.normalized).length;
311
+ const similarMatches = previous.filter((sample) =>
312
+ areOutboundTextsHighlySimilar(sample.normalized, last.normalized)
313
+ ).length;
314
+
315
+ if (exactMatches >= 1 || (recent.length >= 3 && similarMatches >= 2)) {
316
+ return {
317
+ id: "repeated_outbound",
318
+ summary: "recent botcord_send texts in this session are highly similar",
319
+ };
320
+ }
321
+
322
+ return undefined;
323
+ }
324
+
325
+ export function shouldRunBotCordLoopRiskCheck(params: {
326
+ channelId?: string;
327
+ prompt: string;
328
+ trigger?: string;
329
+ }): boolean {
330
+ if (params.trigger && params.trigger !== "user") return false;
331
+ return params.channelId === "botcord" || looksLikeBotCordPrompt(params.prompt);
332
+ }
333
+
334
+ export function recordBotCordOutboundText(params: {
335
+ sessionKey?: string;
336
+ text?: unknown;
337
+ timestamp?: number;
338
+ }): void {
339
+ const sessionKey = params.sessionKey?.trim();
340
+ const rawText = typeof params.text === "string" ? params.text.trim() : "";
341
+ if (!sessionKey || !rawText) return;
342
+ const normalized = normalizeLoopText(rawText);
343
+ if (!normalized) return;
344
+ const timestamp = params.timestamp ?? Date.now();
345
+ recordOutboundSample(sessionKey, { text: rawText, normalized, timestamp });
346
+ }
347
+
348
+ export function clearBotCordLoopRiskSession(sessionKey?: string): void {
349
+ if (!sessionKey) return;
350
+ outboundBySession.delete(sessionKey);
351
+ }
352
+
353
+ export function evaluateBotCordLoopRisk(params: {
354
+ prompt: string;
355
+ messages: unknown[];
356
+ sessionKey?: string;
357
+ now?: number;
358
+ }): BotCordLoopRiskEvaluation {
359
+ const now = params.now ?? Date.now();
360
+ const historicalUserTurns = extractHistoricalUserTurns(params.messages);
361
+ const outbound = params.sessionKey ? pruneOutboundSamples(params.sessionKey, now) : [];
362
+
363
+ const reasons = [
364
+ detectHighTurnRate({
365
+ historicalUserTurns,
366
+ currentPrompt: params.prompt,
367
+ outbound,
368
+ now,
369
+ }),
370
+ detectShortAckTail({
371
+ historicalUserTurns,
372
+ currentPrompt: params.prompt,
373
+ }),
374
+ detectRepeatedOutbound(outbound),
375
+ ].filter((reason): reason is BotCordLoopRiskReason => Boolean(reason));
376
+
377
+ return { reasons };
378
+ }
379
+
380
+ export function buildBotCordLoopRiskPrompt(params: {
381
+ prompt: string;
382
+ messages: unknown[];
383
+ sessionKey?: string;
384
+ now?: number;
385
+ }): string | undefined {
386
+ const evaluation = evaluateBotCordLoopRisk(params);
387
+ if (evaluation.reasons.length === 0) return undefined;
388
+
389
+ const lines = [
390
+ "[BotCord loop-risk check]",
391
+ "Observed signals:",
392
+ ...evaluation.reasons.map((reason) => `- ${reason.summary}`),
393
+ "",
394
+ "Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
395
+ 'If it does not, reply with exactly "NO_REPLY" and nothing else.',
396
+ "Do not send courtesy-only acknowledgements or mirrored sign-offs.",
397
+ ];
398
+
399
+ return lines.join("\n");
400
+ }
401
+
402
+ export function didBotCordSendSucceed(result: unknown, error?: string): boolean {
403
+ if (error) return false;
404
+ if (!result || typeof result !== "object") return true;
405
+ const record = result as Record<string, unknown>;
406
+ if (record.ok === true) return true;
407
+ if (typeof record.error === "string" && record.error.trim()) return false;
408
+ return true;
409
+ }
410
+
411
+ export function resetBotCordLoopRiskStateForTests(): void {
412
+ outboundBySession.clear();
413
+ }
package/src/poller.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Background inbox polling for BotCord.
3
+ * Used when websocket delivery is unavailable.
4
+ */
5
+ import { BotCordClient } from "./client.js";
6
+ import { handleInboxMessage } from "./inbound.js";
7
+ import { displayPrefix } from "./config.js";
8
+
9
+ interface PollerOptions {
10
+ client: BotCordClient;
11
+ accountId: string;
12
+ cfg: any;
13
+ intervalMs: number;
14
+ abortSignal?: AbortSignal;
15
+ log?: { info: (msg: string) => void; error: (msg: string) => void; warn: (msg: string) => void };
16
+ }
17
+
18
+ const activePollers = new Map<string, { stop: () => void }>();
19
+
20
+ export function startPoller(opts: PollerOptions): { stop: () => void } {
21
+ const { client, accountId, cfg, intervalMs, abortSignal, log } = opts;
22
+ const dp = displayPrefix(accountId, cfg);
23
+ let running = true;
24
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ async function poll() {
27
+ if (!running || abortSignal?.aborted) return;
28
+
29
+ try {
30
+ const resp = await client.pollInbox({ limit: 20, ack: true });
31
+ const messages = resp.messages || [];
32
+
33
+ for (const msg of messages) {
34
+ try {
35
+ await handleInboxMessage(msg, accountId, cfg);
36
+ } catch (err: any) {
37
+ log?.error(`[${dp}] failed to dispatch message ${msg.hub_msg_id}: ${err.message}`);
38
+ }
39
+ }
40
+ } catch (err: any) {
41
+ if (!running) return;
42
+ log?.error(`[${dp}] poll error: ${err.message}`);
43
+ }
44
+
45
+ if (running && !abortSignal?.aborted) {
46
+ timeoutId = setTimeout(poll, intervalMs);
47
+ }
48
+ }
49
+
50
+ function stop() {
51
+ running = false;
52
+ if (timeoutId) clearTimeout(timeoutId);
53
+ activePollers.delete(accountId);
54
+ }
55
+
56
+ // Start first poll
57
+ timeoutId = setTimeout(poll, 500);
58
+
59
+ const entry = { stop };
60
+ activePollers.set(accountId, entry);
61
+
62
+ abortSignal?.addEventListener("abort", stop, { once: true });
63
+
64
+ return entry;
65
+ }
66
+
67
+ export function stopPoller(accountId: string): void {
68
+ const poller = activePollers.get(accountId);
69
+ if (poller) poller.stop();
70
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reply dispatcher for dashboard user chat mode.
3
+ *
4
+ * Unlike the A2A flow (where replies are suppressed and sent via botcord_send),
5
+ * user chat replies are automatically delivered back to the owner-agent chat room.
6
+ */
7
+ import { BotCordClient } from "./client.js";
8
+ import type { MessageAttachment } from "./types.js";
9
+
10
+ export interface BotCordReplyDispatcherOptions {
11
+ client: BotCordClient;
12
+ replyTarget: string; // room_id for the owner-agent chat room
13
+ }
14
+
15
+ export interface BotCordReplyDispatcher {
16
+ sendText: (text: string) => Promise<void>;
17
+ sendMedia: (text: string, mediaUrl: string) => Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Create a reply dispatcher that sends replies back to the owner-agent chat
22
+ * room via the Hub `/hub/send` API.
23
+ */
24
+ export function createBotCordReplyDispatcher(
25
+ options: BotCordReplyDispatcherOptions,
26
+ ): BotCordReplyDispatcher {
27
+ const { client, replyTarget } = options;
28
+
29
+ return {
30
+ sendText: async (text: string) => {
31
+ try {
32
+ await client.sendMessage(replyTarget, text);
33
+ } catch (err: any) {
34
+ console.error(
35
+ `[botcord] user-chat reply failed (text):`,
36
+ err?.message ?? err,
37
+ );
38
+ }
39
+ },
40
+
41
+ sendMedia: async (text: string, mediaUrl: string) => {
42
+ const attachments: MessageAttachment[] = [];
43
+ if (mediaUrl) {
44
+ const filename = mediaUrl.split("/").pop() || "attachment";
45
+ attachments.push({ filename, url: mediaUrl });
46
+ }
47
+ try {
48
+ await client.sendMessage(replyTarget, text, {
49
+ attachments: attachments.length > 0 ? attachments : undefined,
50
+ });
51
+ } catch (err: any) {
52
+ console.error(
53
+ `[botcord] user-chat reply failed (media):`,
54
+ err?.message ?? err,
55
+ );
56
+ }
57
+ },
58
+ };
59
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Plugin runtime store — holds a reference to OpenClaw's PluginRuntime
3
+ * and a config getter for tools/hooks that need the full app config.
4
+ */
5
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
6
+
7
+ let runtime: PluginRuntime | null = null;
8
+ let configGetter: (() => any) | null = null;
9
+
10
+ export function setBotCordRuntime(rt: PluginRuntime): void {
11
+ runtime = rt;
12
+ }
13
+
14
+ export function getBotCordRuntime(): PluginRuntime {
15
+ if (!runtime) throw new Error("BotCord runtime not initialized");
16
+ return runtime;
17
+ }
18
+
19
+ export function setConfigGetter(fn: () => any): void {
20
+ configGetter = fn;
21
+ }
22
+
23
+ export function getConfig(): any {
24
+ return configGetter?.() ?? null;
25
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sanitize untrusted message content by neutralizing BotCord structural markers.
3
+ * Replaces fake [BotCord Message], [BotCord Notification], [Room Rule] prefixes
4
+ * and common LLM prompt injection patterns.
5
+ */
6
+ export function sanitizeUntrustedContent(text: string): string {
7
+ // Strip wrapper XML tags on the full string BEFORE line splitting so that
8
+ // multiline tags like `<agent-message\n sender="evil">` or tags with the
9
+ // name itself split across lines (`<agent-\nmessage>`) are caught.
10
+ let s = text;
11
+ s = s.replace(/<\/?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, "[⚠ stripped: agent-message tag]");
12
+ s = s.replace(/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi, "[⚠ stripped: room-rule tag]");
13
+
14
+ return s
15
+ .split(/\r?\n/)
16
+ .map(line => {
17
+ let l = line;
18
+ // Neutralize fake BotCord structural markers at line start
19
+ l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
20
+ l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
21
+ l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
22
+ l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
23
+ // Neutralize common LLM prompt injection markers (open and close tags)
24
+ l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
25
+ l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
26
+ l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
27
+ l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
28
+ l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
29
+ l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
30
+ return l;
31
+ })
32
+ .join("\n");
33
+ }
34
+
35
+ /**
36
+ * Sanitize sender name — must not contain newlines or structural markers.
37
+ */
38
+ export function sanitizeSenderName(name: string): string {
39
+ return name
40
+ .replace(/[\n\r]/g, " ")
41
+ .replace(/\[/g, "⟦").replace(/\]/g, "⟧")
42
+ .slice(0, 100);
43
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Deterministic session key derivation.
3
+ * Must match hub/forward.py build_session_key() exactly.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+
7
+ // UUID v5 namespace — must match hub/constants.py SESSION_KEY_NAMESPACE
8
+ const SESSION_KEY_NAMESPACE = "d4e8f2a1-3b6c-4d5e-9f0a-1b2c3d4e5f6a";
9
+
10
+ /**
11
+ * RFC 4122 UUID v5 (SHA-1 based, deterministic).
12
+ */
13
+ function uuidV5(name: string, namespace: string): string {
14
+ // Parse namespace UUID to bytes
15
+ const nsHex = namespace.replace(/-/g, "");
16
+ const nsBytes = Buffer.from(nsHex, "hex");
17
+
18
+ const hash = createHash("sha1")
19
+ .update(nsBytes)
20
+ .update(Buffer.from(name, "utf-8"))
21
+ .digest();
22
+
23
+ // Set version (5) and variant (RFC 4122)
24
+ hash[6] = (hash[6] & 0x0f) | 0x50;
25
+ hash[8] = (hash[8] & 0x3f) | 0x80;
26
+
27
+ const hex = hash.subarray(0, 16).toString("hex");
28
+ return [
29
+ hex.slice(0, 8),
30
+ hex.slice(8, 12),
31
+ hex.slice(12, 16),
32
+ hex.slice(16, 20),
33
+ hex.slice(20, 32),
34
+ ].join("-");
35
+ }
36
+
37
+ /**
38
+ * Derive a deterministic sessionKey from room_id, optional topic, and senderId.
39
+ * Same inputs always produce the same key.
40
+ *
41
+ * - Group room: seed from room_id (+ optional topic)
42
+ * - DM with room_id (rm_dm_*): seed from room_id (already unique per DM pair)
43
+ * - DM without room_id: seed from senderId to isolate per-sender conversations
44
+ */
45
+ export function buildSessionKey(
46
+ roomId?: string,
47
+ topic?: string,
48
+ senderId?: string,
49
+ ): string {
50
+ let seed: string;
51
+ if (roomId) {
52
+ seed = topic ? `${roomId}:${topic}` : roomId;
53
+ } else if (senderId) {
54
+ seed = `dm:${senderId}`;
55
+ } else {
56
+ seed = "default";
57
+ }
58
+ return `botcord:${uuidV5(seed, SESSION_KEY_NAMESPACE)}`;
59
+ }