@zbruceli/openclaw-dchat 0.1.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +5 -1
- package/README.md +60 -22
- package/package.json +3 -1
- package/src/channel.ts +259 -22
- package/src/ipfs.test.ts +301 -0
- package/src/ipfs.ts +268 -0
- package/src/nkn-bus.ts +213 -1
- 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.3.0",
|
|
4
4
|
"description": "OpenClaw D-Chat/nMobile channel plugin — decentralized E2E encrypted messaging over the NKN relay network",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"typescript": "^5.8.0"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
+
"dev": "echo 'Run: openclaw plugins install -l . && openclaw gateway restart'",
|
|
21
|
+
"dev:test": "vitest --watch",
|
|
20
22
|
"test": "vitest run",
|
|
21
23
|
"test:watch": "vitest"
|
|
22
24
|
},
|
package/src/channel.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_ACCOUNT_ID,
|
|
9
9
|
deleteAccountFromConfigSection,
|
|
10
10
|
normalizeAccountId,
|
|
11
|
+
resolveOutboundMediaUrls,
|
|
11
12
|
resolveSenderCommandAuthorization,
|
|
12
13
|
setAccountEnabledInConfigSection,
|
|
13
14
|
type ChannelPlugin,
|
|
@@ -59,12 +60,15 @@ import {
|
|
|
59
60
|
extractGroupIdFromSessionKey,
|
|
60
61
|
extractTopicFromSessionKey,
|
|
61
62
|
genTopicHash,
|
|
63
|
+
ipfsToNkn,
|
|
62
64
|
nknToInbound,
|
|
65
|
+
parseInlineMediaDataUri,
|
|
63
66
|
parseNknPayload,
|
|
64
67
|
receiptToNkn,
|
|
65
68
|
stripNknSubClientPrefix,
|
|
66
69
|
textToNkn,
|
|
67
70
|
} from "./wire.js";
|
|
71
|
+
import { IpfsService, mimeToIpfsFileType, buildFileMetadata } from "./ipfs.js";
|
|
68
72
|
|
|
69
73
|
// Per-account NKN bus instances and dedup trackers, keyed by accountId
|
|
70
74
|
const busMap = new Map<string, NknBus>();
|
|
@@ -91,7 +95,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
91
95
|
onboarding: dchatOnboardingAdapter,
|
|
92
96
|
capabilities: {
|
|
93
97
|
chatTypes: ["direct", "group"],
|
|
94
|
-
media:
|
|
98
|
+
media: true,
|
|
95
99
|
threads: false,
|
|
96
100
|
reactions: false,
|
|
97
101
|
polls: false,
|
|
@@ -227,6 +231,108 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
227
231
|
messageId: msgData.id,
|
|
228
232
|
};
|
|
229
233
|
},
|
|
234
|
+
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
|
235
|
+
const core = getDchatRuntime();
|
|
236
|
+
const logger = core.logging.getChildLogger({ module: "dchat" });
|
|
237
|
+
const resolvedAccountId =
|
|
238
|
+
accountId ??
|
|
239
|
+
resolveDefaultDchatAccountId(core.config.loadConfig() as CoreConfig);
|
|
240
|
+
const bus = getBusForAccount(resolvedAccountId);
|
|
241
|
+
if (!bus) {
|
|
242
|
+
throw new Error(`D-Chat account "${resolvedAccountId}" not connected`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const accountCfg = resolveDchatAccount({
|
|
246
|
+
cfg: core.config.loadConfig() as CoreConfig,
|
|
247
|
+
accountId: resolvedAccountId,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const topicName = to.startsWith("topic:") ? to.slice("topic:".length) : undefined;
|
|
251
|
+
const groupId = to.startsWith("group:") ? to.slice("group:".length) : undefined;
|
|
252
|
+
|
|
253
|
+
let mediaMessageId: string | undefined;
|
|
254
|
+
|
|
255
|
+
if (mediaUrl) {
|
|
256
|
+
try {
|
|
257
|
+
const media = await core.media.loadWebMedia(mediaUrl);
|
|
258
|
+
const ipfs = new IpfsService(accountCfg.ipfsGateway);
|
|
259
|
+
const uploadResult = await ipfs.upload(Buffer.from(media.buffer));
|
|
260
|
+
const fileType = mimeToIpfsFileType(media.contentType);
|
|
261
|
+
const fileMeta = buildFileMetadata(media);
|
|
262
|
+
const options = ipfs.buildMessageOptions(uploadResult, fileType, {
|
|
263
|
+
fileMimeType: media.contentType,
|
|
264
|
+
...fileMeta,
|
|
265
|
+
});
|
|
266
|
+
const msgData = ipfsToNkn(options, { topic: topicName, groupId });
|
|
267
|
+
const payload = JSON.stringify(msgData);
|
|
268
|
+
mediaMessageId = msgData.id;
|
|
269
|
+
|
|
270
|
+
if (topicName) {
|
|
271
|
+
const topicHash = genTopicHash(topicName);
|
|
272
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
273
|
+
const selfAddr = bus.getAddress();
|
|
274
|
+
const dests = subscribers.filter((addr) => addr !== selfAddr);
|
|
275
|
+
if (dests.length > 0) {
|
|
276
|
+
bus.sendToMultiple(dests, payload);
|
|
277
|
+
}
|
|
278
|
+
} else if (groupId) {
|
|
279
|
+
const dest = to.replace(/^group:/i, "");
|
|
280
|
+
bus.sendNoReply(dest, payload);
|
|
281
|
+
} else {
|
|
282
|
+
const dest = to.replace(/^dchat:/i, "");
|
|
283
|
+
await bus.send(dest, payload);
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
logger.warn(`sendMedia failed for ${mediaUrl}: ${err}`);
|
|
287
|
+
// Fallback: send the URL as plain text so the recipient still gets a link
|
|
288
|
+
const fallbackMsg = textToNkn(mediaUrl, { topic: topicName, groupId });
|
|
289
|
+
const fallbackPayload = JSON.stringify(fallbackMsg);
|
|
290
|
+
mediaMessageId = fallbackMsg.id;
|
|
291
|
+
|
|
292
|
+
if (topicName) {
|
|
293
|
+
const topicHash = genTopicHash(topicName);
|
|
294
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
295
|
+
const selfAddr = bus.getAddress();
|
|
296
|
+
const dests = subscribers.filter((addr) => addr !== selfAddr);
|
|
297
|
+
if (dests.length > 0) {
|
|
298
|
+
bus.sendToMultiple(dests, fallbackPayload);
|
|
299
|
+
}
|
|
300
|
+
} else if (groupId) {
|
|
301
|
+
const dest = to.replace(/^group:/i, "");
|
|
302
|
+
bus.sendNoReply(dest, fallbackPayload);
|
|
303
|
+
} else {
|
|
304
|
+
const dest = to.replace(/^dchat:/i, "");
|
|
305
|
+
await bus.send(dest, fallbackPayload);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Send accompanying text as a separate message if present
|
|
311
|
+
if (text) {
|
|
312
|
+
const msgData = textToNkn(text, { topic: topicName, groupId });
|
|
313
|
+
const payload = JSON.stringify(msgData);
|
|
314
|
+
|
|
315
|
+
if (topicName) {
|
|
316
|
+
const topicHash = genTopicHash(topicName);
|
|
317
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
318
|
+
const selfAddr = bus.getAddress();
|
|
319
|
+
const dests = subscribers.filter((addr) => addr !== selfAddr);
|
|
320
|
+
if (dests.length > 0) {
|
|
321
|
+
bus.sendToMultiple(dests, payload);
|
|
322
|
+
}
|
|
323
|
+
} else if (groupId) {
|
|
324
|
+
const dest = to.replace(/^group:/i, "");
|
|
325
|
+
bus.sendNoReply(dest, payload);
|
|
326
|
+
} else {
|
|
327
|
+
const dest = to.replace(/^dchat:/i, "");
|
|
328
|
+
await bus.send(dest, payload);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { channel: "dchat", messageId: msgData.id };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { channel: "dchat", messageId: mediaMessageId ?? "" };
|
|
335
|
+
},
|
|
230
336
|
},
|
|
231
337
|
setup: {
|
|
232
338
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
@@ -332,6 +438,39 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
332
438
|
const seenTracker = new SeenTracker();
|
|
333
439
|
seenMap.set(account.accountId, seenTracker);
|
|
334
440
|
|
|
441
|
+
// Listen for heartbeat events
|
|
442
|
+
bus.on("heartbeat", ({ success, failures }: { success: boolean; failures: number }) => {
|
|
443
|
+
if (!success) {
|
|
444
|
+
logger.warn(`[${account.accountId}] heartbeat echo failed (${failures} consecutive)`);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
bus.on("heartbeatReconnect", ({ failures }: { failures: number }) => {
|
|
448
|
+
logger.warn(
|
|
449
|
+
`[${account.accountId}] heartbeat failed ${failures} times, reconnecting...`,
|
|
450
|
+
);
|
|
451
|
+
ctx.setStatus({ accountId: account.accountId, connected: false });
|
|
452
|
+
});
|
|
453
|
+
let initialConnectDone = false;
|
|
454
|
+
bus.on("stateChange", (state: string) => {
|
|
455
|
+
if (state === "connected" && initialConnectDone) {
|
|
456
|
+
ctx.setStatus({
|
|
457
|
+
accountId: account.accountId,
|
|
458
|
+
connected: true,
|
|
459
|
+
lastConnectedAt: Date.now(),
|
|
460
|
+
});
|
|
461
|
+
logger.info(`[${account.accountId}] reconnected as ${bus.getAddress()}`);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
bus.on("reconnectFailed", (err: unknown) => {
|
|
465
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
466
|
+
logger.error(`[${account.accountId}] reconnect failed: ${msg}`);
|
|
467
|
+
ctx.setStatus({
|
|
468
|
+
accountId: account.accountId,
|
|
469
|
+
connected: false,
|
|
470
|
+
lastError: `reconnect failed: ${msg}`,
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
335
474
|
try {
|
|
336
475
|
const address = await bus.connect(
|
|
337
476
|
{ seed: account.seed, numSubClients: account.numSubClients },
|
|
@@ -347,6 +486,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
347
486
|
lastConnectedAt: Date.now(),
|
|
348
487
|
});
|
|
349
488
|
logger.info(`[${account.accountId}] connected as ${address}`);
|
|
489
|
+
initialConnectDone = true;
|
|
350
490
|
|
|
351
491
|
// Register inbound message handler
|
|
352
492
|
bus.onMessage((rawSrc, rawPayload) => {
|
|
@@ -490,6 +630,71 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
490
630
|
sessionKey: route.sessionKey,
|
|
491
631
|
});
|
|
492
632
|
|
|
633
|
+
// Download full-resolution IPFS media if present (non-fatal on failure)
|
|
634
|
+
let mediaFields: Record<string, unknown> = {};
|
|
635
|
+
let inboundBodyText = inbound.body;
|
|
636
|
+
if (inbound.ipfsHash && inbound.ipfsOptions) {
|
|
637
|
+
try {
|
|
638
|
+
const ipfs = new IpfsService(account.ipfsGateway);
|
|
639
|
+
const data = await ipfs.download(inbound.ipfsHash, {
|
|
640
|
+
encrypt: inbound.ipfsOptions.ipfsEncrypt,
|
|
641
|
+
encryptKeyBytes: inbound.ipfsOptions.ipfsEncryptKeyBytes,
|
|
642
|
+
encryptNonceSize: inbound.ipfsOptions.ipfsEncryptNonceSize,
|
|
643
|
+
});
|
|
644
|
+
const mime =
|
|
645
|
+
inbound.ipfsOptions.fileMimeType ??
|
|
646
|
+
(await core.media.detectMime?.({ buffer: data })) ??
|
|
647
|
+
"application/octet-stream";
|
|
648
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
649
|
+
data,
|
|
650
|
+
mime,
|
|
651
|
+
"inbound",
|
|
652
|
+
);
|
|
653
|
+
mediaFields = {
|
|
654
|
+
MediaPath: saved.path,
|
|
655
|
+
MediaUrl: `file://${saved.path}`,
|
|
656
|
+
MediaType: mime,
|
|
657
|
+
};
|
|
658
|
+
// Replace placeholder with media tag so the agent sees the
|
|
659
|
+
// full-resolution image from IPFS, not a text placeholder.
|
|
660
|
+
const kind = mime.startsWith("audio/")
|
|
661
|
+
? "audio"
|
|
662
|
+
: mime.startsWith("video/")
|
|
663
|
+
? "video"
|
|
664
|
+
: mime.startsWith("image/")
|
|
665
|
+
? "image"
|
|
666
|
+
: "file";
|
|
667
|
+
inboundBodyText = `<media:${kind}>`;
|
|
668
|
+
} catch (err) {
|
|
669
|
+
logger.warn(`IPFS download failed: ${err}`);
|
|
670
|
+
// Non-fatal: message still delivered with placeholder text
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Handle inline audio (D-Chat/nMobile send voice as base64 data-URI)
|
|
675
|
+
if (!mediaFields.MediaPath && inbound.inlineMediaDataUri) {
|
|
676
|
+
try {
|
|
677
|
+
const parsed = parseInlineMediaDataUri(inbound.inlineMediaDataUri);
|
|
678
|
+
if (parsed) {
|
|
679
|
+
// Normalize nMobile MIME variants to standard audio/aac
|
|
680
|
+
const mime = parsed.mime === "audio/x-aac" ? "audio/aac" : parsed.mime;
|
|
681
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
682
|
+
parsed.buffer,
|
|
683
|
+
mime,
|
|
684
|
+
"inbound",
|
|
685
|
+
);
|
|
686
|
+
mediaFields = {
|
|
687
|
+
MediaPath: saved.path,
|
|
688
|
+
MediaUrl: `file://${saved.path}`,
|
|
689
|
+
MediaType: mime,
|
|
690
|
+
};
|
|
691
|
+
inboundBodyText = "<media:audio>";
|
|
692
|
+
}
|
|
693
|
+
} catch (err) {
|
|
694
|
+
logger.warn(`Inline audio decode failed: ${err}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
493
698
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
494
699
|
channel: "D-Chat",
|
|
495
700
|
from:
|
|
@@ -499,14 +704,15 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
499
704
|
timestamp: msg.timestamp,
|
|
500
705
|
previousTimestamp,
|
|
501
706
|
envelope: envelopeOptions,
|
|
502
|
-
body:
|
|
707
|
+
body: inboundBodyText,
|
|
503
708
|
});
|
|
504
709
|
|
|
505
710
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
506
711
|
Body: body,
|
|
507
|
-
BodyForAgent:
|
|
508
|
-
RawBody:
|
|
509
|
-
CommandBody:
|
|
712
|
+
BodyForAgent: inboundBodyText,
|
|
713
|
+
RawBody: inboundBodyText,
|
|
714
|
+
CommandBody: inboundBodyText,
|
|
715
|
+
...mediaFields,
|
|
510
716
|
From:
|
|
511
717
|
inbound.chatType === "direct"
|
|
512
718
|
? `dchat:${src}`
|
|
@@ -567,28 +773,59 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
567
773
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
568
774
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
569
775
|
deliver: async (payload) => {
|
|
570
|
-
// Deliver reply back to NKN
|
|
571
776
|
const replyText = payload.text ?? "";
|
|
572
|
-
if (!replyText) return;
|
|
573
|
-
|
|
574
777
|
const topic = extractTopicFromSessionKey(inbound.sessionKey);
|
|
575
778
|
const groupIdFromKey = extractGroupIdFromSessionKey(inbound.sessionKey);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
779
|
+
|
|
780
|
+
// Helper to route a payload string via NKN
|
|
781
|
+
const routeNkn = async (nknPayload: string) => {
|
|
782
|
+
if (topic) {
|
|
783
|
+
const topicHash = genTopicHash(topic);
|
|
784
|
+
const subscribers = await bus.getSubscribers(topicHash);
|
|
785
|
+
const dests = subscribers.filter((a) => a !== selfAddress);
|
|
786
|
+
if (dests.length > 0) {
|
|
787
|
+
bus.sendToMultiple(dests, nknPayload);
|
|
788
|
+
}
|
|
789
|
+
} else if (groupIdFromKey) {
|
|
790
|
+
bus.sendNoReply(groupIdFromKey, nknPayload);
|
|
791
|
+
} else {
|
|
792
|
+
bus.sendNoReply(src, nknPayload);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// Handle media URLs (images, audio, files)
|
|
797
|
+
const mediaUrls = resolveOutboundMediaUrls(payload);
|
|
798
|
+
if (mediaUrls.length > 0) {
|
|
799
|
+
const ipfs = new IpfsService(account.ipfsGateway);
|
|
800
|
+
for (const mediaUrl of mediaUrls) {
|
|
801
|
+
try {
|
|
802
|
+
const media = await core.media.loadWebMedia(mediaUrl);
|
|
803
|
+
const uploadResult = await ipfs.upload(Buffer.from(media.buffer));
|
|
804
|
+
const fileType = mimeToIpfsFileType(media.contentType);
|
|
805
|
+
const fileMeta = buildFileMetadata(media);
|
|
806
|
+
const options = ipfs.buildMessageOptions(uploadResult, fileType, {
|
|
807
|
+
fileMimeType: media.contentType,
|
|
808
|
+
...fileMeta,
|
|
809
|
+
});
|
|
810
|
+
const msgData = ipfsToNkn(options, { topic, groupId: groupIdFromKey });
|
|
811
|
+
await routeNkn(JSON.stringify(msgData));
|
|
812
|
+
} catch (err) {
|
|
813
|
+
logger.warn(`media send failed for ${mediaUrl}: ${err}`);
|
|
814
|
+
// Fallback: send the URL as plain text
|
|
815
|
+
try {
|
|
816
|
+
const fallback = textToNkn(mediaUrl, { topic, groupId: groupIdFromKey });
|
|
817
|
+
await routeNkn(JSON.stringify(fallback));
|
|
818
|
+
} catch {
|
|
819
|
+
// fallback send failure is non-fatal
|
|
820
|
+
}
|
|
821
|
+
}
|
|
585
822
|
}
|
|
586
|
-
} else if (groupIdFromKey) {
|
|
587
|
-
// Private group: route reply to the group address
|
|
588
|
-
bus.sendNoReply(groupIdFromKey, replyPayload);
|
|
589
|
-
} else {
|
|
590
|
-
bus.sendNoReply(src, replyPayload);
|
|
591
823
|
}
|
|
824
|
+
|
|
825
|
+
// Handle text (existing logic)
|
|
826
|
+
if (!replyText) return;
|
|
827
|
+
const replyMsg = textToNkn(replyText, { topic, groupId: groupIdFromKey });
|
|
828
|
+
await routeNkn(JSON.stringify(replyMsg));
|
|
592
829
|
},
|
|
593
830
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, ""),
|
|
594
831
|
});
|