@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/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 = "![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.