@zbruceli/openclaw-dchat 0.1.11 → 0.3.0

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.
@@ -2,7 +2,11 @@
2
2
  "permissions": {
3
3
  "allow": [
4
4
  "WebFetch(domain:docs.openclaw.ai)",
5
- "Bash(node:*)"
5
+ "Bash(node:*)",
6
+ "Bash(ls:*)",
7
+ "Bash(npm test:*)",
8
+ "Bash(npm run:*)",
9
+ "Bash(openclaw status:*)"
6
10
  ]
7
11
  }
8
12
  }
package/README.md CHANGED
@@ -7,42 +7,44 @@ OpenClaw channel plugin for **D-Chat / nMobile** — decentralized end-to-end en
7
7
  - Direct messages (DM) with NKN addresses
8
8
  - Topic-based group chat (NKN pub/sub)
9
9
  - Private group messaging
10
- - IPFS media placeholders (image, audio, file)
10
+ - Media support images, voice messages, and file transfers
11
+ - Images and files sent/received over IPFS with AES-128-GCM encryption
12
+ - Voice messages via inline AAC (D-Chat Desktop & nMobile compatible)
13
+ - Graceful fallback to URL text when IPFS upload fails
11
14
  - Delivery receipts
12
- - AES-128-GCM encryption (nMobile wire format compatible)
13
15
  - Multi-account support
14
16
  - DM policy enforcement (pairing, allowlist, open, disabled)
17
+ - Full nMobile wire format compatibility
15
18
 
16
19
  ## Installation
17
20
 
18
21
  ```bash
19
22
  openclaw plugins install @zbruceli/openclaw-dchat
23
+ openclaw gateway restart
20
24
  ```
21
25
 
22
26
  ## Configuration
23
27
 
24
- After installing, add the D-Chat channel:
28
+ Add the D-Chat channel after installing:
25
29
 
26
30
  ```bash
27
- openclaw channels add --channel dchat
31
+ # Interactive wizard
32
+ openclaw channels add
33
+
34
+ # Non-interactive
35
+ openclaw channels add --channel dchat --access-token <64-char-hex-seed>
28
36
  ```
29
37
 
30
- The onboarding wizard will prompt you for:
38
+ You'll need:
31
39
 
32
- 1. **NKN wallet seed** — a 64-character hex string. Generate one with `nkn-sdk` or use an existing seed from D-Chat Desktop / nMobile.
33
- 2. **DM policy** — controls who can send you direct messages:
40
+ 1. **NKN wallet seed** — a 64-character hex string. Generate one in D-Chat Desktop (Settings > 1-click bot generation), or use `nkn-sdk`, or reuse an existing seed.
41
+ 2. **DM policy** — controls who can send direct messages:
34
42
  - `pairing` (default) — new senders must be approved via pairing code
35
43
  - `allowlist` — only explicitly allowed NKN addresses
36
44
  - `open` — accept DMs from anyone
37
45
  - `disabled` — no DMs
38
46
 
39
- You can also configure via environment variables:
40
-
41
- ```bash
42
- export DCHAT_SEED="your-64-char-hex-wallet-seed"
43
- ```
44
-
45
- Or set directly in your OpenClaw config:
47
+ ### Config file
46
48
 
47
49
  ```yaml
48
50
  channels:
@@ -54,22 +56,58 @@ channels:
54
56
  - "nkn-address-hex"
55
57
  ```
56
58
 
59
+ ## Pairing
60
+
61
+ With the default `dmPolicy: pairing`, new senders receive a pairing code that must be approved:
62
+
63
+ ```bash
64
+ openclaw pairing list dchat
65
+ openclaw pairing approve dchat <CODE>
66
+ ```
67
+
68
+ ## Channel Management
69
+
70
+ ```bash
71
+ openclaw channels status # check status
72
+ openclaw channels remove --channel dchat # remove channel
73
+ openclaw plugins uninstall openclaw-dchat # uninstall plugin
74
+ ```
75
+
76
+ ## Debugging
77
+
78
+ ```bash
79
+ openclaw logs | grep -i dchat
80
+ ```
81
+
57
82
  ## Development
58
83
 
59
84
  ```bash
60
- # Install dependencies
61
- npm install
85
+ npm install # install dependencies
86
+ npm test # run tests
87
+ npm run test:watch # watch mode
88
+ ```
89
+
90
+ ### Local Development
62
91
 
63
- # Run tests
64
- npm test
92
+ Use the link workflow to avoid the publish/reinstall cycle:
65
93
 
