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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
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 { parseMentions } from "./mention-utils.js";
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 mentionNames = parseMentions(message);
170
- for (const name of mentionNames) {
171
- const uid = memberMap.get(name);
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 (params.mentionUids && params.mentionUids.length > 0) {
83
- payload.mention = { uids: params.mentionUids };
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 ((params.mentionUids && params.mentionUids.length > 0) || params.mentionAll) {
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
  }
@@ -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 { parseMentions } from "./mention-utils.js";
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
- // Parse @mentions from message content (e.g., "@chenpipi_bot", "@陈皮皮")
354
- const contentMentionNames = parseMentions(content);
355
- for (const name of contentMentionNames) {
356
- if (name && !mentionUids.includes(name)) {
357
- mentionUids.push(name);
358
- console.log(`[dmwork] parsed @mention from content: ${name}`);
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
- if (mentionUids.length > 0) {
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 — detect stale cached token
665
- let hasRefreshedToken = false;
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 once
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
- if (!hasRefreshedToken && !isRefreshingToken && !stopped &&
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
- hasRefreshedToken = true;
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
- hasRefreshedToken = false; // Allow retry on next error (#43)
785
+ // Keep cooldown active even on failure to prevent rapid retry hammering
776
786
  } finally {
777
787
  isRefreshingToken = false;
778
788
  }
@@ -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
+ });