@unicitylabs/openclaw-unicity 0.5.3 → 0.5.5

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/channel.ts +64 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unicitylabs/openclaw-unicity",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^0.10.0",
46
46
  "@sinclair/typebox": "^0.34.48",
47
- "@unicitylabs/sphere-sdk": "^0.5.4"
47
+ "@unicitylabs/sphere-sdk": "^0.6.7"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "openclaw": "*"
package/src/channel.ts CHANGED
@@ -244,38 +244,39 @@ export const unicityChannelPlugin = {
244
244
 
245
245
  // Track DM start time and seen IDs to skip historical replays from the relay.
246
246
  // The SDK fires onDirectMessage for every cached/replayed DM on connect.
247
- const dmStartTime = Math.floor(Date.now() / 1000);
247
+ // msg.timestamp is milliseconds, so dmStartTime must be ms too.
248
+ const dmStartTime = Date.now();
248
249
  const seenDmIds = new Set<string>();
249
250
  const DM_SEEN_MAX = 1000;
250
251
 
251
252
  // Collect all known representations of "self" so we can detect echoed-back
252
- // messages. The SDK's built-in self-check compares transport pubkey against
253
- // chainPubkey, but those may differ in format (33-byte compressed vs 32-byte
254
- // x-only Nostr key), letting self-messages slip through.
253
+ // messages. We must track BOTH the 33-byte compressed chainPubkey (02/03…)
254
+ // and the 32-byte x-only Nostr transport key (chainPubkey without prefix).
255
+ // The SDK's own self-check in CommunicationsModule has a bug where it
256
+ // compares transport pubkey (32-byte) against chainPubkey (33-byte), which
257
+ // never matches — so self-messages can leak through to dmHandlers.
255
258
  const selfPubkeys = new Set<string>();
256
- if (sphere.identity?.chainPubkey) selfPubkeys.add(sphere.identity.chainPubkey);
259
+ if (sphere.identity?.chainPubkey) {
260
+ const cpk = sphere.identity.chainPubkey;
261
+ selfPubkeys.add(cpk);
262
+ // x-only transport key = compressed pubkey without the 02/03 prefix
263
+ if (cpk.length === 66 && (cpk.startsWith("02") || cpk.startsWith("03"))) {
264
+ selfPubkeys.add(cpk.slice(2));
265
+ }
266
+ }
267
+ // Also add the groupChat key (same value but grabbed independently for safety)
257
268
  const myNostrPubkey = sphere.groupChat?.getMyPublicKey?.() ?? null;
258
269
  if (myNostrPubkey) selfPubkeys.add(myNostrPubkey);
259
270
 
260
- const unsub = sphere.communications.onDirectMessage((msg) => {
261
- // Skip messages from self (own DMs echoed back by the relay)
262
- if (selfPubkeys.has(msg.senderPubkey)) return;
271
+ // Per-sender queue: while a reply is being generated for a sender, hold
272
+ // the latest incoming message so it gets processed after the current reply
273
+ // finishes. Prevents duplicate replies while not dropping follow-ups.
274
+ type DmMsg = { id: string; senderPubkey: string; senderNametag?: string; recipientPubkey: string; content: string; timestamp: number; isRead: boolean };
275
+ const sendersInFlight = new Set<string>();
276
+ const pendingPerSender = new Map<string, DmMsg>();
263
277
 
264
- // Deduplicate: skip already-processed messages (relays may deliver dupes)
265
- if (msg.id && seenDmIds.has(msg.id)) return;
266
- if (msg.id) {
267
- seenDmIds.add(msg.id);
268
- if (seenDmIds.size > DM_SEEN_MAX) {
269
- const first = seenDmIds.values().next().value!;
270
- seenDmIds.delete(first);
271
- }
272
- }
273
-
274
- // Skip historical messages replayed on connect
275
- if (msg.timestamp && msg.timestamp < dmStartTime) {
276
- ctx.log?.debug(`[${ctx.account.accountId}] Skipping historical DM (ts=${msg.timestamp} < start=${dmStartTime})`);
277
- return;
278
- }
278
+ function dispatchDm(msg: DmMsg): void {
279
+ sendersInFlight.add(msg.senderPubkey);
279
280
 
280
281
  // Immediately signal that we're composing a reply
281
282
  sphere.communications.sendComposingIndicator(msg.senderPubkey)
@@ -354,7 +355,47 @@ export const unicityChannelPlugin = {
354
355
  })
355
356
  .catch((err: unknown) => {
356
357
  ctx.log?.error(`[${ctx.account.accountId}] Reply dispatch error: ${err}`);
358
+ })
359
+ .finally(() => {
360
+ sendersInFlight.delete(msg.senderPubkey);
361
+ // If a follow-up message arrived while we were replying, process it now
362
+ const pending = pendingPerSender.get(msg.senderPubkey);
363
+ if (pending) {
364
+ pendingPerSender.delete(msg.senderPubkey);
365
+ ctx.log?.info(`[${ctx.account.accountId}] Processing queued DM from ${peerId}`);
366
+ dispatchDm(pending);
367
+ }
357
368
  });
369
+ }
370
+
371
+ const unsub = sphere.communications.onDirectMessage((msg) => {
372
+ // Skip messages from self (own DMs echoed back by the relay)
373
+ if (selfPubkeys.has(msg.senderPubkey)) return;
374
+
375
+ // Deduplicate: skip already-processed messages (relays may deliver dupes)
376
+ if (msg.id && seenDmIds.has(msg.id)) return;
377
+ if (msg.id) {
378
+ seenDmIds.add(msg.id);
379
+ if (seenDmIds.size > DM_SEEN_MAX) {
380
+ const first = seenDmIds.values().next().value!;
381
+ seenDmIds.delete(first);
382
+ }
383
+ }
384
+
385
+ // Skip historical messages replayed on connect
386
+ if (msg.timestamp && msg.timestamp < dmStartTime) {
387
+ ctx.log?.debug(`[${ctx.account.accountId}] Skipping historical DM (ts=${msg.timestamp} < start=${dmStartTime})`);
388
+ return;
389
+ }
390
+
391
+ // Per-sender lock: queue if we're already generating a reply for this sender
392
+ if (sendersInFlight.has(msg.senderPubkey)) {
393
+ ctx.log?.debug(`[${ctx.account.accountId}] Queuing DM from ${msg.senderPubkey.slice(0, 16)}… — reply in flight`);
394
+ pendingPerSender.set(msg.senderPubkey, msg);
395
+ return;
396
+ }
397
+
398
+ dispatchDm(msg);
358
399
  });
359
400
 
360
401
  ctx.log?.info(`[${ctx.account.accountId}] Unicity DM listener active`);