66
- # Watch mode
67
- npm run test:watch
94
+ ```bash
95
+ openclaw plugins install -l /path/to/openclaw-dchat
96
+ openclaw gateway restart # after code changes, just restart
97
+ ```
98
+
99
+ ### Clean Reinstall
100
+
101
+ ```bash
102
+ openclaw plugins uninstall openclaw-dchat
103
+ openclaw channels remove --channel dchat
104
+ openclaw plugins install @zbruceli/openclaw-dchat
105
+ openclaw gateway restart
68
106
  ```
69
107
 
70
- ## How it works
108
+ ## How It Works
71
109
 
72
- The plugin connects to the NKN relay network as a MultiClient node, enabling peer-to-peer messaging without centralized servers. Messages use the same wire format as D-Chat Desktop and nMobile, so you can chat between OpenClaw and any D-Chat/nMobile client.
110
+ The plugin connects to the NKN relay network as a MultiClient node, enabling peer-to-peer messaging without centralized servers. Messages use the same wire format as D-Chat Desktop and nMobile for full interop. Media (images, files) is encrypted with AES-128-GCM and transferred via IPFS; voice messages use inline AAC encoding.
73
111
 
74
112
  ## License
75
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zbruceli/openclaw-dchat",
3
- "version": "0.1.11",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw D-Chat/nMobile channel plugin — decentralized E2E encrypted messaging over the NKN relay network",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -17,6 +17,8 @@
17
17
  "typescript": "^5.8.0"
18
18
  },
19
19
  "scripts": {
20
+ "dev": "echo 'Run: openclaw plugins install -l . && openclaw gateway restart'",
21
+ "dev:test": "vitest --watch",
20
22
  "test": "vitest run",
21
23
  "test:watch": "vitest"
22
24
  },
package/src/channel.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_ACCOUNT_ID,
9
9
  deleteAccountFromConfigSection,
10
10
  normalizeAccountId,
11
+ resolveOutboundMediaUrls,
11
12
  resolveSenderCommandAuthorization,
12
13
  setAccountEnabledInConfigSection,
13
14
  type ChannelPlugin,
@@ -59,12 +60,15 @@ import {
59
60
  extractGroupIdFromSessionKey,
60
61
  extractTopicFromSessionKey,
61
62
  genTopicHash,
63
+ ipfsToNkn,
62
64
  nknToInbound,
65
+ parseInlineMediaDataUri,
63
66
  parseNknPayload,
64
67
  receiptToNkn,
65
68
  stripNknSubClientPrefix,
66
69
  textToNkn,
67
70
  } from "./wire.js";
71
+ import { IpfsService, mimeToIpfsFileType, buildFileMetadata } from "./ipfs.js";
68
72
 
69
73
  // Per-account NKN bus instances and dedup trackers, keyed by accountId
70
74
  const busMap = new Map<string, NknBus>();
@@ -91,7 +95,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
91
95
  onboarding: dchatOnboardingAdapter,
92
96
  capabilities: {
93
97
  chatTypes: ["direct", "group"],
94
- media: false, // IPFS media support is a stretch goal for v2
98
+ media: true,
95
99
  threads: false,
96
100
  reactions: false,
97
101
  polls: false,
@@ -227,6 +231,108 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
227
231
  messageId: msgData.id,
228
232
  };
229
233
  },
