@zbruceli/openclaw-dchat 0.1.10 → 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.
- package/.claude/settings.local.json +3 -1
- package/README.md +60 -22
- package/package.json +3 -1
- package/src/channel.ts +254 -24
- package/src/ipfs.test.ts +301 -0
- package/src/ipfs.ts +268 -0
- package/src/wire.test.ts +131 -0
- package/src/wire.ts +28 -1
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
|
-
-
|
|
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
|
-
|
|
28
|
+
Add the D-Chat channel after installing:
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
|
-
|
|
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
|
-
|
|
38
|
+
You'll need:
|
|
31
39
|
|
|
32
|
-
1. **NKN wallet seed** — a 64-character hex string. Generate one
|
|
33
|
-
2. **DM policy** — controls who can send
|
|
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
|
-
|
|
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
|
-
#
|
|
61
|
-
npm
|
|
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
|
-
|
|
64
|
-
npm test
|
|
92
|
+
Use the link workflow to avoid the publish/reinstall cycle:
|
|
65
93
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
@@ -5,15 +5,43 @@ import {
|
|
|
5
5
|
buildChannelConfigSchema,
|
|
6
6
|
collectStatusIssuesFromLastError,
|
|
7
7
|
createDefaultChannelRuntimeState,
|
|
8
|
-
createScopedPairingAccess,
|
|
9
8
|
DEFAULT_ACCOUNT_ID,
|
|
10
9
|
deleteAccountFromConfigSection,
|
|
11
|
-
formatPairingApproveHint,
|
|
12
10
|
normalizeAccountId,
|
|
11
|
+
resolveOutboundMediaUrls,
|
|
13
12
|
resolveSenderCommandAuthorization,
|
|
14
13
|
setAccountEnabledInConfigSection,
|
|
15
14
|
type ChannelPlugin,
|
|
16
15
|
} from "openclaw/plugin-sdk";
|
|
16
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
17
|
+
|
|
18
|
+
/* ── Inline helpers that may not exist in older OpenClaw versions ── */
|
|
19
|
+
|
|
20
|
+
function createScopedPairingAccess(params: {
|
|
21
|
+
core: PluginRuntime;
|
|
22
|
+
channel: string;
|
|
23
|
+
accountId: string;
|
|
24
|
+
}) {
|
|
25
|
+
const resolvedAccountId = normalizeAccountId(params.accountId);
|
|
26
|
+
return {
|
|
27
|
+
accountId: resolvedAccountId,
|
|
28
|
+
readAllowFromStore: () =>
|
|
29
|
+
params.core.channel.pairing.readAllowFromStore({
|
|
30
|
+
channel: params.channel,
|
|
31
|
+
accountId: resolvedAccountId,
|
|
32
|
+
}),
|
|
33
|
+
upsertPairingRequest: (input: { id: string; meta?: Record<string, unknown> }) =>
|
|
34
|
+
params.core.channel.pairing.upsertPairingRequest({
|
|
35
|
+
channel: params.channel,
|
|
36
|
+
accountId: resolvedAccountId,
|
|
37
|
+
...input,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatPairingApproveHint(channelId: string): string {
|
|
43
|
+
return `Approve via: openclaw pairing list ${channelId} / openclaw pairing approve ${channelId} <code>`;
|
|
44
|
+
}
|
|
17
45
|
import {
|
|
18
46
|
type CoreConfig,
|
|
19
47
|
DchatConfigSchema,
|
|
@@ -32,12 +60,15 @@ import {
|
|
|
32
60
|
extractGroupIdFromSessionKey,
|
|
33
61
|
extractTopicFromSessionKey,
|
|
34
62
|
genTopicHash,
|
|
63
|
+
ipfsToNkn,
|
|
35
64
|
nknToInbound,
|
|
65
|
+
parseInlineMediaDataUri,
|
|
36
66
|
parseNknPayload,
|
|
37
67
|
receiptToNkn,
|
|
38
68
|
stripNknSubClientPrefix,
|
|
39
69
|
textToNkn,
|
|
40
70
|
} from "./wire.js";
|
|
71
|
+
import { IpfsService, mimeToIpfsFileType, buildFileMetadata } from "./ipfs.js";
|
|
41
72
|
|
|
42
73
|
// Per-account NKN bus instances and dedup trackers, keyed by accountId
|
|
43
74
|
const busMap = new Map<string, NknBus>();
|
|
@@ -64,7 +95,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
64
95
|
onboarding: dchatOnboardingAdapter,
|
|
65
96
|
capabilities: {
|
|
66
97
|
chatTypes: ["direct", "group"],
|
|
67
|
-
media:
|
|
98
|
+
media: true,
|
|
68
99
|
threads: false,
|
|
69
100
|
reactions: false,
|
|
70
101
|
polls: false,
|
|
@@ -200,6 +231,108 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
200
231
|
messageId: msgData.id,
|
|
201
232
|
};
|
|
202
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
|
+
},
|
|
203
336
|
},
|
|
204
337
|
setup: {
|
|
205
338
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
@@ -463,6 +596,71 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
463
596
|
sessionKey: route.sessionKey,
|
|
464
597
|
});
|
|
465
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
|
+
|
|
466
664
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
467
665
|
channel: "D-Chat",
|
|
468
666
|
from:
|
|
@@ -472,14 +670,15 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
472
670
|
timestamp: msg.timestamp,
|
|
473
671
|
previousTimestamp,
|
|
474
672
|
envelope: envelopeOptions,
|
|
475
|
-
body:
|
|
673
|
+
body: inboundBodyText,
|
|
476
674
|
});
|
|
477
675
|
|
|
478
676
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
479
677
|
Body: body,
|
|
480
|
-
BodyForAgent:
|
|
481
|
-
RawBody:
|
|
482
|
-
CommandBody:
|
|
678
|
+
BodyForAgent: inboundBodyText,
|
|
679
|
+
RawBody: inboundBodyText,
|
|
680
|
+
CommandBody: inboundBodyText,
|
|
681
|
+
...mediaFields,
|
|
483
682
|
From:
|
|
484
683
|
inbound.chatType === "direct"
|
|
485
684
|
? `dchat:${src}`
|
|
@@ -540,28 +739,59 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
540
739
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
541
740
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
542
741
|
deliver: async (payload) => {
|
|
543
|
-
// Deliver reply back to NKN
|
|
544
742
|
const replyText = payload.text ?? "";
|
|
545
|
-
if (!replyText) return;
|
|
546
|
-
|
|
547
743
|
const topic = extractTopicFromSessionKey(inbound.sessionKey);
|
|
548
744
|
const groupIdFromKey = extractGroupIdFromSessionKey(inbound.sessionKey);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|
|
558
788
|
}
|
|
559
|
-
} else if (groupIdFromKey) {
|
|
560
|
-
// Private group: route reply to the group address
|
|
561
|
-
bus.sendNoReply(groupIdFromKey, replyPayload);
|
|
562
|
-
} else {
|
|
563
|
-
bus.sendNoReply(src, replyPayload);
|
|
564
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));
|
|
565
795
|
},
|
|
566
796
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, ""),
|
|
567
797
|
});
|
package/src/ipfs.test.ts
ADDED
|
@@ -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 = "";
|
|
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 = ``;
|
|
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: ``,
|
|
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. "") 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:
|
|
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: "" 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: 
|
|
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.
|