openclaw-channel-dmwork 0.5.11 → 0.5.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -794,3 +794,83 @@ describe("uploadFileToCOS putParams ContentType", () => {
794
794
  expect(capturedParams.ContentType).toBe("text/plain; charset=utf-8");
795
795
  });
796
796
  });
797
+
798
+ // --- fetchUserInfo ---
799
+ import { fetchUserInfo } from "./api-fetch.js";
800
+
801
+ describe("fetchUserInfo", () => {
802
+ it("returns user info on success", async () => {
803
+ globalThis.fetch = vi.fn().mockResolvedValue({
804
+ ok: true,
805
+ status: 200,
806
+ json: () => Promise.resolve({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" }),
807
+ }) as any;
808
+
809
+ const result = await fetchUserInfo({
810
+ apiUrl: "http://localhost:8090",
811
+ botToken: "tok",
812
+ uid: "s14_abc",
813
+ });
814
+ expect(result).toEqual({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" });
815
+ expect(globalThis.fetch).toHaveBeenCalledWith(
816
+ "http://localhost:8090/v1/bot/user/info?uid=s14_abc",
817
+ expect.objectContaining({ method: "GET" }),
818
+ );
819
+ });
820
+
821
+ it("returns null on 404 (endpoint not implemented)", async () => {
822
+ globalThis.fetch = vi.fn().mockResolvedValue({
823
+ ok: false,
824
+ status: 404,
825
+ }) as any;
826
+
827
+ const result = await fetchUserInfo({
828
+ apiUrl: "http://localhost:8090",
829
+ botToken: "tok",
830
+ uid: "s14_abc",
831
+ });
832
+ expect(result).toBeNull();
833
+ });
834
+
835
+ it("returns null on 500 error", async () => {
836
+ globalThis.fetch = vi.fn().mockResolvedValue({
837
+ ok: false,
838
+ status: 500,
839
+ }) as any;
840
+
841
+ const result = await fetchUserInfo({
842
+ apiUrl: "http://localhost:8090",
843
+ botToken: "tok",
844
+ uid: "s14_abc",
845
+ log: { error: vi.fn() },
846
+ });
847
+ expect(result).toBeNull();
848
+ });
849
+
850
+ it("returns null on network error", async () => {
851
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as any;
852
+
853
+ const result = await fetchUserInfo({
854
+ apiUrl: "http://localhost:8090",
855
+ botToken: "tok",
856
+ uid: "s14_abc",
857
+ log: { error: vi.fn() },
858
+ });
859
+ expect(result).toBeNull();
860
+ });
861
+
862
+ it("returns null when response has no name", async () => {
863
+ globalThis.fetch = vi.fn().mockResolvedValue({
864
+ ok: true,
865
+ status: 200,
866
+ json: () => Promise.resolve({ uid: "s14_abc" }),
867
+ }) as any;
868
+
869
+ const result = await fetchUserInfo({
870
+ apiUrl: "http://localhost:8090",
871
+ botToken: "tok",
872
+ uid: "s14_abc",
873
+ });
874
+ expect(result).toBeNull();
875
+ });
876
+ });
package/src/api-fetch.ts CHANGED
@@ -649,3 +649,40 @@ export async function editMessage(params: {
649
649
  content_edit: params.contentEdit,
650
650
  }, params.signal);
651
651
  }
652
+
653
+ /**
654
+ * Fetch user info by UID. Requires backend `/v1/bot/user/info` endpoint.
655
+ * Returns null if the endpoint is unavailable (404) or returns an error,
656
+ * so callers can gracefully degrade.
657
+ */
658
+ export async function fetchUserInfo(params: {
659
+ apiUrl: string;
660
+ botToken: string;
661
+ uid: string;
662
+ log?: { info?: (msg: string) => void; error?: (msg: string) => void };
663
+ }): Promise<{ uid: string; name: string; avatar?: string } | null> {
664
+ const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/user/info?uid=${encodeURIComponent(params.uid)}`;
665
+ try {
666
+ const resp = await fetch(url, {
667
+ method: "GET",
668
+ headers: { Authorization: `Bearer ${params.botToken}` },
669
+ signal: AbortSignal.timeout(5000),
670
+ });
671
+ if (resp.status === 404) {
672
+ // Endpoint not implemented yet — silent degrade
673
+ return null;
674
+ }
675
+ if (!resp.ok) {
676
+ params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) failed: ${resp.status}`);
677
+ return null;
678
+ }
679
+ const data = await resp.json() as { uid?: string; name?: string; avatar?: string };
680
+ if (data?.name) {
681
+ return { uid: data.uid ?? params.uid, name: data.name, avatar: data.avatar };
682
+ }
683
+ return null;
684
+ } catch (err) {
685
+ params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) error: ${String(err)}`);
686
+ return null;
687
+ }
688
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Bug fix verification tests for WebSocket reconnect v2 (Issue #139)
3
+ *
4
+ * These tests verify that three bugs have been FIXED:
5
+ * Bug A: Exponential backoff now works (reconnectAttempts not reset prematurely)
6
+ * Bug B: Kicked bots reconnect even during cooldown window
7
+ * Bug C: Rapid silent disconnects now trigger onError for token refresh
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+
11
+ // Track mock WS instances created during tests
12
+ const mockWsInstances: any[] = [];
13
+
14
+ vi.mock("ws", () => {
15
+ class MockWS {
16
+ static OPEN = 1;
17
+ binaryType = "arraybuffer";
18
+ readyState = 1;
19
+ private handlers = new Map<string, Function[]>();
20
+
21
+ constructor(public url: string) {
22
+ (globalThis as any).__mockWsInstances?.push(this);
23
+ }
24
+
25
+ on(event: string, handler: Function) {
26
+ if (!this.handlers.has(event)) this.handlers.set(event, []);
27
+ this.handlers.get(event)!.push(handler);
28
+ }
29
+
30
+ send = vi.fn();
31
+
32
+ close() {
33
+ this.readyState = 3; // CLOSED
34
+ queueMicrotask(() => this.emit("close"));
35
+ }
36
+
37
+ emit(event: string, ...args: any[]) {
38
+ const handlers = this.handlers.get(event);
39
+ if (handlers) {
40
+ for (const h of handlers) h(...args);
41
+ }
42
+ }
43
+ }
44
+
45
+ return { default: MockWS, WebSocket: MockWS };
46
+ });
47
+
48
+ vi.mock("curve25519-js", () => ({
49
+ generateKeyPair: () => ({
50
+ private: new Uint8Array(32),
51
+ public: new Uint8Array(32),
52
+ }),
53
+ sharedKey: () => new Uint8Array(32),
54
+ }));
55
+
56
+ import { WKSocket } from "./socket.js";
57
+
58
+ // ─── Helpers ────────────────────────────────────────────────────────────────
59
+
60
+ /** Build a minimal CONNACK packet with reasonCode=1 (success) */
61
+ function buildConnackSuccess(): ArrayBuffer {
62
+ const serverVersion = 4;
63
+ const reasonCode = 1; // success
64
+ const serverKey = Buffer.from(new Uint8Array(32)).toString("base64");
65
+ const salt = "1234567890123456";
66
+
67
+ const body: number[] = [];
68
+ body.push(serverVersion);
69
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
70
+ body.push(reasonCode);
71
+ const keyBytes = [...Buffer.from(serverKey)];
72
+ body.push((keyBytes.length >> 8) & 0xff, keyBytes.length & 0xff);
73
+ body.push(...keyBytes);
74
+ const saltBytes = [...Buffer.from(salt)];
75
+ body.push((saltBytes.length >> 8) & 0xff, saltBytes.length & 0xff);
76
+ body.push(...saltBytes);
77
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
78
+
79
+ const header = (2 << 4) | 1; // CONNACK with hasServerVersion
80
+ const packet = new Uint8Array([header, body.length, ...body]);
81
+ return packet.buffer;
82
+ }
83
+
84
+ /** Build a CONNACK packet with reasonCode=0 (kicked) */
85
+ function buildConnackKicked(): ArrayBuffer {
86
+ const serverVersion = 4;
87
+ const reasonCode = 0; // kicked
88
+
89
+ const body: number[] = [];
90
+ body.push(serverVersion);
91
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
92
+ body.push(reasonCode);
93
+ // serverKey (empty)
94
+ body.push(0, 0);
95
+ // salt (empty)
96
+ body.push(0, 0);
97
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
98
+
99
+ const header = (2 << 4) | 1;
100
+ const packet = new Uint8Array([header, body.length, ...body]);
101
+ return packet.buffer;
102
+ }
103
+
104
+ // ─── Test Suites ────────────────────────────────────────────────────────────
105
+
106
+ describe("Bug A fix: Exponential backoff grows correctly", () => {
107
+ let setTimeoutCalls: { fn: Function; delay: number }[];
108
+ let originalSetTimeout: typeof setTimeout;
109
+ let dateNowSpy: ReturnType<typeof vi.spyOn>;
110
+
111
+ beforeEach(() => {
112
+ mockWsInstances.length = 0;
113
+ (globalThis as any).__mockWsInstances = mockWsInstances;
114
+ setTimeoutCalls = [];
115
+ originalSetTimeout = global.setTimeout;
116
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
117
+ setTimeoutCalls.push({ fn, delay: delay ?? 0 });
118
+ return 999 as any;
119
+ }) as any;
120
+ });
121
+
122
+ afterEach(() => {
123
+ global.setTimeout = originalSetTimeout;
124
+ dateNowSpy?.mockRestore();
125
+ delete (globalThis as any).__mockWsInstances;
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it("delays should GROW after 5 connect→CONNACK→close cycles", () => {
130
+ /**
131
+ * With the fix, reconnectAttempts only resets after the 30s stable timer
132
+ * fires (which requires the connection to stay up for 30s). Connections
133
+ * that live <30s keep the backoff counter, so delays grow exponentially.
134
+ *
135
+ * We mock Date.now so each connection appears to last 6s (>5s threshold)
136
+ * to isolate Bug A from Bug C's rapid-disconnect detection.
137
+ */
138
+ let now = 10000;
139
+ dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
140
+
141
+ const socket = new WKSocket({
142
+ wsUrl: "ws://test:5200",
143
+ uid: "bot1",
144
+ token: "tok1",
145
+ onMessage: vi.fn(),
146
+ onConnected: vi.fn(),
147
+ onDisconnected: vi.fn(),
148
+ });
149
+
150
+ const reconnectDelays: number[] = [];
151
+
152
+ for (let cycle = 0; cycle < 5; cycle++) {
153
+ socket.connect();
154
+ const ws = mockWsInstances[mockWsInstances.length - 1];
155
+ ws.emit("open");
156
+
157
+ // CONNACK success → sets lastConnectTime = now, starts stable timer
158
+ ws.emit("message", buildConnackSuccess());
159
+
160
+ // Advance time by 6s (above 5s rapid-disconnect threshold,
161
+ // but well below the 30s stable timer)
162
+ now += 6000;
163
+
164
+ setTimeoutCalls = [];
165
+ ws.emit("close");
166
+
167
+ // The only setTimeout from the close handler is scheduleReconnect
168
+ if (setTimeoutCalls.length > 0) {
169
+ reconnectDelays.push(setTimeoutCalls[0].delay);
170
+ }
171
+ }
172
+
173
+ // All 5 cycles should have produced reconnect delays
174
+ expect(reconnectDelays).toHaveLength(5);
175
+
176
+ // Delays should strictly increase (exponential backoff working)
177
+ for (let i = 1; i < reconnectDelays.length; i++) {
178
+ expect(reconnectDelays[i]).toBeGreaterThan(reconnectDelays[i - 1]);
179
+ }
180
+
181
+ // First delay ~3000ms (base), with ±25% jitter → [2250, 3750]
182
+ expect(reconnectDelays[0]).toBeGreaterThanOrEqual(2250);
183
+ expect(reconnectDelays[0]).toBeLessThanOrEqual(4500);
184
+
185
+ // Last delay should be much larger (~48000ms at attempt 4)
186
+ expect(reconnectDelays[4]).toBeGreaterThan(20000);
187
+
188
+ socket.disconnect();
189
+ });
190
+ });
191
+
192
+
193
+ describe("Bug B fix: Kicked during cooldown still reconnects", () => {
194
+ it("channel.ts onError handler reconnects even when cooldown is active", () => {
195
+ /**
196
+ * With the fix, the else branch in channel.ts onError reconnects the
197
+ * bot with current credentials when cooldown is active, instead of
198
+ * doing nothing and letting the bot die permanently.
199
+ */
200
+ let lastTokenRefreshAt = 0;
201
+ const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
202
+ let isRefreshingToken = false;
203
+ let stopped = false;
204
+ let tokenRefreshCount = 0;
205
+ let reconnectCount = 0;
206
+
207
+ // Simulate the FIXED onError handler from channel.ts
208
+ function onError(err: Error) {
209
+ const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
210
+ if (cooldownElapsed && !isRefreshingToken && !stopped &&
211
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
212
+ isRefreshingToken = true;
213
+ lastTokenRefreshAt = Date.now();
214
+ tokenRefreshCount++;
215
+ // Would do: socket.disconnect() + socket.updateCredentials() + socket.connect()
216
+ reconnectCount++;
217
+ isRefreshingToken = false;
218
+ } else if (!isRefreshingToken && !stopped &&
219
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
220
+ // FIX: Cooldown active — skip token refresh but still reconnect
221
+ // Would do: socket.disconnect() + socket.connect()
222
+ reconnectCount++;
223
+ }
224
+ }
225
+
226
+ // First kick — cooldown not active yet, full refresh happens
227
+ onError(new Error("Kicked by server"));
228
+ expect(tokenRefreshCount).toBe(1);
229
+ expect(reconnectCount).toBe(1);
230
+
231
+ // Second kick — within 60s cooldown
232
+ onError(new Error("Kicked by server"));
233
+
234
+ // FIX VERIFIED: reconnect still happens (via else branch), just no token refresh
235
+ expect(tokenRefreshCount).toBe(1); // no new refresh (cooldown active)
236
+ expect(reconnectCount).toBe(2); // reconnect DID happen!
237
+ });
238
+
239
+ it("socket reconnects after channel.ts calls disconnect+connect on kick", () => {
240
+ /**
241
+ * When CONNACK returns kicked (reasonCode=0), needReconnect is set to false
242
+ * and the close event won't trigger scheduleReconnect. But channel.ts can
243
+ * call socket.disconnect() + socket.connect() to revive the bot.
244
+ */
245
+ const originalSetTimeout = global.setTimeout;
246
+ const setTimeoutCalls: { fn: Function; delay: number }[] = [];
247
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
248
+ setTimeoutCalls.push({ fn, delay: delay ?? 0 });
249
+ return 999 as any;
250
+ }) as any;
251
+
252
+ mockWsInstances.length = 0;
253
+ (globalThis as any).__mockWsInstances = mockWsInstances;
254
+
255
+ const onError = vi.fn();
256
+ const socket = new WKSocket({
257
+ wsUrl: "ws://test:5200",
258
+ uid: "bot1",
259
+ token: "tok1",
260
+ onMessage: vi.fn(),
261
+ onError,
262
+ });
263
+
264
+ // Initial connection → kicked
265
+ socket.connect();
266
+ const ws1 = mockWsInstances[0];
267
+ ws1.emit("open");
268
+ ws1.emit("message", buildConnackKicked());
269
+
270
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
271
+ message: "Kicked by server",
272
+ }));
273
+
274
+ // needReconnect is false after kick, close won't trigger scheduleReconnect
275
+ setTimeoutCalls.length = 0;
276
+ ws1.emit("close");
277
+ expect(setTimeoutCalls.length).toBe(0);
278
+
279
+ // FIX: channel.ts calls disconnect + connect → bot reconnects
280
+ socket.disconnect();
281
+ socket.connect();
282
+
283
+ // Verify a new WebSocket was created (bot is alive!)
284
+ expect(mockWsInstances.length).toBeGreaterThan(1);
285
+ const ws2 = mockWsInstances[mockWsInstances.length - 1];
286
+ expect(ws2).not.toBe(ws1);
287
+
288
+ socket.disconnect();
289
+ global.setTimeout = originalSetTimeout;
290
+ delete (globalThis as any).__mockWsInstances;
291
+ vi.restoreAllMocks();
292
+ });
293
+ });
294
+
295
+
296
+ describe("Bug C fix: Rapid silent disconnects trigger onError", () => {
297
+ it("onError fires after 3 consecutive rapid disconnects", () => {
298
+ /**
299
+ * With the fix, the close handler tracks rapid disconnects (connections
300
+ * lasting <5s). After 3 consecutive rapid disconnects, it calls onError
301
+ * with "Connect failed: rapid disconnect detected" so channel.ts can
302
+ * refresh the token.
303
+ */
304
+ const originalSetTimeout = global.setTimeout;
305
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
306
+ return 999 as any;
307
+ }) as any;
308
+
309
+ mockWsInstances.length = 0;
310
+ (globalThis as any).__mockWsInstances = mockWsInstances;
311
+
312
+ const onError = vi.fn();
313
+ const socket = new WKSocket({
314
+ wsUrl: "ws://test:5200",
315
+ uid: "bot1",
316
+ token: "tok1",
317
+ onMessage: vi.fn(),
318
+ onError,
319
+ onConnected: vi.fn(),
320
+ onDisconnected: vi.fn(),
321
+ });
322
+
323
+ // Simulate 3 rapid connect→CONNACK→immediate-close cycles
324
+ // (connection lasting <5s each time, simulating server restart)
325
+ for (let i = 0; i < 3; i++) {
326
+ socket.connect();
327
+ const ws = mockWsInstances[mockWsInstances.length - 1];
328
+ ws.emit("open");
329
+ ws.emit("message", buildConnackSuccess());
330
+ // Connection closes immediately (server restart, no Kicked packet)
331
+ ws.emit("close");
332
+ }
333
+
334
+ // FIX VERIFIED: onError IS called after 3 rapid disconnects
335
+ expect(onError).toHaveBeenCalledTimes(1);
336
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
337
+ message: "Connect failed: rapid disconnect detected",
338
+ }));
339
+
340
+ socket.disconnect();
341
+ global.setTimeout = originalSetTimeout;
342
+ delete (globalThis as any).__mockWsInstances;
343
+ vi.restoreAllMocks();
344
+ });
345
+
346
+ it("rapid-disconnect loop with stale token triggers onError for refresh", () => {
347
+ /**
348
+ * Simulates the production scenario: server restarts, bot reconnects with
349
+ * stale token, server silently closes again. After 3 such cycles, onError
350
+ * fires so channel.ts can refresh the token.
351
+ */
352
+ const originalSetTimeout = global.setTimeout;
353
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
354
+ return 999 as any;
355
+ }) as any;
356
+
357
+ mockWsInstances.length = 0;
358
+ (globalThis as any).__mockWsInstances = mockWsInstances;
359
+
360
+ const onError = vi.fn();
361
+ const socket = new WKSocket({
362
+ wsUrl: "ws://test:5200",
363
+ uid: "bot1",
364
+ token: "stale-token",
365
+ onMessage: vi.fn(),
366
+ onError,
367
+ });
368
+
369
+ // 3 rapid connect→close cycles (simulating stale token scenario)
370
+ for (let i = 0; i < 3; i++) {
371
+ socket.connect();
372
+ const ws = mockWsInstances[mockWsInstances.length - 1];
373
+ ws.emit("open");
374
+ ws.emit("message", buildConnackSuccess());
375
+ ws.emit("close");
376
+ }
377
+
378
+ // FIX VERIFIED: After 3 short-lived connections, onError fires
379
+ // This allows channel.ts to refresh the token
380
+ expect(onError).toHaveBeenCalledTimes(1);
381
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
382
+ message: "Connect failed: rapid disconnect detected",
383
+ }));
384
+
385
+ socket.disconnect();
386
+ global.setTimeout = originalSetTimeout;
387
+ delete (globalThis as any).__mockWsInstances;
388
+ vi.restoreAllMocks();
389
+ });
390
+ });