234
+ sendMedia: async ({ to, text, mediaUrl, accountId }) => {
235
+ const core = getDchatRuntime();
236
+ const logger = core.logging.getChildLogger({ module: "dchat" });
237
+ const resolvedAccountId =
238
+ accountId ??
239
+ resolveDefaultDchatAccountId(core.config.loadConfig() as CoreConfig);
240
+ const bus = getBusForAccount(resolvedAccountId);
241
+ if (!bus) {
242
+ throw new Error(`D-Chat account "${resolvedAccountId}" not connected`);
243
+ }
244
+
245
+ const accountCfg = resolveDchatAccount({
246
+ cfg: core.config.loadConfig() as CoreConfig,
247
+ accountId: resolvedAccountId,
248
+ });
249
+
250
+ const topicName = to.startsWith("topic:") ? to.slice("topic:".length) : undefined;
251
+ const groupId = to.startsWith("group:") ? to.slice("group:".length) : undefined;
252
+
253
+ let mediaMessageId: string | undefined;
254
+
255
+ if (mediaUrl) {
256
+ try {
257
+ const media = await core.media.loadWebMedia(mediaUrl);
258
+ const ipfs = new IpfsService(accountCfg.ipfsGateway);
259
+ const uploadResult = await ipfs.upload(Buffer.from(media.buffer));
260
+ const fileType = mimeToIpfsFileType(media.contentType);
261
+ const fileMeta = buildFileMetadata(media);
262
+ const options = ipfs.buildMessageOptions(uploadResult, fileType, {
263
+ fileMimeType: media.contentType,
264
+ ...fileMeta,
265
+ });
266
+ const msgData = ipfsToNkn(options, { topic: topicName, groupId });
267
+ const payload = JSON.stringify(msgData);
268
+ mediaMessageId = msgData.id;
269
+
270
+ if (topicName) {
271
+ const topicHash = genTopicHash(topicName);
272
+ const subscribers = await bus.getSubscribers(topicHash);
273
+ const selfAddr = bus.getAddress();
274
+ const dests = subscribers.filter((addr) => addr !== selfAddr);
275
+ if (dests.length > 0) {
276
+ bus.sendToMultiple(dests, payload);
277
+ }
278
+ } else if (groupId) {
279
+ const dest = to.replace(/^group:/i, "");
280
+ bus.sendNoReply(dest, payload);
281
+ } else {
282
+ const dest = to.replace(/^dchat:/i, "");
283
+ await bus.send(dest, payload);
284
+ }
285
+ } catch (err) {
286
+ logger.warn(`sendMedia failed for ${mediaUrl}: ${err}`);
287
+ // Fallback: send the URL as plain text so the recipient still gets a link
288
+ const fallbackMsg = textToNkn(mediaUrl, { topic: topicName, groupId });
289
+ const fallbackPayload = JSON.stringify(fallbackMsg);
290
+ mediaMessageId = fallbackMsg.id;
291
+
292
+ if (topicName) {
293
+ const topicHash = genTopicHash(topicName);
294
+ const subscribers = await bus.getSubscribers(topicHash);
295
+ const selfAddr = bus.getAddress();
296
+ const dests = subscribers.filter((addr) => addr !== selfAddr);
297
+ if (dests.length > 0) {
298
+ bus.sendToMultiple(dests, fallbackPayload);
299
+ }
300
+ } else if (groupId) {
301
+ const dest = to.replace(/^group:/i, "");
302
+ bus.sendNoReply(dest, fallbackPayload);
303
+ } else {
304
+ const dest = to.replace(/^dchat:/i, "");
305
+ await bus.send(dest, fallbackPayload);
306
+ }
307
+ }
308
+ }
309
+
310
+ // Send accompanying text as a separate message if present
311
+ if (text) {
312
+ const msgData = textToNkn(text, { topic: topicName, groupId });
313
+ const payload = JSON.stringify(msgData);
314
+
315
+ if (topicName) {
316
+ const topicHash = genTopicHash(topicName);
317
+ const subscribers = await bus.getSubscribers(topicHash);
318
+ const selfAddr = bus.getAddress();
319
+ const dests = subscribers.filter((addr) => addr !== selfAddr);
320
+ if (dests.length > 0) {
321
+ bus.sendToMultiple(dests, payload);
322
+ }
323
+ } else if (groupId) {
324
+ const dest = to.replace(/^group:/i, "");
325
+ bus.sendNoReply(dest, payload);
326
+ } else {
327
+ const dest = to.replace(/^dchat:/i, "");
328
+ await bus.send(dest, payload);
329
+ }
330
+
331
+ return { channel: "dchat", messageId: msgData.id };
332
+ }
333
+
334
+ return { channel: "dchat", messageId: mediaMessageId ?? "" };
335
+ },
230
336
  },
