@zbruceli/openclaw-dchat 0.2.0 → 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 +3 -1
- package/package.json +1 -1
- package/src/channel.ts +34 -0
- package/src/nkn-bus.ts +213 -1
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -438,6 +438,39 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
438
438
|
const seenTracker = new SeenTracker();
|
|
439
439
|
seenMap.set(account.accountId, seenTracker);
|
|
440
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
|
+
|
|
441
474
|
try {
|
|
442
475
|
const address = await bus.connect(
|
|
443
476
|
{ seed: account.seed, numSubClients: account.numSubClients },
|
|
@@ -453,6 +486,7 @@ export const dchatPlugin: ChannelPlugin<ResolvedDchatAccount> = {
|
|
|
453
486
|
lastConnectedAt: Date.now(),
|
|
454
487
|
});
|
|
455
488
|
logger.info(`[${account.accountId}] connected as ${address}`);
|
|
489
|
+
initialConnectDone = true;
|
|
456
490
|
|
|
457
491
|
// Register inbound message handler
|
|
458
492
|
bus.onMessage((rawSrc, rawPayload) => {
|
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");
|