@keychat-io/keychat-openclaw 0.1.8

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/src/channel.ts ADDED
@@ -0,0 +1,2607 @@
1
+ /**
2
+ * Keychat channel plugin for OpenClaw.
3
+ *
4
+ * The agent is a full Keychat citizen:
5
+ * - Self-generated Public Key ID (Nostr keypair)
6
+ * - Signal Protocol E2E encryption
7
+ * - Communicates via Nostr relays
8
+ *
9
+ * Uses Keychat (sidecar) for protocol compatibility
10
+ * with the Keychat app.
11
+ */
12
+
13
+ import {
14
+ buildChannelConfigSchema,
15
+ createReplyPrefixOptions,
16
+ DEFAULT_ACCOUNT_ID,
17
+ formatPairingApproveHint,
18
+ type ChannelPlugin,
19
+ } from "openclaw/plugin-sdk";
20
+ import { KeychatConfigSchema } from "./config-schema.js";
21
+ import { getKeychatRuntime } from "./runtime.js";
22
+ import {
23
+ listKeychatAccountIds,
24
+ resolveDefaultKeychatAccountId,
25
+ resolveKeychatAccount,
26
+ type ResolvedKeychatAccount,
27
+ } from "./types.js";
28
+ import {
29
+ KeychatBridgeClient,
30
+ type AccountInfo,
31
+ type InboundMessage,
32
+ type SendMessageResult,
33
+ type MlsGroupInfo,
34
+ type MlsCommitResult,
35
+ } from "./bridge-client.js";
36
+ import { storeMnemonic, retrieveMnemonic } from "./keychain.js";
37
+ import { parseMediaUrl, downloadAndDecrypt, encryptAndUpload } from "./media.js";
38
+ import { join } from "node:path";
39
+ import { existsSync, mkdirSync } from "node:fs";
40
+ import { signalDbPath, qrCodePath, WORKSPACE_KEYCHAT_DIR } from "./paths.js";
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+ // Task 7: Outbound message queue for offline/retry resilience
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+
46
+ interface PendingMessage {
47
+ to: string;
48
+ text: string;
49
+ retries: number;
50
+ accountId: string;
51
+ }
52
+
53
+ const pendingOutbound: PendingMessage[] = [];
54
+ const MAX_PENDING_QUEUE = 100;
55
+ const MAX_MESSAGE_RETRIES = 5;
56
+
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+ // Pending hello messages — queued while waiting for session establishment
59
+ // ═══════════════════════════════════════════════════════════════════════════
60
+
61
+ interface PendingHelloMessage {
62
+ text: string;
63
+ resolve: (result: { channel: "keychat"; to: string; messageId: string }) => void;
64
+ reject: (err: Error) => void;
65
+ timer: ReturnType<typeof setTimeout>;
66
+ }
67
+
68
+ /** Messages waiting for a hello reply to establish session. Keyed by peer nostr pubkey hex. */
69
+ const pendingHelloMessages = new Map<string, PendingHelloMessage[]>();
70
+ /** Peers we've already sent a hello to (avoid duplicate hellos). */
71
+ const helloSentTo = new Set<string>();
72
+ const HELLO_TIMEOUT_MS = 120_000; // 2 minutes to wait for hello reply
73
+
74
+ /** Flush pending hello messages for a peer after session is established. */
75
+ async function flushPendingHelloMessages(bridge: KeychatBridgeClient, accountId: string, peerPubkey: string): Promise<void> {
76
+ const pending = pendingHelloMessages.get(peerPubkey);
77
+ if (!pending || pending.length === 0) return;
78
+ pendingHelloMessages.delete(peerPubkey);
79
+ helloSentTo.delete(peerPubkey);
80
+
81
+ console.log(`[keychat] Flushing ${pending.length} pending message(s) to ${peerPubkey}`);
82
+ for (const msg of pending) {
83
+ clearTimeout(msg.timer);
84
+ try {
85
+ const result = await bridge.sendMessage(peerPubkey, msg.text);
86
+ await handleReceivingAddressRotation(bridge, accountId, result, peerPubkey);
87
+ msg.resolve({
88
+ channel: "keychat" as const,
89
+ to: peerPubkey,
90
+ messageId: result.event_id,
91
+ });
92
+ } catch (err) {
93
+ msg.reject(err instanceof Error ? err : new Error(String(err)));
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Flush pending outbound messages — called after bridge restart and periodically. */
99
+ async function flushPendingOutbound(): Promise<void> {
100
+ if (pendingOutbound.length === 0) return;
101
+
102
+ // Check if any bridge is connected
103
+ const bridges = [...activeBridges.entries()];
104
+ if (bridges.length === 0) return;
105
+
106
+ // Try to flush each message
107
+ const toRetry: PendingMessage[] = [];
108
+ while (pendingOutbound.length > 0) {
109
+ const msg = pendingOutbound.shift()!;
110
+ const bridge = activeBridges.get(msg.accountId);
111
+ if (!bridge) {
112
+ toRetry.push(msg);
113
+ continue;
114
+ }
115
+
116
+ const connected = await bridge.isConnected();
117
+ if (!connected) {
118
+ toRetry.push(msg);
119
+ continue;
120
+ }
121
+
122
+ try {
123
+ await bridge.sendMessage(msg.to, msg.text);
124
+ } catch {
125
+ msg.retries++;
126
+ if (msg.retries >= MAX_MESSAGE_RETRIES) {
127
+ console.warn(`[keychat] Dropping message to ${msg.to} after ${MAX_MESSAGE_RETRIES} retries`);
128
+ } else {
129
+ toRetry.push(msg);
130
+ }
131
+ }
132
+ }
133
+
134
+ // Put failed messages back
135
+ for (const msg of toRetry) {
136
+ if (pendingOutbound.length < MAX_PENDING_QUEUE) {
137
+ pendingOutbound.push(msg);
138
+ } else {
139
+ console.warn(`[keychat] Pending outbound queue full, dropping message to ${msg.to}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Periodic flush every 30s
145
+ setInterval(() => { flushPendingOutbound().catch(() => {}); }, 30_000);
146
+
147
+ /** Queue a message for later delivery when bridge is unavailable. */
148
+ function queueOutbound(to: string, text: string, accountId: string): void {
149
+ if (pendingOutbound.length >= MAX_PENDING_QUEUE) {
150
+ console.warn(`[keychat] Pending outbound queue full, dropping message to ${to}`);
151
+ return;
152
+ }
153
+ pendingOutbound.push({ to, text, retries: 0, accountId });
154
+ }
155
+
156
+ // ═══════════════════════════════════════════════════════════════════════════
157
+ // Task 8: Session recovery tracking
158
+ // ═══════════════════════════════════════════════════════════════════════════
159
+
160
+ // Removed: sessionRecoveryAttempted — we no longer auto-send corruption notices
161
+
162
+ /** Retry a send operation with exponential backoff. */
163
+ async function retrySend<T>(fn: () => Promise<T>, maxRetries = 3, baseDelayMs = 500): Promise<T> {
164
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
165
+ try {
166
+ return await fn();
167
+ } catch (err) {
168
+ if (attempt === maxRetries) throw err;
169
+ const delay = baseDelayMs * Math.pow(2, attempt);
170
+ await new Promise(r => setTimeout(r, delay));
171
+ }
172
+ }
173
+ throw new Error("unreachable");
174
+ }
175
+
176
+ // Active bridge clients per account
177
+ const activeBridges = new Map<string, KeychatBridgeClient>();
178
+ // Cached account info per account
179
+ const accountInfoCache = new Map<string, AccountInfo>();
180
+ // Bridge readiness promises — resolved when startAccount completes
181
+ const bridgeReadyResolvers = new Map<string, () => void>();
182
+ const bridgeReadyPromises = new Map<string, Promise<void>>();
183
+
184
+ /** Wait for a bridge to become ready, with timeout. */
185
+ async function waitForBridge(accountId: string, timeoutMs = 30000): Promise<KeychatBridgeClient> {
186
+ const existing = activeBridges.get(accountId);
187
+ if (existing) return existing;
188
+
189
+ // Wait for the bridge to start
190
+ let readyPromise = bridgeReadyPromises.get(accountId);
191
+ if (!readyPromise) {
192
+ readyPromise = new Promise<void>((resolve) => {
193
+ bridgeReadyResolvers.set(accountId, resolve);
194
+ });
195
+ bridgeReadyPromises.set(accountId, readyPromise);
196
+ }
197
+
198
+ const timeout = new Promise<never>((_, reject) =>
199
+ setTimeout(() => reject(new Error(`Keychat bridge not ready after ${timeoutMs}ms`)), timeoutMs),
200
+ );
201
+
202
+ await Promise.race([readyPromise, timeout]);
203
+ const bridge = activeBridges.get(accountId);
204
+ if (!bridge) throw new Error(`Keychat bridge not running for account ${accountId}`);
205
+ return bridge;
206
+ }
207
+
208
+ /** Peer session info learned from friend requests / hellos. */
209
+ interface PeerSession {
210
+ signalPubkey: string;
211
+ deviceId: number;
212
+ name: string;
213
+ nostrPubkey: string;
214
+ }
215
+
216
+ // Per-account maps (keyed by accountId)
217
+ const peerSessionsByAccount = new Map<string, Map<string, PeerSession>>();
218
+ const addressToPeerByAccount = new Map<string, Map<string, string>>();
219
+ const seenEventIdsByAccount = new Map<string, Set<string>>();
220
+
221
+ // Helpers to get per-account maps (auto-create on first access)
222
+ function getPeerSessions(accountId: string): Map<string, PeerSession> {
223
+ let m = peerSessionsByAccount.get(accountId);
224
+ if (!m) { m = new Map(); peerSessionsByAccount.set(accountId, m); }
225
+ return m;
226
+ }
227
+ function getAddressToPeer(accountId: string): Map<string, string> {
228
+ let m = addressToPeerByAccount.get(accountId);
229
+ if (!m) { m = new Map(); addressToPeerByAccount.set(accountId, m); }
230
+ return m;
231
+ }
232
+ function getSeenEventIds(accountId: string): Set<string> {
233
+ let s = seenEventIdsByAccount.get(accountId);
234
+ if (!s) { s = new Set(); seenEventIdsByAccount.set(accountId, s); }
235
+ return s;
236
+ }
237
+ /**
238
+ * Resolve display name for a keychat account.
239
+ * Priority: channel config name > agent identity name > fallback.
240
+ */
241
+ function resolveDisplayName(cfg: any, accountId: string, channelName?: string, fallback = "Keychat Agent"): string {
242
+ if (channelName) return channelName;
243
+ // Look up agent identity name via bindings
244
+ const bindings = (cfg.bindings ?? []) as Array<{ agentId?: string; match?: { channel?: string; accountId?: string } }>;
245
+ const binding = bindings.find(b => b.match?.channel === "keychat" && b.match?.accountId === accountId);
246
+ const agentId = binding?.agentId ?? (accountId === DEFAULT_ACCOUNT_ID ? "main" : accountId);
247
+ const agents = (cfg.agents?.list ?? []) as Array<{ id?: string; identity?: { name?: string }; name?: string }>;
248
+ const agent = agents.find(a => a.id === agentId);
249
+ return agent?.identity?.name || agent?.name || fallback;
250
+ }
251
+
252
+ // Mutex for friend request processing to prevent concurrent hello corruption
253
+ let helloProcessingLock: Promise<void> = Promise.resolve();
254
+ const SEEN_EVENT_MAX = 1000;
255
+
256
+ /** Mark an event as processed (in-memory + DB). Call BEFORE decrypt to prevent
257
+ * ratchet corruption on retry — Signal decrypt consumes message keys. */
258
+ function markProcessed(bridge: KeychatBridgeClient, accountId: string, eventId: string | undefined, createdAt?: number): void {
259
+ if (!eventId) return;
260
+ const seen = getSeenEventIds(accountId);
261
+ seen.add(eventId);
262
+ if (seen.size > SEEN_EVENT_MAX) {
263
+ const first = seen.values().next().value;
264
+ if (first) seen.delete(first);
265
+ }
266
+ bridge.markEventProcessed(eventId, createdAt).catch(() => {/* best effort */});
267
+ }
268
+ // peerNostrPubkey → list of subscribed receiving addresses (most recent last, max MAX_RECEIVING_ADDRESSES per peer)
269
+ const peerSubscribedAddresses = new Map<string, string[]>();
270
+ const MAX_RECEIVING_ADDRESSES = 3;
271
+
272
+ // ═══════════════════════════════════════════════════════════════════════════
273
+ // MLS (Large Group) state
274
+ // ═══════════════════════════════════════════════════════════════════════════
275
+
276
+ /** Map listen_key → group_id for routing inbound MLS messages */
277
+ const mlsListenKeyToGroup = new Map<string, string>();
278
+ /** Set of MLS-initialized account IDs */
279
+ const mlsInitialized = new Set<string>();
280
+
281
+
282
+ /**
283
+ * Normalize a pubkey: strip nostr: prefix, handle npub/hex.
284
+ */
285
+ function normalizePubkey(input: string): string {
286
+ const trimmed = input.replace(/^nostr:/i, "").trim();
287
+ // If it's hex, lowercase it
288
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
289
+ return trimmed.toLowerCase();
290
+ }
291
+ // Decode npub (bech32) to hex so all keys use a consistent format
292
+ if (trimmed.startsWith("npub1")) {
293
+ try {
294
+ const decoded = bech32Decode(trimmed);
295
+ if (decoded) return decoded.toLowerCase();
296
+ } catch { /* fall through */ }
297
+ }
298
+ return trimmed;
299
+ }
300
+
301
+ /** Decode bech32 npub to hex pubkey. */
302
+ function bech32Decode(npub: string): string | null {
303
+ const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
304
+ const pos = npub.lastIndexOf("1");
305
+ if (pos < 1) return null;
306
+ const data: number[] = [];
307
+ for (let i = pos + 1; i < npub.length; i++) {
308
+ const v = CHARSET.indexOf(npub.charAt(i));
309
+ if (v === -1) return null;
310
+ data.push(v);
311
+ }
312
+ // Remove 6-char checksum
313
+ const values = data.slice(0, -6);
314
+ // Convert from 5-bit groups to 8-bit bytes
315
+ let acc = 0, bits = 0;
316
+ const result: number[] = [];
317
+ for (const v of values) {
318
+ acc = (acc << 5) | v;
319
+ bits += 5;
320
+ if (bits >= 8) {
321
+ bits -= 8;
322
+ result.push((acc >> bits) & 0xff);
323
+ }
324
+ }
325
+ if (result.length !== 32) return null;
326
+ return result.map((b) => b.toString(16).padStart(2, "0")).join("");
327
+ }
328
+
329
+ export const keychatPlugin: ChannelPlugin<ResolvedKeychatAccount> = {
330
+ id: "keychat",
331
+ meta: {
332
+ id: "keychat",
333
+ label: "Keychat",
334
+ selectionLabel: "Keychat (E2E Encrypted)",
335
+ docsPath: "/channels/keychat",
336
+ docsLabel: "keychat",
337
+ blurb:
338
+ "Sovereign identity + E2E encrypted chat via Keychat protocol. Agent generates its own Public Key ID.",
339
+ order: 50,
340
+ },
341
+ capabilities: {
342
+ chatTypes: ["direct", "group"],
343
+ media: true,
344
+ },
345
+ reload: { configPrefixes: ["channels.keychat"] },
346
+ configSchema: buildChannelConfigSchema(KeychatConfigSchema),
347
+
348
+ config: {
349
+ listAccountIds: (cfg) => listKeychatAccountIds(cfg),
350
+ resolveAccount: (cfg, accountId) => resolveKeychatAccount({ cfg, accountId }),
351
+ defaultAccountId: (cfg) => resolveDefaultKeychatAccountId(cfg),
352
+ isConfigured: (account) => account.configured,
353
+ describeAccount: (account) => ({
354
+ accountId: account.accountId,
355
+ name: account.name,
356
+ enabled: account.enabled,
357
+ configured: account.configured,
358
+ publicKey: account.publicKey,
359
+ ...(account.lightningAddress ? { lightningAddress: account.lightningAddress } : {}),
360
+ }),
361
+ resolveAllowFrom: ({ cfg, accountId }) =>
362
+ (resolveKeychatAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
363
+ String(entry),
364
+ ),
365
+ formatAllowFrom: ({ allowFrom }) =>
366
+ allowFrom
367
+ .map((entry) => String(entry).trim())
368
+ .filter(Boolean)
369
+ .map((entry) => {
370
+ if (entry === "*") return "*";
371
+ return normalizePubkey(entry);
372
+ })
373
+ .filter(Boolean),
374
+ },
375
+
376
+ pairing: {
377
+ idLabel: "keychatPubkey",
378
+ normalizeAllowEntry: (entry) => normalizePubkey(entry),
379
+ notifyApproval: async ({ id }) => {
380
+ // Try each active bridge — notifyApproval doesn't receive accountId,
381
+ // so send from whichever bridge has a session with this peer.
382
+ for (const [, bridge] of activeBridges) {
383
+ try {
384
+ await bridge.sendMessage(id, "✅ Pairing approved! You can now chat with this agent.");
385
+ return; // sent successfully
386
+ } catch { /* try next bridge */ }
387
+ }
388
+ // Fallback: wait for default bridge
389
+ try {
390
+ const bridge = await waitForBridge(DEFAULT_ACCOUNT_ID, 10000);
391
+ await bridge.sendMessage(id, "✅ Pairing approved! You can now chat with this agent.");
392
+ } catch { /* bridge not ready, skip notification */ }
393
+ },
394
+ },
395
+
396
+ security: {
397
+ resolveDmPolicy: ({ cfg, account, accountId }) => {
398
+ const channelCfg = (cfg.channels as Record<string, unknown> | undefined)?.keychat as
399
+ | { accounts?: Record<string, unknown> } | undefined;
400
+ const isMultiAccount = channelCfg?.accounts && Object.keys(channelCfg.accounts).length > 0;
401
+ const prefix = isMultiAccount
402
+ ? `channels.keychat.accounts.${accountId ?? DEFAULT_ACCOUNT_ID}`
403
+ : "channels.keychat";
404
+ return {
405
+ policy: account.config.dmPolicy ?? "pairing",
406
+ allowFrom: account.config.allowFrom ?? [],
407
+ policyPath: `${prefix}.dmPolicy`,
408
+ allowFromPath: `${prefix}.allowFrom`,
409
+ approveHint: formatPairingApproveHint("keychat"),
410
+ normalizeEntry: (raw) => normalizePubkey(raw),
411
+ };
412
+ },
413
+ },
414
+
415
+ messaging: {
416
+ normalizeTarget: (target) => normalizePubkey(target),
417
+ targetResolver: {
418
+ looksLikeId: (input) => {
419
+ const trimmed = input.trim();
420
+ return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
421
+ },
422
+ hint: "<npub|hex pubkey>",
423
+ },
424
+ },
425
+
426
+ outbound: {
427
+ deliveryMode: "direct",
428
+ textChunkLimit: 4000,
429
+ sendText: async ({ to, text, accountId }) => {
430
+ const aid = accountId ?? DEFAULT_ACCOUNT_ID;
431
+ const bridge = await waitForBridge(aid);
432
+ const core = getKeychatRuntime();
433
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
434
+ cfg: core.config.loadConfig(),
435
+ channel: "keychat",
436
+ accountId: aid,
437
+ });
438
+ const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
439
+ const normalizedTo = normalizePubkey(to);
440
+
441
+ // Handle /reset signal command — reset Signal session and re-send hello
442
+ if (message.trim() === "/reset signal") {
443
+ const result = await resetPeerSession(normalizedTo, aid, true);
444
+ console.log(`[keychat] [${aid}] Reset session result for ${normalizedTo}:`, result);
445
+ return {
446
+ channel: "keychat" as const,
447
+ to: normalizedTo,
448
+ messageId: `reset-${Date.now()}`,
449
+ };
450
+ }
451
+
452
+ // Check if we have a session with this peer (placeholder mappings with empty signalPubkey don't count)
453
+ const existingPeer = getPeerSessions(aid).get(normalizedTo);
454
+ const hasSession = !!(existingPeer && existingPeer.signalPubkey);
455
+ if (!hasSession) {
456
+ // No session — need to send hello first and queue the message
457
+ console.log(`[keychat] No session with ${normalizedTo}, initiating hello...`);
458
+
459
+ // Send hello if we haven't already
460
+ if (!helloSentTo.has(normalizedTo)) {
461
+ helloSentTo.add(normalizedTo);
462
+ try {
463
+ const accountInfo = accountInfoCache.get(aid);
464
+ const name = accountInfo?.pubkey_npub ? "Keychat Agent" : "Keychat Agent";
465
+ const helloResult = await bridge.sendHello(normalizedTo, name);
466
+ console.log(`[keychat] Hello sent to ${normalizedTo} (event: ${helloResult.event_id})`);
467
+
468
+ // Register the onetimekey as an address mapping so the hello reply
469
+ // (sent to our onetimekey) can be routed to this peer.
470
+ if (helloResult.onetimekey) {
471
+ getAddressToPeer(aid).set(helloResult.onetimekey, normalizedTo);
472
+ console.log(`[keychat] Registered onetimekey ${helloResult.onetimekey.slice(0, 16)}... → ${normalizedTo.slice(0, 16)}`);
473
+ try {
474
+ await bridge.saveAddressMapping(helloResult.onetimekey, normalizedTo);
475
+ } catch { /* best effort */ }
476
+ }
477
+ } catch (err) {
478
+ helloSentTo.delete(normalizedTo);
479
+ console.error(`[keychat] Failed to send hello to ${normalizedTo}: ${err}`);
480
+ // Queue for later delivery
481
+ queueOutbound(normalizedTo, message, aid);
482
+ return {
483
+ channel: "keychat" as const,
484
+ to: normalizedTo,
485
+ messageId: `queued-${Date.now()}`,
486
+ };
487
+ }
488
+ }
489
+
490
+ // Queue the message and wait for session establishment
491
+ return new Promise<{ channel: "keychat"; to: string; messageId: string }>((resolve, reject) => {
492
+ const timer = setTimeout(() => {
493
+ // Timeout — remove from queue and reject
494
+ const pending = pendingHelloMessages.get(normalizedTo);
495
+ if (pending) {
496
+ const idx = pending.findIndex((m) => m.timer === timer);
497
+ if (idx >= 0) pending.splice(idx, 1);
498
+ if (pending.length === 0) {
499
+ pendingHelloMessages.delete(normalizedTo);
500
+ helloSentTo.delete(normalizedTo);
501
+ }
502
+ }
503
+ // Fall back to queuing for retry
504
+ queueOutbound(normalizedTo, message, aid);
505
+ resolve({
506
+ channel: "keychat" as const,
507
+ to: normalizedTo,
508
+ messageId: `queued-hello-timeout-${Date.now()}`,
509
+ });
510
+ }, HELLO_TIMEOUT_MS);
511
+
512
+ const entry: PendingHelloMessage = { text: message, resolve, reject, timer };
513
+ const existing = pendingHelloMessages.get(normalizedTo) ?? [];
514
+ existing.push(entry);
515
+ pendingHelloMessages.set(normalizedTo, existing);
516
+ });
517
+ }
518
+
519
+ // Existing session — send directly
520
+ try {
521
+ const result = await retrySend(() => bridge.sendMessage(normalizedTo, message));
522
+ // Handle receiving address rotation
523
+ await handleReceivingAddressRotation(bridge, aid, result, normalizedTo);
524
+ return {
525
+ channel: "keychat" as const,
526
+ to: normalizedTo,
527
+ messageId: result.event_id,
528
+ };
529
+ } catch (err) {
530
+ // Queue for later delivery instead of throwing
531
+ queueOutbound(normalizedTo, message, aid);
532
+ console.warn(`[keychat] sendText failed, queued for retry: ${err}`);
533
+ return {
534
+ channel: "keychat" as const,
535
+ to: normalizedTo,
536
+ messageId: `queued-${Date.now()}`,
537
+ };
538
+ }
539
+ },
540
+ sendMedia: async ({ to, text, mediaUrl: incomingMediaUrl, accountId }) => {
541
+ const aid = accountId ?? DEFAULT_ACCOUNT_ID;
542
+ const bridge = await waitForBridge(aid);
543
+
544
+ // Media URL is resolved by the SDK before reaching the plugin
545
+ const mediaUrl = incomingMediaUrl ?? "";
546
+
547
+ // Send the media URL as a message (same as Keychat app)
548
+ const caption = text;
549
+ const messageText = caption ? `${mediaUrl}\n${caption}` : mediaUrl;
550
+
551
+ // Check if target is an MLS group
552
+ const mlsGroupMatch = to.match(/^mls-group:(.+)$/);
553
+ if (mlsGroupMatch) {
554
+ const groupId = mlsGroupMatch[1];
555
+ try {
556
+ const result = await retrySend(() => bridge.mlsSendMessage(groupId, messageText));
557
+ return {
558
+ channel: "keychat" as const,
559
+ to,
560
+ messageId: result.event_id,
561
+ };
562
+ } catch (err) {
563
+ console.warn(`[keychat] sendMedia to MLS group failed: ${err}`);
564
+ return {
565
+ channel: "keychat" as const,
566
+ to,
567
+ messageId: `failed-${Date.now()}`,
568
+ };
569
+ }
570
+ }
571
+
572
+ // 1:1 DM
573
+ const normalizedTo = normalizePubkey(to);
574
+ try {
575
+ const result = await retrySend(() => bridge.sendMessage(normalizedTo, messageText));
576
+ await handleReceivingAddressRotation(bridge, aid, result, normalizedTo);
577
+ return {
578
+ channel: "keychat" as const,
579
+ to: normalizedTo,
580
+ messageId: result.event_id,
581
+ };
582
+ } catch (err) {
583
+ queueOutbound(normalizedTo, messageText, aid);
584
+ console.warn(`[keychat] sendMedia failed, queued for retry: ${err}`);
585
+ return {
586
+ channel: "keychat" as const,
587
+ to: normalizedTo,
588
+ messageId: `queued-${Date.now()}`,
589
+ };
590
+ }
591
+ },
592
+ },
593
+
594
+ status: {
595
+ defaultRuntime: {
596
+ accountId: DEFAULT_ACCOUNT_ID,
597
+ running: false,
598
+ lastStartAt: null,
599
+ lastStopAt: null,
600
+ lastError: null,
601
+ },
602
+ collectStatusIssues: (accounts) => {
603
+ const issues: Array<{
604
+ channel: string;
605
+ accountId: string;
606
+ kind: "runtime" | "config";
607
+ message: string;
608
+ }> = [];
609
+
610
+ // Check bridge binary exists (shared across all accounts)
611
+ const bridgePath = join(
612
+ import.meta.dirname ?? __dirname,
613
+ "..",
614
+ "bridge",
615
+ "target",
616
+ "release",
617
+ "keychat-openclaw",
618
+ );
619
+ if (!existsSync(bridgePath)) {
620
+ issues.push({
621
+ channel: "keychat",
622
+ accountId: accounts[0]?.accountId ?? DEFAULT_ACCOUNT_ID,
623
+ kind: "runtime",
624
+ message: "Bridge binary not found (will auto-download on start)",
625
+ });
626
+ }
627
+
628
+ // Check peer sessions — warn if ALL accounts have zero peers
629
+ const anyPeers = [...peerSessionsByAccount.values()].some((m) => m.size > 0);
630
+ if (!anyPeers) {
631
+ issues.push({
632
+ channel: "keychat",
633
+ accountId: accounts[0]?.accountId ?? DEFAULT_ACCOUNT_ID,
634
+ kind: "runtime",
635
+ message: "No peers connected yet",
636
+ });
637
+ }
638
+
639
+ // Check Signal DB exists for each account
640
+ for (const account of accounts) {
641
+ const dbPath = signalDbPath(account.accountId);
642
+ if (!existsSync(dbPath)) {
643
+ issues.push({
644
+ channel: "keychat",
645
+ accountId: account.accountId,
646
+ kind: "runtime",
647
+ message: "Signal DB file missing",
648
+ });
649
+ }
650
+
651
+ // Per-account errors
652
+ const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
653
+ if (lastError) {
654
+ issues.push({
655
+ channel: "keychat",
656
+ accountId: account.accountId,
657
+ kind: "runtime",
658
+ message: `Channel error: ${lastError}`,
659
+ });
660
+ }
661
+ }
662
+
663
+ return issues;
664
+ },
665
+ buildChannelSummary: ({ snapshot }) => ({
666
+ configured: snapshot.configured ?? false,
667
+ publicKey: (snapshot as Record<string, unknown>).publicKey ?? null,
668
+ npub: (snapshot as Record<string, unknown>).npub ?? null,
669
+ running: snapshot.running ?? false,
670
+ lastStartAt: snapshot.lastStartAt ?? null,
671
+ lastStopAt: snapshot.lastStopAt ?? null,
672
+ lastError: snapshot.lastError ?? null,
673
+ }),
674
+ buildAccountSnapshot: ({ account, runtime }) => ({
675
+ accountId: account.accountId,
676
+ name: account.name,
677
+ enabled: account.enabled,
678
+ configured: account.configured,
679
+ publicKey: account.publicKey,
680
+ running: runtime?.running ?? false,
681
+ lastStartAt: runtime?.lastStartAt ?? null,
682
+ lastStopAt: runtime?.lastStopAt ?? null,
683
+ lastError: runtime?.lastError ?? null,
684
+ lastInboundAt: runtime?.lastInboundAt ?? null,
685
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
686
+ }),
687
+ },
688
+
689
+ gateway: {
690
+ startAccount: async (ctx) => {
691
+ const runtime = getKeychatRuntime();
692
+ const account = ctx.account;
693
+
694
+ ctx.log?.info(`[${account.accountId}] Starting Keychat channel...`);
695
+
696
+ // Clean up any existing bridge from a previous start
697
+ const oldBridge = activeBridges.get(account.accountId);
698
+ if (oldBridge) {
699
+ ctx.log?.info(`[${account.accountId}] Cleaning up previous bridge instance`);
700
+ try {
701
+ oldBridge.disableAutoRestart();
702
+ await oldBridge.stop();
703
+ } catch {
704
+ // Best effort — old bridge may already be dead
705
+ }
706
+ activeBridges.delete(account.accountId);
707
+ }
708
+
709
+ // 1. Start the Rust bridge sidecar (auto-download binary if missing)
710
+ const { ensureBinary } = await import("./ensure-binary.js");
711
+ await ensureBinary();
712
+ const bridge = new KeychatBridgeClient();
713
+ await bridge.start();
714
+ ctx.log?.info(`[${account.accountId}] Bridge sidecar started`);
715
+
716
+ // 2. Initialize Signal Protocol DB
717
+ const dbPath = `~/.openclaw/keychat/signal-${account.accountId}.db`;
718
+ await bridge.init(dbPath);
719
+ ctx.log?.info(`[${account.accountId}] Signal DB initialized`);
720
+
721
+ // 3. Generate or restore identity
722
+ // Priority: config mnemonic > keychain mnemonic > generate new
723
+ let info: AccountInfo;
724
+ let mnemonic = account.mnemonic;
725
+ if (!mnemonic) {
726
+ // Try keychain
727
+ const keychainMnemonic = await retrieveMnemonic(account.accountId);
728
+ if (keychainMnemonic) {
729
+ mnemonic = keychainMnemonic;
730
+ ctx.log?.info(`[${account.accountId}] Mnemonic retrieved from system keychain`);
731
+ }
732
+ }
733
+
734
+ if (mnemonic) {
735
+ // Restore from mnemonic
736
+ info = await bridge.importIdentity(mnemonic);
737
+ ctx.log?.info(`[${account.accountId}] Identity restored from mnemonic`);
738
+
739
+ // If mnemonic was in config, try migrating to keychain
740
+ if (account.mnemonic) {
741
+ const stored = await storeMnemonic(account.accountId, mnemonic);
742
+ if (stored) {
743
+ ctx.log?.info(`[${account.accountId}] Mnemonic migrated to system keychain`);
744
+ // Remove mnemonic from config (keep only publicKey/npub)
745
+ const cfg = runtime.config.loadConfig();
746
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
747
+ const keychatCfg = (channels.keychat ?? {}) as Record<string, unknown>;
748
+ const { mnemonic: _removed, ...keychatCfgClean } = keychatCfg;
749
+ await runtime.config.writeConfigFile({
750
+ ...cfg,
751
+ channels: { ...channels, keychat: keychatCfgClean },
752
+ });
753
+ ctx.log?.info(`[${account.accountId}] Mnemonic removed from config file`);
754
+ }
755
+ }
756
+ } else {
757
+ // Generate new identity
758
+ info = await bridge.generateIdentity();
759
+ ctx.log?.info(
760
+ `[${account.accountId}] New Keychat identity generated: ${info.pubkey_npub}`,
761
+ );
762
+
763
+ // Store mnemonic in keychain first, fall back to config
764
+ const stored = await storeMnemonic(account.accountId, info.mnemonic!);
765
+
766
+ // Persist keys to config (mnemonic only if keychain failed)
767
+ const cfg = runtime.config.loadConfig();
768
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
769
+ const keychatCfg = (channels.keychat ?? {}) as Record<string, unknown>;
770
+ await runtime.config.writeConfigFile({
771
+ ...cfg,
772
+ channels: {
773
+ ...channels,
774
+ keychat: {
775
+ ...keychatCfg,
776
+ ...(stored ? {} : { mnemonic: info.mnemonic }),
777
+ publicKey: info.pubkey_hex,
778
+ npub: info.pubkey_npub,
779
+ },
780
+ },
781
+ });
782
+ }
783
+
784
+ accountInfoCache.set(account.accountId, info);
785
+
786
+ // 4. Generate pre-key bundle (for Signal handshake with peers)
787
+ const bundle = await bridge.generatePrekeyBundle();
788
+ ctx.log?.info(`[${account.accountId}] Pre-key bundle generated`);
789
+
790
+ // 5. Connect to Nostr relays
791
+ await bridge.connect(account.relays);
792
+ ctx.log?.info(
793
+ `[${account.accountId}] Connected to ${account.relays.length} relay(s)`,
794
+ );
795
+
796
+ // 6. Log the agent's Keychat ID for the owner
797
+ const contactUrl = `https://www.keychat.io/u/?k=${info.pubkey_npub}`;
798
+ const qrPath = qrCodePath(account.accountId);
799
+
800
+ // Generate QR code (best-effort)
801
+ let qrSaved = false;
802
+ try {
803
+ mkdirSync(WORKSPACE_KEYCHAT_DIR, { recursive: true });
804
+ const QRCode = await import("qrcode");
805
+ await QRCode.toFile(qrPath, contactUrl, { width: 256 });
806
+ qrSaved = true;
807
+ } catch { /* qrcode not installed, skip */ }
808
+
809
+ const cfg = runtime.config.loadConfig();
810
+ const displayName = resolveDisplayName(cfg, account.accountId, account.name);
811
+
812
+ ctx.log?.info(`\n` +
813
+ `═══════════════════════════════════════════════════\n` +
814
+ ` 🔑 ${displayName} — Keychat ID:\n` +
815
+ `\n` +
816
+ ` ${info.pubkey_npub}\n` +
817
+ `\n` +
818
+ ` 📱 Add contact (tap or scan):\n` +
819
+ ` ${contactUrl}\n` +
820
+ (qrSaved ? `\n 🖼️ QR code: ${qrPath}\n` : ``) +
821
+ `═══════════════════════════════════════════════════\n`,
822
+ );
823
+
824
+ // Log agent readiness (visible in gateway logs)
825
+ ctx.log?.info?.(
826
+ `[${account.accountId}] Agent "${displayName}" is online. ` +
827
+ `Use keychat_identity tool to get contact link and QR code.`,
828
+ );
829
+
830
+ ctx.setStatus({
831
+ accountId: account.accountId,
832
+ publicKey: info.pubkey_hex,
833
+ contactUrl,
834
+ qrCodePath: qrPath,
835
+ running: true,
836
+ configured: true,
837
+ lastStartAt: Date.now(),
838
+ } as any);
839
+
840
+ activeBridges.set(account.accountId, bridge);
841
+
842
+ // 7. Restore peer sessions and receiving addresses from DB
843
+ try {
844
+ const { mappings } = await bridge.getPeerMappings();
845
+ if (mappings.length > 0) {
846
+ ctx.log?.info(`[${account.accountId}] Restored ${mappings.length} peer mapping(s) from DB`);
847
+ for (const m of mappings) {
848
+ // Skip placeholder rows created by outgoing hello before reply arrives
849
+ if (!m.signal_pubkey) continue;
850
+ getPeerSessions(account.accountId).set(m.nostr_pubkey, {
851
+ signalPubkey: m.signal_pubkey,
852
+ deviceId: m.device_id,
853
+ name: m.name,
854
+ nostrPubkey: m.nostr_pubkey,
855
+ });
856
+ }
857
+ } else {
858
+ // Fallback to raw sessions if no mappings exist yet (backward compat)
859
+ const { sessions } = await bridge.getAllSessions();
860
+ if (sessions.length > 0) {
861
+ ctx.log?.info(`[${account.accountId}] Restored ${sessions.length} peer session(s) from DB (no mappings yet)`);
862
+ for (const s of sessions) {
863
+ getPeerSessions(account.accountId).set(s.signal_pubkey, {
864
+ signalPubkey: s.signal_pubkey,
865
+ deviceId: parseInt(s.device_id, 10),
866
+ name: "",
867
+ nostrPubkey: s.signal_pubkey,
868
+ });
869
+ }
870
+ }
871
+ }
872
+
873
+ // Restore address-to-peer mappings from DB and populate peerSubscribedAddresses
874
+ const { mappings: addrMappings } = await bridge.getAddressMappings();
875
+ if (addrMappings.length > 0) {
876
+ for (const am of addrMappings) {
877
+ getAddressToPeer(account.accountId).set(am.address, am.peer_nostr_pubkey);
878
+ // Track in peerSubscribedAddresses so cleanup works after restart
879
+ const peerList = peerSubscribedAddresses.get(am.peer_nostr_pubkey) ?? [];
880
+ peerList.push(am.address);
881
+ peerSubscribedAddresses.set(am.peer_nostr_pubkey, peerList);
882
+ }
883
+ ctx.log?.info(`[${account.accountId}] Restored ${addrMappings.length} address-to-peer mapping(s) from DB`);
884
+ }
885
+
886
+ // Sync: ensure each peer has up to MAX_RECEIVING_ADDRESSES from session alice_addrs
887
+ // This fills in missing addresses for peers that had sessions but incomplete mappings
888
+ const { addresses: allAliceAddrs } = await bridge.getReceivingAddresses();
889
+ if (allAliceAddrs.length > 0) {
890
+ // Build signal_pubkey → nostr_pubkey mapping from getPeerSessions
891
+ const signalToNostr = new Map<string, string>();
892
+ for (const [nostrPk, info] of getPeerSessions(account.accountId).entries()) {
893
+ signalToNostr.set(info.signalPubkey, nostrPk);
894
+ }
895
+
896
+ // Group alice_addrs by peer (via session_address → signal_pubkey → nostr_pubkey)
897
+ const aliceByPeer = new Map<string, string[]>();
898
+ for (const a of allAliceAddrs) {
899
+ const peerNostr = signalToNostr.get(a.session_address);
900
+ if (!peerNostr) continue;
901
+ let list = aliceByPeer.get(peerNostr);
902
+ if (!list) { list = []; aliceByPeer.set(peerNostr, list); }
903
+ list.push(a.nostr_pubkey);
904
+ }
905
+
906
+ // For each peer, take latest MAX_RECEIVING_ADDRESSES and save any missing ones
907
+ for (const [peerNostr, aliceAddrs] of aliceByPeer) {
908
+ const latest = aliceAddrs.slice(-MAX_RECEIVING_ADDRESSES);
909
+ const existing = peerSubscribedAddresses.get(peerNostr) ?? [];
910
+ const existingSet = new Set(existing);
911
+ for (const addr of latest) {
912
+ if (!existingSet.has(addr)) {
913
+ getAddressToPeer(account.accountId).set(addr, peerNostr);
914
+ existing.push(addr);
915
+ try { await bridge.saveAddressMapping(addr, peerNostr); } catch { /* */ }
916
+ }
917
+ }
918
+ // Trim to MAX if over
919
+ while (existing.length > MAX_RECEIVING_ADDRESSES) {
920
+ const old = existing.shift()!;
921
+ getAddressToPeer(account.accountId).delete(old);
922
+ try { await bridge.deleteAddressMapping(old); } catch { /* */ }
923
+ }
924
+ peerSubscribedAddresses.set(peerNostr, existing);
925
+ }
926
+ }
927
+
928
+ // Subscribe to all receiving addresses
929
+ const toSubscribe = Array.from(getAddressToPeer(account.accountId).keys());
930
+ if (toSubscribe.length > 0) {
931
+ await bridge.addSubscription(toSubscribe);
932
+ ctx.log?.info(
933
+ `[${account.accountId}] Subscribed to ${toSubscribe.length} receiving address(es)`,
934
+ );
935
+ }
936
+ } catch (err) {
937
+ ctx.log?.error(`[${account.accountId}] Failed to restore sessions from DB: ${err}`);
938
+ }
939
+
940
+ // 8. Restore groups from DB
941
+ try {
942
+ const { groups } = await bridge.getAllGroups();
943
+ if (groups.length > 0) {
944
+ ctx.log?.info(`[${account.accountId}] Restored ${groups.length} group(s) from DB`);
945
+ for (const g of groups) {
946
+ ctx.log?.info(`[${account.accountId}] Group: "${g.name}" (${g.group_id})`);
947
+ }
948
+ }
949
+ } catch (err) {
950
+ ctx.log?.error(`[${account.accountId}] Failed to restore groups from DB: ${err}`);
951
+ }
952
+
953
+ // 9. Initialize MLS (large group support)
954
+ try {
955
+ const mlsDbPath = dbPath.replace(/\.db$/, "-mls.db");
956
+ await bridge.mlsInit(mlsDbPath);
957
+ mlsInitialized.add(account.accountId);
958
+ ctx.log?.info(`[${account.accountId}] MLS initialized`);
959
+
960
+ // Publish KeyPackage (kind:10443) so others can invite us to MLS groups
961
+ try {
962
+ const kpResult = await bridge.mlsPublishKeyPackage();
963
+ ctx.log?.info(`[${account.accountId}] MLS KeyPackage published (event ${kpResult.event_id})`);
964
+ } catch (err) {
965
+ ctx.log?.error(`[${account.accountId}] Failed to publish MLS KeyPackage: ${err}`);
966
+ }
967
+
968
+ // Restore MLS groups and subscribe to their listen keys
969
+ const { groups: mlsGroups } = await bridge.mlsGetGroups();
970
+ for (const groupId of mlsGroups) {
971
+ try {
972
+ const { listen_key } = await bridge.mlsGetListenKey(groupId);
973
+ mlsListenKeyToGroup.set(listen_key, groupId);
974
+ await bridge.addSubscription([listen_key]);
975
+ const info = await bridge.mlsGetGroupInfo(groupId);
976
+ ctx.log?.info(`[${account.accountId}] MLS group restored: "${info.name}" (${groupId}), listen key: ${listen_key.slice(0, 12)}...`);
977
+ } catch (err) {
978
+ ctx.log?.error(`[${account.accountId}] Failed to restore MLS group ${groupId}: ${err}`);
979
+ }
980
+ }
981
+ } catch (err) {
982
+ ctx.log?.error(`[${account.accountId}] MLS init failed (non-fatal): ${err}`);
983
+ }
984
+
985
+ // 10. Initialize NWC (Nostr Wallet Connect) if configured
986
+ if (account.nwcUri) {
987
+ try {
988
+ const { initNwc } = await import("./nwc.js");
989
+ const nwc = await initNwc(account.nwcUri);
990
+ const desc = nwc.describe();
991
+ ctx.log?.info(`[${account.accountId}] ⚡ NWC connected: relay=${desc.relay}, wallet=${desc.walletPubkey.slice(0, 16)}...`);
992
+ try {
993
+ const balSats = await nwc.getBalanceSats();
994
+ ctx.log?.info(`[${account.accountId}] ⚡ Wallet balance: ${balSats} sats`);
995
+ } catch (err) {
996
+ ctx.log?.info(`[${account.accountId}] ⚡ NWC connected (balance check not supported or failed: ${err})`);
997
+ }
998
+ } catch (err) {
999
+ ctx.log?.error(`[${account.accountId}] NWC init failed (non-fatal): ${err}`);
1000
+ }
1001
+ }
1002
+
1003
+ // Cache init args for auto-restart
1004
+ bridge.setInitArgs({ dbPath, mnemonic: account.mnemonic, relays: account.relays });
1005
+
1006
+ // Register post-restart hook to restore peer sessions and subscriptions
1007
+ bridge.setRestartHook(async () => {
1008
+ ctx.log?.info(`[${account.accountId}] Restoring sessions after bridge restart...`);
1009
+ try {
1010
+ // Re-generate pre-key bundle
1011
+ await bridge.generatePrekeyBundle();
1012
+ // Restore peer mappings
1013
+ const { mappings } = await bridge.getPeerMappings();
1014
+ for (const m of mappings) {
1015
+ if (!m.signal_pubkey) continue;
1016
+ getPeerSessions(account.accountId).set(m.nostr_pubkey, {
1017
+ signalPubkey: m.signal_pubkey,
1018
+ deviceId: m.device_id,
1019
+ name: m.name,
1020
+ nostrPubkey: m.nostr_pubkey,
1021
+ });
1022
+ }
1023
+ // Restore address→peer mappings and peerSubscribedAddresses
1024
+ const { mappings: addrMappings } = await bridge.getAddressMappings();
1025
+ peerSubscribedAddresses.clear();
1026
+ for (const am of addrMappings) {
1027
+ getAddressToPeer(account.accountId).set(am.address, am.peer_nostr_pubkey);
1028
+ const peerList = peerSubscribedAddresses.get(am.peer_nostr_pubkey) ?? [];
1029
+ peerList.push(am.address);
1030
+ peerSubscribedAddresses.set(am.peer_nostr_pubkey, peerList);
1031
+ }
1032
+ // Re-subscribe to receiving addresses from address_peer_mapping
1033
+ const toSubRestart = Array.from(getAddressToPeer(account.accountId).keys());
1034
+ if (toSubRestart.length > 0) {
1035
+ await bridge.addSubscription(toSubRestart);
1036
+ }
1037
+ ctx.log?.info(`[${account.accountId}] Sessions restored: ${mappings.length} peer(s), ${addrMappings.length} address mapping(s)`);
1038
+ // Flush any pending outbound messages after restart
1039
+ await flushPendingOutbound();
1040
+ } catch (err) {
1041
+ ctx.log?.error(`[${account.accountId}] Failed to restore sessions after restart: ${err}`);
1042
+ }
1043
+ });
1044
+
1045
+ // Start periodic health check (ping every 60s, restart if unresponsive)
1046
+ bridge.startHealthCheck();
1047
+
1048
+ // Set up inbound message handler
1049
+ bridge.setInboundHandler(async (msg: InboundMessage) => {
1050
+ try {
1051
+ ctx.log?.info(`[${account.accountId}] ▶ Inbound handler invoked: kind=${msg.event_kind} from=${msg.from_pubkey?.slice(0,16)} to=${msg.to_address?.slice(0,16)} prekey=${msg.is_prekey} event=${msg.event_id?.slice(0,16)}`);
1052
+ // Deduplicate events — check in-memory first, then DB
1053
+ if (msg.event_id) {
1054
+ if (getSeenEventIds(account.accountId).has(msg.event_id)) {
1055
+ return; // Already processed (in-memory)
1056
+ }
1057
+ // Check persistent DB
1058
+ try {
1059
+ const { processed } = await bridge.isEventProcessed(msg.event_id);
1060
+ if (processed) {
1061
+ getSeenEventIds(account.accountId).add(msg.event_id);
1062
+ return; // Already processed (persisted)
1063
+ }
1064
+ } catch {
1065
+ // DB check failed — continue with processing
1066
+ }
1067
+ }
1068
+
1069
+ if (msg.event_kind === 1059) {
1070
+ markProcessed(bridge, account.accountId, msg.event_id, msg.created_at);
1071
+
1072
+ // Check if this is an MLS group message (to_address matches a known listen key)
1073
+ const mlsGroupId = msg.to_address ? mlsListenKeyToGroup.get(msg.to_address) : undefined;
1074
+ ctx.log?.info(`[${account.accountId}] Kind:1059 routing: to_address=${msg.to_address ?? 'null'}, inner_kind=${msg.inner_kind ?? 'null'}, mlsGroupId=${mlsGroupId ?? 'null'}, mlsKeys=[${[...mlsListenKeyToGroup.keys()].map(k => k.slice(0, 12)).join(',')}]`);
1075
+
1076
+ if (mlsGroupId && !msg.inner_kind) {
1077
+ // ── MLS group message (raw kind:1059, not Gift Wrap) ──
1078
+ await handleMlsGroupMessage(bridge, account.accountId, mlsGroupId, msg, ctx, runtime);
1079
+ } else if (msg.inner_kind === 444) {
1080
+ // ── MLS Welcome (Gift Wrap with inner kind:444) ──
1081
+ await handleMlsWelcome(bridge, account.accountId, msg, ctx, runtime);
1082
+ } else {
1083
+ // ── Gift Wrap (friend request / hello) ──
1084
+ await handleFriendRequest(bridge, account.accountId, msg, ctx, runtime);
1085
+ }
1086
+ } else if (msg.event_kind === 4) {
1087
+ // ── Kind:4 DM ──
1088
+ markProcessed(bridge, account.accountId, msg.event_id, msg.created_at);
1089
+ if (msg.nip04_decrypted) {
1090
+ // NIP-04 pre-decrypted message (e.g., group invite via Nip4ChatService)
1091
+ // Skip Signal decrypt — plaintext is already in msg.text / msg.encrypted_content
1092
+ await handleNip04Message(bridge, account.accountId, msg, ctx, runtime);
1093
+ } else {
1094
+ // Signal-encrypted message — decrypt consumes message keys, cannot retry
1095
+ await handleEncryptedDM(bridge, account.accountId, msg, ctx, runtime);
1096
+ }
1097
+ } else {
1098
+ ctx.log?.info(
1099
+ `[${account.accountId}] Ignoring inbound event_kind=${msg.event_kind}`,
1100
+ );
1101
+ markProcessed(bridge, account.accountId, msg.event_id, msg.created_at);
1102
+ }
1103
+ } catch (err) {
1104
+ ctx.log?.error(
1105
+ `[${account.accountId}] Error handling inbound message: ${err}`,
1106
+ );
1107
+ }
1108
+ });
1109
+
1110
+ // Signal bridge readiness — unblock any queued outbound sends
1111
+ const readyResolver = bridgeReadyResolvers.get(account.accountId);
1112
+ if (readyResolver) {
1113
+ readyResolver();
1114
+ bridgeReadyResolvers.delete(account.accountId);
1115
+ bridgeReadyPromises.delete(account.accountId);
1116
+ }
1117
+
1118
+ // Keep the channel alive until abortSignal fires (OpenClaw expects startAccount
1119
+ // to stay pending while the channel is running — resolving triggers auto-restart)
1120
+ const abortSignal = (ctx as any).abortSignal as AbortSignal | undefined;
1121
+ if (abortSignal) {
1122
+ await new Promise<void>((resolve) => {
1123
+ if (abortSignal.aborted) { resolve(); return; }
1124
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
1125
+ });
1126
+ } else {
1127
+ // Fallback: wait forever (shouldn't happen in practice)
1128
+ await new Promise<void>(() => {});
1129
+ }
1130
+
1131
+ // Cleanup on abort
1132
+ bridge.disableAutoRestart();
1133
+ await bridge.disconnect();
1134
+ await bridge.stop();
1135
+ activeBridges.delete(account.accountId);
1136
+ accountInfoCache.delete(account.accountId);
1137
+ bridgeReadyPromises.delete(account.accountId);
1138
+ bridgeReadyResolvers.delete(account.accountId);
1139
+ ctx.log?.info(`[${account.accountId}] Keychat provider stopped`);
1140
+ },
1141
+ },
1142
+ };
1143
+
1144
+ // ═══════════════════════════════════════════════════════════════════════════
1145
+ // Inbound message helpers
1146
+ // ═══════════════════════════════════════════════════════════════════════════
1147
+
1148
+ /** Handle a Gift Wrap (kind:1059) friend request. */
1149
+ async function handleFriendRequest(
1150
+ bridge: KeychatBridgeClient,
1151
+ accountId: string,
1152
+ msg: InboundMessage,
1153
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1154
+ runtime: ReturnType<typeof getKeychatRuntime>,
1155
+ ): Promise<void> {
1156
+ // Serialize hello processing to prevent concurrent hellos from corrupting each other's sessions
1157
+ const previousLock = helloProcessingLock;
1158
+ let releaseLock: () => void;
1159
+ helloProcessingLock = new Promise<void>((resolve) => { releaseLock = resolve; });
1160
+ await previousLock;
1161
+ try {
1162
+ await handleFriendRequestInner(bridge, accountId, msg, ctx, runtime);
1163
+ } finally {
1164
+ releaseLock!();
1165
+ }
1166
+ }
1167
+
1168
+ async function handleFriendRequestInner(
1169
+ bridge: KeychatBridgeClient,
1170
+ accountId: string,
1171
+ msg: InboundMessage,
1172
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1173
+ runtime: ReturnType<typeof getKeychatRuntime>,
1174
+ ): Promise<void> {
1175
+ ctx.log?.info(`[${accountId}] Friend request (kind:1059) from ${msg.from_pubkey} (created_at=${msg.created_at})`);
1176
+
1177
+ // Skip stale friend requests (relay replays old events on reconnect)
1178
+ const MAX_FRIEND_REQUEST_AGE_SECS = 60; // 1 minute
1179
+ if (msg.created_at) {
1180
+ const ageSecs = Math.floor(Date.now() / 1000) - msg.created_at;
1181
+ if (ageSecs > MAX_FRIEND_REQUEST_AGE_SECS) {
1182
+ ctx.log?.info(`[${accountId}] Ignoring stale friend request from ${msg.from_pubkey} (age=${ageSecs}s > ${MAX_FRIEND_REQUEST_AGE_SECS}s)`);
1183
+ return;
1184
+ }
1185
+ }
1186
+
1187
+ // If we already have a session, re-process the hello to handle re-pairing
1188
+ // (e.g. peer deleted us and re-added, or our previous hello reply wasn't received)
1189
+ const existingPeer = getPeerSessions(accountId).get(msg.from_pubkey);
1190
+ if (existingPeer) {
1191
+ ctx.log?.info(`[${accountId}] Re-processing friend request from ${msg.from_pubkey} (existing session will be replaced)`);
1192
+ }
1193
+
1194
+ // Check DM policy before processing — reject unauthorized friend requests
1195
+ const core = runtime;
1196
+ const cfg = core.config.loadConfig();
1197
+ const account = resolveKeychatAccount({ cfg, accountId });
1198
+ const displayName = resolveDisplayName(cfg, accountId, account.name);
1199
+ const policy = account.config.dmPolicy ?? "pairing";
1200
+ const allowFrom = (account.config.allowFrom ?? []).map((e) => normalizePubkey(String(e)));
1201
+
1202
+ if (policy === "disabled") {
1203
+ ctx.log?.info(`[${accountId}] Rejecting friend request — dmPolicy is disabled`);
1204
+ return;
1205
+ }
1206
+
1207
+ const senderNormalized = normalizePubkey(msg.from_pubkey);
1208
+
1209
+ if (policy === "allowlist" && !allowFrom.includes(senderNormalized)) {
1210
+ ctx.log?.info(`[${accountId}] Rejecting friend request from ${msg.from_pubkey} — not in allowlist`);
1211
+ return;
1212
+ }
1213
+
1214
+ if (policy === "pairing" && !allowFrom.includes(senderNormalized)) {
1215
+ // Not yet approved — we still establish the session (so we can send the pending message)
1216
+ // but flag it for approval via OpenClaw's pairing system
1217
+ ctx.log?.info(`[${accountId}] Friend request from ${msg.from_pubkey} — pending pairing approval`);
1218
+ // Continue processing but will send a "pending approval" message instead of full access
1219
+ }
1220
+
1221
+ // Process hello via bridge — establishes Signal session
1222
+ const hello = await bridge.processHello(msg.encrypted_content);
1223
+ if (!hello.session_established) {
1224
+ ctx.log?.error(`[${accountId}] Failed to establish session from hello`);
1225
+ return;
1226
+ }
1227
+
1228
+ ctx.log?.info(
1229
+ `[${accountId}] Session established with ${hello.peer_name} (nostr: ${hello.peer_nostr_pubkey}, signal: ${hello.peer_signal_pubkey})`,
1230
+ );
1231
+
1232
+ // Store/update peer session info for this specific nostr pubkey only
1233
+ const peer: PeerSession = {
1234
+ signalPubkey: hello.peer_signal_pubkey,
1235
+ deviceId: hello.device_id,
1236
+ name: hello.peer_name,
1237
+ nostrPubkey: hello.peer_nostr_pubkey,
1238
+ };
1239
+
1240
+ // Clean up only the legacy restore entry for THIS peer's signal pubkey (if it was keyed wrong)
1241
+ if (getPeerSessions(accountId).has(hello.peer_signal_pubkey) && hello.peer_signal_pubkey !== hello.peer_nostr_pubkey) {
1242
+ getPeerSessions(accountId).delete(hello.peer_signal_pubkey);
1243
+ ctx.log?.info(`[${accountId}] Cleaned up legacy signal-keyed entry: ${hello.peer_signal_pubkey}`);
1244
+ }
1245
+
1246
+ // Update getAddressToPeer entries that pointed to the old signal key to use nostr key
1247
+ for (const [addr, oldPeerKey] of getAddressToPeer(accountId)) {
1248
+ if (oldPeerKey === hello.peer_signal_pubkey) {
1249
+ getAddressToPeer(accountId).set(addr, hello.peer_nostr_pubkey);
1250
+ }
1251
+ }
1252
+
1253
+ getPeerSessions(accountId).set(hello.peer_nostr_pubkey, peer);
1254
+
1255
+ // NOTE: peer mapping already persisted by Rust handle_process_hello (with local Signal keys).
1256
+ // Do NOT call savePeerMapping here — it would overwrite local_signal_pubkey/privkey with NULL.
1257
+
1258
+ // Auto-reply with hello (type 102 = DM_ADD_CONTACT_FROM_BOB)
1259
+ const isPendingApproval = policy === "pairing" && !allowFrom.includes(senderNormalized);
1260
+ const greetingText = isPendingApproval
1261
+ ? "👋 Hi! I received your request. It's pending approval — the owner will review it shortly."
1262
+ : `👋 Hi! I'm ${displayName}. We're connected now — feel free to chat!`;
1263
+ // Wrap as KeychatMessage so the receiver can identify this as a hello reply (type 102)
1264
+ const helloReplyMsg = JSON.stringify({
1265
+ type: 100, // KeyChatEventKinds.dm — Keychat app displays type 100 as chat message (type 102 is silently dropped)
1266
+ c: "signal",
1267
+ msg: greetingText,
1268
+ });
1269
+ const sendResult = await retrySend(() => bridge.sendMessage(hello.peer_nostr_pubkey, helloReplyMsg, {
1270
+ isHelloReply: true,
1271
+ senderName: displayName,
1272
+ }));
1273
+ ctx.log?.info(`[${accountId}] Sent hello reply to ${hello.peer_nostr_pubkey}`);
1274
+
1275
+ // Send profile so peer knows our display name
1276
+ try {
1277
+ await retrySend(() => bridge.sendProfile(hello.peer_nostr_pubkey, { name: displayName }));
1278
+ ctx.log?.info(`[${accountId}] Sent profile to ${hello.peer_nostr_pubkey}`);
1279
+ } catch (e) {
1280
+ ctx.log?.error(`[${accountId}] Failed to send profile to ${hello.peer_nostr_pubkey}: ${e}`);
1281
+ }
1282
+
1283
+ // Handle receiving address rotation after send (per-peer, limited to MAX_RECEIVING_ADDRESSES)
1284
+ await handleReceivingAddressRotation(bridge, accountId, sendResult, hello.peer_nostr_pubkey);
1285
+
1286
+ // Flush any pending messages that were waiting for this session
1287
+ if (pendingHelloMessages.has(hello.peer_nostr_pubkey)) {
1288
+ ctx.log?.info(`[${accountId}] Flushing pending hello messages for ${hello.peer_nostr_pubkey}`);
1289
+ await flushPendingHelloMessages(bridge, accountId, hello.peer_nostr_pubkey);
1290
+ }
1291
+
1292
+ // Dispatch the peer's greeting through the agent pipeline so the AI can generate a proper welcome
1293
+ // But skip dispatch if we initiated the hello (we already know who they are)
1294
+ const weInitiated = helloSentTo.has(hello.peer_nostr_pubkey) || pendingHelloMessages.has(hello.peer_nostr_pubkey);
1295
+ if (!weInitiated) {
1296
+ const greetingText = `[New contact] ${hello.peer_name} connected via Keychat. Their greeting: ${hello.greeting || "(no message)"}`;
1297
+ await dispatchToAgent(bridge, accountId, hello.peer_nostr_pubkey, hello.peer_name, greetingText, msg.event_id + "_hello", runtime, ctx);
1298
+ } else {
1299
+ ctx.log?.info(`[${accountId}] Skipping dispatch for self-initiated hello to ${hello.peer_nostr_pubkey}`);
1300
+ helloSentTo.delete(hello.peer_nostr_pubkey);
1301
+ }
1302
+ }
1303
+
1304
+ /** Handle a NIP-04 pre-decrypted message (e.g., group invite). */
1305
+ async function handleNip04Message(
1306
+ bridge: KeychatBridgeClient,
1307
+ accountId: string,
1308
+ msg: InboundMessage,
1309
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1310
+ runtime: ReturnType<typeof getKeychatRuntime>,
1311
+ ): Promise<void> {
1312
+ const plaintext = msg.text || msg.encrypted_content;
1313
+ ctx.log?.info(`[${accountId}] NIP-04 decrypted message from ${msg.from_pubkey}: ${plaintext.slice(0, 80)}...`);
1314
+
1315
+ // Try to parse as KeychatMessage
1316
+ let displayText = plaintext;
1317
+ try {
1318
+ const parsed = JSON.parse(plaintext);
1319
+
1320
+ // Handle group invite (type=11, c="group")
1321
+ if (parsed && parsed.type === 11 && parsed.c === "group" && parsed.msg) {
1322
+ const roomProfile = JSON.parse(parsed.msg);
1323
+ let senderIdPubkey = msg.from_pubkey;
1324
+ let inviteMessage = "Group invite received";
1325
+ if (parsed.name) {
1326
+ try {
1327
+ const nameData = JSON.parse(parsed.name);
1328
+ if (Array.isArray(nameData) && nameData.length >= 2) {
1329
+ inviteMessage = nameData[0];
1330
+ senderIdPubkey = nameData[1];
1331
+ }
1332
+ } catch { /* ignore */ }
1333
+ }
1334
+
1335
+ ctx.log?.info(`[${accountId}] Received group invite via NIP-04: ${roomProfile.name} from ${senderIdPubkey}`);
1336
+ const joinResult = await bridge.joinGroup(roomProfile, senderIdPubkey);
1337
+ ctx.log?.info(`[${accountId}] Joined group '${joinResult.name}' (${joinResult.group_id}), ${joinResult.member_count} members`);
1338
+
1339
+ // Send hello to the group
1340
+ try {
1341
+ const helloText = `😃 Hi, I am Agent`;
1342
+ await bridge.sendGroupMessage(joinResult.group_id, helloText, { subtype: 14 });
1343
+ ctx.log?.info(`[${accountId}] Sent group hello to ${joinResult.group_id}`);
1344
+ } catch (err) {
1345
+ ctx.log?.error(`[${accountId}] Failed to send group hello: ${err}`);
1346
+ }
1347
+
1348
+ // Dispatch to agent
1349
+ displayText = `[Group Invite] ${inviteMessage}. Joined group "${joinResult.name}" with ${joinResult.member_count} members.`;
1350
+ const senderLabel = getPeerSessions(accountId).get(senderIdPubkey)?.name || senderIdPubkey.slice(0, 12);
1351
+ await dispatchGroupToAgent(bridge, accountId, joinResult.group_id, senderIdPubkey, senderLabel, displayText, msg.event_id, runtime, ctx, { message: displayText, pubkey: joinResult.group_id });
1352
+ return;
1353
+ }
1354
+
1355
+ // Other NIP-04 messages — extract msg field if it's a KeychatMessage
1356
+ if (parsed && typeof parsed.msg === "string") {
1357
+ displayText = parsed.msg;
1358
+ }
1359
+ } catch {
1360
+ // Not JSON — use as-is
1361
+ }
1362
+
1363
+ // Dispatch as regular DM from the sender
1364
+ const senderPubkey = msg.from_pubkey;
1365
+ const peer = getPeerSessions(accountId).get(senderPubkey);
1366
+ const senderLabel = peer?.name || senderPubkey.slice(0, 12);
1367
+ await dispatchToAgent(bridge, accountId, senderPubkey, senderLabel, displayText, msg.event_id, runtime, ctx);
1368
+ }
1369
+
1370
+ // ═══════════════════════════════════════════════════════════════════════════
1371
+ // MLS Group Message Handlers
1372
+ // ═══════════════════════════════════════════════════════════════════════════
1373
+
1374
+ /** Handle an incoming MLS group message (kind:1059 on listen key, not Gift Wrap). */
1375
+ async function handleMlsGroupMessage(
1376
+ bridge: KeychatBridgeClient,
1377
+ accountId: string,
1378
+ groupId: string,
1379
+ msg: InboundMessage,
1380
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1381
+ runtime: ReturnType<typeof getKeychatRuntime>,
1382
+ ): Promise<void> {
1383
+ try {
1384
+ // Parse the message type first
1385
+ const msgType = await bridge.mlsParseMessageType(groupId, msg.encrypted_content);
1386
+ ctx.log?.info(`[${accountId}] MLS message type: ${msgType} for group ${groupId}`);
1387
+
1388
+ switch (msgType) {
1389
+ case "Application": {
1390
+ // Decrypt the application message
1391
+ const decrypted = await bridge.mlsDecryptMessage(groupId, msg.encrypted_content);
1392
+ ctx.log?.info(`[${accountId}] MLS message from ${decrypted.sender.slice(0, 12)} in group ${groupId}`);
1393
+
1394
+ // Skip messages from ourselves
1395
+ const myPubkey = accountInfoCache.get(accountId)?.pubkey_hex;
1396
+ if (decrypted.sender === myPubkey) {
1397
+ ctx.log?.info(`[${accountId}] Skipping own MLS message`);
1398
+ return;
1399
+ }
1400
+
1401
+ // Get group info for context
1402
+ let groupName = groupId.slice(0, 12);
1403
+ try {
1404
+ const info = await bridge.mlsGetGroupInfo(groupId);
1405
+ groupName = info.name || groupName;
1406
+ } catch { /* best effort */ }
1407
+
1408
+ // Check if message is an encrypted media URL
1409
+ let mlsDisplayText = decrypted.plaintext;
1410
+ let mlsMediaPath: string | undefined;
1411
+ const mlsMediaInfo = parseMediaUrl(decrypted.plaintext);
1412
+ if (mlsMediaInfo) {
1413
+ try {
1414
+ mlsMediaPath = await downloadAndDecrypt(mlsMediaInfo);
1415
+ ctx.log?.info(`[${accountId}] MLS group media downloaded: ${mlsMediaInfo.kctype} → ${mlsMediaPath}`);
1416
+ mlsDisplayText = `[${mlsMediaInfo.kctype}: ${mlsMediaInfo.sourceName || mlsMediaInfo.suffix}] (saved to ${mlsMediaPath})`;
1417
+ } catch (err) {
1418
+ ctx.log?.error(`[${accountId}] MLS group media download failed: ${err}`);
1419
+ mlsDisplayText = `[${mlsMediaInfo.kctype} message — download failed]`;
1420
+ }
1421
+ }
1422
+
1423
+ // Route to agent
1424
+ ctx.log?.info(`[${accountId}] MLS dispatching to agent: group="${groupName}", sender=${decrypted.sender.slice(0, 12)}, text=${mlsDisplayText.slice(0, 80)}`);
1425
+ await dispatchMlsGroupToAgent(
1426
+ bridge, accountId, groupId, groupName,
1427
+ decrypted.sender, decrypted.sender.slice(0, 12),
1428
+ mlsDisplayText, msg.event_id, runtime, ctx, mlsMediaPath,
1429
+ );
1430
+ ctx.log?.info(`[${accountId}] MLS dispatch complete for group="${groupName}"`);
1431
+ break;
1432
+ }
1433
+ case "Commit": {
1434
+ // Process the commit (add/remove/update/etc.)
1435
+ const commitResult = await bridge.mlsProcessCommit(groupId, msg.encrypted_content);
1436
+ ctx.log?.info(`[${accountId}] MLS commit: ${commitResult.commit_type} by ${commitResult.sender.slice(0, 12)} in group ${groupId}`);
1437
+
1438
+ // Update listen key subscription
1439
+ const oldListenKey = msg.to_address;
1440
+ if (oldListenKey && oldListenKey !== commitResult.listen_key) {
1441
+ mlsListenKeyToGroup.delete(oldListenKey);
1442
+ try { await bridge.removeSubscription([oldListenKey]); } catch { /* best effort */ }
1443
+ }
1444
+ mlsListenKeyToGroup.set(commitResult.listen_key, groupId);
1445
+ await bridge.addSubscription([commitResult.listen_key]);
1446
+
1447
+ // Generate system message based on commit type
1448
+ let systemMsg = "";
1449
+ switch (commitResult.commit_type) {
1450
+ case "Add":
1451
+ systemMsg = `[System] ${commitResult.sender.slice(0, 12)} added [${commitResult.operated_members.map(m => m.slice(0, 12)).join(", ")}] to the group`;
1452
+ break;
1453
+ case "Remove": {
1454
+ const myPubkey = accountInfoCache.get(accountId)?.pubkey_hex;
1455
+ if (commitResult.operated_members.includes(myPubkey ?? "")) {
1456
+ systemMsg = "[System] You have been removed from the group";
1457
+ } else {
1458
+ systemMsg = `[System] ${commitResult.sender.slice(0, 12)} removed [${commitResult.operated_members.map(m => m.slice(0, 12)).join(", ")}]`;
1459
+ }
1460
+ break;
1461
+ }
1462
+ case "Update":
1463
+ systemMsg = `[System] ${commitResult.sender.slice(0, 12)} updated their key`;
1464
+ break;
1465
+ case "GroupContextExtensions": {
1466
+ // Refresh group info
1467
+ try {
1468
+ const info = await bridge.mlsGetGroupInfo(groupId);
1469
+ systemMsg = `[System] ${commitResult.sender.slice(0, 12)} updated group info: ${info.name}`;
1470
+ if (info.status === "dissolved") {
1471
+ systemMsg = "[System] The admin closed this group chat";
1472
+ }
1473
+ } catch {
1474
+ systemMsg = `[System] ${commitResult.sender.slice(0, 12)} updated group info`;
1475
+ }
1476
+ break;
1477
+ }
1478
+ }
1479
+
1480
+ if (systemMsg) {
1481
+ ctx.log?.info(`[${accountId}] MLS system: ${systemMsg}`);
1482
+ }
1483
+ break;
1484
+ }
1485
+ case "Proposal":
1486
+ ctx.log?.info(`[${accountId}] MLS proposal received in group ${groupId} (not processed)`);
1487
+ break;
1488
+ default:
1489
+ ctx.log?.info(`[${accountId}] Unhandled MLS message type: ${msgType} in group ${groupId}`);
1490
+ }
1491
+ } catch (err) {
1492
+ ctx.log?.error(`[${accountId}] Failed to handle MLS group message: ${err}`);
1493
+ }
1494
+ }
1495
+
1496
+ /** Handle an MLS Welcome message (inner kind:444 from Gift Wrap). */
1497
+ async function handleMlsWelcome(
1498
+ bridge: KeychatBridgeClient,
1499
+ accountId: string,
1500
+ msg: InboundMessage,
1501
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1502
+ runtime: ReturnType<typeof getKeychatRuntime>,
1503
+ ): Promise<void> {
1504
+ try {
1505
+ const welcomeContent = msg.text || msg.encrypted_content;
1506
+ if (!welcomeContent) {
1507
+ ctx.log?.error(`[${accountId}] MLS Welcome: no content`);
1508
+ return;
1509
+ }
1510
+
1511
+ // Extract group_id from inner rumor's p-tags
1512
+ // The Keychat app sends Welcome (kind:444) with additionalTags: [[p, groupPubkey]]
1513
+ const innerPTags = (msg as any).inner_tags_p as string[] | undefined;
1514
+ const groupId = innerPTags?.[0];
1515
+
1516
+ if (!groupId) {
1517
+ ctx.log?.error(`[${accountId}] MLS Welcome from ${msg.from_pubkey.slice(0, 12)}: no group_id in inner p-tags`);
1518
+ return;
1519
+ }
1520
+
1521
+ ctx.log?.info(`[${accountId}] MLS Welcome from ${msg.from_pubkey.slice(0, 12)} for group ${groupId.slice(0, 12)}...`);
1522
+
1523
+ // Join the group
1524
+ const joinResult = await bridge.mlsJoinGroup(groupId, welcomeContent);
1525
+ ctx.log?.info(`[${accountId}] Joined MLS group ${groupId.slice(0, 12)}, listen key: ${joinResult.listen_key.slice(0, 12)}...`);
1526
+
1527
+ // Subscribe to the group's listen key
1528
+ mlsListenKeyToGroup.set(joinResult.listen_key, groupId);
1529
+ await bridge.addSubscription([joinResult.listen_key]);
1530
+
1531
+ // Get group info
1532
+ const info = await bridge.mlsGetGroupInfo(groupId);
1533
+ ctx.log?.info(`[${accountId}] MLS group "${info.name}": ${info.members.length} members`);
1534
+
1535
+ // Send greeting: self_update produces a commit that must be published + committed
1536
+ try {
1537
+ const greetingResult = await bridge.mlsSelfUpdate(groupId, {
1538
+ name: "Agent",
1539
+ msg: "[System] Hello everyone! I am Agent",
1540
+ status: "confirmed",
1541
+ });
1542
+
1543
+ // Publish the commit to the group's listen key
1544
+ await bridge.mlsPublishToGroup(joinResult.listen_key, greetingResult.encrypted_msg);
1545
+ ctx.log?.info(`[${accountId}] Published MLS greeting commit to group "${info.name}"`);
1546
+
1547
+ // Merge pending commit
1548
+ await bridge.mlsSelfCommit(groupId);
1549
+
1550
+ // Listen key changes after commit — re-subscribe
1551
+ const { listen_key: newKey } = await bridge.mlsGetListenKey(groupId);
1552
+ if (newKey !== joinResult.listen_key) {
1553
+ mlsListenKeyToGroup.delete(joinResult.listen_key);
1554
+ mlsListenKeyToGroup.set(newKey, groupId);
1555
+ await bridge.removeSubscription([joinResult.listen_key]);
1556
+ await bridge.addSubscription([newKey]);
1557
+ ctx.log?.info(`[${accountId}] MLS listen key rotated after greeting: ${newKey.slice(0, 12)}...`);
1558
+ }
1559
+ } catch (err) {
1560
+ ctx.log?.error(`[${accountId}] Failed to send MLS greeting: ${err}`);
1561
+ }
1562
+
1563
+ // Re-publish KeyPackage since the old one was consumed
1564
+ try {
1565
+ const kpResult = await bridge.mlsPublishKeyPackage();
1566
+ ctx.log?.info(`[${accountId}] Re-published MLS KeyPackage after join (event ${kpResult.event_id})`);
1567
+ } catch (err) {
1568
+ ctx.log?.error(`[${accountId}] Failed to re-publish KeyPackage after join: ${err}`);
1569
+ }
1570
+ } catch (err) {
1571
+ ctx.log?.error(`[${accountId}] Failed to handle MLS Welcome: ${err}`);
1572
+ }
1573
+ }
1574
+
1575
+ /** Dispatch an MLS group message to the agent. */
1576
+ async function dispatchMlsGroupToAgent(
1577
+ bridge: KeychatBridgeClient,
1578
+ accountId: string,
1579
+ groupId: string,
1580
+ groupName: string,
1581
+ senderPubkey: string,
1582
+ senderName: string,
1583
+ displayText: string,
1584
+ eventId: string,
1585
+ runtime: ReturnType<typeof getKeychatRuntime>,
1586
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1587
+ mediaPath?: string,
1588
+ ): Promise<void> {
1589
+ const core = runtime;
1590
+ const cfg = core.config.loadConfig();
1591
+
1592
+ ctx.log?.info(`[${accountId}] dispatchMlsGroupToAgent: resolving route for group=${groupId.slice(0, 12)}`);
1593
+ const route = core.channel.routing.resolveAgentRoute({
1594
+ cfg,
1595
+ channel: "keychat",
1596
+ accountId,
1597
+ peer: {
1598
+ kind: "group",
1599
+ id: groupId,
1600
+ },
1601
+ });
1602
+ ctx.log?.info(`[${accountId}] dispatchMlsGroupToAgent: route resolved, sessionKey=${route.sessionKey}`);
1603
+
1604
+ const body = core.channel.reply.formatAgentEnvelope({
1605
+ channel: "Keychat",
1606
+ from: senderName,
1607
+ timestamp: Date.now(),
1608
+ body: displayText,
1609
+ });
1610
+
1611
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
1612
+ Body: body,
1613
+ RawBody: displayText,
1614
+ CommandBody: displayText,
1615
+ From: `keychat:${senderPubkey}`,
1616
+ To: `keychat:mls-group:${groupId}`,
1617
+ SessionKey: route.sessionKey,
1618
+ AccountId: accountId,
1619
+ ChatType: "group" as const,
1620
+ SenderName: senderName,
1621
+ SenderId: senderPubkey,
1622
+ GroupId: groupId,
1623
+ GroupName: groupName,
1624
+ Provider: "keychat" as const,
1625
+ Surface: "keychat" as const,
1626
+ MessageSid: eventId,
1627
+ OriginatingChannel: "keychat" as const,
1628
+ OriginatingTo: `keychat:mls-group:${groupId}`,
1629
+ ...(mediaPath ? { MediaPath: mediaPath } : {}),
1630
+ });
1631
+
1632
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
1633
+ cfg,
1634
+ channel: "keychat",
1635
+ accountId,
1636
+ });
1637
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
1638
+ cfg,
1639
+ agentId: route.agentId,
1640
+ channel: "keychat",
1641
+ accountId,
1642
+ });
1643
+
1644
+ // Buffer and merge deliver() calls
1645
+ let deliverBuffer: string[] = [];
1646
+ let deliverTimer: ReturnType<typeof setTimeout> | null = null;
1647
+ const DELIVER_DEBOUNCE_MS = 1500;
1648
+
1649
+ const flushDeliverBuffer = async () => {
1650
+ deliverTimer = null;
1651
+ if (deliverBuffer.length === 0) return;
1652
+ const merged = deliverBuffer.join("\n\n").trim();
1653
+ deliverBuffer = [];
1654
+ if (!merged) return;
1655
+ try {
1656
+ await retrySend(() => bridge.mlsSendMessage(groupId, merged));
1657
+ } catch (err) {
1658
+ ctx.log?.error(`[${accountId}] MLS group reply delivery failed: ${err}`);
1659
+ }
1660
+ };
1661
+
1662
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1663
+ ctx: ctxPayload,
1664
+ cfg,
1665
+ dispatcherOptions: {
1666
+ ...prefixOptions,
1667
+ deliver: async (payload: { text?: string }) => {
1668
+ if (!payload.text) return;
1669
+ const message = core.channel.text.convertMarkdownTables(payload.text, tableMode);
1670
+ deliverBuffer.push(message);
1671
+ if (deliverTimer) clearTimeout(deliverTimer);
1672
+ deliverTimer = setTimeout(() => { flushDeliverBuffer(); }, DELIVER_DEBOUNCE_MS);
1673
+ },
1674
+ onError: (err: unknown) => {
1675
+ ctx.log?.error(`[${accountId}] MLS group reply delivery failed: ${err}`);
1676
+ },
1677
+ },
1678
+ replyOptions: {
1679
+ onModelSelected,
1680
+ },
1681
+ });
1682
+
1683
+ // Flush remaining
1684
+ if (deliverTimer) clearTimeout(deliverTimer);
1685
+ await flushDeliverBuffer();
1686
+ }
1687
+
1688
+ /** Handle a kind:4 DM (Signal-encrypted message). */
1689
+ async function handleEncryptedDM(
1690
+ bridge: KeychatBridgeClient,
1691
+ accountId: string,
1692
+ msg: InboundMessage,
1693
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
1694
+ runtime: ReturnType<typeof getKeychatRuntime>,
1695
+ ): Promise<void> {
1696
+ // from_pubkey is EPHEMERAL — use to_address to find the actual peer
1697
+ let peerNostrPubkey: string | null = null;
1698
+ if (msg.to_address) {
1699
+ peerNostrPubkey = getAddressToPeer(accountId).get(msg.to_address) ?? null;
1700
+ }
1701
+
1702
+ // Fallback: try from_pubkey directly (may work for initial messages)
1703
+ if (!peerNostrPubkey) {
1704
+ peerNostrPubkey = getPeerSessions(accountId).has(msg.from_pubkey) ? msg.from_pubkey : null;
1705
+ }
1706
+
1707
+ // Fallback: query DB for address mapping before brute-force
1708
+ if (!peerNostrPubkey && msg.to_address) {
1709
+ try {
1710
+ const { mappings: dbMappings } = await bridge.getAddressMappings();
1711
+ const found = dbMappings.find((m) => m.address === msg.to_address);
1712
+ if (found) {
1713
+ peerNostrPubkey = found.peer_nostr_pubkey;
1714
+ getAddressToPeer(accountId).set(msg.to_address, peerNostrPubkey);
1715
+ ctx.log?.info(`[${accountId}] Resolved peer from DB address mapping: ${peerNostrPubkey}`);
1716
+ }
1717
+ } catch { /* DB lookup failed */ }
1718
+ }
1719
+
1720
+ // ---- PreKey-based peer identification (replaces brute-force) ----
1721
+ // If we still can't identify the peer, try the PreKey path: extract the
1722
+ // Signal identity key from the ciphertext and look up the peer directly.
1723
+ // This is deterministic and avoids the old brute-force fallback.
1724
+ const hasPeerSession = peerNostrPubkey ? getPeerSessions(accountId).has(peerNostrPubkey) : false;
1725
+ ctx.log?.info(`[${accountId}] DEBUG: peerNostrPubkey=${peerNostrPubkey}, hasPeerSession=${hasPeerSession}, is_prekey=${msg.is_prekey}, to_address=${msg.to_address}`);
1726
+ if ((!peerNostrPubkey || !hasPeerSession) && msg.is_prekey) {
1727
+ ctx.log?.info(`[${accountId}] Entering PreKey path, encrypted_content length=${msg.encrypted_content?.length}, first40=${msg.encrypted_content?.slice(0, 40)}`);
1728
+ try {
1729
+ const prekeyInfo = await bridge.parsePrekeySender(msg.encrypted_content);
1730
+ ctx.log?.info(`[${accountId}] parsePrekeySender result: is_prekey=${prekeyInfo.is_prekey}, signal_identity_key=${prekeyInfo.signal_identity_key}`);
1731
+ if (prekeyInfo.is_prekey && prekeyInfo.signal_identity_key) {
1732
+ const sigKey = prekeyInfo.signal_identity_key;
1733
+ ctx.log?.info(`[${accountId}] PreKey message detected — signal identity: ${sigKey}`);
1734
+
1735
+ // Strategy 1: Look up Signal key in existing getPeerSessions
1736
+ for (const [nostrPk, ps] of getPeerSessions(accountId)) {
1737
+ if (ps.signalPubkey === sigKey) {
1738
+ peerNostrPubkey = nostrPk;
1739
+ ctx.log?.info(`[${accountId}] PreKey routed to existing peer ${nostrPk} via signal key match`);
1740
+ break;
1741
+ }
1742
+ }
1743
+
1744
+ // Strategy 2: Look up in DB peer_mapping
1745
+ if (!peerNostrPubkey) {
1746
+ try {
1747
+ const { mappings } = await bridge.getPeerMappings();
1748
+ const found = mappings.find((m) => m.signal_pubkey === sigKey);
1749
+ if (found) {
1750
+ peerNostrPubkey = found.nostr_pubkey;
1751
+ ctx.log?.info(`[${accountId}] PreKey routed to peer ${peerNostrPubkey} via DB peer_mapping`);
1752
+ }
1753
+ } catch { /* DB lookup failed */ }
1754
+ }
1755
+
1756
+ // Strategy 3: This is a new peer replying to our hello — decrypt first,
1757
+ // then identify via PrekeyMessageModel.nostr_id or helloSentTo set
1758
+ if (!peerNostrPubkey || !getPeerSessions(accountId).has(peerNostrPubkey)) {
1759
+ ctx.log?.info(`[${accountId}] PreKey from unknown signal key ${sigKey} — attempting decrypt to identify sender`);
1760
+
1761
+ const decryptResult = await bridge.decryptMessage(sigKey, msg.encrypted_content, true);
1762
+ const { plaintext } = decryptResult;
1763
+
1764
+ // Try to extract nostr_id from PrekeyMessageModel
1765
+ let senderNostrId: string | null = null;
1766
+ let senderName = sigKey.slice(0, 12);
1767
+ try {
1768
+ const parsed = JSON.parse(plaintext);
1769
+ if (parsed?.nostrId) {
1770
+ senderNostrId = parsed.nostrId;
1771
+ senderName = parsed.name || senderName;
1772
+ ctx.log?.info(`[${accountId}] PreKey sender identified via PrekeyMessageModel: nostr_id=${senderNostrId}`);
1773
+ }
1774
+ } catch { /* not a PrekeyMessageModel */ }
1775
+
1776
+ // Fallback: use peerNostrPubkey from addressToPeer (onetimekey mapping)
1777
+ if (!senderNostrId && peerNostrPubkey) {
1778
+ senderNostrId = peerNostrPubkey;
1779
+ ctx.log?.info(`[${accountId}] PreKey sender identified via onetimekey addressToPeer mapping: ${senderNostrId}`);
1780
+ }
1781
+
1782
+ // Fallback: if only one pending hello, assume it's the responder
1783
+ if (!senderNostrId && helloSentTo.size === 1) {
1784
+ senderNostrId = helloSentTo.values().next().value ?? null;
1785
+ ctx.log?.info(`[${accountId}] PreKey sender inferred from single pending hello: ${senderNostrId}`);
1786
+ }
1787
+
1788
+ // Fallback: if there are pending hellos, try to match
1789
+ if (!senderNostrId && helloSentTo.size > 1) {
1790
+ // Multiple pending hellos — can't determine which one. Log warning.
1791
+ ctx.log?.error(
1792
+ `[${accountId}] ⚠️ PreKey from unknown signal key ${sigKey} with ${helloSentTo.size} pending hellos — cannot determine sender. Dropping message.`,
1793
+ );
1794
+ return;
1795
+ }
1796
+
1797
+ if (!senderNostrId) {
1798
+ ctx.log?.error(
1799
+ `[${accountId}] ⚠️ PreKey from unknown signal key ${sigKey} — no pending hellos and no PrekeyMessageModel. Dropping.`,
1800
+ );
1801
+ return;
1802
+ }
1803
+
1804
+ // Register the peer session
1805
+ const newPeer: PeerSession = {
1806
+ signalPubkey: sigKey,
1807
+ deviceId: 1,
1808
+ name: senderName,
1809
+ nostrPubkey: senderNostrId,
1810
+ };
1811
+ getPeerSessions(accountId).set(senderNostrId, newPeer);
1812
+ await bridge.savePeerMapping(senderNostrId, sigKey, 1, senderName);
1813
+ ctx.log?.info(`[${accountId}] ✅ Session established with ${senderNostrId} (signal: ${sigKey})`);
1814
+
1815
+ // After PreKey decrypt, subscribe to receiving addresses for this new peer.
1816
+ // alice_addrs from decrypt contains ratchet-derived addresses; take only latest MAX_RECEIVING_ADDRESSES.
1817
+ try {
1818
+ const aliceAddrs: string[] = decryptResult?.alice_addrs ?? [];
1819
+ if (aliceAddrs.length > 0) {
1820
+ const latest = aliceAddrs.slice(-MAX_RECEIVING_ADDRESSES);
1821
+ const newAddrs = latest.filter((a) => !getAddressToPeer(accountId).has(a));
1822
+ if (newAddrs.length > 0) {
1823
+ await bridge.addSubscription(newAddrs);
1824
+ const peerAddrs = peerSubscribedAddresses.get(senderNostrId!) ?? [];
1825
+ for (const a of newAddrs) {
1826
+ getAddressToPeer(accountId).set(a, senderNostrId!);
1827
+ peerAddrs.push(a);
1828
+ try { await bridge.saveAddressMapping(a, senderNostrId!); } catch { /* */ }
1829
+ }
1830
+ // Trim to MAX_RECEIVING_ADDRESSES
1831
+ while (peerAddrs.length > MAX_RECEIVING_ADDRESSES) {
1832
+ const old = peerAddrs.shift()!;
1833
+ getAddressToPeer(accountId).delete(old);
1834
+ try { await bridge.removeSubscription([old]); } catch { /* */ }
1835
+ try { await bridge.deleteAddressMapping(old); } catch { /* */ }
1836
+ }
1837
+ peerSubscribedAddresses.set(senderNostrId!, peerAddrs);
1838
+ ctx.log?.info(`[${accountId}] Registered ${newAddrs.length} receiving address(es) for new peer ${senderNostrId!.slice(0,16)} (kept ${peerAddrs.length})`);
1839
+ }
1840
+ }
1841
+ } catch { /* best effort */ }
1842
+
1843
+ // Flush pending hello messages now that session is established
1844
+ if (pendingHelloMessages.has(senderNostrId)) {
1845
+ ctx.log?.info(`[${accountId}] Flushing ${pendingHelloMessages.get(senderNostrId)?.length} pending message(s) to ${senderNostrId}`);
1846
+ await flushPendingHelloMessages(bridge, accountId, senderNostrId);
1847
+ }
1848
+ helloSentTo.delete(senderNostrId);
1849
+
1850
+ // Parse and dispatch the decrypted content
1851
+ let displayText = plaintext;
1852
+ try {
1853
+ const parsed = JSON.parse(plaintext);
1854
+ // PrekeyMessageModel uses 'message' field; KeychatMessage uses 'msg'
1855
+ if (parsed && typeof parsed.message === "string") {
1856
+ displayText = parsed.message;
1857
+ // The message field may contain a nested KeychatMessage JSON
1858
+ try {
1859
+ const inner = JSON.parse(parsed.message);
1860
+ if (inner && typeof inner.msg === "string") {
1861
+ displayText = inner.msg;
1862
+ }
1863
+ } catch { /* not nested JSON */ }
1864
+ } else if (parsed && typeof parsed.msg === "string") {
1865
+ displayText = parsed.msg;
1866
+ }
1867
+ // If this is a PrekeyMessageModel (hello reply wrapper), don't dispatch to agent
1868
+ if (parsed?.nostrId && parsed?.signalId) {
1869
+ ctx.log?.info(`[${accountId}] Hello reply from ${senderNostrId}: ${displayText.slice(0, 80)}`);
1870
+ return; // Protocol overhead — don't dispatch
1871
+ }
1872
+ } catch { /* not JSON — dispatch as regular message */ }
1873
+
1874
+ // Dispatch non-hello PreKey messages to agent
1875
+ await dispatchToAgent(bridge, accountId, senderNostrId, senderName, displayText, msg.event_id, runtime, ctx);
1876
+ return;
1877
+ }
1878
+ }
1879
+ } catch (err) {
1880
+ ctx.log?.error(`[${accountId}] PreKey parse/decrypt FAILED: ${err}`);
1881
+ console.error(`[keychat] PreKey error:`, err);
1882
+ }
1883
+ }
1884
+
1885
+ // Last resort: brute-force peer lookup (only for non-PreKey messages)
1886
+ if (!peerNostrPubkey) {
1887
+ ctx.log?.error(
1888
+ `[${accountId}] ⚠️ Address mapping miss for to_address=${msg.to_address} — falling back to brute-force peer lookup.`,
1889
+ );
1890
+ if (getPeerSessions(accountId).size === 1) {
1891
+ peerNostrPubkey = getPeerSessions(accountId).keys().next().value ?? null;
1892
+ } else {
1893
+ for (const [key] of getPeerSessions(accountId)) {
1894
+ peerNostrPubkey = key;
1895
+ break;
1896
+ }
1897
+ }
1898
+ if (peerNostrPubkey && msg.to_address) {
1899
+ getAddressToPeer(accountId).set(msg.to_address, peerNostrPubkey);
1900
+ try {
1901
+ await bridge.saveAddressMapping(msg.to_address, peerNostrPubkey);
1902
+ } catch { /* best effort */ }
1903
+ }
1904
+ }
1905
+
1906
+ if (!peerNostrPubkey) {
1907
+ ctx.log?.error(
1908
+ `[${accountId}] Cannot identify peer for inbound kind:4 (to_address=${msg.to_address}, from=${msg.from_pubkey})`,
1909
+ );
1910
+ return;
1911
+ }
1912
+
1913
+ const peer = getPeerSessions(accountId).get(peerNostrPubkey);
1914
+ if (!peer) {
1915
+ ctx.log?.error(`[${accountId}] No session info for peer ${peerNostrPubkey}`);
1916
+ return;
1917
+ }
1918
+
1919
+ // Decrypt using peer's Signal (curve25519) pubkey
1920
+ ctx.log?.info(`[${accountId}] Routing decrypt to peer ${peerNostrPubkey} (signal: ${peer.signalPubkey})`);
1921
+ let decryptResult;
1922
+ let actualPeer = peer;
1923
+ try {
1924
+ decryptResult = await bridge.decryptMessage(peer.signalPubkey, msg.encrypted_content, msg.is_prekey);
1925
+ } catch (err) {
1926
+ ctx.log?.error(
1927
+ `[${accountId}] ⚠️ Decrypt failed for mapped peer ${peerNostrPubkey} (signal: ${peer.signalPubkey}): ${err}. Trying other peers...`,
1928
+ );
1929
+ // Fallback: try other peers — this means address→peer mapping was wrong
1930
+ for (const [key, otherPeer] of getPeerSessions(accountId)) {
1931
+ if (key === peerNostrPubkey) continue;
1932
+ try {
1933
+ decryptResult = await bridge.decryptMessage(otherPeer.signalPubkey, msg.encrypted_content, msg.is_prekey);
1934
+ actualPeer = otherPeer;
1935
+ peerNostrPubkey = key;
1936
+ // Fix address mapping
1937
+ if (msg.to_address) getAddressToPeer(accountId).set(msg.to_address, key);
1938
+ ctx.log?.info(`[${accountId}] Decrypt succeeded with peer ${key} — address mapping corrected`);
1939
+ break;
1940
+ } catch { continue; }
1941
+ }
1942
+ if (!decryptResult) {
1943
+ ctx.log?.error(
1944
+ `[${accountId}] ⚠️ All decrypt attempts failed for peer ${peerNostrPubkey} (event_id=${msg.event_id}, created_at=${msg.created_at}). Skipping message.`,
1945
+ );
1946
+ return;
1947
+ }
1948
+ }
1949
+ const peer_ = actualPeer;
1950
+ const { plaintext } = decryptResult;
1951
+
1952
+ // After decrypt, use alice_addrs from decrypt result (per-peer ratchet addresses).
1953
+ // handleReceivingAddressRotation handles the per-message new_receiving_address from send;
1954
+ // for decrypt, we use alice_addrs which contains the current peer's ratchet addresses.
1955
+ try {
1956
+ const aliceAddrs: string[] = (decryptResult as any)?.alice_addrs ?? [];
1957
+ if (aliceAddrs.length > 0) {
1958
+ const latest = aliceAddrs.slice(-MAX_RECEIVING_ADDRESSES);
1959
+ const newAddrs = latest.filter((a) => !getAddressToPeer(accountId).has(a));
1960
+ if (newAddrs.length > 0) {
1961
+ await bridge.addSubscription(newAddrs);
1962
+ const peerAddrs = peerSubscribedAddresses.get(peerNostrPubkey) ?? [];
1963
+ for (const a of newAddrs) {
1964
+ getAddressToPeer(accountId).set(a, peerNostrPubkey);
1965
+ peerAddrs.push(a);
1966
+ try { await bridge.saveAddressMapping(a, peerNostrPubkey); } catch { /* */ }
1967
+ }
1968
+ while (peerAddrs.length > MAX_RECEIVING_ADDRESSES) {
1969
+ const old = peerAddrs.shift()!;
1970
+ getAddressToPeer(accountId).delete(old);
1971
+ try { await bridge.removeSubscription([old]); } catch { /* */ }
1972
+ try { await bridge.deleteAddressMapping(old); } catch { /* */ }
1973
+ }
1974
+ peerSubscribedAddresses.set(peerNostrPubkey, peerAddrs);
1975
+ ctx.log?.info(
1976
+ `[${accountId}] Updated ${newAddrs.length} receiving address(es) after decrypt (peer: ${peerNostrPubkey.slice(0,16)}, kept ${peerAddrs.length})`,
1977
+ );
1978
+ }
1979
+ }
1980
+ } catch (err) {
1981
+ ctx.log?.error(`[${accountId}] Failed to update receiving addresses after decrypt: ${err}`);
1982
+ }
1983
+
1984
+ // The decrypted content may be a KeychatMessage JSON — extract the `msg` field
1985
+ // and optionally the `name` field (MsgReply for quoted messages)
1986
+ let displayText = plaintext;
1987
+ let groupContext: { groupId: string; groupMessage: { message: string; pubkey: string; subtype?: number; ext?: string } } | null = null;
1988
+ ctx.log?.info(`[${accountId}] Raw plaintext (first 300 chars): ${plaintext.slice(0, 300)}`);
1989
+ try {
1990
+ const parsed = JSON.parse(plaintext);
1991
+ if (parsed && typeof parsed.msg === "string") {
1992
+ // Check if this is a group message (type=30, c="group")
1993
+ if (parsed.type === 30 && parsed.c === "group") {
1994
+ try {
1995
+ const gm = JSON.parse(parsed.msg);
1996
+ if (gm && typeof gm.message === "string" && typeof gm.pubkey === "string") {
1997
+ groupContext = { groupId: gm.pubkey, groupMessage: gm };
1998
+ displayText = gm.message;
1999
+
2000
+ // Handle group system messages
2001
+ const isSystemMsg = [14, 15, 16, 17, 20, 32].includes(gm.subtype);
2002
+ if (isSystemMsg) {
2003
+ displayText = `[System] ${gm.message}`;
2004
+
2005
+ // Update DB state for destructive group events
2006
+ if (gm.subtype === 17) {
2007
+ // groupDissolve — mark group as disabled
2008
+ try {
2009
+ await bridge.updateGroupStatus(gm.pubkey, "disabled");
2010
+ ctx.log?.info(`[${accountId}] Group ${gm.pubkey} dissolved, marked disabled`);
2011
+ } catch (err) {
2012
+ ctx.log?.error(`[${accountId}] Failed to disable dissolved group: ${err}`);
2013
+ }
2014
+ } else if (gm.subtype === 16) {
2015
+ // groupSelfLeave — remove the member who left
2016
+ try {
2017
+ await bridge.removeGroupMember(gm.pubkey, peerNostrPubkey);
2018
+ ctx.log?.info(`[${accountId}] Removed ${peerNostrPubkey} from group ${gm.pubkey} (self-leave)`);
2019
+ } catch (err) {
2020
+ ctx.log?.error(`[${accountId}] Failed to remove left member: ${err}`);
2021
+ }
2022
+ } else if (gm.subtype === 32 && gm.ext) {
2023
+ // groupRemoveSingleMember — ext contains the removed member's id_pubkey
2024
+ try {
2025
+ await bridge.removeGroupMember(gm.pubkey, gm.ext);
2026
+ ctx.log?.info(`[${accountId}] Removed ${gm.ext} from group ${gm.pubkey} (kicked)`);
2027
+ } catch (err) {
2028
+ ctx.log?.error(`[${accountId}] Failed to remove kicked member: ${err}`);
2029
+ }
2030
+ } else if (gm.subtype === 20 && gm.ext) {
2031
+ // groupChangeRoomName — ext contains the new name
2032
+ try {
2033
+ await bridge.updateGroupName(gm.pubkey, gm.ext);
2034
+ ctx.log?.info(`[${accountId}] Group ${gm.pubkey} renamed to "${gm.ext}"`);
2035
+ } catch (err) {
2036
+ ctx.log?.error(`[${accountId}] Failed to update group name: ${err}`);
2037
+ }
2038
+ }
2039
+ }
2040
+ }
2041
+ } catch {
2042
+ displayText = parsed.msg;
2043
+ }
2044
+ } else {
2045
+ displayText = parsed.msg;
2046
+
2047
+ // Check for quoted/reply message in `name` field (MsgReply JSON)
2048
+ // Format: { id?: string, user: string, content: string }
2049
+ if (parsed.name && parsed.type === 100) {
2050
+ try {
2051
+ const reply = JSON.parse(parsed.name);
2052
+ if (reply && typeof reply.content === "string") {
2053
+ const quotedUser = reply.user || "unknown";
2054
+ displayText = `[Replying to ${quotedUser}: "${reply.content}"]\n${parsed.msg}`;
2055
+ }
2056
+ } catch {
2057
+ // name is not MsgReply JSON (could be other data), ignore
2058
+ }
2059
+ }
2060
+ }
2061
+ }
2062
+ } catch {
2063
+ // Not JSON — use plaintext as-is
2064
+ }
2065
+
2066
+ // Handle group invite messages (type=11, c="group")
2067
+ if (!groupContext) {
2068
+ try {
2069
+ const parsed = JSON.parse(plaintext);
2070
+ if (parsed && parsed.type === 11 && parsed.c === "group" && parsed.msg) {
2071
+ const roomProfile = JSON.parse(parsed.msg);
2072
+ // Extract sender info from parsed.name: JSON array [realMessage, senderIdPubkey]
2073
+ let senderIdPubkey = peerNostrPubkey;
2074
+ let inviteMessage = "Group invite received";
2075
+ if (parsed.name) {
2076
+ try {
2077
+ const nameData = JSON.parse(parsed.name);
2078
+ if (Array.isArray(nameData) && nameData.length >= 2) {
2079
+ inviteMessage = nameData[0];
2080
+ senderIdPubkey = nameData[1];
2081
+ }
2082
+ } catch { /* ignore */ }
2083
+ }
2084
+
2085
+ // Join the group via bridge
2086
+ ctx.log?.info(`[${accountId}] Received group invite: ${roomProfile.name} from ${senderIdPubkey}`);
2087
+ const joinResult = await bridge.joinGroup(roomProfile, senderIdPubkey);
2088
+ ctx.log?.info(`[${accountId}] Joined group '${joinResult.name}' (${joinResult.group_id}), ${joinResult.member_count} members`);
2089
+
2090
+ // Send hello to the group
2091
+ const helloText = `😃 Hi, I am Agent`;
2092
+ try {
2093
+ await bridge.sendGroupMessage(joinResult.group_id, helloText, { subtype: 14 });
2094
+ } catch (err) {
2095
+ ctx.log?.error(`[${accountId}] Failed to send group hello: ${err}`);
2096
+ }
2097
+
2098
+ // Dispatch invite notification to agent
2099
+ displayText = `[Group Invite] ${inviteMessage}. Joined group "${joinResult.name}" with ${joinResult.member_count} members.`;
2100
+ // Route as group message
2101
+ groupContext = { groupId: joinResult.group_id, groupMessage: { message: displayText, pubkey: joinResult.group_id } };
2102
+ }
2103
+ } catch {
2104
+ // Not a group invite, continue normal processing
2105
+ }
2106
+ }
2107
+
2108
+ // Check if message is an encrypted media URL
2109
+ let mediaPath: string | undefined;
2110
+ const mediaInfo = parseMediaUrl(displayText);
2111
+ if (mediaInfo) {
2112
+ try {
2113
+ const localPath = await downloadAndDecrypt(mediaInfo);
2114
+ mediaPath = localPath;
2115
+ ctx.log?.info(`[${accountId}] Downloaded ${mediaInfo.kctype}: ${localPath}`);
2116
+ displayText = `[${mediaInfo.kctype}: ${mediaInfo.sourceName || mediaInfo.suffix}] (saved to ${localPath})`;
2117
+ } catch (err) {
2118
+ ctx.log?.error(`[${accountId}] Failed to download media: ${err}`);
2119
+ displayText = `[${mediaInfo.kctype} message — download failed]`;
2120
+ }
2121
+ }
2122
+
2123
+ ctx.log?.info(
2124
+ `[${accountId}] Decrypted from ${peer_.name} (${peerNostrPubkey}): ${displayText.slice(0, 50)}...`,
2125
+ );
2126
+
2127
+ // Forward to OpenClaw's message pipeline via shared dispatch helper
2128
+ const senderLabel = peer_.name || peerNostrPubkey.slice(0, 12);
2129
+
2130
+ if (groupContext) {
2131
+ // Route group messages to a group-specific dispatch
2132
+ ctx.log?.info(`[${accountId}] Detected group message: groupId=${groupContext.groupId}, subtype=${groupContext.groupMessage.subtype}, sender=${peerNostrPubkey}`);
2133
+ await dispatchGroupToAgent(bridge, accountId, groupContext.groupId, peerNostrPubkey, senderLabel, displayText, msg.event_id, runtime, ctx, groupContext.groupMessage, mediaPath);
2134
+ } else {
2135
+ ctx.log?.info(`[${accountId}] Routing as 1:1 DM (no group context detected)`);
2136
+ await dispatchToAgent(bridge, accountId, peerNostrPubkey, senderLabel, displayText, msg.event_id, runtime, ctx, mediaPath);
2137
+ }
2138
+ }
2139
+
2140
+ /** Shared helper: dispatch a message through the agent pipeline. */
2141
+ async function dispatchToAgent(
2142
+ bridge: KeychatBridgeClient,
2143
+ accountId: string,
2144
+ peerNostrPubkey: string,
2145
+ peerName: string,
2146
+ displayText: string,
2147
+ eventId: string,
2148
+ runtime: ReturnType<typeof getKeychatRuntime>,
2149
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
2150
+ mediaPath?: string,
2151
+ ): Promise<void> {
2152
+ const core = runtime;
2153
+ const cfg = core.config.loadConfig();
2154
+
2155
+ const route = core.channel.routing.resolveAgentRoute({
2156
+ cfg,
2157
+ channel: "keychat",
2158
+ accountId,
2159
+ peer: {
2160
+ kind: "direct",
2161
+ id: peerNostrPubkey,
2162
+ },
2163
+ });
2164
+
2165
+ const senderLabel = peerName || peerNostrPubkey.slice(0, 12);
2166
+ const body = core.channel.reply.formatAgentEnvelope({
2167
+ channel: "Keychat",
2168
+ from: senderLabel,
2169
+ timestamp: Date.now(),
2170
+ body: displayText,
2171
+ });
2172
+
2173
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
2174
+ Body: body,
2175
+ RawBody: displayText,
2176
+ CommandBody: displayText,
2177
+ From: `keychat:${peerNostrPubkey}`,
2178
+ To: `keychat:${accountId}`,
2179
+ SessionKey: route.sessionKey,
2180
+ AccountId: accountId,
2181
+ ChatType: "direct" as const,
2182
+ SenderName: senderLabel,
2183
+ SenderId: peerNostrPubkey,
2184
+ Provider: "keychat" as const,
2185
+ Surface: "keychat" as const,
2186
+ MessageSid: eventId,
2187
+ OriginatingChannel: "keychat" as const,
2188
+ OriginatingTo: `keychat:${accountId}`,
2189
+ ...(mediaPath ? { MediaPath: mediaPath } : {}),
2190
+ });
2191
+
2192
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
2193
+ cfg,
2194
+ channel: "keychat",
2195
+ accountId,
2196
+ });
2197
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
2198
+ cfg,
2199
+ agentId: route.agentId,
2200
+ channel: "keychat",
2201
+ accountId,
2202
+ });
2203
+
2204
+ const peerPubkey = peerNostrPubkey;
2205
+
2206
+ // Buffer multiple deliver() calls and merge them into a single Keychat message.
2207
+ // The dispatcher may call deliver() multiple times for tool-call narration, thinking
2208
+ // leakage, or chunked streaming — we batch them to avoid message spam.
2209
+ let deliverBuffer: string[] = [];
2210
+ let deliverTimer: ReturnType<typeof setTimeout> | null = null;
2211
+ const DELIVER_DEBOUNCE_MS = 1500;
2212
+
2213
+ const flushDeliverBuffer = async () => {
2214
+ deliverTimer = null;
2215
+ if (deliverBuffer.length === 0) return;
2216
+ const merged = deliverBuffer.join("\n\n").trim();
2217
+ deliverBuffer = [];
2218
+ if (!merged) return;
2219
+ try {
2220
+ const result = await retrySend(() => bridge.sendMessage(peerPubkey, merged));
2221
+ await handleReceivingAddressRotation(bridge, accountId, result, peerPubkey);
2222
+ } catch (err) {
2223
+ ctx.log?.error(`[${accountId}] Reply delivery failed: ${err}`);
2224
+ }
2225
+ };
2226
+
2227
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2228
+ ctx: ctxPayload,
2229
+ cfg,
2230
+ dispatcherOptions: {
2231
+ ...prefixOptions,
2232
+ deliver: async (payload: { text?: string }) => {
2233
+ if (!payload.text) return;
2234
+ const message = core.channel.text.convertMarkdownTables(payload.text, tableMode);
2235
+ deliverBuffer.push(message);
2236
+ // Reset debounce timer — wait for more chunks before sending
2237
+ if (deliverTimer) clearTimeout(deliverTimer);
2238
+ deliverTimer = setTimeout(() => { flushDeliverBuffer(); }, DELIVER_DEBOUNCE_MS);
2239
+ },
2240
+ onError: (err: unknown) => {
2241
+ ctx.log?.error(`[${accountId}] Reply delivery failed: ${err}`);
2242
+ },
2243
+ },
2244
+ replyOptions: {
2245
+ onModelSelected,
2246
+ },
2247
+ });
2248
+
2249
+ // Flush any remaining buffered text after dispatcher completes
2250
+ if (deliverTimer) clearTimeout(deliverTimer);
2251
+ await flushDeliverBuffer();
2252
+ }
2253
+
2254
+ /** Shared helper: dispatch a GROUP message through the agent pipeline. */
2255
+ async function dispatchGroupToAgent(
2256
+ bridge: KeychatBridgeClient,
2257
+ accountId: string,
2258
+ groupId: string,
2259
+ peerNostrPubkey: string,
2260
+ peerName: string,
2261
+ displayText: string,
2262
+ eventId: string,
2263
+ runtime: ReturnType<typeof getKeychatRuntime>,
2264
+ ctx: { log?: { info: (m: string) => void; error: (m: string) => void; warn?: (m: string) => void }; setStatus: (s: Record<string, unknown> | any) => void },
2265
+ groupMessage: { message: string; pubkey: string; subtype?: number; ext?: string },
2266
+ mediaPath?: string,
2267
+ ): Promise<void> {
2268
+ const core = runtime;
2269
+ const cfg = core.config.loadConfig();
2270
+
2271
+ // Use group-specific session key
2272
+ const route = core.channel.routing.resolveAgentRoute({
2273
+ cfg,
2274
+ channel: "keychat",
2275
+ accountId,
2276
+ peer: {
2277
+ kind: "group",
2278
+ id: groupId,
2279
+ },
2280
+ });
2281
+
2282
+ const senderLabel = peerName || peerNostrPubkey.slice(0, 12);
2283
+
2284
+ // Get group info for context
2285
+ let groupName = groupId.slice(0, 12);
2286
+ try {
2287
+ const groupInfo = await bridge.getGroup(groupId);
2288
+ if (groupInfo.name) groupName = groupInfo.name;
2289
+ } catch { /* best effort */ }
2290
+
2291
+ const body = core.channel.reply.formatAgentEnvelope({
2292
+ channel: "Keychat",
2293
+ from: senderLabel,
2294
+ timestamp: Date.now(),
2295
+ body: displayText,
2296
+ });
2297
+
2298
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
2299
+ Body: body,
2300
+ RawBody: displayText,
2301
+ CommandBody: displayText,
2302
+ From: `keychat:${peerNostrPubkey}`,
2303
+ To: `keychat:group:${groupId}`,
2304
+ SessionKey: route.sessionKey,
2305
+ AccountId: accountId,
2306
+ ChatType: "group" as const,
2307
+ SenderName: senderLabel,
2308
+ SenderId: peerNostrPubkey,
2309
+ GroupId: groupId,
2310
+ GroupName: groupName,
2311
+ Provider: "keychat" as const,
2312
+ Surface: "keychat" as const,
2313
+ MessageSid: eventId,
2314
+ OriginatingChannel: "keychat" as const,
2315
+ OriginatingTo: `keychat:group:${groupId}`,
2316
+ ...(mediaPath ? { MediaPath: mediaPath } : {}),
2317
+ });
2318
+
2319
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
2320
+ cfg,
2321
+ channel: "keychat",
2322
+ accountId,
2323
+ });
2324
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
2325
+ cfg,
2326
+ agentId: route.agentId,
2327
+ channel: "keychat",
2328
+ accountId,
2329
+ });
2330
+
2331
+ // Buffer and merge deliver() calls
2332
+ let deliverBuffer: string[] = [];
2333
+ let deliverTimer: ReturnType<typeof setTimeout> | null = null;
2334
+ const DELIVER_DEBOUNCE_MS = 1500;
2335
+
2336
+ const flushDeliverBuffer = async () => {
2337
+ deliverTimer = null;
2338
+ if (deliverBuffer.length === 0) return;
2339
+ const merged = deliverBuffer.join("\n\n").trim();
2340
+ deliverBuffer = [];
2341
+ if (!merged) return;
2342
+ try {
2343
+ // Send reply to the GROUP, not individual peer
2344
+ await retrySend(() => bridge.sendGroupMessage(groupId, merged));
2345
+ } catch (err) {
2346
+ ctx.log?.error(`[${accountId}] Group reply delivery failed: ${err}`);
2347
+ }
2348
+ };
2349
+
2350
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2351
+ ctx: ctxPayload,
2352
+ cfg,
2353
+ dispatcherOptions: {
2354
+ ...prefixOptions,
2355
+ deliver: async (payload: { text?: string }) => {
2356
+ if (!payload.text) return;
2357
+ const message = core.channel.text.convertMarkdownTables(payload.text, tableMode);
2358
+ deliverBuffer.push(message);
2359
+ if (deliverTimer) clearTimeout(deliverTimer);
2360
+ deliverTimer = setTimeout(() => { flushDeliverBuffer(); }, DELIVER_DEBOUNCE_MS);
2361
+ },
2362
+ onError: (err: unknown) => {
2363
+ ctx.log?.error(`[${accountId}] Group reply delivery failed: ${err}`);
2364
+ },
2365
+ },
2366
+ replyOptions: {
2367
+ onModelSelected,
2368
+ },
2369
+ });
2370
+
2371
+ // Flush remaining
2372
+ if (deliverTimer) clearTimeout(deliverTimer);
2373
+ await flushDeliverBuffer();
2374
+ }
2375
+
2376
+ /** After each send or decrypt, rotate receiving addresses if a new one was generated. */
2377
+ async function handleReceivingAddressRotation(
2378
+ bridge: KeychatBridgeClient,
2379
+ accountId: string,
2380
+ sendResult: SendMessageResult,
2381
+ peerKey?: string,
2382
+ ): Promise<void> {
2383
+ if (!sendResult.new_receiving_address) return;
2384
+
2385
+ const { address } = await bridge.computeAddress(sendResult.new_receiving_address);
2386
+
2387
+ // Map the new address to the peer (in-memory + DB)
2388
+ if (peerKey) {
2389
+ getAddressToPeer(accountId).set(address, peerKey);
2390
+ try {
2391
+ await bridge.saveAddressMapping(address, peerKey);
2392
+ } catch {
2393
+ // Best effort persistence
2394
+ }
2395
+ }
2396
+
2397
+ const peerAddrKey = peerKey || accountId;
2398
+ const addrs = peerSubscribedAddresses.get(peerAddrKey) ?? [];
2399
+ addrs.push(address);
2400
+
2401
+ // Keep only the latest MAX_RECEIVING_ADDRESSES addresses per peer
2402
+ const staleAddrs: string[] = [];
2403
+ while (addrs.length > MAX_RECEIVING_ADDRESSES) {
2404
+ const old = addrs.shift()!;
2405
+ getAddressToPeer(accountId).delete(old);
2406
+ staleAddrs.push(old);
2407
+ }
2408
+ peerSubscribedAddresses.set(peerAddrKey, addrs);
2409
+
2410
+ // Remove stale addresses from relay subscription and DB
2411
+ if (staleAddrs.length > 0) {
2412
+ try { await bridge.removeSubscription(staleAddrs); } catch { /* best effort */ }
2413
+ for (const old of staleAddrs) {
2414
+ try { await bridge.deleteAddressMapping(old); } catch { /* best effort */ }
2415
+ }
2416
+ }
2417
+
2418
+ // Add new address to relay subscription
2419
+ await bridge.addSubscription([address]);
2420
+ }
2421
+
2422
+ /**
2423
+ * Perform an MLS self-update (key rotation) for the given group.
2424
+ * This generates a new epoch, rotates the listen key, and re-publishes the KeyPackage.
2425
+ */
2426
+ export async function updateGroupKey(
2427
+ groupId: string,
2428
+ accountId: string = DEFAULT_ACCOUNT_ID,
2429
+ ): Promise<void> {
2430
+ const bridge = activeBridges.get(accountId);
2431
+ if (!bridge) throw new Error(`No bridge for account ${accountId}`);
2432
+
2433
+ const log = (...args: unknown[]) => console.log(`[keychat:${accountId}] MLS key rotation:`, ...args);
2434
+
2435
+ // 1. Get current listen key
2436
+ const { listen_key: oldKey } = await bridge.mlsGetListenKey(groupId);
2437
+
2438
+ // 2. Generate self-update commit
2439
+ const result = await bridge.mlsSelfUpdate(groupId, { name: "Agent" });
2440
+
2441
+ // 3. Publish commit to the OLD listen key
2442
+ await bridge.mlsPublishToGroup(oldKey, result.encrypted_msg);
2443
+
2444
+ // 4. Merge pending commit locally
2445
+ await bridge.mlsSelfCommit(groupId);
2446
+
2447
+ // 5. Get new listen key
2448
+ const { listen_key: newKey } = await bridge.mlsGetListenKey(groupId);
2449
+
2450
+ // 6. Update subscriptions if key changed
2451
+ if (newKey !== oldKey) {
2452
+ mlsListenKeyToGroup.delete(oldKey);
2453
+ mlsListenKeyToGroup.set(newKey, groupId);
2454
+ await bridge.removeSubscription([oldKey]);
2455
+ await bridge.addSubscription([newKey]);
2456
+ log(`listen key rotated: ${oldKey.slice(0, 12)}... → ${newKey.slice(0, 12)}...`);
2457
+ } else {
2458
+ log(`completed (key unchanged)`);
2459
+ }
2460
+
2461
+ // 7. Re-publish KeyPackage
2462
+ try {
2463
+ await bridge.mlsPublishKeyPackage();
2464
+ } catch (err) {
2465
+ log(`warning: failed to re-publish KeyPackage: ${err}`);
2466
+ }
2467
+
2468
+ }
2469
+
2470
+ /**
2471
+ * Get the agent's Keychat ID info for display/pairing.
2472
+ */
2473
+ export function getAgentKeychatId(
2474
+ accountId: string = DEFAULT_ACCOUNT_ID,
2475
+ ): AccountInfo | undefined {
2476
+ return accountInfoCache.get(accountId);
2477
+ }
2478
+
2479
+ /**
2480
+ * Generate a Keychat add-contact URL for the agent.
2481
+ * Users can open this URL or scan the QR code with Keychat app.
2482
+ */
2483
+ export function getAgentKeychatUrl(accountId: string = DEFAULT_ACCOUNT_ID): string | null {
2484
+ const info = accountInfoCache.get(accountId);
2485
+ if (!info) return null;
2486
+ return `https://www.keychat.io/u/?k=${info.pubkey_npub}`;
2487
+ }
2488
+
2489
+ /**
2490
+ * Get the agent's contact info for pairing/sharing.
2491
+ * Returns npub, contact URL, and QR code path if available.
2492
+ */
2493
+ export function getContactInfo(accountId: string = DEFAULT_ACCOUNT_ID): {
2494
+ npub: string;
2495
+ contactUrl: string;
2496
+ qrCodePath: string;
2497
+ } | null {
2498
+ const info = accountInfoCache.get(accountId);
2499
+ if (!info) return null;
2500
+ return {
2501
+ npub: info.pubkey_npub,
2502
+ contactUrl: `https://www.keychat.io/u/?k=${info.pubkey_npub}`,
2503
+ qrCodePath: qrCodePath(accountId),
2504
+ };
2505
+ }
2506
+
2507
+ /**
2508
+ * Get contact info for ALL active Keychat accounts/agents.
2509
+ * Returns an array of { accountId, npub, contactUrl, qrCodePath, name }.
2510
+ */
2511
+ export function getAllAgentContacts(): Array<{
2512
+ accountId: string;
2513
+ npub: string;
2514
+ contactUrl: string;
2515
+ qrCodePath: string;
2516
+ }> {
2517
+ const results: Array<{
2518
+ accountId: string;
2519
+ npub: string;
2520
+ contactUrl: string;
2521
+ qrCodePath: string;
2522
+ }> = [];
2523
+ for (const [accountId, info] of accountInfoCache.entries()) {
2524
+ results.push({
2525
+ accountId,
2526
+ npub: info.pubkey_npub,
2527
+ contactUrl: `https://www.keychat.io/u/?k=${info.pubkey_npub}`,
2528
+ qrCodePath: qrCodePath(accountId),
2529
+ });
2530
+ }
2531
+ return results;
2532
+ }
2533
+
2534
+ /**
2535
+ * Reset the Signal session with a peer and optionally re-send hello.
2536
+ * Equivalent to "Reset Signal Session" in the Keychat app.
2537
+ *
2538
+ * @param peerPubkey - Nostr pubkey (hex or npub) of the peer
2539
+ * @param accountId - Account to reset session for
2540
+ * @param resendHello - Whether to send a new hello after reset (default: true)
2541
+ */
2542
+ export async function resetPeerSession(
2543
+ peerPubkey: string,
2544
+ accountId: string = DEFAULT_ACCOUNT_ID,
2545
+ resendHello: boolean = true,
2546
+ ): Promise<{ reset: boolean; helloSent?: boolean; error?: string }> {
2547
+ const normalizedPeer = normalizePubkey(peerPubkey);
2548
+ const bridge = activeBridges.get(accountId);
2549
+ if (!bridge) {
2550
+ return { reset: false, error: `No active bridge for account ${accountId}` };
2551
+ }
2552
+
2553
+ // 1. Find the peer's signal pubkey
2554
+ const peerInfo = getPeerSessions(accountId).get(normalizedPeer);
2555
+ const signalPubkey = peerInfo?.signalPubkey;
2556
+
2557
+ // 2. Delete Signal session via bridge RPC
2558
+ if (signalPubkey) {
2559
+ try {
2560
+ await bridge.deleteSession(signalPubkey);
2561
+ console.log(`[keychat] [${accountId}] Deleted Signal session for ${normalizedPeer} (signal: ${signalPubkey})`);
2562
+ } catch (err) {
2563
+ console.error(`[keychat] [${accountId}] Failed to delete Signal session: ${err}`);
2564
+ }
2565
+ }
2566
+
2567
+ // 3. Clear in-memory maps
2568
+ getPeerSessions(accountId).delete(normalizedPeer);
2569
+
2570
+ // Clear address mappings pointing to this peer
2571
+ const addrMap = getAddressToPeer(accountId);
2572
+ for (const [addr, peer] of addrMap) {
2573
+ if (peer === normalizedPeer) {
2574
+ addrMap.delete(addr);
2575
+ try { await bridge.deleteAddressMapping(addr); } catch { /* best effort */ }
2576
+ }
2577
+ }
2578
+
2579
+ // Clear from helloSentTo to allow re-sending
2580
+ helloSentTo.delete(normalizedPeer);
2581
+ pendingHelloMessages.delete(normalizedPeer);
2582
+
2583
+ console.log(`[keychat] [${accountId}] Session reset for peer ${normalizedPeer}`);
2584
+
2585
+ // 4. Optionally re-send hello
2586
+ if (resendHello) {
2587
+ try {
2588
+ const accountInfo = accountInfoCache.get(accountId);
2589
+ const name = accountInfo?.pubkey_npub ?? "Keychat Agent";
2590
+ helloSentTo.add(normalizedPeer);
2591
+ const helloResult = await bridge.sendHello(normalizedPeer, name);
2592
+ console.log(`[keychat] [${accountId}] Hello re-sent to ${normalizedPeer} (event: ${helloResult.event_id})`);
2593
+
2594
+ if (helloResult.onetimekey) {
2595
+ getAddressToPeer(accountId).set(helloResult.onetimekey, normalizedPeer);
2596
+ try { await bridge.saveAddressMapping(helloResult.onetimekey, normalizedPeer); } catch { /* */ }
2597
+ }
2598
+ return { reset: true, helloSent: true };
2599
+ } catch (err) {
2600
+ helloSentTo.delete(normalizedPeer);
2601
+ console.error(`[keychat] [${accountId}] Failed to re-send hello: ${err}`);
2602
+ return { reset: true, helloSent: false, error: `Reset OK but hello failed: ${err}` };
2603
+ }
2604
+ }
2605
+
2606
+ return { reset: true };
2607
+ }