@zbruceli/openclaw-dchat 0.1.11 → 0.2.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,9 @@
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:*)"
6
8
  ]
7
9
  }
8
10
  }
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.2.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),
@@ -490,6 +596,71 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
490
596
  sessionKey: route.sessionKey,
491
597
  });
492
598
 
599
+ // Download full-resolution IPFS media if present (non-fatal on failure)
600
+ let mediaFields: Record<string, unknown> = {};
601
+ let inboundBodyText = inbound.body;
602
+ if (inbound.ipfsHash && inbound.ipfsOptions) {
603
+ try {
604
+ const ipfs = new IpfsService(account.ipfsGateway);
605
+ const data = await ipfs.download(inbound.ipfsHash, {
606
+ encrypt: inbound.ipfsOptions.ipfsEncrypt,
607
+ encryptKeyBytes: inbound.ipfsOptions.ipfsEncryptKeyBytes,
608
+ encryptNonceSize: inbound.ipfsOptions.ipfsEncryptNonceSize,
609
+ });
610
+ const mime =
611
+ inbound.ipfsOptions.fileMimeType ??
612
+ (await core.media.detectMime?.({ buffer: data })) ??
613
+ "application/octet-stream";
614
+ const saved = await core.channel.media.saveMediaBuffer(
615
+ data,
616
+ mime,
617
+ "inbound",
618
+ );
619
+ mediaFields = {
620
+ MediaPath: saved.path,
621
+ MediaUrl: `file://${saved.path}`,
622
+ MediaType: mime,
623
+ };
624
+ // Replace placeholder with media tag so the agent sees the
625
+ // full-resolution image from IPFS, not a text placeholder.
626
+ const kind = mime.startsWith("audio/")
627
+ ? "audio"
628
+ : mime.startsWith("video/")
629
+ ? "video"
630
+ : mime.startsWith("image/")
631
+ ? "image"
632
+ : "file";
633
+ inboundBodyText = `<media:${kind}>`;
634
+ } catch (err) {
635
+ logger.warn(`IPFS download failed: ${err}`);
636
+ // Non-fatal: message still delivered with placeholder text
637
+ }
638
+ }
639
+
640
+ // Handle inline audio (D-Chat/nMobile send voice as base64 data-URI)
641
+ if (!mediaFields.MediaPath && inbound.inlineMediaDataUri) {
642
+ try {
643
+ const parsed = parseInlineMediaDataUri(inbound.inlineMediaDataUri);
644
+ if (parsed) {
645
+ // Normalize nMobile MIME variants to standard audio/aac
646
+ const mime = parsed.mime === "audio/x-aac" ? "audio/aac" : parsed.mime;
647
+ const saved = await core.channel.media.saveMediaBuffer(
648
+ parsed.buffer,
649
+ mime,
650
+ "inbound",
651
+ );
652
+ mediaFields = {
653
+ MediaPath: saved.path,
654
+ MediaUrl: `file://${saved.path}`,
655
+ MediaType: mime,
656
+ };
657
+ inboundBodyText = "<media:audio>";
658
+ }
659
+ } catch (err) {
660
+ logger.warn(`Inline audio decode failed: ${err}`);
661
+ }
662
+ }
663
+
493
664
  const body = core.channel.reply.formatAgentEnvelope({
494
665
  channel: "D-Chat",
495
666
  from:
@@ -499,14 +670,15 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
499
670
  timestamp: msg.timestamp,
500
671
  previousTimestamp,
501
672
  envelope: envelopeOptions,
502
- body: inbound.body,
673
+ body: inboundBodyText,
503
674
  });
504
675
 
