openclaw-channel-dmwork 0.5.9 → 0.5.11
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/actions.ts +7 -8
- package/src/api-fetch.test.ts +78 -0
- package/src/api-fetch.ts +40 -6
- package/src/channel.test.ts +53 -0
- package/src/channel.ts +32 -22
- package/src/inbound.test.ts +79 -0
- package/src/inbound.ts +133 -119
- package/src/mention-utils.test.ts +396 -5
- package/src/mention-utils.ts +295 -8
- package/src/socket.test.ts +222 -0
- package/src/socket.ts +15 -1
- package/src/types.ts +14 -0
package/package.json
CHANGED
package/src/actions.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
updateGroupMd,
|
|
17
17
|
} from "./api-fetch.js";
|
|
18
18
|
import { uploadAndSendMedia } from "./inbound.js";
|
|
19
|
-
import {
|
|
19
|
+
import { buildEntitiesFromFallback } from "./mention-utils.js";
|
|
20
|
+
import type { MentionEntity } from "./types.js";
|
|
20
21
|
import { getKnownGroupIds } from "./group-md.js";
|
|
21
22
|
|
|
22
23
|
export interface MessageActionResult {
|
|
@@ -164,15 +165,12 @@ async function handleSend(params: {
|
|
|
164
165
|
// Send text message
|
|
165
166
|
if (message) {
|
|
166
167
|
let mentionUids: string[] = [];
|
|
168
|
+
let mentionEntities: MentionEntity[] = [];
|
|
167
169
|
|
|
168
170
|
if (channelType === ChannelType.Group && memberMap) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (uid && !mentionUids.includes(uid)) {
|
|
173
|
-
mentionUids.push(uid);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
171
|
+
const { entities, uids } = buildEntitiesFromFallback(message, memberMap);
|
|
172
|
+
mentionUids = uids;
|
|
173
|
+
mentionEntities = entities;
|
|
176
174
|
}
|
|
177
175
|
|
|
178
176
|
await sendMessage({
|
|
@@ -182,6 +180,7 @@ async function handleSend(params: {
|
|
|
182
180
|
channelType,
|
|
183
181
|
content: message,
|
|
184
182
|
...(mentionUids.length > 0 ? { mentionUids } : {}),
|
|
183
|
+
...(mentionEntities.length > 0 ? { mentionEntities } : {}),
|
|
185
184
|
});
|
|
186
185
|
}
|
|
187
186
|
|
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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* These are used by inbound/outbound where the full DMWorkAPI class is not available.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { ChannelType, MessageType } from "./types.js";
|
|
6
|
+
import { ChannelType, MessageType, type MentionEntity } from "./types.js";
|
|
7
7
|
import path from "path";
|
|
8
8
|
import { open } from "node:fs/promises";
|
|
9
9
|
// @ts-ignore — cos-nodejs-sdk-v5 has incomplete TypeScript definitions
|
|
@@ -63,6 +63,7 @@ export async function sendMediaMessage(params: {
|
|
|
63
63
|
width?: number;
|
|
64
64
|
height?: number;
|
|
65
65
|
mentionUids?: string[];
|
|
66
|
+
mentionEntities?: MentionEntity[];
|
|
66
67
|
signal?: AbortSignal;
|
|
67
68
|
}): Promise<void> {
|
|
68
69
|
const payload: Record<string, unknown> = {
|
|
@@ -79,8 +80,18 @@ export async function sendMediaMessage(params: {
|
|
|
79
80
|
if (params.size != null) payload.size = params.size;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
if (
|
|
83
|
-
|
|
83
|
+
if (
|
|
84
|
+
(params.mentionUids && params.mentionUids.length > 0) ||
|
|
85
|
+
(params.mentionEntities && params.mentionEntities.length > 0)
|
|
86
|
+
) {
|
|
87
|
+
const mention: Record<string, unknown> = {};
|
|
88
|
+
if (params.mentionUids && params.mentionUids.length > 0) {
|
|
89
|
+
mention.uids = params.mentionUids;
|
|
90
|
+
}
|
|
91
|
+
if (params.mentionEntities && params.mentionEntities.length > 0) {
|
|
92
|
+
mention.entities = params.mentionEntities;
|
|
93
|
+
}
|
|
94
|
+
payload.mention = mention;
|
|
84
95
|
}
|
|
85
96
|
await postJson(params.apiUrl, params.botToken, "/v1/bot/sendMessage", {
|
|
86
97
|
channel_id: params.channelId,
|
|
@@ -103,11 +114,25 @@ export function inferContentType(filename: string): string {
|
|
|
103
114
|
".pdf": "application/pdf", ".zip": "application/zip",
|
|
104
115
|
".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
105
116
|
".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
106
|
-
".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",
|
|
107
121
|
};
|
|
108
122
|
return map[ext] ?? "application/octet-stream";
|
|
109
123
|
}
|
|
110
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
|
+
|
|
111
136
|
/**
|
|
112
137
|
* Parse image dimensions from buffer (PNG/JPEG/GIF/WebP).
|
|
113
138
|
* Lightweight — reads only the header bytes, no external dependencies.
|
|
@@ -169,6 +194,7 @@ export async function sendMessage(params: {
|
|
|
169
194
|
channelType: ChannelType;
|
|
170
195
|
content: string;
|
|
171
196
|
mentionUids?: string[];
|
|
197
|
+
mentionEntities?: MentionEntity[];
|
|
172
198
|
mentionAll?: boolean;
|
|
173
199
|
streamNo?: string;
|
|
174
200
|
replyMsgId?: string;
|
|
@@ -178,12 +204,19 @@ export async function sendMessage(params: {
|
|
|
178
204
|
type: MessageType.Text,
|
|
179
205
|
content: params.content,
|
|
180
206
|
};
|
|
181
|
-
// Add mention field if any UIDs specified or mentionAll
|
|
182
|
-
if (
|
|
207
|
+
// Add mention field if any UIDs specified, entities present, or mentionAll
|
|
208
|
+
if (
|
|
209
|
+
(params.mentionUids && params.mentionUids.length > 0) ||
|
|
210
|
+
(params.mentionEntities && params.mentionEntities.length > 0) ||
|
|
211
|
+
params.mentionAll
|
|
212
|
+
) {
|
|
183
213
|
const mention: Record<string, unknown> = {};
|
|
184
214
|
if (params.mentionUids && params.mentionUids.length > 0) {
|
|
185
215
|
mention.uids = params.mentionUids;
|
|
186
216
|
}
|
|
217
|
+
if (params.mentionEntities && params.mentionEntities.length > 0) {
|
|
218
|
+
mention.entities = params.mentionEntities;
|
|
219
|
+
}
|
|
187
220
|
if (params.mentionAll) {
|
|
188
221
|
mention.all = true;
|
|
189
222
|
}
|
|
@@ -558,6 +591,7 @@ export async function uploadFileToCOS(params: {
|
|
|
558
591
|
Region: params.region,
|
|
559
592
|
Key: params.key,
|
|
560
593
|
Body: params.fileBody,
|
|
594
|
+
ContentType: params.contentType,
|
|
561
595
|
};
|
|
562
596
|
if (params.fileSize != null) {
|
|
563
597
|
putParams.ContentLength = params.fileSize;
|
package/src/channel.test.ts
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
|
|
3
|
+
// ─── Token refresh cooldown tests ───────────────────────────────────────────
|
|
4
|
+
// These test the time-based cooldown pattern used in channel.ts onError handler
|
|
5
|
+
// to prevent token refresh storms.
|
|
6
|
+
|
|
7
|
+
describe("token refresh cooldown logic", () => {
|
|
8
|
+
it("should allow refresh when cooldown has elapsed", () => {
|
|
9
|
+
let lastTokenRefreshAt = 0;
|
|
10
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
13
|
+
expect(cooldownElapsed).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should block refresh within cooldown window", () => {
|
|
17
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
|
|
18
|
+
let lastTokenRefreshAt = Date.now(); // just refreshed
|
|
19
|
+
|
|
20
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
21
|
+
expect(cooldownElapsed).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should allow refresh after cooldown expires", () => {
|
|
25
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
|
|
26
|
+
// Simulate a refresh that happened 61 seconds ago
|
|
27
|
+
let lastTokenRefreshAt = Date.now() - 61_000;
|
|
28
|
+
|
|
29
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
30
|
+
expect(cooldownElapsed).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should keep cooldown active even after failed refresh (no reset)", () => {
|
|
34
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
|
|
35
|
+
let lastTokenRefreshAt = 0;
|
|
36
|
+
|
|
37
|
+
// Simulate a refresh attempt (set timestamp before trying)
|
|
38
|
+
lastTokenRefreshAt = Date.now();
|
|
39
|
+
|
|
40
|
+
// Simulate failure — in the old code, hasRefreshedToken was reset to false
|
|
41
|
+
// In the new code, lastTokenRefreshAt stays set (no reset in catch block)
|
|
42
|
+
// So subsequent attempts within cooldown should be blocked
|
|
43
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
44
|
+
expect(cooldownElapsed).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should apply stagger delay before reconnect", async () => {
|
|
48
|
+
// Verify the stagger delay pattern works
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
const staggerMs = Math.floor(Math.random() * 5000);
|
|
51
|
+
expect(staggerMs).toBeGreaterThanOrEqual(0);
|
|
52
|
+
expect(staggerMs).toBeLessThan(5000);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
3
56
|
/**
|
|
4
57
|
* Tests for channel.ts singleton timer behavior.
|
|
5
58
|
* Verifies that cleanup timer doesn't accumulate during hot reloads.
|
package/src/channel.ts
CHANGED
|
@@ -11,11 +11,12 @@ 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";
|
|
18
|
-
import {
|
|
18
|
+
import { buildEntitiesFromFallback } from "./mention-utils.js";
|
|
19
|
+
import type { MentionEntity } from "./types.js";
|
|
19
20
|
import { handleDmworkMessageAction, parseTarget } from "./actions.js";
|
|
20
21
|
import { createDmworkManagementTools } from "./agent-tools.js";
|
|
21
22
|
import { getOrCreateGroupMdCache, registerBotGroupIds, getKnownGroupIds } from "./group-md.js";
|
|
@@ -349,18 +350,20 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
349
350
|
|
|
350
351
|
const { channelId, channelType } = parseTarget(targetForParse, undefined, getKnownGroupIds());
|
|
351
352
|
|
|
353
|
+
let mentionEntities: MentionEntity[] = [];
|
|
354
|
+
|
|
352
355
|
if (channelType === ChannelType.Group) {
|
|
353
|
-
//
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
// Resolve @name to uid via memberMap (fixes name-as-uid bug)
|
|
357
|
+
const accountMemberMap = getOrCreateMemberMap(
|
|
358
|
+
ctx.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
359
|
+
);
|
|
360
|
+
const { entities, uids } = buildEntitiesFromFallback(content, accountMemberMap);
|
|
361
|
+
for (const uid of uids) {
|
|
362
|
+
if (!mentionUids.includes(uid)) {
|
|
363
|
+
mentionUids.push(uid);
|
|
359
364
|
}
|
|
360
365
|
}
|
|
361
|
-
|
|
362
|
-
console.log(`[dmwork] sending message with mentionUids: ${mentionUids.join(", ")}`);
|
|
363
|
-
}
|
|
366
|
+
mentionEntities = entities;
|
|
364
367
|
}
|
|
365
368
|
|
|
366
369
|
await sendMessage({
|
|
@@ -370,6 +373,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
370
373
|
channelType,
|
|
371
374
|
content,
|
|
372
375
|
...(mentionUids.length > 0 ? { mentionUids } : {}),
|
|
376
|
+
...(mentionEntities.length > 0 ? { mentionEntities } : {}),
|
|
373
377
|
});
|
|
374
378
|
|
|
375
379
|
return { channel: "dmwork", to: ctx.to, messageId: "" };
|
|
@@ -441,7 +445,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
441
445
|
tempPath = dl.tempPath;
|
|
442
446
|
localFilePath = dl.tempPath;
|
|
443
447
|
contentType = dl.contentType;
|
|
444
|
-
if (!contentType) contentType = inferContentType(filename);
|
|
448
|
+
if (!contentType || contentType === "application/octet-stream") contentType = inferContentType(filename);
|
|
445
449
|
const st = statSync(tempPath);
|
|
446
450
|
fileBody = createReadStream(tempPath);
|
|
447
451
|
fileSize = st.size;
|
|
@@ -465,7 +469,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
465
469
|
key: creds.key,
|
|
466
470
|
fileBody,
|
|
467
471
|
fileSize,
|
|
468
|
-
contentType,
|
|
472
|
+
contentType: ensureTextCharset(contentType),
|
|
469
473
|
cdnBaseUrl: creds.cdnBaseUrl,
|
|
470
474
|
});
|
|
471
475
|
|
|
@@ -661,8 +665,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
661
665
|
// 4d. Group cache timestamps — track when each group's members were last fetched
|
|
662
666
|
const groupCacheTimestamps = getOrCreateGroupCacheTimestamps(account.accountId);
|
|
663
667
|
|
|
664
|
-
// 5. Token refresh state —
|
|
665
|
-
let
|
|
668
|
+
// 5. Token refresh state — time-based cooldown to prevent refresh storms
|
|
669
|
+
let lastTokenRefreshAt = 0;
|
|
670
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000; // 60 seconds
|
|
666
671
|
let isRefreshingToken = false; // Guard against concurrent refreshes (#43)
|
|
667
672
|
|
|
668
673
|
// 5b. Heartbeat failure tracking — reconnect after consecutive failures (#42)
|
|
@@ -738,9 +743,6 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
738
743
|
log?.info?.(`dmwork: WebSocket connected to ${wsUrl}`);
|
|
739
744
|
statusSink({ lastError: null });
|
|
740
745
|
startHeartbeat();
|
|
741
|
-
// WS connected successfully = WuKongIM accepted the token
|
|
742
|
-
// Reset refresh flag so we can refresh again if kicked later (#92)
|
|
743
|
-
hasRefreshedToken = false;
|
|
744
746
|
},
|
|
745
747
|
|
|
746
748
|
onDisconnected: () => {
|
|
@@ -752,12 +754,14 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
752
754
|
log?.error?.(`dmwork: WebSocket error: ${err.message}`);
|
|
753
755
|
statusSink({ lastError: err.message });
|
|
754
756
|
|
|
755
|
-
// If kicked or connect failed, try refreshing the IM token
|
|
757
|
+
// If kicked or connect failed, try refreshing the IM token with a cooldown
|
|
758
|
+
// to prevent refresh storms (e.g. 9000+ refreshes across 11 bots).
|
|
756
759
|
// Use isRefreshingToken to prevent concurrent refresh attempts (#43)
|
|
757
|
-
|
|
760
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
761
|
+
if (cooldownElapsed && !isRefreshingToken && !stopped &&
|
|
758
762
|
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
759
763
|
isRefreshingToken = true;
|
|
760
|
-
|
|
764
|
+
lastTokenRefreshAt = Date.now();
|
|
761
765
|
log?.warn?.("dmwork: connection rejected — refreshing IM token...");
|
|
762
766
|
try {
|
|
763
767
|
const fresh = await registerBot({
|
|
@@ -769,10 +773,16 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
769
773
|
log?.info?.("dmwork: got fresh IM token, reconnecting WS...");
|
|
770
774
|
socket.disconnect();
|
|
771
775
|
socket.updateCredentials(fresh.robot_id, fresh.im_token);
|
|
776
|
+
// Stagger reconnect to avoid thundering herd when multiple bots
|
|
777
|
+
// refresh tokens simultaneously after server-wide token expiry
|
|
778
|
+
const staggerMs = Math.floor(Math.random() * 5000);
|
|
779
|
+
log?.info?.(`dmwork: staggering reconnect by ${staggerMs}ms`);
|
|
780
|
+
await new Promise(r => setTimeout(r, staggerMs));
|
|
781
|
+
if (stopped) return; // account was stopped during stagger delay
|
|
772
782
|
socket.connect();
|
|
773
783
|
} catch (refreshErr) {
|
|
774
784
|
log?.error?.(`dmwork: token refresh failed: ${String(refreshErr)}`);
|
|
775
|
-
|
|
785
|
+
// Keep cooldown active even on failure to prevent rapid retry hammering
|
|
776
786
|
} finally {
|
|
777
787
|
isRefreshingToken = false;
|
|
778
788
|
}
|
package/src/inbound.test.ts
CHANGED
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
downloadToTemp,
|
|
12
12
|
uploadAndSendMedia,
|
|
13
13
|
downloadMediaToLocal,
|
|
14
|
+
buildMemberListPrefix,
|
|
14
15
|
type ResolveFileResult,
|
|
15
16
|
} from "./inbound.js";
|
|
17
|
+
import { extractMentionUids } from "./mention-utils.js";
|
|
16
18
|
import { existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -786,3 +788,80 @@ describe("downloadMediaToLocal", () => {
|
|
|
786
788
|
tempFiles.push(result!);
|
|
787
789
|
});
|
|
788
790
|
});
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Tests for Bot @ detection with entities support.
|
|
794
|
+
*/
|
|
795
|
+
describe("Bot @ 检测(entities 支持)", () => {
|
|
796
|
+
it("应从 entities 检测 bot 被 @", () => {
|
|
797
|
+
const mention: MentionPayload = {
|
|
798
|
+
entities: [{ uid: "bot_uid", offset: 0, length: 4 }],
|
|
799
|
+
};
|
|
800
|
+
const mentionUids = extractMentionUids(mention);
|
|
801
|
+
expect(mentionUids.includes("bot_uid")).toBe(true);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("entities 无效时应从 uids 检测", () => {
|
|
805
|
+
const mention: MentionPayload = {
|
|
806
|
+
entities: [{} as any],
|
|
807
|
+
uids: ["bot_uid"],
|
|
808
|
+
};
|
|
809
|
+
const mentionUids = extractMentionUids(mention);
|
|
810
|
+
expect(mentionUids.includes("bot_uid")).toBe(true);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("buildMemberListPrefix", () => {
|
|
815
|
+
it("should return empty string for empty map", () => {
|
|
816
|
+
const map = new Map<string, string>();
|
|
817
|
+
expect(buildMemberListPrefix(map)).toBe("");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("should inject full member list when ≤ 10 members", () => {
|
|
821
|
+
const map = new Map<string, string>([
|
|
822
|
+
["uid_alice", "Alice"],
|
|
823
|
+
["uid_bob", "Bob"],
|
|
824
|
+
["uid_chen", "陈皮皮"],
|
|
825
|
+
]);
|
|
826
|
+
const result = buildMemberListPrefix(map);
|
|
827
|
+
expect(result).toContain("[Group Members]");
|
|
828
|
+
expect(result).toContain("Alice (uid_alice)");
|
|
829
|
+
expect(result).toContain("Bob (uid_bob)");
|
|
830
|
+
expect(result).toContain("陈皮皮 (uid_chen)");
|
|
831
|
+
expect(result).toContain("@[uid:displayName]");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("should inject full member list when exactly 10 members", () => {
|
|
835
|
+
const map = new Map<string, string>();
|
|
836
|
+
for (let i = 1; i <= 10; i++) {
|
|
837
|
+
map.set(`uid_${i}`, `User${i}`);
|
|
838
|
+
}
|
|
839
|
+
const result = buildMemberListPrefix(map);
|
|
840
|
+
expect(result).toContain("[Group Members]");
|
|
841
|
+
expect(result).toContain("User1 (uid_1)");
|
|
842
|
+
expect(result).toContain("User10 (uid_10)");
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("should inject hint message when > 10 members", () => {
|
|
846
|
+
const map = new Map<string, string>();
|
|
847
|
+
for (let i = 1; i <= 11; i++) {
|
|
848
|
+
map.set(`uid_${i}`, `User${i}`);
|
|
849
|
+
}
|
|
850
|
+
const result = buildMemberListPrefix(map);
|
|
851
|
+
expect(result).toContain("[Group Info]");
|
|
852
|
+
expect(result).toContain("11 members");
|
|
853
|
+
expect(result).toContain("group management tool");
|
|
854
|
+
expect(result).not.toContain("[Group Members]");
|
|
855
|
+
expect(result).not.toContain("User1 (uid_1)");
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("should inject hint message for large groups", () => {
|
|
859
|
+
const map = new Map<string, string>();
|
|
860
|
+
for (let i = 1; i <= 50; i++) {
|
|
861
|
+
map.set(`uid_${i}`, `User${i}`);
|
|
862
|
+
}
|
|
863
|
+
const result = buildMemberListPrefix(map);
|
|
864
|
+
expect(result).toContain("[Group Info]");
|
|
865
|
+
expect(result).toContain("50 members");
|
|
866
|
+
});
|
|
867
|
+
});
|