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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
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
 
@@ -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 (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,
@@ -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", ".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",
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 ((params.mentionUids && params.mentionUids.length > 0) || params.mentionAll) {
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;
@@ -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 { 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: "" };
@@ -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 — 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
+ });