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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -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", ".json": "application/json",
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?.("dmwork: too many heartbeat failures, triggering reconnect...");
647
+ log?.warn?.(`dmwork: [${account.accountId}] too many heartbeat failures, triggering reconnect...`);
648
648
  consecutiveHeartbeatFailures = 0;
649
- socket.disconnect();
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. Heartbeat failure trackingreconnect after consecutive failures (#42)
678
+ // 5b. Cooldown reconnect timerdeduplicate 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?.("dmwork: WebSocket disconnected, will reconnect...");
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?.("dmwork: connection rejected — refreshing IM token...");
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?.("dmwork: got fresh IM token, reconnecting WS...");
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