@unicitylabs/openclaw-unicity 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unicitylabs/openclaw-unicity",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Unicity wallet identity and encrypted DMs for OpenClaw agents — powered by Sphere SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/channel.ts CHANGED
@@ -10,6 +10,25 @@ import type { UnicityConfig } from "./config.js";
10
10
 
11
11
  const DEFAULT_ACCOUNT_ID = "default";
12
12
 
13
+ /** How long (ms) to wait after the last group message before declaring backfill complete. */
14
+ export const GROUP_BACKFILL_DEBOUNCE_MS = 3_000;
15
+
16
+
17
+ interface GroupBackfillState {
18
+ phase: "buffering" | "live";
19
+ latestMsg: {
20
+ id?: string;
21
+ groupId: string;
22
+ senderPubkey: string;
23
+ senderNametag?: string;
24
+ content: string;
25
+ timestamp: number;
26
+ replyToId?: string;
27
+ } | null;
28
+ bufferedCount: number;
29
+ timer: ReturnType<typeof setTimeout> | null;
30
+ }
31
+
13
32
  // ---------------------------------------------------------------------------
14
33
  // Account config shape (read from openclaw config under channels.unicity)
15
34
  // ---------------------------------------------------------------------------
@@ -422,21 +441,22 @@ export const unicityChannelPlugin = {
422
441
  });
423
442
  });
424
443
 
