@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/LICENSE +661 -0
- package/README.md +231 -0
- package/docs/setup-guide.md +124 -0
- package/index.ts +108 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +33 -0
- package/scripts/install.sh +154 -0
- package/scripts/postinstall.mjs +97 -0
- package/scripts/publish.sh +25 -0
- package/src/bridge-client.ts +840 -0
- package/src/channel.ts +2607 -0
- package/src/config-schema.ts +50 -0
- package/src/ensure-binary.ts +65 -0
- package/src/keychain.ts +75 -0
- package/src/lightning.ts +128 -0
- package/src/media.ts +189 -0
- package/src/nwc.ts +360 -0
- package/src/paths.ts +38 -0
- package/src/qrcode-types.d.ts +4 -0
- package/src/qrcode.ts +9 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +125 -0
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
|
+
}
|