openclaw-channel-dmwork 0.5.9 → 0.5.10
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.ts +24 -5
- package/src/channel.test.ts +53 -0
- package/src/channel.ts +29 -19
- package/src/inbound.test.ts +79 -0
- package/src/inbound.ts +131 -117
- 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.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,
|
|
@@ -169,6 +180,7 @@ export async function sendMessage(params: {
|
|
|
169
180
|
channelType: ChannelType;
|
|
170
181
|
content: string;
|
|
171
182
|
mentionUids?: string[];
|
|
183
|
+
mentionEntities?: MentionEntity[];
|
|
172
184
|
mentionAll?: boolean;
|
|
173
185
|
streamNo?: string;
|
|
174
186
|
replyMsgId?: string;
|
|
@@ -178,12 +190,19 @@ export async function sendMessage(params: {
|
|
|
178
190
|
type: MessageType.Text,
|
|
179
191
|
content: params.content,
|
|
180
192
|
};
|
|
181
|
-
// Add mention field if any UIDs specified or mentionAll
|
|
182
|
-
if (
|
|
193
|
+
// Add mention field if any UIDs specified, entities present, or mentionAll
|
|
194
|
+
if (
|
|
195
|
+
(params.mentionUids && params.mentionUids.length > 0) ||
|
|
196
|
+
(params.mentionEntities && params.mentionEntities.length > 0) ||
|
|
197
|
+
params.mentionAll
|
|
198
|
+
) {
|
|
183
199
|
const mention: Record<string, unknown> = {};
|
|
184
200
|
if (params.mentionUids && params.mentionUids.length > 0) {
|
|
185
201
|
mention.uids = params.mentionUids;
|
|
186
202
|
}
|
|
203
|
+
if (params.mentionEntities && params.mentionEntities.length > 0) {
|
|
204
|
+
mention.entities = params.mentionEntities;
|
|
205
|
+
}
|
|
187
206
|
if (params.mentionAll) {
|
|
188
207
|
mention.all = true;
|
|
189
208
|
}
|
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
|
@@ -15,7 +15,8 @@ import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContent
|
|
|
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: "" };
|
|
@@ -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
|
+
});
|