425
- // Subscribe to incoming group messages
426
- const unsubGroupMessage = sphere.groupChat?.onMessage?.((msg: {
427
- id: string;
444
+ // -- Group message dispatch helper & backfill debounce --------------------
445
+
446
+ type GroupMsg = {
447
+ id?: string;
428
448
  groupId: string;
429
- groupName?: string;
430
449
  senderPubkey: string;
431
450
  senderNametag?: string;
432
451
  content: string;
433
- timestamp?: number;
434
- }) => {
435
- // Skip messages from self
436
- if (msg.senderPubkey === sphere.identity?.chainPubkey) return;
452
+ timestamp: number;
453
+ replyToId?: string;
454
+ };
437
455
 
456
+ function dispatchGroupMessage(msg: GroupMsg): void {
438
457
  const senderName = msg.senderNametag ?? msg.senderPubkey.slice(0, 12);
439
- const groupName = msg.groupName ?? msg.groupId;
458
+ const groupData = sphere.groupChat?.getGroup?.(msg.groupId);
459
+ const groupName = groupData?.name ?? msg.groupId;
440
460
  const isOwner = isSenderOwner(msg.senderPubkey, msg.senderNametag);
441
461
  const metadataHeader = `[SenderName: ${senderName} | SenderId: ${msg.senderPubkey} | GroupId: ${msg.groupId} | GroupName: ${groupName} | IsOwner: ${isOwner} | CommandAuthorized: ${isOwner}]`;
442
462
  const sanitizedContent = msg.content.replace(/\[(?:SenderName|SenderId|IsOwner|CommandAuthorized|GroupId|GroupName)\s*:/gi, "[BLOCKED:");
@@ -487,40 +507,95 @@ export const unicityChannelPlugin = {
487
507
  .catch((err: unknown) => {
488
508
  ctx.log?.error(`[${ctx.account.accountId}] Group message dispatch error: ${err}`);
489
509
  });
510
+ }
511
+
512
+ // Nostr pubkey for self-message detection. Group messages use the 32-byte
513
+ // x-only Nostr pubkey (event.pubkey), NOT the 33-byte compressed chainPubkey.
514
+ // sphere.groupChat.getMyPublicKey() returns the correct Nostr-format key.
515
+ const myNostrPubkey = sphere.groupChat?.getMyPublicKey?.() ?? null;
516
+
517
+ // Per-group backfill state: buffer messages during the initial burst, then
518
+ // switch to live dispatch once the burst settles.
519
+ const groupBackfillStates = new Map<string, GroupBackfillState>();
520
+
521
+ // Subscribe to incoming group messages
522
+ const unsubGroupMessage = sphere.groupChat?.onMessage?.((msg: GroupMsg) => {
523
+ // Skip messages from self (echoed back by the relay).
524
+ // Compare against the Nostr x-only pubkey, not chainPubkey.
525
+ if (myNostrPubkey && msg.senderPubkey === myNostrPubkey) return;
526
+
527
+ // Lookup or create per-group backfill state
528
+ let state = groupBackfillStates.get(msg.groupId);
529
+ if (!state) {
530
+ state = { phase: "buffering", latestMsg: null, bufferedCount: 0, timer: null };
531
+ groupBackfillStates.set(msg.groupId, state);
532
+ }
533
+
534
+ // Already past backfill — dispatch immediately
535
+ if (state.phase === "live") {
536
+ dispatchGroupMessage(msg);
537
+ return;
538
+ }
539
+
540
+ // BUFFERING: keep only the latest message, reset the debounce timer
541
+ state.latestMsg = msg;
542
+ state.bufferedCount++;
543
+ if (state.timer) clearTimeout(state.timer);
544
+ state.timer = setTimeout(() => {
545
+ state!.phase = "live";
546
+ state!.timer = null;
547
+ ctx.log?.info(
548
+ `[${ctx.account.accountId}] Group backfill settled for ${msg.groupId}, ${state!.bufferedCount} message(s) buffered`,
549
+ );
550
+ // Dispatch the most recent buffered message so the agent has context
551
+ if (state!.latestMsg) {
552
+ dispatchGroupMessage(state!.latestMsg);
553
+ state!.latestMsg = null;
554
+ }
555
+ }, GROUP_BACKFILL_DEBOUNCE_MS);
490
556
  }) ?? (() => {});
491
557
 
492
558
  // Subscribe to group lifecycle events and notify owner
493
- const unsubGroupJoined = sphere.on?.("groupchat:joined", (event: { id: string; name?: string }) => {
559
+ const unsubGroupJoined = sphere.on?.("groupchat:joined", (event: { groupId: string; groupName: string }) => {
494
560
  const owner = getOwnerIdentity();
495
561
  if (owner) {
496
- const label = event.name ? `${event.name} (${event.id})` : event.id;
562
+ const label = event.groupName ? `${event.groupName} (${event.groupId})` : event.groupId;
497
563
  sphere.communications.sendDM(`@${owner}`, `I joined group ${label}`).catch((err) => {
498
564
  ctx.log?.error(`[${ctx.account.accountId}] Failed to notify owner about group join: ${err}`);
499
565
  });
500
566
  }
501
567
  }) ?? (() => {});
502
568
 
503
- const unsubGroupLeft = sphere.on?.("groupchat:left", (event: { id: string; name?: string }) => {
569
+ const unsubGroupLeft = sphere.on?.("groupchat:left", (event: { groupId: string }) => {
504
570
  const owner = getOwnerIdentity();
505
571
  if (owner) {
506
- const label = event.name ?? event.id;
572
+ const groupData = sphere.groupChat?.getGroup?.(event.groupId);
573
+ const label = groupData?.name ?? event.groupId;
507
574
  sphere.communications.sendDM(`@${owner}`, `I left group ${label}`).catch((err) => {
508
575
  ctx.log?.error(`[${ctx.account.accountId}] Failed to notify owner about group leave: ${err}`);
509
576
  });
510
577
  }
511
578
  }) ?? (() => {});
512
579
 
513
- const unsubGroupKicked = sphere.on?.("groupchat:kicked", (event: { id: string; name?: string }) => {
580
+ const unsubGroupKicked = sphere.on?.("groupchat:kicked", (event: { groupId: string; groupName: string }) => {
514
581
  const owner = getOwnerIdentity();
515
582
  if (owner) {
516
- const label = event.name ?? event.id;
583
+ const label = event.groupName ? `${event.groupName} (${event.groupId})` : event.groupId;
517
584
  sphere.communications.sendDM(`@${owner}`, `I was kicked from group ${label}`).catch((err) => {
518
585
  ctx.log?.error(`[${ctx.account.accountId}] Failed to notify owner about group kick: ${err}`);
519
586
  });
520
587
  }
521
588
  }) ?? (() => {});
522
589
 
590
+ function clearBackfillTimers(): void {
591
+ for (const state of groupBackfillStates.values()) {
592
+ if (state.timer) clearTimeout(state.timer);
593
+ }
594
+ groupBackfillStates.clear();
595
+ }
596
+
523
597
  ctx.abortSignal.addEventListener("abort", () => {
598
+ clearBackfillTimers();
524
599
  unsub();
525
600
  unsubTransfer();
526
601
  unsubPaymentRequest();
@@ -532,6 +607,7 @@ export const unicityChannelPlugin = {
532
607
 
533
608
  return {
534
609
  stop: () => {
610
+ clearBackfillTimers();
535
611
  unsub();
536
612
  unsubTransfer();
537
613
  unsubPaymentRequest();
@@ -28,8 +28,10 @@ export const createPrivateGroupTool = {
28
28
  visibility: "private",
29
29
  });
30
30
 
31
- const invite = await sphere.groupChat.createInvite(group.id);
32
- const joinCode = invite.code;
31
+ const joinCode = await sphere.groupChat.createInvite(group.id);
32
+ if (!joinCode) {
33
+ throw new Error("Failed to generate invite code for the private group");
34
+ }
33
35
 
34
36
  const lines = [
35
37
  `Private group created: ${group.name} (${group.id})`,