@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.
@@ -0,0 +1,840 @@
1
+ /**
2
+ * TypeScript client for the Keychat sidecar (keychat-openclaw).
3
+ * Communicates via JSON-RPC over stdin/stdout of a child process.
4
+ */
5
+
6
+ import { spawn, type ChildProcess } from "node:child_process";
7
+ import { createInterface, type Interface } from "node:readline";
8
+ import { join } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { bridgeEnv } from "./paths.js";
11
+
12
+ export interface AccountInfo {
13
+ pubkey_hex: string;
14
+ pubkey_npub: string;
15
+ prikey_nsec: string;
16
+ curve25519_pk_hex: string;
17
+ mnemonic: string | null;
18
+ }
19
+
20
+ export interface PrekeyBundleInfo {
21
+ registration_id: number;
22
+ identity_key_hex: string;
23
+ signed_prekey_id: number;
24
+ signed_prekey_public_hex: string;
25
+ signed_prekey_signature_hex: string;
26
+ prekey_id: number;
27
+ prekey_public_hex: string;
28
+ }
29
+
30
+ interface RpcResponse {
31
+ id: number;
32
+ result?: unknown;
33
+ error?: { code: number; message: string };
34
+ }
35
+
36
+ type PendingRequest = {
37
+ resolve: (value: unknown) => void;
38
+ reject: (error: Error) => void;
39
+ };
40
+
41
+ /** Inbound message pushed from the bridge when a relay delivers a DM. */
42
+ export interface InboundMessage {
43
+ from_pubkey: string;
44
+ text: string;
45
+ event_id: string;
46
+ created_at: number;
47
+ is_prekey: boolean;
48
+ encrypted_content: string;
49
+ event_kind: number;
50
+ to_address: string | null;
51
+ nip04_decrypted: boolean;
52
+ /** For Gift Wrap events: the inner rumor's kind (14=DM, 444=MLS Welcome) */
53
+ inner_kind?: number;
54
+ inner_tags_p?: string[];
55
+ }
56
+
57
+ export interface SendMessageResult {
58
+ sent: boolean;
59
+ event_id: string;
60
+ new_receiving_address?: string;
61
+ derived_receiving_address?: string;
62
+ is_prekey?: boolean;
63
+ sending_to_onetimekey?: boolean;
64
+ }
65
+
66
+ export interface ProcessHelloResult {
67
+ session_established: boolean;
68
+ peer_nostr_pubkey: string;
69
+ peer_signal_pubkey: string;
70
+ peer_name: string;
71
+ device_id: number;
72
+ msg_type: number;
73
+ greeting: string;
74
+ }
75
+
76
+ export type InboundMessageHandler = (msg: InboundMessage) => void;
77
+
78
+ export class KeychatBridgeClient {
79
+ private process: ChildProcess | null = null;
80
+ private readline: Interface | null = null;
81
+ private nextId = 1;
82
+ private pending = new Map<number, PendingRequest>();
83
+ private bridgePath: string;
84
+ private onInboundMessage: InboundMessageHandler | null = null;
85
+ private pendingInbound: InboundMessage[] = [];
86
+
87
+ // Auto-restart on crash
88
+ private autoRestart = true;
89
+ private restartAttempts = 0;
90
+ private maxRestartAttempts = 5;
91
+ private restartDelayMs = 1000;
92
+ private initArgs: { dbPath?: string; mnemonic?: string; relays?: string[] } | null = null;
93
+
94
+ constructor(bridgePath?: string) {
95
+ // Default: look for the binary relative to the extension directory
96
+ this.bridgePath =
97
+ bridgePath ??
98
+ join(
99
+ import.meta.dirname ?? __dirname,
100
+ "..",
101
+ "bridge",
102
+ "target",
103
+ "release",
104
+ "keychat-openclaw",
105
+ );
106
+ }
107
+
108
+ /** Start the bridge sidecar process. */
109
+ async start(): Promise<void> {
110
+ if (this.process) {
111
+ throw new Error("Bridge already started");
112
+ }
113
+
114
+ if (!existsSync(this.bridgePath)) {
115
+ throw new Error(
116
+ `keychat-openclaw binary not found at ${this.bridgePath}. Run 'cargo build --release' in the bridge directory.`,
117
+ );
118
+ }
119
+
120
+ this.process = spawn(this.bridgePath, [], {
121
+ stdio: ["pipe", "pipe", "pipe"],
122
+ env: bridgeEnv(),
123
+ });
124
+
125
+ this.readline = createInterface({
126
+ input: this.process.stdout!,
127
+ crlfDelay: Infinity,
128
+ });
129
+
130
+ this.readline.on("line", (line: string) => {
131
+ try {
132
+ const parsed = JSON.parse(line);
133
+
134
+ // Check if this is an unsolicited push event (id=0, has "event" field)
135
+ if (parsed.id === 0 && parsed.event === "inbound_message" && parsed.data) {
136
+ console.log(`[keychat-bridge] Inbound push received: event_kind=${(parsed.data as any).event_kind} from=${(parsed.data as any).from_pubkey?.slice(0,16)} to=${(parsed.data as any).to_address?.slice(0,16)} prekey=${(parsed.data as any).is_prekey}`);
137
+ if (this.onInboundMessage) {
138
+ this.onInboundMessage(parsed.data as InboundMessage);
139
+ } else {
140
+ // Buffer messages until handler is ready
141
+ this.pendingInbound.push(parsed.data as InboundMessage);
142
+ }
143
+ return;
144
+ }
145
+
146
+ // Otherwise it's a response to a request
147
+ const response = parsed as RpcResponse;
148
+ const pending = this.pending.get(response.id);
149
+ if (pending) {
150
+ this.pending.delete(response.id);
151
+ if (response.error) {
152
+ pending.reject(new Error(response.error.message));
153
+ } else {
154
+ pending.resolve(response.result);
155
+ }
156
+ }
157
+ } catch {
158
+ // Ignore non-JSON lines
159
+ }
160
+ });
161
+
162
+ this.process.stderr?.on("data", (data: Buffer) => {
163
+ // Bridge logs go to stderr — forward to OpenClaw logger
164
+ const msg = data.toString().trim();
165
+ if (msg) {
166
+ console.error(`[keychat-openclaw] ${msg}`);
167
+ }
168
+ });
169
+
170
+ this.process.on("exit", (code) => {
171
+ // Reject all pending requests
172
+ for (const [id, pending] of this.pending) {
173
+ pending.reject(new Error(`Bridge process exited with code ${code}`));
174
+ }
175
+ this.pending.clear();
176
+ this.process = null;
177
+ this.readline = null;
178
+
179
+ // Auto-restart on unexpected exit
180
+ if (this.autoRestart && code !== 0) {
181
+ const delay = Math.min(this.restartDelayMs * Math.pow(2, this.restartAttempts), 30000);
182
+ console.error(`[keychat-openclaw] Unexpected exit (code=${code}), restarting in ${delay}ms (attempt ${this.restartAttempts + 1}/${this.maxRestartAttempts})`);
183
+ setTimeout(() => this.restart(), delay);
184
+ }
185
+ });
186
+
187
+ // Verify the bridge is alive
188
+ await this.call("ping");
189
+ }
190
+
191
+ /** Cache init params so the bridge can replay them on restart. */
192
+ setInitArgs(args: { dbPath?: string; mnemonic?: string; relays?: string[] }): void {
193
+ this.initArgs = args;
194
+ }
195
+
196
+ /** Disable auto-restart (call before intentional stop). */
197
+ disableAutoRestart(): void {
198
+ this.autoRestart = false;
199
+ }
200
+
201
+ /** Callback invoked after a successful restart — lets channel.ts restore sessions/subscriptions. */
202
+ private onRestartComplete: (() => Promise<void>) | null = null;
203
+
204
+ /** Register a post-restart hook to restore peer sessions and subscriptions. */
205
+ setRestartHook(hook: () => Promise<void>): void {
206
+ this.onRestartComplete = hook;
207
+ }
208
+
209
+ /** Restart the bridge after an unexpected crash. */
210
+ private async restart(): Promise<void> {
211
+ if (this.restartAttempts >= this.maxRestartAttempts) {
212
+ console.error(`[keychat-openclaw] Max restart attempts (${this.maxRestartAttempts}) reached, giving up`);
213
+ return;
214
+ }
215
+ this.restartAttempts++;
216
+ try {
217
+ await this.start();
218
+ // Replay init sequence
219
+ if (this.initArgs) {
220
+ if (this.initArgs.dbPath) await this.init(this.initArgs.dbPath);
221
+ if (this.initArgs.mnemonic) await this.importIdentity(this.initArgs.mnemonic);
222
+ if (this.initArgs.relays) await this.connect(this.initArgs.relays);
223
+ }
224
+ // Restore peer sessions and subscriptions via hook
225
+ if (this.onRestartComplete) {
226
+ try {
227
+ await this.onRestartComplete();
228
+ } catch (err) {
229
+ console.error(`[keychat-openclaw] Post-restart hook failed: ${err}`);
230
+ }
231
+ }
232
+ this.restartAttempts = 0;
233
+ console.error(`[keychat-openclaw] Restart successful (sessions restored)`);
234
+ } catch (err) {
235
+ console.error(`[keychat-openclaw] Restart failed: ${err}`);
236
+ }
237
+ }
238
+
239
+ /** Periodic health check — ping the bridge and restart if unresponsive. */
240
+ private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
241
+ private readonly HEALTH_CHECK_INTERVAL_MS = 60_000; // 1 minute
242
+ private readonly HEALTH_CHECK_TIMEOUT_MS = 10_000;
243
+
244
+ /** Start periodic health checks. */
245
+ startHealthCheck(): void {
246
+ this.stopHealthCheck();
247
+ this.healthCheckInterval = setInterval(async () => {
248
+ if (!this.process) return;
249
+ try {
250
+ const pingPromise = this.call("ping");
251
+ const timeoutPromise = new Promise((_, reject) =>
252
+ setTimeout(() => reject(new Error("health check timeout")), this.HEALTH_CHECK_TIMEOUT_MS),
253
+ );
254
+ await Promise.race([pingPromise, timeoutPromise]);
255
+ } catch {
256
+ console.error(`[keychat-openclaw] Health check failed — killing stale process`);
257
+ try { this.process?.kill(); } catch { /* ignore */ }
258
+ // Auto-restart will trigger from the exit handler
259
+ }
260
+ }, this.HEALTH_CHECK_INTERVAL_MS);
261
+ }
262
+
263
+ /** Stop periodic health checks. */
264
+ stopHealthCheck(): void {
265
+ if (this.healthCheckInterval) {
266
+ clearInterval(this.healthCheckInterval);
267
+ this.healthCheckInterval = null;
268
+ }
269
+ }
270
+
271
+ /** Stop the bridge sidecar. */
272
+ async stop(): Promise<void> {
273
+ this.autoRestart = false;
274
+ this.stopHealthCheck();
275
+ if (this.process) {
276
+ this.process.stdin?.end();
277
+ this.process.kill();
278
+ this.process = null;
279
+ this.readline = null;
280
+ }
281
+ }
282
+
283
+ /** Send a JSON-RPC call and wait for the response. */
284
+ private call(method: string, params?: Record<string, unknown>): Promise<unknown> {
285
+ return new Promise((resolve, reject) => {
286
+ if (!this.process?.stdin?.writable) {
287
+ reject(new Error("Bridge not started or stdin not writable"));
288
+ return;
289
+ }
290
+
291
+ const id = this.nextId++;
292
+ this.pending.set(id, { resolve, reject });
293
+
294
+ const request = JSON.stringify({ id, method, params: params ?? {} });
295
+ this.process.stdin.write(request + "\n");
296
+
297
+ // Timeout after 30 seconds
298
+ setTimeout(() => {
299
+ if (this.pending.has(id)) {
300
+ this.pending.delete(id);
301
+ reject(new Error(`Bridge call '${method}' timed out`));
302
+ }
303
+ }, 30000);
304
+ });
305
+ }
306
+
307
+ /** Register a handler for inbound messages pushed from the bridge. */
308
+ setInboundHandler(handler: InboundMessageHandler): void {
309
+ this.onInboundMessage = handler;
310
+ // Flush any messages that arrived before the handler was ready
311
+ if (this.pendingInbound.length > 0) {
312
+ const pending = this.pendingInbound.splice(0);
313
+ for (const msg of pending) {
314
+ handler(msg);
315
+ }
316
+ }
317
+ }
318
+
319
+ // =========================================================================
320
+ // Public API methods
321
+ // =========================================================================
322
+
323
+ /** Initialize Signal Protocol DB. */
324
+ async init(dbPath: string): Promise<{ initialized: boolean; db_path: string }> {
325
+ return (await this.call("init", { db_path: dbPath })) as {
326
+ initialized: boolean;
327
+ db_path: string;
328
+ };
329
+ }
330
+
331
+ /** Generate a new Keychat identity (mnemonic + keypairs). */
332
+ async generateIdentity(): Promise<AccountInfo> {
333
+ return (await this.call("generate_identity")) as AccountInfo;
334
+ }
335
+
336
+ /** Import identity from mnemonic. */
337
+ async importIdentity(mnemonic: string, password?: string): Promise<AccountInfo> {
338
+ return (await this.call("import_identity", { mnemonic, password })) as AccountInfo;
339
+ }
340
+
341
+ /** Get current account public info. */
342
+ async getAccountInfo(): Promise<AccountInfo> {
343
+ return (await this.call("get_account_info")) as AccountInfo;
344
+ }
345
+
346
+ /** Generate a Signal pre-key bundle for key exchange. */
347
+ async generatePrekeyBundle(): Promise<PrekeyBundleInfo> {
348
+ return (await this.call("generate_prekey_bundle")) as PrekeyBundleInfo;
349
+ }
350
+
351
+ /** Process a peer's pre-key bundle to establish Signal session. */
352
+ async processPrekeyBundle(params: {
353
+ remote_address: string;
354
+ device_id?: number;
355
+ registration_id?: number;
356
+ identity_key: string;
357
+ signed_prekey_id: number;
358
+ signed_prekey_public: string;
359
+ signed_prekey_signature: string;
360
+ prekey_id: number;
361
+ prekey_public: string;
362
+ }): Promise<{ session_established: boolean }> {
363
+ return (await this.call("process_prekey_bundle", params)) as {
364
+ session_established: boolean;
365
+ };
366
+ }
367
+
368
+ /** Send an encrypted message. */
369
+ async sendMessage(
370
+ to: string,
371
+ text: string,
372
+ opts?: { isHelloReply?: boolean; senderName?: string },
373
+ ): Promise<SendMessageResult> {
374
+ return (await this.call("send_message", {
375
+ to,
376
+ text,
377
+ is_hello_reply: opts?.isHelloReply ?? false,
378
+ sender_name: opts?.senderName,
379
+ })) as SendMessageResult;
380
+ }
381
+
382
+ /** Process an incoming hello/friend-request to establish a Signal session. */
383
+ async processHello(message: string): Promise<ProcessHelloResult> {
384
+ return (await this.call("process_hello", { message })) as ProcessHelloResult;
385
+ }
386
+
387
+ /** Compute a Nostr address (pubkey) from a receiving-address seed. */
388
+ async computeAddress(seed: string): Promise<{ address: string }> {
389
+ return (await this.call("compute_address", { seed })) as { address: string };
390
+ }
391
+
392
+ /** Subscribe to additional Nostr pubkeys for inbound messages. */
393
+ async addSubscription(pubkeys: string[]): Promise<{ subscribed: boolean }> {
394
+ return (await this.call("add_subscription", { pubkeys })) as { subscribed: boolean };
395
+ }
396
+
397
+ async removeSubscription(pubkeys: string[]): Promise<{ removed: boolean }> {
398
+ return (await this.call("remove_subscription", { pubkeys })) as { removed: boolean };
399
+ }
400
+
401
+ /** Decrypt a received message. */
402
+ async decryptMessage(
403
+ from: string,
404
+ ciphertext: string,
405
+ isPrekey?: boolean,
406
+ ): Promise<{ plaintext: string; alice_addrs?: string[] }> {
407
+ return (await this.call("decrypt_message", {
408
+ from,
409
+ ciphertext,
410
+ is_prekey: isPrekey ?? false,
411
+ })) as { plaintext: string; alice_addrs?: string[] };
412
+ }
413
+
414
+ /** Connect to Nostr relays. */
415
+ async connect(relays?: string[]): Promise<{ connected: boolean; relays: string[] }> {
416
+ return (await this.call("connect", { relays })) as {
417
+ connected: boolean;
418
+ relays: string[];
419
+ };
420
+ }
421
+
422
+ /** Disconnect from relays. */
423
+ async disconnect(): Promise<{ disconnected: boolean }> {
424
+ return (await this.call("disconnect")) as { disconnected: boolean };
425
+ }
426
+
427
+ /** Check if a Signal session exists with a peer. */
428
+ async hasSession(pubkey: string): Promise<{ exists: boolean }> {
429
+ return (await this.call("has_session", { pubkey })) as { exists: boolean };
430
+ }
431
+
432
+ /** Get all receiving addresses from Signal sessions in DB (for resubscription on restart). */
433
+ async getReceivingAddresses(): Promise<{ addresses: Array<{ session_address: string; seed: string; nostr_pubkey: string }> }> {
434
+ return (await this.call("get_receiving_addresses", {})) as any;
435
+ }
436
+
437
+ /** Get all peer sessions from DB. */
438
+ async getAllSessions(): Promise<{ sessions: Array<{ signal_pubkey: string; device_id: string }> }> {
439
+ return (await this.call("get_all_sessions", {})) as any;
440
+ }
441
+
442
+ /** Get all peer mappings (nostr↔signal pubkey). */
443
+ async getPeerMappings(): Promise<{ mappings: Array<{ nostr_pubkey: string; signal_pubkey: string; device_id: number; name: string }> }> {
444
+ return (await this.call("get_peer_mappings", {})) as any;
445
+ }
446
+
447
+ /** Save a peer mapping. */
448
+ async savePeerMapping(nostrPubkey: string, signalPubkey: string, deviceId: number, name: string): Promise<{ saved: boolean }> {
449
+ return (await this.call("save_peer_mapping", { nostr_pubkey: nostrPubkey, signal_pubkey: signalPubkey, device_id: deviceId, name })) as any;
450
+ }
451
+
452
+ /** Mark an event as processed (persisted to DB). */
453
+ async markEventProcessed(eventId: string, createdAt?: number): Promise<{ marked: boolean }> {
454
+ return (await this.call("mark_event_processed", { event_id: eventId, created_at: createdAt })) as any;
455
+ }
456
+
457
+ /** Check if an event was already processed. */
458
+ async isEventProcessed(eventId: string): Promise<{ processed: boolean }> {
459
+ return (await this.call("is_event_processed", { event_id: eventId })) as any;
460
+ }
461
+
462
+ /** Send a hello/friend request to a Nostr pubkey via Gift Wrap. */
463
+ async sendHello(toPubkey: string, name?: string): Promise<{ sent: boolean; event_id: string; to_pubkey: string; onetimekey?: string }> {
464
+ return (await this.call("send_hello", { to_pubkey: toPubkey, name })) as any;
465
+ }
466
+
467
+ /** Parse sender identity key from a PreKey Signal message (before decryption). */
468
+ async parsePrekeySender(ciphertext: string): Promise<{ is_prekey: boolean; signal_identity_key?: string }> {
469
+ return (await this.call("parse_prekey_sender", { ciphertext })) as any;
470
+ }
471
+
472
+ /** Send a profile update (type 48) to an existing peer. */
473
+ async sendProfile(peerNostrPubkey: string, opts?: { name?: string; avatar?: string; lightning?: string; bio?: string }): Promise<{ sent: boolean; event_id?: string }> {
474
+ return (await this.call("send_profile", {
475
+ peer_nostr_pubkey: peerNostrPubkey,
476
+ name: opts?.name,
477
+ avatar: opts?.avatar,
478
+ lightning: opts?.lightning,
479
+ bio: opts?.bio,
480
+ })) as any;
481
+ }
482
+
483
+ /** Save an address-to-peer mapping. */
484
+ async saveAddressMapping(address: string, peerNostrPubkey: string): Promise<{ saved: boolean }> {
485
+ return (await this.call("save_address_mapping", { address, peer_nostr_pubkey: peerNostrPubkey })) as any;
486
+ }
487
+
488
+ /** Get all address-to-peer mappings. */
489
+ async getAddressMappings(): Promise<{ mappings: Array<{ address: string; peer_nostr_pubkey: string }> }> {
490
+ return (await this.call("get_address_mappings", {})) as any;
491
+ }
492
+
493
+ /** Delete an address-to-peer mapping. */
494
+ async deleteAddressMapping(address: string): Promise<{ deleted: boolean }> {
495
+ return (await this.call("delete_address_mapping", { address })) as any;
496
+ }
497
+
498
+ /** Check if bridge is connected and responsive (ping with 5s timeout). */
499
+ async isConnected(): Promise<boolean> {
500
+ if (!this.process?.stdin?.writable) return false;
501
+ try {
502
+ const pingPromise = this.call("ping");
503
+ const timeoutPromise = new Promise((_, reject) =>
504
+ setTimeout(() => reject(new Error("ping timeout")), 5000),
505
+ );
506
+ await Promise.race([pingPromise, timeoutPromise]);
507
+ return true;
508
+ } catch {
509
+ return false;
510
+ }
511
+ }
512
+
513
+ /** Delete a Signal session for a peer. */
514
+ async deleteSession(signalPubkey: string, deviceId?: number): Promise<{ deleted: boolean }> {
515
+ return (await this.call("delete_session", { signal_pubkey: signalPubkey, device_id: deviceId })) as any;
516
+ }
517
+
518
+ /** Sign a Blossom (kind:24242) Nostr event for media upload auth. */
519
+ async signBlossomEvent(content: string, tags: string[][]): Promise<string> {
520
+ const result = (await this.call("sign_blossom_event", { content, tags })) as { event_json: string };
521
+ return result.event_json;
522
+ }
523
+
524
+ // =========================================================================
525
+ // Group (small group / sendAll)
526
+ // =========================================================================
527
+
528
+ /** Create a new small group. */
529
+ async createGroup(name: string): Promise<{ group_id: string; name: string }> {
530
+ return (await this.call("create_group", { name })) as any;
531
+ }
532
+
533
+ /** Get group info. */
534
+ async getGroup(groupId: string): Promise<{
535
+ group_id: string | null;
536
+ name?: string;
537
+ my_id_pubkey?: string;
538
+ status?: string;
539
+ version?: number;
540
+ members?: Array<{ idPubkey: string; name: string; isAdmin: boolean }>;
541
+ }> {
542
+ return (await this.call("get_group", { group_id: groupId })) as any;
543
+ }
544
+
545
+ /** Get all groups. */
546
+ async getAllGroups(): Promise<{
547
+ groups: Array<{ group_id: string; name: string; my_id_pubkey: string; status: string; version: number }>;
548
+ }> {
549
+ return (await this.call("get_all_groups")) as any;
550
+ }
551
+
552
+ /** Join a group from an invite (RoomProfile). */
553
+ async joinGroup(roomProfile: Record<string, unknown>, senderIdPubkey?: string): Promise<{
554
+ joined: boolean;
555
+ group_id: string;
556
+ name: string;
557
+ member_count: number;
558
+ }> {
559
+ return (await this.call("join_group", {
560
+ room_profile: roomProfile,
561
+ sender_id_pubkey: senderIdPubkey,
562
+ })) as any;
563
+ }
564
+
565
+ /** Add a member to a group. */
566
+ async addGroupMember(groupId: string, idPubkey: string, name?: string, isAdmin?: boolean): Promise<{ added: boolean }> {
567
+ return (await this.call("add_group_member", {
568
+ group_id: groupId,
569
+ id_pubkey: idPubkey,
570
+ name: name ?? "",
571
+ is_admin: isAdmin ?? false,
572
+ })) as any;
573
+ }
574
+
575
+ /** Remove a member from a group. */
576
+ async removeGroupMember(groupId: string, idPubkey: string): Promise<{ removed: boolean }> {
577
+ return (await this.call("remove_group_member", { group_id: groupId, id_pubkey: idPubkey })) as any;
578
+ }
579
+
580
+ /** Get group members. */
581
+ async getGroupMembers(groupId: string): Promise<{
582
+ members: Array<{ idPubkey: string; name: string; isAdmin: boolean }>;
583
+ }> {
584
+ return (await this.call("get_group_members", { group_id: groupId })) as any;
585
+ }
586
+
587
+ /** Send a message to all members of a group (sendAll). */
588
+ async sendGroupMessage(groupId: string, text: string, opts?: { subtype?: number; ext?: string }): Promise<{
589
+ sent: boolean;
590
+ group_id: string;
591
+ sent_count: number;
592
+ total_members: number;
593
+ event_ids: string[];
594
+ errors: string[];
595
+ }> {
596
+ return (await this.call("send_group_message", {
597
+ group_id: groupId,
598
+ text,
599
+ subtype: opts?.subtype,
600
+ ext: opts?.ext,
601
+ })) as any;
602
+ }
603
+
604
+ /** Update group name. */
605
+ async updateGroupName(groupId: string, name: string): Promise<{ updated: boolean }> {
606
+ return (await this.call("update_group_name", { group_id: groupId, name })) as any;
607
+ }
608
+
609
+ /** Update group status (enabled/disabled). */
610
+ async updateGroupStatus(groupId: string, status: string): Promise<{ updated: boolean }> {
611
+ return (await this.call("update_group_status", { group_id: groupId, status })) as any;
612
+ }
613
+
614
+ /** Delete a group. */
615
+ async deleteGroup(groupId: string): Promise<{ deleted: boolean }> {
616
+ return (await this.call("delete_group", { group_id: groupId })) as any;
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // MLS (Large Group) methods
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /** Initialize MLS for the current identity. */
624
+ async mlsInit(dbPath?: string): Promise<{ initialized: boolean; nostr_id: string }> {
625
+ return (await this.call("mls_init", { db_path: dbPath })) as any;
626
+ }
627
+
628
+ /** Create a key package for MLS (to be published as kind:10443). */
629
+ async mlsCreateKeyPackage(): Promise<{
630
+ key_package: string;
631
+ mls_protocol_version: string;
632
+ ciphersuite: string;
633
+ extensions: string;
634
+ }> {
635
+ return (await this.call("mls_create_key_package", {})) as any;
636
+ }
637
+
638
+ /** Create a new MLS group. */
639
+ async mlsCreateGroup(params: {
640
+ group_id: string;
641
+ name: string;
642
+ description?: string;
643
+ admin_pubkeys?: string[];
644
+ relays?: string[];
645
+ status?: string;
646
+ }): Promise<{ created: boolean; group_id: string }> {
647
+ return (await this.call("mls_create_group", params)) as any;
648
+ }
649
+
650
+ /** Add members to an MLS group using their key packages. */
651
+ async mlsAddMembers(groupId: string, keyPackages: string[]): Promise<{
652
+ commit_msg: string;
653
+ welcome: string; // base64
654
+ listen_key: string;
655
+ }> {
656
+ return (await this.call("mls_add_members", {
657
+ group_id: groupId,
658
+ key_packages: keyPackages,
659
+ })) as any;
660
+ }
661
+
662
+ /** Merge pending commit (call after add_members, self_update, etc.). */
663
+ async mlsSelfCommit(groupId: string): Promise<{ committed: boolean }> {
664
+ return (await this.call("mls_self_commit", { group_id: groupId })) as any;
665
+ }
666
+
667
+ /** Join an MLS group from a Welcome message (base64). */
668
+ async mlsJoinGroup(groupId: string, welcomeBase64: string): Promise<MlsGroupInfo> {
669
+ return (await this.call("mls_join_group", {
670
+ group_id: groupId,
671
+ welcome: welcomeBase64,
672
+ })) as any;
673
+ }
674
+
675
+ /** Encrypt a message for an MLS group. */
676
+ async mlsCreateMessage(groupId: string, text: string): Promise<{
677
+ encrypted_msg: string;
678
+ listen_key: string;
679
+ }> {
680
+ return (await this.call("mls_create_message", {
681
+ group_id: groupId,
682
+ text,
683
+ })) as any;
684
+ }
685
+
686
+ /** Decrypt a received MLS group message. */
687
+ async mlsDecryptMessage(groupId: string, message: string): Promise<{
688
+ plaintext: string;
689
+ sender: string;
690
+ listen_key: string;
691
+ }> {
692
+ return (await this.call("mls_decrypt_message", {
693
+ group_id: groupId,
694
+ message,
695
+ })) as any;
696
+ }
697
+
698
+ /** Parse the type of an MLS message without consuming it. */
699
+ async mlsParseMessageType(groupId: string, data: string): Promise<MlsMessageInType> {
700
+ return (await this.call("mls_parse_message_type", {
701
+ group_id: groupId,
702
+ data,
703
+ })) as any;
704
+ }
705
+
706
+ /** Process a commit from another member (add/remove/update/etc.). */
707
+ async mlsProcessCommit(groupId: string, message: string): Promise<MlsCommitResult> {
708
+ return (await this.call("mls_process_commit", {
709
+ group_id: groupId,
710
+ message,
711
+ })) as any;
712
+ }
713
+
714
+ /** Get the current listen key (onetimekey) for an MLS group. */
715
+ async mlsGetListenKey(groupId: string): Promise<{ listen_key: string }> {
716
+ return (await this.call("mls_get_listen_key", { group_id: groupId })) as any;
717
+ }
718
+
719
+ /** Get MLS group info. */
720
+ async mlsGetGroupInfo(groupId: string): Promise<MlsGroupInfo> {
721
+ return (await this.call("mls_get_group_info", { group_id: groupId })) as any;
722
+ }
723
+
724
+ /** List all MLS groups. */
725
+ async mlsGetGroups(): Promise<{ groups: string[] }> {
726
+ return (await this.call("mls_get_groups", {})) as any;
727
+ }
728
+
729
+ /** Self-update key material in a group. */
730
+ async mlsSelfUpdate(groupId: string, extension?: Record<string, unknown>): Promise<{
731
+ encrypted_msg: string;
732
+ listen_key: string;
733
+ }> {
734
+ return (await this.call("mls_self_update", {
735
+ group_id: groupId,
736
+ extension,
737
+ })) as any;
738
+ }
739
+
740
+ /** Update group context extensions (name, description, etc.). */
741
+ async mlsUpdateGroupExtensions(groupId: string, opts: {
742
+ name?: string;
743
+ description?: string;
744
+ admin_pubkeys?: string[];
745
+ relays?: string[];
746
+ status?: string;
747
+ }): Promise<{ encrypted_msg: string; listen_key: string }> {
748
+ return (await this.call("mls_update_group_extensions", {
749
+ group_id: groupId,
750
+ ...opts,
751
+ })) as any;
752
+ }
753
+
754
+ /** Remove members from an MLS group. */
755
+ async mlsRemoveMembers(groupId: string, members: string[]): Promise<{
756
+ encrypted_msg: string;
757
+ listen_key: string;
758
+ }> {
759
+ return (await this.call("mls_remove_members", {
760
+ group_id: groupId,
761
+ members,
762
+ })) as any;
763
+ }
764
+
765
+ /** Delete an MLS group. */
766
+ async mlsDeleteGroup(groupId: string): Promise<{ deleted: boolean }> {
767
+ return (await this.call("mls_delete_group", { group_id: groupId })) as any;
768
+ }
769
+
770
+ /** Get sender of an MLS message without consuming it. */
771
+ async mlsGetSender(groupId: string, message: string): Promise<{ sender: string | null }> {
772
+ return (await this.call("mls_get_sender", {
773
+ group_id: groupId,
774
+ message,
775
+ })) as any;
776
+ }
777
+
778
+ /** Send a message to an MLS group (encrypt + publish to relay). */
779
+ async mlsSendMessage(groupId: string, text: string): Promise<{
780
+ sent: boolean;
781
+ event_id: string;
782
+ listen_key: string;
783
+ }> {
784
+ return (await this.call("mls_send_message", {
785
+ group_id: groupId,
786
+ text,
787
+ })) as any;
788
+ }
789
+
790
+ /** Publish pre-encrypted content (e.g., MLS commit) to a group's listen key. */
791
+ async mlsPublishToGroup(listenKey: string, content: string): Promise<{ event_id: string }> {
792
+ return (await this.call("mls_publish_to_group", {
793
+ listen_key: listenKey,
794
+ content,
795
+ })) as any;
796
+ }
797
+
798
+ /** Create a new KeyPackage, publish it as kind:10443, and return result. */
799
+ async mlsPublishKeyPackage(): Promise<{ event_id: string; key_package: string }> {
800
+ return (await this.call("mls_publish_key_package", {})) as any;
801
+ }
802
+
803
+ /** Fetch the latest KeyPackage (kind:10443) for a pubkey from relays. */
804
+ async mlsFetchKeyPackage(pubkey: string): Promise<{ key_package: string | null }> {
805
+ return (await this.call("mls_fetch_key_package", { pubkey })) as any;
806
+ }
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // MLS Types
811
+ // ---------------------------------------------------------------------------
812
+
813
+ export interface MlsGroupInfo {
814
+ group_id: string;
815
+ name: string;
816
+ description: string;
817
+ admin_pubkeys: string[];
818
+ relays: string[];
819
+ status: string;
820
+ members: string[];
821
+ listen_key: string;
822
+ }
823
+
824
+ export type MlsMessageInType =
825
+ | "Application"
826
+ | "Proposal"
827
+ | "Commit"
828
+ | "Welcome"
829
+ | "GroupInfo"
830
+ | "KeyPackage"
831
+ | "Custom";
832
+
833
+ export type MlsCommitType = "Add" | "Update" | "Remove" | "GroupContextExtensions";
834
+
835
+ export interface MlsCommitResult {
836
+ sender: string;
837
+ commit_type: MlsCommitType;
838
+ operated_members: string[];
839
+ listen_key: string;
840
+ }