505
676
  const ctxPayload = core.channel.reply.finalizeInboundContext({
506
677
  Body: body,
507
- BodyForAgent: inbound.body,
508
- RawBody: inbound.body,
509
- CommandBody: inbound.body,
678
+ BodyForAgent: inboundBodyText,
679
+ RawBody: inboundBodyText,
680
+ CommandBody: inboundBodyText,
681
+ ...mediaFields,
510
682
  From:
511
683
  inbound.chatType === "direct"
512
684
  ? `dchat:${src}`
@@ -567,28 +739,59 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
567
739
  const { dispatcher, replyOptions, markDispatchIdle } =
568
740
  core.channel.reply.createReplyDispatcherWithTyping({
569
741
  deliver: async (payload) => {
570
- // Deliver reply back to NKN
571
742
  const replyText = payload.text ?? "";
572
- if (!replyText) return;
573
-
574
743
  const topic = extractTopicFromSessionKey(inbound.sessionKey);
575
744
  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);
745
+
746
+ // Helper to route a payload string via NKN
747
+ const routeNkn = async (nknPayload: string) => {
748
+ if (topic) {
749
+ const topicHash = genTopicHash(topic);
750
+ const subscribers = await bus.getSubscribers(topicHash);
751
+ const dests = subscribers.filter((a) => a !== selfAddress);
752
+ if (dests.length > 0) {
753
+ bus.sendToMultiple(dests, nknPayload);
754
+ }
755
+ } else if (groupIdFromKey) {
756
+ bus.sendNoReply(groupIdFromKey, nknPayload);
757
+ } else {
758
+ bus.sendNoReply(src, nknPayload);
759
+ }
760
+ };
761
+
762
+ // Handle media URLs (images, audio, files)
763
+ const mediaUrls = resolveOutboundMediaUrls(payload);
764
+ if (mediaUrls.length > 0) {
765
+ const ipfs = new IpfsService(account.ipfsGateway);
766
+ for (const mediaUrl of mediaUrls) {
767
+ try {
768
+ const media = await core.media.loadWebMedia(mediaUrl);
769
+ const uploadResult = await ipfs.upload(Buffer.from(media.buffer));
770
+ const fileType = mimeToIpfsFileType(media.contentType);
771
+ const fileMeta = buildFileMetadata(media);
772
+ const options = ipfs.buildMessageOptions(uploadResult, fileType, {
773
+ fileMimeType: media.contentType,
774
+ ...fileMeta,
775
+ });
776
+ const msgData = ipfsToNkn(options, { topic, groupId: groupIdFromKey });
777
+ await routeNkn(JSON.stringify(msgData));
778
+ } catch (err) {
779
+ logger.warn(`media send failed for ${mediaUrl}: ${err}`);
780
+ // Fallback: send the URL as plain text
781
+ try {
782
+ const fallback = textToNkn(mediaUrl, { topic, groupId: groupIdFromKey });
783
+ await routeNkn(JSON.stringify(fallback));
784
+ } catch {
785
+ // fallback send failure is non-fatal
786
+ }
787
+ }
585
788
  }
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
789
  }
790
+
791
+ // Handle text (existing logic)
792
+ if (!replyText) return;
793
+ const replyMsg = textToNkn(replyText, { topic, groupId: groupIdFromKey });
794
+ await routeNkn(JSON.stringify(replyMsg));
592
795
  },
593
796
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, ""),
594
797
  });