231
337
  setup: {
232
338
  resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
@@ -332,6 +438,39 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
332
438
  const seenTracker = new SeenTracker();
333
439
  seenMap.set(account.accountId, seenTracker);
334
440
 
441
+ // Listen for heartbeat events
442
+ bus.on("heartbeat", ({ success, failures }: { success: boolean; failures: number }) => {
443
+ if (!success) {
444
+ logger.warn(`[${account.accountId}] heartbeat echo failed (${failures} consecutive)`);
445
+ }
446
+ });
447
+ bus.on("heartbeatReconnect", ({ failures }: { failures: number }) => {
448
+ logger.warn(
449
+ `[${account.accountId}] heartbeat failed ${failures} times, reconnecting...`,
450
+ );
451
+ ctx.setStatus({ accountId: account.accountId, connected: false });
452
+ });
453
+ let initialConnectDone = false;
454
+ bus.on("stateChange", (state: string) => {
455
+ if (state === "connected" && initialConnectDone) {
456
+ ctx.setStatus({
457
+ accountId: account.accountId,
458
+ connected: true,
459
+ lastConnectedAt: Date.now(),
460
+ });
461
+ logger.info(`[${account.accountId}] reconnected as ${bus.getAddress()}`);
462
+ }
463
+ });
464
+ bus.on("reconnectFailed", (err: unknown) => {
465
+ const msg = err instanceof Error ? err.message : String(err);
466
+ logger.error(`[${account.accountId}] reconnect failed: ${msg}`);
467
+ ctx.setStatus({
468
+ accountId: account.accountId,
469
+ connected: false,
470
+ lastError: `reconnect failed: ${msg}`,
471
+ });
472
+ });
473
+
335
474
  try {
336
475
  const address = await bus.connect(
337
476
  { seed: account.seed, numSubClients: account.numSubClients },
@@ -347,6 +486,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
347
486
  lastConnectedAt: Date.now(),
348
487
  });
349
488
  logger.info(`[${account.accountId}] connected as ${address}`);
489
+ initialConnectDone = true;
350
490
 
351
491
  // Register inbound message handler
352
492
  bus.onMessage((rawSrc, rawPayload) => {
@@ -490,6 +630,71 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
490
630
  sessionKey: route.sessionKey,
491
631
  });
492
632
 
633
+ // Download full-resolution IPFS media if present (non-fatal on failure)
634
+ let mediaFields: Record<string, unknown> = {};
635
+ let inboundBodyText = inbound.body;
636
+ if (inbound.ipfsHash && inbound.ipfsOptions) {
637
+ try {
638
+ const ipfs = new IpfsService(account.ipfsGateway);
639
+ const data = await ipfs.download(inbound.ipfsHash, {
640
+ encrypt: inbound.ipfsOptions.ipfsEncrypt,
641
+ encryptKeyBytes: inbound.ipfsOptions.ipfsEncryptKeyBytes,
642
+ encryptNonceSize: inbound.ipfsOptions.ipfsEncryptNonceSize,
643
+ });
644
+ const mime =
645
+ inbound.ipfsOptions.fileMimeType ??
646
+ (await core.media.detectMime?.({ buffer: data })) ??
647
+ "application/octet-stream";
648
+ const saved = await core.channel.media.saveMediaBuffer(
649
+ data,
650
+ mime,
651
+ "inbound",
652
+ );
653
+ mediaFields = {
654
+ MediaPath: saved.path,
655
+ MediaUrl: `file://${saved.path}`,
656
+ MediaType: mime,
657
+ };
658
+ // Replace placeholder with media tag so the agent sees the
659
+ // full-resolution image from IPFS, not a text placeholder.
660
+ const kind = mime.startsWith("audio/")
661
+ ? "audio"
662
+ : mime.startsWith("video/")
663
+ ? "video"
664
+ : mime.startsWith("image/")
665
+ ? "image"
666
+ : "file";
667
+ inboundBodyText = `<media:${kind}>`;
668
+ } catch (err) {
669
+ logger.warn(`IPFS download failed: ${err}`);
670
+ // Non-fatal: message still delivered with placeholder text
671
+ }
672
+ }
673
+
674
+ // Handle inline audio (D-Chat/nMobile send voice as base64 data-URI)
675
+ if (!mediaFields.MediaPath && inbound.inlineMediaDataUri) {
676
+ try {
677
+ const parsed = parseInlineMediaDataUri(inbound.inlineMediaDataUri);
678
+ if (parsed) {
679
+ // Normalize nMobile MIME variants to standard audio/aac
680
+ const mime = parsed.mime === "audio/x-aac" ? "audio/aac" : parsed.mime;
681
+ const saved = await core.channel.media.saveMediaBuffer(
682
+ parsed.buffer,
683
+ mime,
684
+ "inbound",
685
+ );
686
+ mediaFields = {
687
+ MediaPath: saved.path,
688
+ MediaUrl: `file://${saved.path}`,
689
+ MediaType: mime,
690
+ };
691
+ inboundBodyText = "<media:audio>";
692
+ }
693
+ } catch (err) {
694
+ logger.warn(`Inline audio decode failed: ${err}`);
695
+ }
696
+ }
697
+
493
698
  const body = core.channel.reply.formatAgentEnvelope({
494
699
  channel: "D-Chat",
495
700
  from:
@@ -499,14 +704,15 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
499
704
  timestamp: msg.timestamp,
500
705
  previousTimestamp,
501
706
  envelope: envelopeOptions,
502
- body: inbound.body,
707
+ body: inboundBodyText,
503
708
  });
504
709
 
505
710
  const ctxPayload = core.channel.reply.finalizeInboundContext({
506
711
  Body: body,
507
- BodyForAgent: inbound.body,
508
- RawBody: inbound.body,
509
- CommandBody: inbound.body,
712
+ BodyForAgent: inboundBodyText,
713
+ RawBody: inboundBodyText,
714
+ CommandBody: inboundBodyText,
715
+ ...mediaFields,
510
716
  From:
511
717
  inbound.chatType === "direct"
512
718
  ? `dchat:${src}`
@@ -567,28 +773,59 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
567
773
  const { dispatcher, replyOptions, markDispatchIdle } =
568
774
  core.channel.reply.createReplyDispatcherWithTyping({
569
775
  deliver: async (payload) => {
570
- // Deliver reply back to NKN
571
776
  const replyText = payload.text ?? "";
572
- if (!replyText) return;
573
-
574
777
  const topic = extractTopicFromSessionKey(inbound.sessionKey);
575
778
  const groupIdFromKey = extractGroupIdFromSessionKey(inbound.sessionKey);
576
- const replyMsg = textToNkn(replyText, { topic, groupId: groupIdFromKey });
577
- const replyPayload = JSON.stringify(replyMsg);
578
-
579
- if (topic) {
580
- const topicHash = genTopicHash(topic);
581
- const subscribers = await bus.getSubscribers(topicHash);
582
- const dests = subscribers.filter((a) => a !== selfAddress);
583
- if (dests.length > 0) {
584
- bus.sendToMultiple(dests, replyPayload);
779
+
780
+ // Helper to route a payload string via NKN
781
+ const routeNkn = async (nknPayload: string) => {
782
+ if (topic) {
783
+ const topicHash = genTopicHash(topic);
784
+ const subscribers = await bus.getSubscribers(topicHash);
785
+ const dests = subscribers.filter((a) => a !== selfAddress);
786
+ if (dests.length > 0) {
787
+ bus.sendToMultiple(dests, nknPayload);
788
+ }
789
+ } else if (groupIdFromKey) {
790
+ bus.sendNoReply(groupIdFromKey, nknPayload);
791
+ } else {
792
+ bus.sendNoReply(src, nknPayload);
793
+ }
794
+ };
795
+
796
+ // Handle media URLs (images, audio, files)
797
+ const mediaUrls = resolveOutboundMediaUrls(payload);
798
+ if (mediaUrls.length > 0) {
799
+ const ipfs = new IpfsService(account.ipfsGateway);
800
+ for (const mediaUrl of mediaUrls) {
801
+ try {
802
+ const media = await core.media.loadWebMedia(mediaUrl);
803
+ const uploadResult = await ipfs.upload(Buffer.from(media.buffer));
804
+ const fileType = mimeToIpfsFileType(media.contentType);
805
+ const fileMeta = buildFileMetadata(media);
806
+ const options = ipfs.buildMessageOptions(uploadResult, fileType, {
807
+ fileMimeType: media.contentType,
808
+ ...fileMeta,
809
+ });
810
+ const msgData = ipfsToNkn(options, { topic, groupId: groupIdFromKey });
811
+ await routeNkn(JSON.stringify(msgData));
812
+ } catch (err) {
813
+ logger.warn(`media send failed for ${mediaUrl}: ${err}`);
814
+ // Fallback: send the URL as plain text
815
+ try {
816
+ const fallback = textToNkn(mediaUrl, { topic, groupId: groupIdFromKey });
817
+ await routeNkn(JSON.stringify(fallback));
818
+ } catch {
819
+ // fallback send failure is non-fatal
820
+ }
821
+ }
585
822
  }
586
- } else if (groupIdFromKey) {
587
- // Private group: route reply to the group address
588
- bus.sendNoReply(groupIdFromKey, replyPayload);
589
- } else {
590
- bus.sendNoReply(src, replyPayload);
591
823
  }
824
+
825
+ // Handle text (existing logic)
826
+ if (!replyText) return;
827
+ const replyMsg = textToNkn(replyText, { topic, groupId: groupIdFromKey });
828
+ await routeNkn(JSON.stringify(replyMsg));
592
829
  },
593
830
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, ""),
594
831
  });