openclaw-channel-dmwork 0.5.10 → 0.5.12
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 +1 -1
- package/src/api-fetch.test.ts +78 -0
- package/src/api-fetch.ts +16 -1
- package/src/bug-repro.test.ts +390 -0
- package/src/channel.ts +46 -16
- package/src/inbound.ts +2 -2
- package/src/reconnect-fixes.test.ts +541 -0
- package/src/socket.test.ts +12 -12
- package/src/socket.ts +88 -2
package/package.json
CHANGED
package/src/api-fetch.test.ts
CHANGED
|
@@ -716,3 +716,81 @@ describe("sendMediaMessage", () => {
|
|
|
716
716
|
expect(payload.height).toBeUndefined();
|
|
717
717
|
});
|
|
718
718
|
});
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// ensureTextCharset
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
describe("ensureTextCharset", () => {
|
|
724
|
+
it("appends charset=utf-8 to text/plain", async () => {
|
|
725
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
726
|
+
expect(ensureTextCharset("text/plain")).toBe("text/plain; charset=utf-8");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("appends charset=utf-8 to text/markdown", async () => {
|
|
730
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
731
|
+
expect(ensureTextCharset("text/markdown")).toBe("text/markdown; charset=utf-8");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("appends charset=utf-8 to text/html", async () => {
|
|
735
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
736
|
+
expect(ensureTextCharset("text/html")).toBe("text/html; charset=utf-8");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("does not modify image/jpeg", async () => {
|
|
740
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
741
|
+
expect(ensureTextCharset("image/jpeg")).toBe("image/jpeg");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("does not double-add charset if already present", async () => {
|
|
745
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
746
|
+
expect(ensureTextCharset("text/plain; charset=utf-8")).toBe("text/plain; charset=utf-8");
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("does not override existing charset=gbk", async () => {
|
|
750
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
751
|
+
expect(ensureTextCharset("text/plain; charset=gbk")).toBe("text/plain; charset=gbk");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("does not modify application/json", async () => {
|
|
755
|
+
const { ensureTextCharset } = await import("./api-fetch.js");
|
|
756
|
+
expect(ensureTextCharset("application/json")).toBe("application/json");
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// uploadFileToCOS — putParams includes ContentType
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
describe("uploadFileToCOS putParams ContentType", () => {
|
|
764
|
+
it("passes ContentType to cos.putObject", async () => {
|
|
765
|
+
let capturedParams: any = null;
|
|
766
|
+
|
|
767
|
+
vi.resetModules();
|
|
768
|
+
|
|
769
|
+
// Mock cos-nodejs-sdk-v5 before importing api-fetch
|
|
770
|
+
vi.doMock("cos-nodejs-sdk-v5", () => {
|
|
771
|
+
return {
|
|
772
|
+
default: class FakeCOS {
|
|
773
|
+
putObject(params: any, cb: any) {
|
|
774
|
+
capturedParams = params;
|
|
775
|
+
cb(null, { Location: "bucket.cos.region.myqcloud.com/key" });
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const { uploadFileToCOS } = await import("./api-fetch.js");
|
|
782
|
+
await uploadFileToCOS({
|
|
783
|
+
credentials: { tmpSecretId: "id", tmpSecretKey: "key", sessionToken: "tok" },
|
|
784
|
+
startTime: 0,
|
|
785
|
+
expiredTime: 9999999999,
|
|
786
|
+
bucket: "test-bucket",
|
|
787
|
+
region: "ap-test",
|
|
788
|
+
key: "test/file.txt",
|
|
789
|
+
fileBody: Buffer.from("hello"),
|
|
790
|
+
contentType: "text/plain; charset=utf-8",
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
expect(capturedParams).not.toBeNull();
|
|
794
|
+
expect(capturedParams.ContentType).toBe("text/plain; charset=utf-8");
|
|
795
|
+
});
|
|
796
|
+
});
|
package/src/api-fetch.ts
CHANGED
|
@@ -114,11 +114,25 @@ export function inferContentType(filename: string): string {
|
|
|
114
114
|
".pdf": "application/pdf", ".zip": "application/zip",
|
|
115
115
|
".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
116
116
|
".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
117
|
-
".txt": "text/plain", ".
|
|
117
|
+
".txt": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown",
|
|
118
|
+
".csv": "text/csv", ".html": "text/html", ".htm": "text/html",
|
|
119
|
+
".css": "text/css", ".xml": "text/xml", ".yaml": "text/yaml", ".yml": "text/yaml",
|
|
120
|
+
".json": "application/json",
|
|
118
121
|
};
|
|
119
122
|
return map[ext] ?? "application/octet-stream";
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Ensure text/* content types include a charset parameter.
|
|
127
|
+
* If the content type starts with "text/" and has no charset, appends "; charset=utf-8".
|
|
128
|
+
*/
|
|
129
|
+
export function ensureTextCharset(contentType: string): string {
|
|
130
|
+
if (contentType.startsWith("text/") && !contentType.includes("charset")) {
|
|
131
|
+
return contentType + "; charset=utf-8";
|
|
132
|
+
}
|
|
133
|
+
return contentType;
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
/**
|
|
123
137
|
* Parse image dimensions from buffer (PNG/JPEG/GIF/WebP).
|
|
124
138
|
* Lightweight — reads only the header bytes, no external dependencies.
|
|
@@ -577,6 +591,7 @@ export async function uploadFileToCOS(params: {
|
|
|
577
591
|
Region: params.region,
|
|
578
592
|
Key: params.key,
|
|
579
593
|
Body: params.fileBody,
|
|
594
|
+
ContentType: params.contentType,
|
|
580
595
|
};
|
|
581
596
|
if (params.fileSize != null) {
|
|
582
597
|
putParams.ContentLength = params.fileSize;
|
|
@@ -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
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
resolveDmworkAccount,
|
|
12
12
|
type ResolvedDmworkAccount,
|
|
13
13
|
} from "./accounts.js";
|
|
14
|
-
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, fetchBotGroups, getGroupMd, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
14
|
+
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, ensureTextCharset, fetchBotGroups, getGroupMd, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
15
15
|
import { WKSocket } from "./socket.js";
|
|
16
16
|
import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
|
|
17
17
|
import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
|
|
@@ -445,7 +445,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
445
445
|
tempPath = dl.tempPath;
|
|
446
446
|
localFilePath = dl.tempPath;
|
|
447
447
|
contentType = dl.contentType;
|
|
448
|
-
if (!contentType) contentType = inferContentType(filename);
|
|
448
|
+
if (!contentType || contentType === "application/octet-stream") contentType = inferContentType(filename);
|
|
449
449
|
const st = statSync(tempPath);
|
|
450
450
|
fileBody = createReadStream(tempPath);
|
|
451
451
|
fileSize = st.size;
|
|
@@ -469,7 +469,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
469
469
|
key: creds.key,
|
|
470
470
|
fileBody,
|
|
471
471
|
fileSize,
|
|
472
|
-
contentType,
|
|
472
|
+
contentType: ensureTextCharset(contentType),
|
|
473
473
|
cdnBaseUrl: creds.cdnBaseUrl,
|
|
474
474
|
});
|
|
475
475
|
|
|
@@ -640,13 +640,18 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
640
640
|
botToken: account.config.botToken!,
|
|
641
641
|
}).then(() => {
|
|
642
642
|
consecutiveHeartbeatFailures = 0; // Reset on success
|
|
643
|
-
}).catch((err) => {
|
|
643
|
+
}).catch(async (err) => {
|
|
644
644
|
consecutiveHeartbeatFailures++;
|
|
645
|
-
log?.error?.(`dmwork: heartbeat failed (${consecutiveHeartbeatFailures}/${MAX_HEARTBEAT_FAILURES}): ${String(err)}`);
|
|
645
|
+
log?.error?.(`dmwork: [${account.accountId}] heartbeat failed (${consecutiveHeartbeatFailures}/${MAX_HEARTBEAT_FAILURES}): ${String(err)}`);
|
|
646
646
|
if (consecutiveHeartbeatFailures >= MAX_HEARTBEAT_FAILURES && !stopped) {
|
|
647
|
-
log?.warn?.(
|
|
647
|
+
log?.warn?.(`dmwork: [${account.accountId}] too many heartbeat failures, triggering reconnect...`);
|
|
648
648
|
consecutiveHeartbeatFailures = 0;
|
|
649
|
-
|
|
649
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
650
|
+
const backoffMs = 3000 + Math.floor(Math.random() * 2000);
|
|
651
|
+
await new Promise(r => setTimeout(r, backoffMs));
|
|
652
|
+
if (stopped) return;
|
|
653
|
+
await socket.disconnectAndWait();
|
|
654
|
+
socket.stopReconnectTimer();
|
|
650
655
|
socket.connect();
|
|
651
656
|
}
|
|
652
657
|
});
|
|
@@ -670,7 +675,10 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
670
675
|
const TOKEN_REFRESH_COOLDOWN_MS = 60_000; // 60 seconds
|
|
671
676
|
let isRefreshingToken = false; // Guard against concurrent refreshes (#43)
|
|
672
677
|
|
|
673
|
-
// 5b.
|
|
678
|
+
// 5b. Cooldown reconnect timer — deduplicate to prevent self-kick storms (#139)
|
|
679
|
+
let cooldownReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
680
|
+
|
|
681
|
+
// 5c. Heartbeat failure tracking — reconnect after consecutive failures (#42)
|
|
674
682
|
let consecutiveHeartbeatFailures = 0;
|
|
675
683
|
const MAX_HEARTBEAT_FAILURES = 3;
|
|
676
684
|
|
|
@@ -740,18 +748,20 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
740
748
|
},
|
|
741
749
|
|
|
742
750
|
onConnected: () => {
|
|
743
|
-
log?.info?.(`dmwork: WebSocket connected to ${wsUrl}`);
|
|
751
|
+
log?.info?.(`dmwork: [${account.accountId}] WebSocket connected to ${wsUrl}`);
|
|
744
752
|
statusSink({ lastError: null });
|
|
753
|
+
consecutiveHeartbeatFailures = 0;
|
|
745
754
|
startHeartbeat();
|
|
746
755
|
},
|
|
747
756
|
|
|
748
757
|
onDisconnected: () => {
|
|
749
|
-
log?.warn?.(
|
|
758
|
+
log?.warn?.(`dmwork: [${account.accountId}] WebSocket disconnected, will reconnect...`);
|
|
759
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
750
760
|
statusSink({ lastError: "disconnected" });
|
|
751
761
|
},
|
|
752
762
|
|
|
753
763
|
onError: async (err: Error) => {
|
|
754
|
-
log?.error?.(`dmwork: WebSocket error: ${err.message}`);
|
|
764
|
+
log?.error?.(`dmwork: [${account.accountId}] WebSocket error: ${err.message}`);
|
|
755
765
|
statusSink({ lastError: err.message });
|
|
756
766
|
|
|
757
767
|
// If kicked or connect failed, try refreshing the IM token with a cooldown
|
|
@@ -762,30 +772,49 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
762
772
|
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
763
773
|
isRefreshingToken = true;
|
|
764
774
|
lastTokenRefreshAt = Date.now();
|
|
765
|
-
log?.warn?.(
|
|
775
|
+
log?.warn?.(`dmwork: [${account.accountId}] connection rejected — refreshing IM token...`);
|
|
766
776
|
try {
|
|
777
|
+
await socket.disconnectAndWait();
|
|
767
778
|
const fresh = await registerBot({
|
|
768
779
|
apiUrl: account.config.apiUrl,
|
|
769
780
|
botToken: account.config.botToken!,
|
|
770
781
|
forceRefresh: true,
|
|
771
782
|
});
|
|
772
783
|
credentials = fresh;
|
|
773
|
-
log?.info?.(
|
|
774
|
-
socket.disconnect();
|
|
784
|
+
log?.info?.(`dmwork: [${account.accountId}] got fresh IM token, reconnecting WS...`);
|
|
775
785
|
socket.updateCredentials(fresh.robot_id, fresh.im_token);
|
|
776
786
|
// Stagger reconnect to avoid thundering herd when multiple bots
|
|
777
787
|
// refresh tokens simultaneously after server-wide token expiry
|
|
778
788
|
const staggerMs = Math.floor(Math.random() * 5000);
|
|
779
|
-
log?.info?.(`dmwork: staggering reconnect by ${staggerMs}ms`);
|
|
789
|
+
log?.info?.(`dmwork: [${account.accountId}] staggering reconnect by ${staggerMs}ms`);
|
|
780
790
|
await new Promise(r => setTimeout(r, staggerMs));
|
|
781
791
|
if (stopped) return; // account was stopped during stagger delay
|
|
782
792
|
socket.connect();
|
|
783
793
|
} catch (refreshErr) {
|
|
784
|
-
log?.error?.(`dmwork: token refresh failed: ${String(refreshErr)}`);
|
|
794
|
+
log?.error?.(`dmwork: [${account.accountId}] token refresh failed: ${String(refreshErr)}`);
|
|
785
795
|
// Keep cooldown active even on failure to prevent rapid retry hammering
|
|
786
796
|
} finally {
|
|
787
797
|
isRefreshingToken = false;
|
|
788
798
|
}
|
|
799
|
+
} else if (!isRefreshingToken && !stopped &&
|
|
800
|
+
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
801
|
+
// Cooldown active — skip token refresh but still reconnect with current credentials.
|
|
802
|
+
// Deduplicate: clear any pending cooldown reconnect timer to prevent self-kick storms
|
|
803
|
+
// where multiple setTimeout callbacks fire simultaneously, each calling connect(),
|
|
804
|
+
// causing the same bot to have multiple WS connections that kick each other (#139).
|
|
805
|
+
if (cooldownReconnectTimer) {
|
|
806
|
+
clearTimeout(cooldownReconnectTimer);
|
|
807
|
+
}
|
|
808
|
+
log?.warn?.(`dmwork: [${account.accountId}] cooldown active, scheduling reconnect with current credentials...`);
|
|
809
|
+
const backoffMs = 5000 + Math.floor(Math.random() * 5000);
|
|
810
|
+
cooldownReconnectTimer = setTimeout(async () => {
|
|
811
|
+
cooldownReconnectTimer = null;
|
|
812
|
+
if (!stopped) {
|
|
813
|
+
await socket.disconnectAndWait();
|
|
814
|
+
socket.stopReconnectTimer();
|
|
815
|
+
socket.connect();
|
|
816
|
+
}
|
|
817
|
+
}, backoffMs);
|
|
789
818
|
}
|
|
790
819
|
},
|
|
791
820
|
});
|
|
@@ -799,6 +828,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
799
828
|
stopped = true;
|
|
800
829
|
socket.disconnect();
|
|
801
830
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
831
|
+
if (cooldownReconnectTimer) { clearTimeout(cooldownReconnectTimer); cooldownReconnectTimer = null; }
|
|
802
832
|
ctx.setStatus({
|
|
803
833
|
accountId: account.accountId,
|
|
804
834
|
running: false,
|
package/src/inbound.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
2
|
+
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
3
3
|
import type { ResolvedDmworkAccount } from "./accounts.js";
|
|
4
4
|
import type { BotMessage } from "./types.js";
|
|
5
5
|
import { ChannelType, MessageType } from "./types.js";
|
|
@@ -160,7 +160,7 @@ export async function uploadAndSendMedia(params: {
|
|
|
160
160
|
key: creds.key,
|
|
161
161
|
fileBody,
|
|
162
162
|
fileSize,
|
|
163
|
-
contentType,
|
|
163
|
+
contentType: ensureTextCharset(contentType),
|
|
164
164
|
cdnBaseUrl: creds.cdnBaseUrl,
|
|
165
165
|
});
|
|
166
166
|
|