@@ -0,0 +1,301 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import http from "http";
3
+ import { EventEmitter } from "events";
4
+ import { IpfsService, IPFS_FILE_TYPE, mimeToIpfsFileType, buildFileMetadata } from "./ipfs.js";
5
+ import { decryptAesGcm, encryptAesGcm, keyToByteArray, byteArrayToKey } from "./crypto.js";
6
+
7
+ describe("mimeToIpfsFileType", () => {
8
+ it("maps image/* to IMAGE (1)", () => {
9
+ expect(mimeToIpfsFileType("image/png")).toBe(IPFS_FILE_TYPE.IMAGE);
10
+ expect(mimeToIpfsFileType("image/jpeg")).toBe(IPFS_FILE_TYPE.IMAGE);
11
+ expect(mimeToIpfsFileType("image/gif")).toBe(IPFS_FILE_TYPE.IMAGE);
12
+ expect(mimeToIpfsFileType("Image/WebP")).toBe(IPFS_FILE_TYPE.IMAGE);
13
+ });
14
+
15
+ it("maps audio/* to AUDIO (2)", () => {
16
+ expect(mimeToIpfsFileType("audio/mpeg")).toBe(IPFS_FILE_TYPE.AUDIO);
17
+ expect(mimeToIpfsFileType("audio/ogg")).toBe(IPFS_FILE_TYPE.AUDIO);
18
+ expect(mimeToIpfsFileType("Audio/WAV")).toBe(IPFS_FILE_TYPE.AUDIO);
19
+ });
20
+
21
+ it("maps video/* to VIDEO (3)", () => {
22
+ expect(mimeToIpfsFileType("video/mp4")).toBe(IPFS_FILE_TYPE.VIDEO);
23
+ expect(mimeToIpfsFileType("Video/WebM")).toBe(IPFS_FILE_TYPE.VIDEO);
24
+ });
25
+
26
+ it("maps unknown types to FILE (0)", () => {
27
+ expect(mimeToIpfsFileType("application/pdf")).toBe(IPFS_FILE_TYPE.FILE);
28
+ expect(mimeToIpfsFileType("text/plain")).toBe(IPFS_FILE_TYPE.FILE);
29
+ });
30
+
31
+ it("defaults to IMAGE when undefined", () => {
32
+ expect(mimeToIpfsFileType(undefined)).toBe(IPFS_FILE_TYPE.IMAGE);
33
+ });
34
+ });
35
+
36
+ describe("IPFS_FILE_TYPE constants", () => {
37
+ it("has correct values", () => {
38
+ expect(IPFS_FILE_TYPE.FILE).toBe(0);
39
+ expect(IPFS_FILE_TYPE.IMAGE).toBe(1);
40
+ expect(IPFS_FILE_TYPE.AUDIO).toBe(2);
41
+ expect(IPFS_FILE_TYPE.VIDEO).toBe(3);
42
+ });
43
+ });
44
+
45
+ describe("IpfsService.buildMessageOptions", () => {
46
+ const service = new IpfsService("10.0.0.1:5001");
47
+
48
+ const fakeResult = {
49
+ hash: "QmTestHash123",
50
+ key: Buffer.alloc(16, 0xab),
51
+ nonce: Buffer.alloc(12, 0xcd),
52
+ nonceSize: 12,
53
+ };
54
+
55
+ it("produces correct wire format for IMAGE", () => {
56
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE);
57
+
58
+ expect(opts.ipfsHash).toBe("QmTestHash123");
59
+ expect(opts.ipfsIp).toBe("10.0.0.1:5001");
60
+ expect(opts.ipfsEncrypt).toBe(1);
61
+ expect(opts.ipfsEncryptAlgorithm).toBe("AES/GCM/NoPadding");
62
+ expect(opts.ipfsEncryptKeyBytes).toEqual(keyToByteArray(fakeResult.key));
63
+ expect(opts.ipfsEncryptNonceSize).toBe(12);
64
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.IMAGE);
65
+ });
66
+
67
+ it("produces correct wire format for AUDIO with duration", () => {
68
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.AUDIO, {
69
+ mediaDuration: 5.3,
70
+ fileMimeType: "audio/ogg",
71
+ });
72
+
73
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.AUDIO);
74
+ expect(opts.mediaDuration).toBe(5.3);
75
+ expect(opts.fileMimeType).toBe("audio/ogg");
76
+ });
77
+
78
+ it("produces correct wire format for FILE with name/ext", () => {
79
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.FILE, {
80
+ fileName: "doc.pdf",
81
+ fileExt: ".pdf",
82
+ fileMimeType: "application/pdf",
83
+ fileSize: 12345,
84
+ });
85
+
86
+ expect(opts.fileType).toBe(IPFS_FILE_TYPE.FILE);
87
+ expect(opts.fileName).toBe("doc.pdf");
88
+ expect(opts.fileExt).toBe(".pdf");
89
+ expect(opts.fileMimeType).toBe("application/pdf");
90
+ expect(opts.fileSize).toBe(12345);
91
+ });
92
+
93
+ it("omits optional extra fields when not provided", () => {
94
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE);
95
+
96
+ expect(opts.fileMimeType).toBeUndefined();
97
+ expect(opts.fileName).toBeUndefined();
98
+ expect(opts.fileExt).toBeUndefined();
99
+ expect(opts.fileSize).toBeUndefined();
100
+ expect(opts.mediaWidth).toBeUndefined();
101
+ expect(opts.mediaHeight).toBeUndefined();
102
+ expect(opts.mediaDuration).toBeUndefined();
103
+ });
104
+
105
+ it("includes image dimensions when provided", () => {
106
+ const opts = service.buildMessageOptions(fakeResult, IPFS_FILE_TYPE.IMAGE, {
107
+ mediaWidth: 1920,
108
+ mediaHeight: 1080,
109
+ });
110
+
111
+ expect(opts.mediaWidth).toBe(1920);
112
+ expect(opts.mediaHeight).toBe(1080);
113
+ });
114
+ });
115
+
116
+ describe("IpfsService constructor", () => {
117
+ it("parses host and port from gateway string", () => {
118
+ const service = new IpfsService("192.168.1.1:8080");
119
+ const opts = service.buildMessageOptions(
120
+ { hash: "Qm", key: Buffer.alloc(16), nonce: Buffer.alloc(12), nonceSize: 12 },
121
+ IPFS_FILE_TYPE.IMAGE,
122
+ );
123
+ expect(opts.ipfsIp).toBe("192.168.1.1:8080");
124
+ });
125
+
126
+ it("uses default gateway when none provided", () => {
127
+ const service = new IpfsService();
128
+ const opts = service.buildMessageOptions(
129
+ { hash: "Qm", key: Buffer.alloc(16), nonce: Buffer.alloc(12), nonceSize: 12 },
130
+ IPFS_FILE_TYPE.IMAGE,
131
+ );
132
+ expect(opts.ipfsIp).toBe("64.225.88.71:80");
133
+ });
134
+ });
135
+
136
+ describe("encrypt → upload → download → decrypt round-trip", () => {
137
+ it("data survives round-trip through encryption and key serialization", () => {
138
+ // Simulate the full data flow without a real IPFS server:
139
+ // 1. Encrypt (what upload does internally)
140
+ const original = Buffer.from("Hello IPFS image data!");
141
+ const { ciphertext, key, nonce } = encryptAesGcm(original);
142
+
143
+ // 2. Serialize key to wire format (what buildMessageOptions does)
144
+ const wireKeyBytes = keyToByteArray(key);
145
+ const nonceSize = nonce.length;
146
+
147
+ // 3. Deserialize and decrypt (what download does)
148
+ const restoredKey = byteArrayToKey(wireKeyBytes);
149
+ const decrypted = decryptAesGcm(ciphertext, restoredKey, nonceSize);
150
+
151
+ expect(decrypted.toString()).toBe(original.toString());
152
+ });
153
+ });
154
+
155
+ describe("IpfsService HTTP error handling", () => {
156
+ beforeEach(() => {
157
+ vi.restoreAllMocks();
158
+ });
159
+
160
+ it("rejects on non-200 response", async () => {
161
+ // Mock http.request to return a 500 response
162
+ const mockRes = new EventEmitter() as any;
163
+ mockRes.statusCode = 500;
164
+
165
+ const mockReq = new EventEmitter() as any;
166
+ mockReq.write = vi.fn();
167
+ mockReq.end = vi.fn();
168
+ mockReq.destroy = vi.fn();
169
+
170
+ vi.spyOn(http, "request").mockImplementation((_opts: any, callback: any) => {
171
+ process.nextTick(() => {
172
+ callback(mockRes);
173
+ mockRes.emit("data", Buffer.from("Internal Server Error"));
174
+ mockRes.emit("end");
175
+ });
176
+ return mockReq;
177
+ });
178
+
179
+ const service = new IpfsService("127.0.0.1:5001");
180
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("IPFS HTTP 500");
181
+ });
182
+
183
+ it("rejects on request timeout", async () => {
184
+ const mockReq = new EventEmitter() as any;
185
+ mockReq.write = vi.fn();
186
+ mockReq.end = vi.fn();
187
+ mockReq.destroy = vi.fn();
188
+
189
+ vi.spyOn(http, "request").mockImplementation(() => {
190
+ process.nextTick(() => {
191
+ mockReq.emit("timeout");
192
+ });
193
+ return mockReq;
194
+ });
195
+
196
+ const service = new IpfsService("127.0.0.1:5001");
197
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("IPFS request timed out");
198
+ });
199
+
200
+ it("rejects on connection error", async () => {
201
+ const mockReq = new EventEmitter() as any;
202
+ mockReq.write = vi.fn();
203
+ mockReq.end = vi.fn();
204
+ mockReq.destroy = vi.fn();
205
+
206
+ vi.spyOn(http, "request").mockImplementation(() => {
207
+ process.nextTick(() => {
208
+ mockReq.emit("error", new Error("ECONNREFUSED"));
209
+ });
210
+ return mockReq;
211
+ });
212
+
213
+ const service = new IpfsService("127.0.0.1:5001");
214
+ await expect(service.upload(Buffer.from("test"))).rejects.toThrow("ECONNREFUSED");
215
+ });
216
+
217
+ it("download rejects on non-200 response", async () => {
218
+ const mockRes = new EventEmitter() as any;
219
+ mockRes.statusCode = 404;
220
+
221
+ const mockReq = new EventEmitter() as any;
222
+ mockReq.write = vi.fn();
223
+ mockReq.end = vi.fn();
224
+ mockReq.destroy = vi.fn();
225
+
226
+ vi.spyOn(http, "request").mockImplementation((_opts: any, callback: any) => {
227
+ process.nextTick(() => {
228
+ callback(mockRes);
229
+ mockRes.emit("data", Buffer.from("not found"));
230
+ mockRes.emit("end");
231
+ });
232
+ return mockReq;
233
+ });
234
+
235
+ const service = new IpfsService("127.0.0.1:5001");
236
+ await expect(
237
+ service.download("QmMissing", { encrypt: 1, encryptKeyBytes: Array.from(Buffer.alloc(16)), encryptNonceSize: 12 }),
238
+ ).rejects.toThrow("IPFS HTTP 404");
239
+ });
240
+ });
241
+
242
+ describe("buildFileMetadata", () => {
243
+ it("extracts extension from fileName", () => {
244
+ const result = buildFileMetadata({
245
+ buffer: Buffer.from("pdf content"),
246
+ contentType: "application/pdf",
247
+ fileName: "report.pdf",
248
+ });
249
+ expect(result.fileName).toBe("report.pdf");
250
+ expect(result.fileExt).toBe("pdf");
251
+ expect(result.fileSize).toBe(11);
252
+ });
253
+
254
+ it("derives extension from MIME when fileName has no extension", () => {
255
+ const result = buildFileMetadata({
256
+ buffer: Buffer.from("data"),
257
+ contentType: "application/pdf",
258
+ fileName: "report",
259
+ });
260
+ expect(result.fileName).toBe("report.pdf");
261
+ expect(result.fileExt).toBe("pdf");
262
+ });
263
+
264
+ it("derives extension from MIME when no fileName", () => {
265
+ const result = buildFileMetadata({
266
+ buffer: Buffer.from("data"),
267
+ contentType: "image/png",
268
+ });
269
+ expect(result.fileName).toBe("file.png");
270
+ expect(result.fileExt).toBe("png");
271
+ });
272
+
273
+ it("falls back to bin for unknown MIME and no fileName", () => {
274
+ const result = buildFileMetadata({
275
+ buffer: Buffer.from("data"),
276
+ contentType: "application/x-unknown",
277
+ });
278
+ expect(result.fileName).toBe("file.bin");
279
+ expect(result.fileExt).toBe("bin");
280
+ });
281
+
282
+ it("handles docx MIME type", () => {
283
+ const result = buildFileMetadata({
284
+ buffer: Buffer.from("docx"),
285
+ contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
286
+ fileName: "letter.docx",
287
+ });
288
+ expect(result.fileExt).toBe("docx");
289
+ expect(result.fileName).toBe("letter.docx");
290
+ });
291
+
292
+ it("prefers fileName extension over MIME", () => {
293
+ const result = buildFileMetadata({
294
+ buffer: Buffer.from("data"),
295
+ contentType: "application/octet-stream",
296
+ fileName: "archive.tar.gz",
297
+ });
298
+ expect(result.fileExt).toBe("gz");
299
+ expect(result.fileName).toBe("archive.tar.gz");
300
+ });
301
+ });
package/src/ipfs.ts ADDED
@@ -0,0 +1,268 @@
1
+ import http from "http";
2
+ import https from "https";
3
+ import { encryptAesGcm, decryptAesGcm, keyToByteArray, byteArrayToKey } from "./crypto.js";
4
+ import type { MessageOptions } from "./types.js";
5
+
6
+ /** IPFS file type constants matching nMobile wire format. */
7
+ export const IPFS_FILE_TYPE = {
8
+ FILE: 0,
9
+ IMAGE: 1,
10
+ AUDIO: 2,
11
+ VIDEO: 3,
12
+ } as const;
13
+
14
+ /** Map a MIME type string to an nMobile IPFS file type number. */
15
+ export function mimeToIpfsFileType(mime?: string): number {
16
+ if (!mime) return IPFS_FILE_TYPE.IMAGE; // default to image
17
+ const lower = mime.toLowerCase();
18
+ if (lower.startsWith("image/")) return IPFS_FILE_TYPE.IMAGE;
19
+ if (lower.startsWith("audio/")) return IPFS_FILE_TYPE.AUDIO;
20
+ if (lower.startsWith("video/")) return IPFS_FILE_TYPE.VIDEO;
21
+ return IPFS_FILE_TYPE.FILE;
22
+ }
23
+
24
+ /** Common MIME → extension mappings for file transfers. */
25
+ const MIME_TO_EXT: Record<string, string> = {
26
+ "application/pdf": "pdf",
27
+ "application/zip": "zip",
28
+ "application/x-zip-compressed": "zip",
29
+ "application/msword": "doc",
30
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
31
+ "application/vnd.ms-excel": "xls",
32
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
33
+ "application/vnd.ms-powerpoint": "ppt",
34
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
35
+ "text/plain": "txt",
36
+ "text/csv": "csv",
37
+ "text/html": "html",
38
+ "application/json": "json",
39
+ "application/xml": "xml",
40
+ "image/png": "png",
41
+ "image/jpeg": "jpg",
42
+ "image/gif": "gif",
43
+ "image/webp": "webp",
44
+ "audio/aac": "aac",
45
+ "audio/mpeg": "mp3",
46
+ "audio/ogg": "ogg",
47
+ "audio/wav": "wav",
48
+ "video/mp4": "mp4",
49
+ "video/webm": "webm",
50
+ };
51
+
52
+ /**
53
+ * Build file metadata (fileName, fileExt, fileSize) for outbound media.
54
+ * Derives extension from the original filename or MIME type.
55
+ */
56
+ export function buildFileMetadata(media: {
57
+ buffer: Buffer;
58
+ contentType?: string;
59
+ fileName?: string;
60
+ }): { fileName: string; fileExt: string; fileSize: number } {
61
+ let ext: string | undefined;
62
+ let name = media.fileName;
63
+
64
+ // Try to get extension from filename
65
+ if (name) {
66
+ const dotIdx = name.lastIndexOf(".");
67
+ if (dotIdx > 0) {
68
+ ext = name.substring(dotIdx + 1).toLowerCase();
69
+ }
70
+ }
71
+
72
+ // Fall back to MIME type
73
+ if (!ext && media.contentType) {
74
+ ext = MIME_TO_EXT[media.contentType.toLowerCase()];
75
+ }
76
+
77
+ ext = ext || "bin";
78
+
79
+ // Ensure filename has extension
80
+ if (!name) {
81
+ name = `file.${ext}`;
82
+ } else if (!name.includes(".")) {
83
+ name = `${name}.${ext}`;
84
+ }
85
+
86
+ return { fileName: name, fileExt: ext, fileSize: media.buffer.length };
87
+ }
88
+
89
+ export interface IpfsUploadResult {
90
+ hash: string;
91
+ key: Buffer;
92
+ nonce: Buffer;
93
+ nonceSize: number;
94
+ }
95
+
96
+ const DEFAULT_TIMEOUT = 60_000;
97
+
98
+ /**
99
+ * IPFS service for uploading/downloading encrypted media
100
+ * via an IPFS HTTP API gateway (compatible with nMobile).
101
+ */
102
+ export class IpfsService {
103
+ private host: string;
104
+ private port: number;
105
+ private protocol: "http" | "https";
106
+
107
+ constructor(gateway?: string) {
108
+ const gw = gateway ?? "64.225.88.71:80";
109
+ const parts = gw.split(":");
110
+ this.host = parts[0];
111
+ this.port = parseInt(parts[1] ?? "80", 10);
112
+ this.protocol = this.port === 443 ? "https" : "http";
113
+ }
114
+
115
+ /**
116
+ * Encrypt plaintext with AES-128-GCM, then upload to IPFS via /api/v0/add.
117
+ */
118
+ async upload(plaintext: Buffer): Promise<IpfsUploadResult> {
119
+ const { ciphertext, key, nonce } = encryptAesGcm(plaintext);
120
+
121
+ const boundary = "----IpfsBoundary" + Date.now().toString(36);
122
+ const fieldName = "file";
123
+ const fileName = "upload";
124
+
125
+ // Build multipart form-data manually
126
+ const header = Buffer.from(
127
+ `--${boundary}\r\n` +
128
+ `Content-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\n` +
129
+ `Content-Type: application/octet-stream\r\n\r\n`,
130
+ );
131
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
132
+ const body = Buffer.concat([header, ciphertext, footer]);
133
+
134
+ const responseBody = await this._request("POST", "/api/v0/add", body, {
135
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
136
+ });
137
+
138
+ const result = JSON.parse(responseBody);
139
+ const hash = result.Hash;
140
+ if (!hash) {
141
+ throw new Error(`IPFS add response missing Hash: ${responseBody}`);
142
+ }
143
+
144
+ return { hash, key, nonce, nonceSize: nonce.length };
145
+ }
146
+
147
+ /**
148
+ * Download from IPFS via /api/v0/cat, then decrypt with AES-128-GCM.
149
+ */
150
+ async download(
151
+ hash: string,
152
+ encryptOpts: {
153
+ encrypt?: number;
154
+ encryptKeyBytes?: number[];
155
+ encryptNonceSize?: number;
156
+ },
157
+ ): Promise<Buffer> {
158
+ const responseBody = await this._requestRaw("POST", `/api/v0/cat?arg=${hash}`);
159
+
160
+ if (!encryptOpts.encrypt || !encryptOpts.encryptKeyBytes) {
161
+ // Not encrypted — return raw
162
+ return responseBody;
163
+ }
164
+
165
+ const key = byteArrayToKey(encryptOpts.encryptKeyBytes);
166
+ const nonceSize = encryptOpts.encryptNonceSize ?? 12;
167
+ return decryptAesGcm(responseBody, key, nonceSize);
168
+ }
169
+
170
+ /**
171
+ * Build nMobile-compatible MessageOptions from an upload result.
172
+ */
173
+ buildMessageOptions(
174
+ uploadResult: IpfsUploadResult,
175
+ fileType: number,
176
+ extra?: {
177
+ fileMimeType?: string;
178
+ fileName?: string;
179
+ fileExt?: string;
180
+ fileSize?: number;
181
+ mediaWidth?: number;
182
+ mediaHeight?: number;
183
+ mediaDuration?: number;
184
+ },
185
+ ): MessageOptions {
186
+ const options: MessageOptions = {
187
+ ipfsHash: uploadResult.hash,
188
+ ipfsIp: `${this.host}:${this.port}`,
189
+ ipfsEncrypt: 1,
190
+ ipfsEncryptAlgorithm: "AES/GCM/NoPadding",
191
+ ipfsEncryptKeyBytes: keyToByteArray(uploadResult.key),
192
+ ipfsEncryptNonceSize: uploadResult.nonceSize,
193
+ fileType,
194
+ };
195
+
196
+ if (extra?.fileMimeType) options.fileMimeType = extra.fileMimeType;
197
+ if (extra?.fileName) options.fileName = extra.fileName;
198
+ if (extra?.fileExt) options.fileExt = extra.fileExt;
199
+ if (extra?.fileSize !== undefined) options.fileSize = extra.fileSize;
200
+ if (extra?.mediaWidth !== undefined) options.mediaWidth = extra.mediaWidth;
201
+ if (extra?.mediaHeight !== undefined) options.mediaHeight = extra.mediaHeight;
202
+ if (extra?.mediaDuration !== undefined) options.mediaDuration = extra.mediaDuration;
203
+
204
+ return options;
205
+ }
206
+
207
+ /** HTTP request returning text response. */
208
+ private _request(
209
+ method: string,
210
+ path: string,
211
+ body?: Buffer,
212
+ headers?: Record<string, string>,
213
+ ): Promise<string> {
214
+ return this._requestRaw(method, path, body, headers).then((buf) => buf.toString("utf-8"));
215
+ }
216
+
217
+ /** HTTP request returning raw Buffer response. */
218
+ private _requestRaw(
219
+ method: string,
220
+ path: string,
221
+ body?: Buffer,
222
+ headers?: Record<string, string>,
223
+ ): Promise<Buffer> {
224
+ const transport = this.protocol === "https" ? https : http;
225
+
226
+ return new Promise<Buffer>((resolve, reject) => {
227
+ const req = transport.request(
228
+ {
229
+ hostname: this.host,
230
+ port: this.port,
231
+ path,
232
+ method,
233
+ headers: {
234
+ ...headers,
235
+ ...(body ? { "Content-Length": body.length.toString() } : {}),
236
+ },
237
+ timeout: DEFAULT_TIMEOUT,
238
+ },
239
+ (res) => {
240
+ const chunks: Buffer[] = [];
241
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
242
+ res.on("end", () => {
243
+ const result = Buffer.concat(chunks);
244
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
245
+ reject(
246
+ new Error(
247
+ `IPFS HTTP ${res.statusCode}: ${result.toString("utf-8").slice(0, 200)}`,
248
+ ),
249
+ );
250
+ return;
251
+ }
252
+ resolve(result);
253
+ });
254
+ res.on("error", reject);
255
+ },
256
+ );
257
+
258
+ req.on("timeout", () => {
259
+ req.destroy();
260
+ reject(new Error("IPFS request timed out"));
261
+ });
262
+ req.on("error", reject);
263
+
264
+ if (body) req.write(body);
265
+ req.end();
266
+ });
267
+ }
268
+ }
package/src/wire.test.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  isControlMessage,
9
9
  isDisplayableMessage,
