@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/src/nkn-bus.ts
CHANGED
|
@@ -5,12 +5,20 @@ import { NKN_SEED_RPC_SERVERS, type NknConnectionState } from "./types.js";
|
|
|
5
5
|
export interface NknBusOptions {
|
|
6
6
|
seed: string;
|
|
7
7
|
numSubClients?: number;
|
|
8
|
+
/** Heartbeat echo-test interval in ms (default 60 000). Set 0 to disable. */
|
|
9
|
+
heartbeatIntervalMs?: number;
|
|
10
|
+
/** Consecutive heartbeat failures before reconnecting (default 3). */
|
|
11
|
+
heartbeatMaxFailures?: number;
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
/** Payload used for heartbeat echo-test messages (self → self). */
|
|
15
|
+
const HEARTBEAT_ECHO_PREFIX = "__nkn_heartbeat_echo__:";
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* NKN MultiClient wrapper for D-Chat wire-format messaging.
|
|
12
|
-
* Handles connect, send, receive, subscribe, and reconnection.
|
|
19
|
+
* Handles connect, send, receive, subscribe, heartbeat, and reconnection.
|
|
13
20
|
*/
|
|
21
|
+
|
|
14
22
|
export class NknBus extends EventEmitter {
|
|
15
23
|
private client: nkn.MultiClient | null = null;
|
|
16
24
|
private state: NknConnectionState = "disconnected";
|
|
@@ -20,6 +28,14 @@ export class NknBus extends EventEmitter {
|
|
|
20
28
|
private numSubClients: number;
|
|
21
29
|
private abortSignal: AbortSignal | undefined;
|
|
22
30
|
|
|
31
|
+
private heartbeatIntervalMs: number = 60_000;
|
|
32
|
+
private heartbeatMaxFailures: number = 3;
|
|
33
|
+
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
private heartbeatFailures: number = 0;
|
|
35
|
+
private pendingEchoId: string | null = null;
|
|
36
|
+
private pendingEchoResolve: (() => void) | null = null;
|
|
37
|
+
private isReconnecting: boolean = false;
|
|
38
|
+
|
|
23
39
|
constructor() {
|
|
24
40
|
super();
|
|
25
41
|
this.numSubClients = 4;
|
|
@@ -40,6 +56,8 @@ export class NknBus extends EventEmitter {
|
|
|
40
56
|
|
|
41
57
|
this.seed = opts.seed;
|
|
42
58
|
this.numSubClients = opts.numSubClients ?? 4;
|
|
59
|
+
this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 60_000;
|
|
60
|
+
this.heartbeatMaxFailures = opts.heartbeatMaxFailures ?? 3;
|
|
43
61
|
this.abortSignal = abortSignal;
|
|
44
62
|
this.setState("connecting");
|
|
45
63
|
|
|
@@ -86,9 +104,23 @@ export class NknBus extends EventEmitter {
|
|
|
86
104
|
} else {
|
|
87
105
|
data = payload;
|
|
88
106
|
}
|
|
107
|
+
|
|
108
|
+
// Intercept heartbeat echo responses — don't emit as regular messages
|
|
109
|
+
if (data.startsWith(HEARTBEAT_ECHO_PREFIX)) {
|
|
110
|
+
const echoId = data.slice(HEARTBEAT_ECHO_PREFIX.length);
|
|
111
|
+
if (echoId === this.pendingEchoId && this.pendingEchoResolve) {
|
|
112
|
+
this.pendingEchoResolve();
|
|
113
|
+
this.pendingEchoResolve = null;
|
|
114
|
+
this.pendingEchoId = null;
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
this.emit("message", src, data);
|
|
90
120
|
});
|
|
91
121
|
|
|
122
|
+
this.startHeartbeat();
|
|
123
|
+
|
|
92
124
|
return this.address;
|
|
93
125
|
} catch (err) {
|
|
94
126
|
this.setState("disconnected");
|
|
@@ -105,6 +137,7 @@ export class NknBus extends EventEmitter {
|
|
|
105
137
|
}
|
|
106
138
|
|
|
107
139
|
async disconnect(): Promise<void> {
|
|
140
|
+
this.stopHeartbeat();
|
|
108
141
|
if (this.reconnectTimer) {
|
|
109
142
|
clearTimeout(this.reconnectTimer);
|
|
110
143
|
this.reconnectTimer = null;
|
|
@@ -200,6 +233,185 @@ export class NknBus extends EventEmitter {
|
|
|
200
233
|
this.on("message", handler);
|
|
201
234
|
}
|
|
202
235
|
|
|
236
|
+
/** Start periodic heartbeat echo test (self → self). */
|
|
237
|
+
private startHeartbeat(): void {
|
|
238
|
+
if (this.heartbeatIntervalMs <= 0) return;
|
|
239
|
+
this.heartbeatFailures = 0;
|
|
240
|
+
this.scheduleNextHeartbeat();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private stopHeartbeat(): void {
|
|
244
|
+
if (this.heartbeatTimer) {
|
|
245
|
+
clearTimeout(this.heartbeatTimer);
|
|
246
|
+
this.heartbeatTimer = null;
|
|
247
|
+
}
|
|
248
|
+
// Reject any pending echo wait
|
|
249
|
+
this.pendingEchoResolve = null;
|
|
250
|
+
this.pendingEchoId = null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private scheduleNextHeartbeat(): void {
|
|
254
|
+
if (this.heartbeatTimer) {
|
|
255
|
+
clearTimeout(this.heartbeatTimer);
|
|
256
|
+
}
|
|
257
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
258
|
+
void this.runHeartbeat();
|
|
259
|
+
}, this.heartbeatIntervalMs);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async runHeartbeat(): Promise<void> {
|
|
263
|
+
if (!this.client || this.state !== "connected" || !this.address) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const echoId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
268
|
+
const echoPayload = HEARTBEAT_ECHO_PREFIX + echoId;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const echoReceived = await new Promise<boolean>((resolve) => {
|
|
272
|
+
this.pendingEchoId = echoId;
|
|
273
|
+
// Timeout: half the heartbeat interval or 15s, whichever is smaller
|
|
274
|
+
const timeout = Math.min(this.heartbeatIntervalMs / 2, 15_000);
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
this.pendingEchoResolve = null;
|
|
277
|
+
this.pendingEchoId = null;
|
|
278
|
+
resolve(false);
|
|
279
|
+
}, timeout);
|
|
280
|
+
|
|
281
|
+
this.pendingEchoResolve = () => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
resolve(true);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Send echo to self (fire-and-forget send, we wait for the message handler)
|
|
287
|
+
this.client!.send(this.address!, echoPayload, { noReply: true });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (echoReceived) {
|
|
291
|
+
this.heartbeatFailures = 0;
|
|
292
|
+
this.emit("heartbeat", { success: true, failures: 0 });
|
|
293
|
+
} else {
|
|
294
|
+
this.heartbeatFailures++;
|
|
295
|
+
this.emit("heartbeat", { success: false, failures: this.heartbeatFailures });
|
|
296
|
+
|
|
297
|
+
if (this.heartbeatFailures >= this.heartbeatMaxFailures) {
|
|
298
|
+
this.emit("heartbeatReconnect", { failures: this.heartbeatFailures });
|
|
299
|
+
await this.reconnect();
|
|
300
|
+
return; // reconnect starts a new heartbeat loop
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
this.heartbeatFailures++;
|
|
305
|
+
this.emit("heartbeat", { success: false, failures: this.heartbeatFailures });
|
|
306
|
+
|
|
307
|
+
if (this.heartbeatFailures >= this.heartbeatMaxFailures) {
|
|
308
|
+
this.emit("heartbeatReconnect", { failures: this.heartbeatFailures });
|
|
309
|
+
await this.reconnect();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Schedule next heartbeat if still connected
|
|
315
|
+
if (this.state === "connected") {
|
|
316
|
+
this.scheduleNextHeartbeat();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Close the current connection and create a new one using stored options. */
|
|
321
|
+
private async reconnect(): Promise<void> {
|
|
322
|
+
if (this.isReconnecting || !this.seed) return;
|
|
323
|
+
this.isReconnecting = true;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
this.stopHeartbeat();
|
|
327
|
+
|
|
328
|
+
// Close existing client
|
|
329
|
+
if (this.client) {
|
|
330
|
+
try {
|
|
331
|
+
this.client.close();
|
|
332
|
+
} catch {
|
|
333
|
+
// ignore close errors
|
|
334
|
+
}
|
|
335
|
+
this.client = null;
|
|
336
|
+
}
|
|
337
|
+
this.address = undefined;
|
|
338
|
+
this.setState("connecting");
|
|
339
|
+
|
|
340
|
+
// Create a fresh client
|
|
341
|
+
this.client = new nkn.MultiClient({
|
|
342
|
+
seed: this.seed,
|
|
343
|
+
numSubClients: this.numSubClients,
|
|
344
|
+
originalClient: false,
|
|
345
|
+
rpcServerAddr: NKN_SEED_RPC_SERVERS[0],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await new Promise<void>((resolve, reject) => {
|
|
349
|
+
const timeout = setTimeout(() => {
|
|
350
|
+
reject(new Error("NKN reconnection timeout after 30s"));
|
|
351
|
+
}, 30_000);
|
|
352
|
+
|
|
353
|
+
if (this.abortSignal?.aborted) {
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
reject(new Error("Aborted"));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const onAbort = () => {
|
|
360
|
+
clearTimeout(timeout);
|
|
361
|
+
reject(new Error("Aborted"));
|
|
362
|
+
};
|
|
363
|
+
this.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
364
|
+
|
|
365
|
+
this.client!.onConnect(() => {
|
|
366
|
+
clearTimeout(timeout);
|
|
367
|
+
this.abortSignal?.removeEventListener("abort", onAbort);
|
|
368
|
+
resolve();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
this.address = this.client.addr;
|
|
373
|
+
this.setState("connected");
|
|
374
|
+
|
|
375
|
+
// Re-register message handler
|
|
376
|
+
this.client.onMessage(({ src, payload }: { src: string; payload: Uint8Array | string }) => {
|
|
377
|
+
let data: string;
|
|
378
|
+
if (payload instanceof Uint8Array) {
|
|
379
|
+
data = new TextDecoder().decode(payload);
|
|
380
|
+
} else {
|
|
381
|
+
data = payload;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (data.startsWith(HEARTBEAT_ECHO_PREFIX)) {
|
|
385
|
+
const id = data.slice(HEARTBEAT_ECHO_PREFIX.length);
|
|
386
|
+
if (id === this.pendingEchoId && this.pendingEchoResolve) {
|
|
387
|
+
this.pendingEchoResolve();
|
|
388
|
+
this.pendingEchoResolve = null;
|
|
389
|
+
this.pendingEchoId = null;
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.emit("message", src, data);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
this.heartbeatFailures = 0;
|
|
398
|
+
this.startHeartbeat();
|
|
399
|
+
} catch (err) {
|
|
400
|
+
this.setState("disconnected");
|
|
401
|
+
if (this.client) {
|
|
402
|
+
try {
|
|
403
|
+
this.client.close();
|
|
404
|
+
} catch {
|
|
405
|
+
// ignore
|
|
406
|
+
}
|
|
407
|
+
this.client = null;
|
|
408
|
+
}
|
|
409
|
+
this.emit("reconnectFailed", err);
|
|
410
|
+
} finally {
|
|
411
|
+
this.isReconnecting = false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
203
415
|
private ensureConnected(): void {
|
|
204
416
|
if (!this.client || this.state !== "connected") {
|
|
205
417
|
throw new Error("NKN client not connected");
|
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.
|