10
10
  nknToInbound,
11
+ parseInlineMediaDataUri,
11
12
  parseNknPayload,
12
13
  receiptToNkn,
13
14
  stripNknSubClientPrefix,
@@ -172,6 +173,65 @@ describe("nknToInbound", () => {
172
173
  expect(result!.body).toBe("[Voice Message]");
173
174
  });
174
175
 
176
+ it("extracts IPFS hash from audio message with options.ipfsHash", () => {
177
+ const msg: MessageData = {
178
+ id: "msg-audio-ipfs-1",
179
+ contentType: "audio",
180
+ content: "QmAudioHash...",
181
+ options: {
182
+ ipfsHash: "QmAudioHash...",
183
+ ipfsEncrypt: 1,
184
+ ipfsEncryptAlgorithm: "AES/GCM/NoPadding",
185
+ ipfsEncryptKeyBytes: Array.from(Buffer.alloc(16, 0xab)),
186
+ ipfsEncryptNonceSize: 12,
187
+ fileType: 2,
188
+ mediaDuration: 5.3,
189
+ },
190
+ timestamp: Date.now(),
191
+ };
192
+ const result = nknToInbound("sender", msg, selfAddr);
193
+ expect(result!.body).toBe("[Voice Message]");
194
+ expect(result!.ipfsHash).toBe("QmAudioHash...");
195
+ expect(result!.ipfsOptions).toBeDefined();
196
+ expect(result!.ipfsOptions!.ipfsEncrypt).toBe(1);
197
+ expect(result!.ipfsOptions!.mediaDuration).toBe(5.3);
198
+ });
199
+
200
+ it("extracts IPFS hash from audio message content when options.ipfsHash is missing", () => {
201
+ const msg: MessageData = {
202
+ id: "msg-audio-ipfs-2",
203
+ contentType: "audio",
204
+ content: "QmAudioContentHash",
205
+ options: {
206
+ ipfsEncrypt: 1,
207
+ ipfsEncryptKeyBytes: Array.from(Buffer.alloc(16, 0xcd)),
208
+ ipfsEncryptNonceSize: 12,
209
+ },
210
+ timestamp: Date.now(),
211
+ };
212
+ const result = nknToInbound("sender", msg, selfAddr);
213
+ expect(result!.ipfsHash).toBe("QmAudioContentHash");
214
+ expect(result!.ipfsOptions).toBeDefined();
215
+ });
216
+
217
+ it("translates IPFS audio message (contentType ipfs, fileType 2)", () => {
218
+ const msg: MessageData = {
219
+ id: "msg-ipfs-audio",
220
+ contentType: "ipfs",
221
+ content: "QmIpfsAudio...",
222
+ options: {
223
+ ipfsHash: "QmIpfsAudio...",
224
+ fileType: 2,
225
+ mediaDuration: 12.5,
226
+ },
227
+ timestamp: Date.now(),
228
+ };
229
+ const result = nknToInbound("sender", msg, selfAddr);
230
+ expect(result!.body).toBe("[Audio]");
231
+ expect(result!.ipfsHash).toBe("QmIpfsAudio...");
232
+ expect(result!.ipfsOptions!.mediaDuration).toBe(12.5);
233
+ });
234
+
175
235
  it("returns null for control messages", () => {
176
236
  const receipt: MessageData = {
177
237
  id: "msg-6",
@@ -264,3 +324,74 @@ describe("stripNknSubClientPrefix", () => {
264
324
  expect(stripNknSubClientPrefix("cd3530abcdef")).toBe("cd3530abcdef");
265
325
  });
266
326
  });
327
+
328
+ describe("parseInlineMediaDataUri", () => {
329
+ it("parses D-Chat markdown audio data-URI (audio/x-aac)", () => {
330
+ const raw = "![audio](data:audio/x-aac;base64,AAAA)";
331
+ const result = parseInlineMediaDataUri(raw);
332
+ expect(result).not.toBeNull();
333
+ expect(result!.mime).toBe("audio/x-aac");
334
+ expect(result!.buffer).toEqual(Buffer.from("AAAA", "base64"));
335
+ });
336
+
337
+ it("parses nMobile markdown audio data-URI (audio/aac)", () => {
338
+ const b64 = Buffer.from("hello audio").toString("base64");
339
+ const raw = `![audio](data:audio/aac;base64,${b64})`;
340
+ const result = parseInlineMediaDataUri(raw);
341
+ expect(result).not.toBeNull();
342
+ expect(result!.mime).toBe("audio/aac");
343
+ expect(result!.buffer.toString()).toBe("hello audio");
344
+ });
345
+
346
+ it("parses raw data-URI without markdown wrapper", () => {
347
+ const b64 = Buffer.from("raw data").toString("base64");
348
+ const raw = `data:audio/ogg;base64,${b64}`;
349
+ const result = parseInlineMediaDataUri(raw);
350
+ expect(result).not.toBeNull();
351
+ expect(result!.mime).toBe("audio/ogg");
352
+ expect(result!.buffer.toString()).toBe("raw data");
353
+ });
354
+
355
+ it("returns null for non-data-URI content", () => {
356
+ expect(parseInlineMediaDataUri("QmSomeIpfsHash")).toBeNull();
357
+ expect(parseInlineMediaDataUri("just plain text")).toBeNull();
358
+ expect(parseInlineMediaDataUri("")).toBeNull();
359
+ });
360
+
361
+ it("returns null for invalid base64", () => {
362
+ expect(parseInlineMediaDataUri("data:audio/aac;utf8,notbase64")).toBeNull();
363
+ });
364
+ });
365
+
366
+ describe("nknToInbound — inline audio", () => {
367
+ const selfAddr = "self-address-abc123";
368
+
369
+ it("sets inlineMediaDataUri for audio with data-URI content", () => {
370
+ const b64 = Buffer.from("aac-audio-data").toString("base64");
371
+ const msg: MessageData = {
372
+ id: "msg-voice-1",
373
+ contentType: "audio",
374
+ content: `![audio](data:audio/x-aac;base64,${b64})`,
375
+ options: { fileType: 2, fileExt: "aac", mediaDuration: 3.5 },
376
+ timestamp: Date.now(),
377
+ };
378
+ const result = nknToInbound("sender", msg, selfAddr);
379
+ expect(result!.body).toBe("[Voice Message]");
380
+ expect(result!.inlineMediaDataUri).toBe(msg.content);
381
+ // ipfsHash should also be set as fallback (content contains "data:" but also matches)
382
+ // but IPFS download will fail gracefully — inline path takes priority in channel.ts
383
+ });
384
+
385
+ it("does not set inlineMediaDataUri for audio without data-URI", () => {
386
+ const msg: MessageData = {
387
+ id: "msg-voice-2",
388
+ contentType: "audio",
389
+ content: "QmSomeHash",
390
+ options: { ipfsHash: "QmSomeHash" },
391
+ timestamp: Date.now(),
392
+ };
393
+ const result = nknToInbound("sender", msg, selfAddr);
394
+ expect(result!.inlineMediaDataUri).toBeUndefined();
395
+ expect(result!.ipfsHash).toBe("QmSomeHash");
396
+ });
397
+ });
package/src/wire.ts CHANGED
@@ -59,6 +59,8 @@ export interface InboundMessageResult {
59
59
  groupSubject?: string;
60
60
  ipfsHash?: string;
61
61
  ipfsOptions?: MessageOptions;
62
+ /** Raw inline media data-URI (e.g. "![audio](data:audio/aac;base64,...)") for voice messages. */
63
+ inlineMediaDataUri?: string;
62
64
  }
63
65
 
64
66
  /**
@@ -141,11 +143,36 @@ export function nknToInbound(
141
143
  senderId: src,
142
144
  senderName,
143
145
  groupSubject,
144
- ipfsHash: msg.options?.ipfsHash || (ct === "ipfs" ? msg.content : undefined),
146
+ ipfsHash:
147
+ msg.options?.ipfsHash || (ct === "ipfs" || ct === "audio" ? msg.content : undefined),
145
148
  ipfsOptions: ct === "ipfs" || ct === "audio" ? msg.options : undefined,
149
+ // D-Chat/nMobile send voice messages inline as base64 data-URI in content
150
+ inlineMediaDataUri:
151
+ ct === "audio" && msg.content?.includes("data:") ? msg.content : undefined,
146
152
  };
147
153
  }
148
154
 
155
+ /**
156
+ * Parse an inline media data-URI from nMobile/D-Chat format.
157
+ * Handles: "![audio](data:audio/aac;base64,...)" and raw "data:audio/aac;base64,..."
158
+ * Returns { mime, buffer } or null if not a valid data-URI.
159
+ */
160
+ export function parseInlineMediaDataUri(
161
+ raw: string,
162
+ ): { mime: string; buffer: Buffer } | null {
163
+ // Extract data-URI from markdown image syntax: ![...](data:...)
164
+ const mdMatch = raw.match(/!\[.*?\]\((data:[^)]+)\)/);
165
+ const dataUri = mdMatch ? mdMatch[1] : raw.startsWith("data:") ? raw : null;
166
+ if (!dataUri) return null;
167
+
168
+ const match = dataUri.match(/^data:([^;]+);base64,(.+)$/s);
169
+ if (!match) return null;
170
+
171
+ const mime = match[1];
172
+ const buffer = Buffer.from(match[2], "base64");
173
+ return { mime, buffer };
174
+ }
175
+
149
176
  /**
150
177
  * Build an outbound NKN MessageData from text.
151
178
  * Sets appropriate content type and topic/groupId fields.