openclaw-channel-dmwork 0.5.13 → 0.5.15

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.13",
3
+ "version": "0.5.15",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -874,3 +874,67 @@ describe("fetchUserInfo", () => {
874
874
  expect(result).toBeNull();
875
875
  });
876
876
  });
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // sendMessage — mentionAll serialization
880
+ // ---------------------------------------------------------------------------
881
+ describe("sendMessage — mentionAll serialization", () => {
882
+ const originalFetch = global.fetch;
883
+
884
+ beforeEach(() => {
885
+ vi.restoreAllMocks();
886
+ });
887
+
888
+ afterEach(() => {
889
+ global.fetch = originalFetch;
890
+ });
891
+
892
+ it("mention.all 应序列化为数字 1 而非布尔 true", async () => {
893
+ let sentBody: any = null;
894
+ global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
895
+ sentBody = JSON.parse(init?.body as string);
896
+ return new Response(JSON.stringify({}), {
897
+ status: 200,
898
+ headers: { "Content-Type": "application/json" },
899
+ });
900
+ }) as unknown as typeof fetch;
901
+
902
+ const { sendMessage } = await import("./api-fetch.js");
903
+ await sendMessage({
904
+ apiUrl: "http://localhost:8090",
905
+ botToken: "test-token",
906
+ channelId: "group1",
907
+ channelType: ChannelType.Group,
908
+ content: "hello @all",
909
+ mentionAll: true,
910
+ });
911
+
912
+ expect(sentBody).not.toBeNull();
913
+ const mention = sentBody.payload.mention;
914
+ expect(mention.all).toBe(1);
915
+ expect(mention.all).not.toBe(true);
916
+ expect(typeof mention.all).toBe("number");
917
+ });
918
+
919
+ it("未设置 mentionAll 时不应包含 mention.all 字段", async () => {
920
+ let sentBody: any = null;
921
+ global.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
922
+ sentBody = JSON.parse(init?.body as string);
923
+ return new Response(JSON.stringify({}), {
924
+ status: 200,
925
+ headers: { "Content-Type": "application/json" },
926
+ });
927
+ }) as unknown as typeof fetch;
928
+
929
+ const { sendMessage } = await import("./api-fetch.js");
930
+ await sendMessage({
931
+ apiUrl: "http://localhost:8090",
932
+ botToken: "test-token",
933
+ channelId: "group1",
934
+ channelType: ChannelType.Group,
935
+ content: "hello",
936
+ });
937
+
938
+ expect(sentBody.payload.mention).toBeUndefined();
939
+ });
940
+ });
package/src/api-fetch.ts CHANGED
@@ -218,7 +218,7 @@ export async function sendMessage(params: {
218
218
  mention.entities = params.mentionEntities;
219
219
  }
220
220
  if (params.mentionAll) {
221
- mention.all = true;
221
+ mention.all = 1;
222
222
  }
223
223
  payload.mention = mention;
224
224
  }
@@ -127,3 +127,260 @@ describe("dmworkPlugin structure", () => {
127
127
  expect(dmworkPlugin.capabilities?.chatTypes).toContain("group");
128
128
  });
129
129
  });
130
+
131
+ // ─── Group → Account mapping tests ──────────────────────────────────────────
132
+
133
+ describe("resolveAccountForGroup — prefetch registration", () => {
134
+ beforeEach(() => {
135
+ vi.resetModules();
136
+ });
137
+
138
+ it("should register groups during startup prefetch", async () => {
139
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
140
+
141
+ // Simulate prefetch registration
142
+ registerGroupToAccount("group_abc", "acct_1");
143
+
144
+ // resolveAccountForGroup should now return the registered account
145
+ expect(resolveAccountForGroup("group_abc")).toBe("acct_1");
146
+ });
147
+ });
148
+
149
+ // ─── resolveOutboundAccountId tests ──────────────────────────────────────────
150
+
151
+ describe("resolveOutboundAccountId", () => {
152
+ beforeEach(() => {
153
+ vi.resetModules();
154
+ });
155
+
156
+ it("should strip @uid suffix from group target and resolve account", async () => {
157
+ const { registerGroupToAccount, resolveOutboundAccountId } = await import("./channel.js");
158
+
159
+ registerGroupToAccount("abc", "acct_A");
160
+
161
+ // "group:abc@uid1,uid2" → should strip @uid1,uid2, resolve group "abc"
162
+ const result = resolveOutboundAccountId("group:abc@uid1,uid2", "fallback");
163
+ expect(result).toBe("acct_A");
164
+ });
165
+
166
+ it("should resolve plain group target without @suffix", async () => {
167
+ const { registerGroupToAccount, resolveOutboundAccountId } = await import("./channel.js");
168
+
169
+ registerGroupToAccount("abc", "acct_B");
170
+
171
+ const result = resolveOutboundAccountId("group:abc", "fallback");
172
+ expect(result).toBe("acct_B");
173
+ });
174
+
175
+ it("should return fallback for DM targets (no correction)", async () => {
176
+ const { resolveOutboundAccountId } = await import("./channel.js");
177
+
178
+ // DM target — resolveOutboundAccountId should not correct, return fallback
179
+ const result = resolveOutboundAccountId("user:some_uid", "fallback_acct");
180
+ expect(result).toBe("fallback_acct");
181
+ });
182
+ });
183
+
184
+ describe("resolveOutboundAccountId — explicit accountId should not be overridden", () => {
185
+ beforeEach(() => {
186
+ vi.resetModules();
187
+ });
188
+
189
+ it("should NOT override explicit non-default accountId even when group is registered to another account", async () => {
190
+ const { registerGroupToAccount, resolveOutboundAccountId } = await import("./channel.js");
191
+
192
+ // group registered to thomas_fu_bot
193
+ registerGroupToAccount("some_group", "thomas_fu_bot");
194
+
195
+ // User explicitly passes allen-imtest — resolveOutboundAccountId would return thomas_fu_bot,
196
+ // but the caller (sendText/sendMedia) should not call resolveOutboundAccountId when accountId != default.
197
+ // We test that resolveOutboundAccountId itself still resolves to the registered account...
198
+ const resolved = resolveOutboundAccountId("group:some_group", "allen-imtest");
199
+ expect(resolved).toBe("thomas_fu_bot"); // resolveOutboundAccountId always resolves
200
+
201
+ // ...but the sendText/sendMedia logic should gate on rawAccountId === DEFAULT_ACCOUNT_ID.
202
+ // Simulate the gating logic:
203
+ const rawAccountId: string = "allen-imtest"; // explicit, non-default
204
+ const DEFAULT_ACCOUNT_ID = "default";
205
+ const accountId = (rawAccountId === DEFAULT_ACCOUNT_ID)
206
+ ? resolveOutboundAccountId("group:some_group", rawAccountId)
207
+ : rawAccountId;
208
+ expect(accountId).toBe("allen-imtest"); // NOT corrected
209
+ });
210
+
211
+ it("should correct when accountId is default", async () => {
212
+ const { registerGroupToAccount, resolveOutboundAccountId } = await import("./channel.js");
213
+
214
+ registerGroupToAccount("some_group", "thomas_fu_bot");
215
+
216
+ const DEFAULT_ACCOUNT_ID = "default";
217
+ const rawAccountId = DEFAULT_ACCOUNT_ID;
218
+ const accountId = (rawAccountId === DEFAULT_ACCOUNT_ID)
219
+ ? resolveOutboundAccountId("group:some_group", rawAccountId)
220
+ : rawAccountId;
221
+ expect(accountId).toBe("thomas_fu_bot"); // corrected
222
+ });
223
+ });
224
+
225
+ describe("outbound accountId correction pattern", () => {
226
+ beforeEach(() => {
227
+ vi.resetModules();
228
+ });
229
+
230
+ it("should use resolveAccountForGroup for group targets", async () => {
231
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
232
+ const { parseTarget } = await import("./actions.js");
233
+
234
+ registerGroupToAccount("group_xyz", "correct_acct");
235
+
236
+ const target = "group:group_xyz";
237
+ const { channelId, channelType } = parseTarget(target);
238
+
239
+ // Simulate the correction logic
240
+ let accountId = "wrong_acct";
241
+ if (channelType === 2) { // ChannelType.Group
242
+ const correct = resolveAccountForGroup(channelId);
243
+ if (correct) accountId = correct;
244
+ }
245
+
246
+ expect(accountId).toBe("correct_acct");
247
+ });
248
+ });
249
+
250
+ // ─── @all / @所有人 hasAtAll regex tests ──────────────────────────────────────
251
+
252
+ describe("hasAtAll regex — @所有人 support", () => {
253
+ const hasAtAllRegex = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i;
254
+
255
+ it("should match @all", () => {
256
+ expect(hasAtAllRegex.test("hello @all please check")).toBe(true);
257
+ });
258
+
259
+ it("should match @All (case-insensitive)", () => {
260
+ expect(hasAtAllRegex.test("hello @All please")).toBe(true);
261
+ });
262
+
263
+ it("should match @所有人", () => {
264
+ expect(hasAtAllRegex.test("大家好 @所有人 请注意")).toBe(true);
265
+ });
266
+
267
+ it("should match @所有人 at start of string", () => {
268
+ expect(hasAtAllRegex.test("@所有人 请注意")).toBe(true);
269
+ });
270
+
271
+ it("should match @all at start of string", () => {
272
+ expect(hasAtAllRegex.test("@all check this")).toBe(true);
273
+ });
274
+
275
+ it("should match @所有人 at end of string", () => {
276
+ expect(hasAtAllRegex.test("通知 @所有人")).toBe(true);
277
+ });
278
+
279
+ it("should NOT match @Alice (not all)", () => {
280
+ expect(hasAtAllRegex.test("hello @Alice")).toBe(false);
281
+ });
282
+
283
+ it("should NOT match email with @all in domain", () => {
284
+ expect(hasAtAllRegex.test("email user@all.com")).toBe(false);
285
+ });
286
+ });
287
+
288
+ // ─── sendText v2 structured mention handling (unit logic) ─────────────────────
289
+
290
+ describe("sendText v2 mention processing logic", () => {
291
+ it("should convert @[uid:name] to @name + entities", async () => {
292
+ const { parseStructuredMentions, convertStructuredMentions, buildEntitiesFromFallback } = await import("./mention-utils.js");
293
+
294
+ const content = "请 @[abc123:张三] 确认";
295
+ const uidToNameMap = new Map([["abc123", "张三"]]);
296
+ const memberMap = new Map([["张三", "abc123"]]);
297
+ const validUids = new Set(uidToNameMap.keys());
298
+
299
+ // v2 path
300
+ const structuredMentions = parseStructuredMentions(content);
301
+ expect(structuredMentions).toHaveLength(1);
302
+
303
+ const converted = convertStructuredMentions(content, structuredMentions, validUids);
304
+ expect(converted.content).toBe("请 @张三 确认");
305
+ expect(converted.entities).toHaveLength(1);
306
+ expect(converted.entities[0]).toEqual({ uid: "abc123", offset: 2, length: 3 });
307
+ expect(converted.uids).toEqual(["abc123"]);
308
+
309
+ // v1 fallback on converted content should find @张三 but not create duplicate
310
+ const fallback = buildEntitiesFromFallback(converted.content, memberMap);
311
+ expect(fallback.uids).toEqual(["abc123"]);
312
+ });
313
+
314
+ it("should handle mixed v2 + v1 mentions", async () => {
315
+ const { parseStructuredMentions, convertStructuredMentions, buildEntitiesFromFallback } = await import("./mention-utils.js");
316
+
317
+ const content = "@[abc:张三] 和 @李四";
318
+ const uidToNameMap = new Map([["abc", "张三"]]);
319
+ const memberMap = new Map([["张三", "abc"], ["李四", "def"]]);
320
+ const validUids = new Set(uidToNameMap.keys());
321
+
322
+ // v2 path
323
+ const structuredMentions = parseStructuredMentions(content);
324
+ expect(structuredMentions).toHaveLength(1);
325
+
326
+ const converted = convertStructuredMentions(content, structuredMentions, validUids);
327
+ expect(converted.content).toBe("@张三 和 @李四");
328
+
329
+ // v1 fallback resolves @李四
330
+ const fallback = buildEntitiesFromFallback(converted.content, memberMap);
331
+
332
+ // Merge with dedup
333
+ const mentionEntities = [...converted.entities];
334
+ const existingOffsets = new Set(mentionEntities.map(e => e.offset));
335
+ for (const entity of fallback.entities) {
336
+ if (!existingOffsets.has(entity.offset)) {
337
+ mentionEntities.push(entity);
338
+ }
339
+ }
340
+
341
+ expect(mentionEntities).toHaveLength(2);
342
+ expect(mentionEntities.map(e => e.uid).sort()).toEqual(["abc", "def"]);
343
+ });
344
+
345
+ it("pure v1 content should work unchanged", async () => {
346
+ const { parseStructuredMentions, buildEntitiesFromFallback } = await import("./mention-utils.js");
347
+
348
+ const content = "@张三 你好";
349
+ const structuredMentions = parseStructuredMentions(content);
350
+ expect(structuredMentions).toHaveLength(0);
351
+
352
+ const memberMap = new Map([["张三", "abc"]]);
353
+ const fallback = buildEntitiesFromFallback(content, memberMap);
354
+ expect(fallback.uids).toEqual(["abc"]);
355
+ expect(fallback.entities).toHaveLength(1);
356
+ });
357
+
358
+ it("@[uid:name] with @所有人 should only produce entity for name, not 所有人", async () => {
359
+ const { parseStructuredMentions, convertStructuredMentions, buildEntitiesFromFallback } = await import("./mention-utils.js");
360
+
361
+ const content = "@[abc:张三] @所有人";
362
+ const validUids = new Set(["abc"]);
363
+ const memberMap = new Map([["张三", "abc"]]);
364
+
365
+ const structured = parseStructuredMentions(content);
366
+ const converted = convertStructuredMentions(content, structured, validUids);
367
+ expect(converted.content).toBe("@张三 @所有人");
368
+
369
+ const fallback = buildEntitiesFromFallback(converted.content, memberMap);
370
+ // @所有人 should be skipped by buildEntitiesFromFallback
371
+ const allEntities = [...converted.entities];
372
+ const existingOffsets = new Set(allEntities.map(e => e.offset));
373
+ for (const entity of fallback.entities) {
374
+ if (!existingOffsets.has(entity.offset)) {
375
+ allEntities.push(entity);
376
+ }
377
+ }
378
+ // Only 张三 should have an entity
379
+ expect(allEntities).toHaveLength(1);
380
+ expect(allEntities[0].uid).toBe("abc");
381
+
382
+ // hasAtAll should be true
383
+ const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(converted.content);
384
+ expect(hasAtAll).toBe(true);
385
+ });
386
+ });
package/src/channel.ts CHANGED
@@ -15,7 +15,7 @@ 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 { buildEntitiesFromFallback } from "./mention-utils.js";
18
+ import { buildEntitiesFromFallback, parseStructuredMentions, convertStructuredMentions } from "./mention-utils.js";
19
19
  import type { MentionEntity } from "./types.js";
20
20
  import { handleDmworkMessageAction, parseTarget } from "./actions.js";
21
21
  import { createDmworkManagementTools } from "./agent-tools.js";
@@ -125,16 +125,26 @@ function getOrCreateGroupCacheTimestamps(accountId: string): Map<string, number>
125
125
  }
126
126
 
127
127
 
128
- // --- Group → Account mapping: tracks which account each group was received from ---
128
+ // --- Group → Account mapping: tracks which accounts are active in each group ---
129
129
  // Used by handleAction to resolve the correct account when framework passes wrong accountId
130
- const _groupToAccount = new Map<string, string>(); // groupNo accountId
130
+ // A group may have multiple bots (1:N), so we store a Set of accountIds per group.
131
+ const _groupToAccount = new Map<string, Set<string>>(); // groupNo → Set<accountId>
131
132
 
132
133
  export function registerGroupToAccount(groupNo: string, accountId: string): void {
133
- _groupToAccount.set(groupNo, accountId);
134
+ let accounts = _groupToAccount.get(groupNo);
135
+ if (!accounts) {
136
+ accounts = new Set<string>();
137
+ _groupToAccount.set(groupNo, accounts);
138
+ }
139
+ accounts.add(accountId);
134
140
  }
135
141
 
136
142
  export function resolveAccountForGroup(groupNo: string): string | undefined {
137
- return _groupToAccount.get(groupNo);
143
+ const accounts = _groupToAccount.get(groupNo);
144
+ if (!accounts || accounts.size === 0) return undefined;
145
+ // Only resolve when exactly one bot owns the group; multi-bot → ambiguous
146
+ if (accounts.size === 1) return accounts.values().next().value;
147
+ return undefined;
138
148
  }
139
149
 
140
150
  // --- Cache cleanup: evict groups inactive for >4 hours ---
@@ -212,6 +222,22 @@ async function checkForUpdates(
212
222
  }
213
223
  }
214
224
 
225
+ /** Resolve correct accountId for outbound context using group→account mapping */
226
+ export function resolveOutboundAccountId(ctxTo: string, fallbackAccountId: string): string {
227
+ let targetForParse = ctxTo;
228
+ if (ctxTo.startsWith("group:")) {
229
+ const groupPart = ctxTo.slice(6);
230
+ const atIdx = groupPart.indexOf("@");
231
+ if (atIdx >= 0) targetForParse = "group:" + groupPart.slice(0, atIdx);
232
+ }
233
+ const { channelId, channelType } = parseTarget(targetForParse, undefined, getKnownGroupIds());
234
+ if (channelType === ChannelType.Group) {
235
+ const correctAccountId = resolveAccountForGroup(channelId);
236
+ if (correctAccountId) return correctAccountId;
237
+ }
238
+ return fallbackAccountId;
239
+ }
240
+
215
241
  const meta = {
216
242
  id: "dmwork",
217
243
  label: "DMWork",
@@ -253,11 +279,14 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
253
279
  handleAction: async (ctx: any) => {
254
280
  // Resolve correct accountId: framework may pass wrong one when agent has multiple accounts.
255
281
  // Use currentChannelId to look up which account actually owns the group.
282
+ // When multiple bots share the same group, do NOT correct — the caller's accountId is authoritative.
256
283
  let accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
257
284
  const currentChannelId = ctx.toolContext?.currentChannelId;
258
285
  if (currentChannelId) {
259
286
  const rawGroupNo = currentChannelId.replace(/^dmwork:/, '');
260
287
  const correctAccountId = resolveAccountForGroup(rawGroupNo);
288
+ // Only correct when resolveAccountForGroup returns a definitive answer
289
+ // (exactly one bot owns the group); multi-bot → undefined → no correction
261
290
  if (correctAccountId && correctAccountId !== accountId) {
262
291
  ctx.log?.info?.(`dmwork: handleAction accountId corrected: ${accountId} → ${correctAccountId} (group=${rawGroupNo})`);
263
292
  accountId = correctAccountId;
@@ -323,9 +352,14 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
323
352
  outbound: {
324
353
  deliveryMode: "direct",
325
354
  sendText: async (ctx) => {
355
+ // Resolve correct accountId — framework may pass wrong one for multi-bot setups
356
+ const accountId = resolveOutboundAccountId(
357
+ ctx.to,
358
+ ctx.accountId ?? DEFAULT_ACCOUNT_ID,
359
+ );
326
360
  const account = resolveDmworkAccount({
327
361
  cfg: ctx.cfg as OpenClawConfig,
328
- accountId: ctx.accountId ?? DEFAULT_ACCOUNT_ID,
362
+ accountId,
329
363
  });
330
364
  if (!account.config.botToken) {
331
365
  throw new Error("DMWork botToken is not configured");
@@ -352,37 +386,66 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
352
386
  const { channelId, channelType } = parseTarget(targetForParse, undefined, getKnownGroupIds());
353
387
 
354
388
  let mentionEntities: MentionEntity[] = [];
389
+ let finalContent = content;
355
390
 
356
391
  if (channelType === ChannelType.Group) {
357
- // Resolve @name to uid via memberMap (fixes name-as-uid bug)
358
- const accountMemberMap = getOrCreateMemberMap(
359
- ctx.accountId ?? DEFAULT_ACCOUNT_ID,
360
- );
361
- const { entities, uids } = buildEntitiesFromFallback(content, accountMemberMap);
392
+ const accountMemberMap = getOrCreateMemberMap(accountId);
393
+ const uidToNameMap = getOrCreateUidToNameMap(accountId);
394
+
395
+ // v2 path: convert @[uid:name] → @name + entities
396
+ const structuredMentions = parseStructuredMentions(finalContent);
397
+ if (structuredMentions.length > 0) {
398
+ const validUids = new Set(uidToNameMap.keys());
399
+ const converted = convertStructuredMentions(finalContent, structuredMentions, validUids);
400
+ finalContent = converted.content;
401
+ mentionEntities = [...converted.entities];
402
+ for (const uid of converted.uids) {
403
+ if (!mentionUids.includes(uid)) {
404
+ mentionUids.push(uid);
405
+ }
406
+ }
407
+ }
408
+
409
+ // v1 fallback: resolve remaining @name via memberMap
410
+ const { entities, uids } = buildEntitiesFromFallback(finalContent, accountMemberMap);
411
+ const existingOffsets = new Set(mentionEntities.map(e => e.offset));
412
+ for (const entity of entities) {
413
+ if (!existingOffsets.has(entity.offset)) {
414
+ mentionEntities.push(entity);
415
+ }
416
+ }
362
417
  for (const uid of uids) {
363
418
  if (!mentionUids.includes(uid)) {
364
419
  mentionUids.push(uid);
365
420
  }
366
421
  }
367
- mentionEntities = entities;
368
422
  }
369
423
 
424
+ // Detect @all/@所有人 in content
425
+ const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(finalContent);
426
+
370
427
  await sendMessage({
371
428
  apiUrl: account.config.apiUrl,
372
429
  botToken: account.config.botToken,
373
430
  channelId,
374
431
  channelType,
375
- content,
432
+ content: finalContent,
376
433
  ...(mentionUids.length > 0 ? { mentionUids } : {}),
377
434
  ...(mentionEntities.length > 0 ? { mentionEntities } : {}),
435
+ mentionAll: hasAtAll || undefined,
378
436
  });
379
437
 
380
438
  return { channel: "dmwork", to: ctx.to, messageId: "" };
381
439
  },
382
440
  sendMedia: async (ctx) => {
441
+ // Resolve correct accountId — framework may pass wrong one for multi-bot setups
442
+ const accountId = resolveOutboundAccountId(
443
+ ctx.to,
444
+ ctx.accountId ?? DEFAULT_ACCOUNT_ID,
445
+ );
383
446
  const account = resolveDmworkAccount({
384
447
  cfg: ctx.cfg as OpenClawConfig,
385
- accountId: ctx.accountId ?? DEFAULT_ACCOUNT_ID,
448
+ accountId,
386
449
  });
387
450
  if (!account.config.botToken) {
388
451
  throw new Error("DMWork botToken is not configured");
@@ -603,6 +666,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
603
666
  let mdCount = 0;
604
667
  let memberCount = 0;
605
668
  for (const g of groups) {
669
+ // Register group → account mapping for outbound accountId resolution
670
+ registerGroupToAccount(g.group_no, account.accountId);
671
+
606
672
  // Prefetch GROUP.md
607
673
  try {
608
674
  const md = await getGroupMd({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
package/src/inbound.ts CHANGED
@@ -1580,6 +1580,9 @@ export async function handleInboundMessage(params: {
1580
1580
  }
1581
1581
  }
1582
1582
 
1583
+ // Detect @all/@所有人 in final content
1584
+ const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(finalContent);
1585
+
1583
1586
  await sendMessage({
1584
1587
  apiUrl: account.config.apiUrl,
1585
1588
  botToken: account.config.botToken ?? "",
@@ -1588,6 +1591,7 @@ export async function handleInboundMessage(params: {
1588
1591
  content: finalContent,
1589
1592
  ...(replyMentionUids.length > 0 ? { mentionUids: replyMentionUids } : {}),
1590
1593
  ...(replyMentionEntities.length > 0 ? { mentionEntities: replyMentionEntities } : {}),
1594
+ mentionAll: hasAtAll || undefined,
1591
1595
  });
1592
1596
 
1593
1597
  statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
@@ -10,6 +10,7 @@ import {
10
10
  extractMentionUids,
11
11
  convertContentForLLM,
12
12
  buildSenderPrefix,
13
+ tryLongestMemberMatch,
13
14
  } from "./mention-utils.js";
14
15
  import type { MentionPayload } from "./types.js";
15
16
 
@@ -558,3 +559,130 @@ describe("buildSenderPrefix with cross-space", () => {
558
559
  expect(buildSenderPrefix("s14_abc", map)).toBe("s14_abc");
559
560
  });
560
561
  });
562
+
563
+ // ── Space-name @mention support ──────────────────────────────────────────────
564
+
565
+ describe("buildEntitiesFromFallback — 空格昵称支持", () => {
566
+ it("应匹配含空格的昵称 @Anyang Su", () => {
567
+ const memberMap = new Map([
568
+ ["Anyang Su", "uid_anyang"],
569
+ ["Bob", "uid_bob"],
570
+ ]);
571
+ const { entities, uids } = buildEntitiesFromFallback(
572
+ "Hello @Anyang Su and @Bob",
573
+ memberMap,
574
+ );
575
+ expect(uids).toEqual(["uid_anyang", "uid_bob"]);
576
+ expect(entities).toHaveLength(2);
577
+ expect(entities[0]).toEqual({ uid: "uid_anyang", offset: 6, length: 10 });
578
+ expect(entities[1]).toEqual({ uid: "uid_bob", offset: 21, length: 4 });
579
+ });
580
+
581
+ it("应优先匹配最长名称", () => {
582
+ const memberMap = new Map([
583
+ ["Anyang", "uid_short"],
584
+ ["Anyang Su", "uid_full"],
585
+ ]);
586
+ const { entities, uids } = buildEntitiesFromFallback(
587
+ "@Anyang Su hello",
588
+ memberMap,
589
+ );
590
+ expect(uids).toEqual(["uid_full"]);
591
+ expect(entities[0]).toEqual({ uid: "uid_full", offset: 0, length: 10 });
592
+ });
593
+
594
+ it("不应跨词误匹配 @Anyang Superman", () => {
595
+ const memberMap = new Map([["Anyang Su", "uid_anyang"]]);
596
+ const { entities, uids } = buildEntitiesFromFallback(
597
+ "@Anyang Superman",
598
+ memberMap,
599
+ );
600
+ expect(uids).toEqual([]);
601
+ expect(entities).toEqual([]);
602
+ });
603
+
604
+ it("应处理多个空格昵称", () => {
605
+ const memberMap = new Map([
606
+ ["Anyang Su", "uid_anyang"],
607
+ ["Li Wei", "uid_li"],
608
+ ]);
609
+ const { entities, uids } = buildEntitiesFromFallback(
610
+ "@Anyang Su @Li Wei",
611
+ memberMap,
612
+ );
613
+ expect(uids).toEqual(["uid_anyang", "uid_li"]);
614
+ expect(entities).toHaveLength(2);
615
+ expect(entities[0]).toEqual({ uid: "uid_anyang", offset: 0, length: 10 });
616
+ expect(entities[1]).toEqual({ uid: "uid_li", offset: 11, length: 7 });
617
+ });
618
+
619
+ it("无空格名称时行为不变", () => {
620
+ const memberMap = new Map([["Bob", "uid_bob"]]);
621
+ const { entities, uids } = buildEntitiesFromFallback("@Bob hi", memberMap);
622
+ expect(uids).toEqual(["uid_bob"]);
623
+ expect(entities[0]).toEqual({ uid: "uid_bob", offset: 0, length: 4 });
624
+ });
625
+ });
626
+
627
+ describe("buildEntitiesFromFallback — @all 跳过", () => {
628
+ it("@all 不应生成 entity", () => {
629
+ const memberMap = new Map([["Bob", "uid_bob"]]);
630
+ const { entities, uids } = buildEntitiesFromFallback("@all @Bob", memberMap);
631
+ expect(uids).toEqual(["uid_bob"]);
632
+ expect(entities).toHaveLength(1);
633
+ expect(entities[0]).toEqual({ uid: "uid_bob", offset: 5, length: 4 });
634
+ });
635
+
636
+ it("@All (大小写) 也不应生成 entity", () => {
637
+ const memberMap = new Map([["Bob", "uid_bob"]]);
638
+ const { entities, uids } = buildEntitiesFromFallback("@All @Bob", memberMap);
639
+ expect(uids).toEqual(["uid_bob"]);
640
+ expect(entities).toHaveLength(1);
641
+ });
642
+
643
+ it("@ALL 全大写不应生成 entity", () => {
644
+ const memberMap = new Map<string, string>();
645
+ const { entities, uids } = buildEntitiesFromFallback("@ALL please check", memberMap);
646
+ expect(uids).toEqual([]);
647
+ expect(entities).toEqual([]);
648
+ });
649
+
650
+ it("@all 单独出现也不应生成 entity", () => {
651
+ const memberMap = new Map<string, string>();
652
+ const { entities, uids } = buildEntitiesFromFallback("@all", memberMap);
653
+ expect(uids).toEqual([]);
654
+ expect(entities).toEqual([]);
655
+ });
656
+
657
+ it("@所有人 不应生成 entity", () => {
658
+ const memberMap = new Map([["Bob", "uid_bob"]]);
659
+ const { entities, uids } = buildEntitiesFromFallback("@所有人 @Bob", memberMap);
660
+ expect(uids).toEqual(["uid_bob"]);
661
+ expect(entities).toHaveLength(1);
662
+ expect(entities[0]).toEqual({ uid: "uid_bob", offset: 5, length: 4 });
663
+ });
664
+
665
+ it("@所有人 单独出现也不应生成 entity", () => {
666
+ const memberMap = new Map<string, string>();
667
+ const { entities, uids } = buildEntitiesFromFallback("@所有人", memberMap);
668
+ expect(uids).toEqual([]);
669
+ expect(entities).toEqual([]);
670
+ });
671
+
672
+ it("混合 @all 和 @所有人 都不应生成 entity", () => {
673
+ const memberMap = new Map([["Bob", "uid_bob"]]);
674
+ const { entities, uids } = buildEntitiesFromFallback("@all @所有人 @Bob", memberMap);
675
+ expect(uids).toEqual(["uid_bob"]);
676
+ expect(entities).toHaveLength(1);
677
+ });
678
+ });
679
+
680
+ describe("convertContentForLLM — 空格昵称支持", () => {
681
+ it("v1 memberMap 路径应匹配空格昵称", () => {
682
+ const content = "@Anyang Su 你好";
683
+ const mention: MentionPayload = { uids: ["uid_anyang"] };
684
+ const memberMap = new Map([["Anyang Su", "uid_anyang"]]);
685
+ const result = convertContentForLLM(content, mention, memberMap);
686
+ expect(result).toBe("@[uid_anyang:Anyang Su] 你好");
687
+ });
688
+ });
@@ -146,6 +146,36 @@ export function convertStructuredMentions(
146
146
 
147
147
  // ── Build entities from plain @name (fallback path) ──────────────────────────
148
148
 
149
+ /** Name character class — mirrors MENTION_PATTERN's inner char set (without space) */
150
+ const NAME_CHAR_RE =
151
+ /[\w\u00C0-\u024F\u4e00-\u9fff\u3040-\u30FF\uAC00-\uD7AF.\-]/;
152
+
153
+ /**
154
+ * 从 @atPos 位置尝试匹配 memberMap 中最长的名称(支持含空格昵称)。
155
+ * sortedNames 必须按长度降序排列。
156
+ *
157
+ * 边界检查:匹配到的名称末尾之后的字符必须是终止字符(非"名字字符"),
158
+ * 防止 @Anyang Su 从 "@Anyang Superman" 中误匹配。
159
+ */
160
+ export function tryLongestMemberMatch(
161
+ text: string,
162
+ atPos: number,
163
+ memberMap: Map<string, string>,
164
+ sortedNames: string[],
165
+ ): { name: string; uid: string } | undefined {
166
+ const after = text.substring(atPos + 1);
167
+ for (const candidate of sortedNames) {
168
+ if (after.startsWith(candidate)) {
169
+ const ch = text[atPos + 1 + candidate.length];
170
+ if (ch === undefined || !NAME_CHAR_RE.test(ch)) {
171
+ const uid = memberMap.get(candidate);
172
+ if (uid) return { name: candidate, uid };
173
+ }
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+
149
179
  /**
150
180
  * 从纯 @name 格式的文本中构建 entities(fallback 路径)。
151
181
  * 通过 memberMap(displayName → uid)解析每个 @name 对应的 uid。
@@ -163,15 +193,38 @@ export function buildEntitiesFromFallback(
163
193
  const pattern = new RegExp(MENTION_PATTERN.source, "g");
164
194
  let match;
165
195
 
196
+ // 按长度降序排列,优先匹配最长名称
197
+ const sortedNames = [...memberMap.keys()].sort((a, b) => b.length - a.length);
198
+
166
199
  while ((match = pattern.exec(content)) !== null) {
167
200
  const name = match[1];
168
- const uid = memberMap.get(name);
201
+
202
+ // Skip @all / @All etc. — handled separately as mentionAll, not as entity
203
+ if (name.toLowerCase() === "all" || name === "所有人") continue;
204
+
205
+ let uid: string | undefined;
206
+ let matchedName = name;
207
+
208
+ // 优先尝试最长前缀匹配(支持含空格昵称)
209
+ const longer = tryLongestMemberMatch(
210
+ content, match.index, memberMap, sortedNames,
211
+ );
212
+ if (longer) {
213
+ uid = longer.uid;
214
+ matchedName = longer.name;
215
+ } else {
216
+ // 回退到精确正则匹配
217
+ uid = memberMap.get(name);
218
+ }
169
219
 
170
220
  if (!uid) continue;
171
221
 
172
- const atName = `@${name}`;
222
+ const atName = `@${matchedName}`;
173
223
  entities.push({ uid, offset: match.index, length: atName.length });
174
224
  uids.push(uid);
225
+
226
+ // 跳过完整匹配长度,防止空格名称被部分重复匹配
227
+ pattern.lastIndex = match.index + atName.length;
175
228
  }
176
229
 
177
230
  return { entities, uids };
@@ -282,12 +335,27 @@ export function convertContentForLLM(
282
335
  replacement: string;
283
336
  }[] = [];
284
337
 
338
+ // 按长度降序排列,优先匹配最长名称
339
+ const sortedNames = hasMemberMap
340
+ ? [...memberMap!.keys()].sort((a, b) => b.length - a.length)
341
+ : [];
342
+
285
343
  while ((match = pattern.exec(content)) !== null) {
286
344
  const name = match[1];
287
345
  let uid: string | undefined;
346
+ let matchedName = name;
288
347
 
289
348
  if (hasMemberMap) {
290
- uid = memberMap!.get(name);
349
+ // 优先尝试最长前缀匹配(支持含空格昵称)
350
+ const longer = tryLongestMemberMatch(
351
+ content, match.index, memberMap!, sortedNames,
352
+ );
353
+ if (longer) {
354
+ uid = longer.uid;
355
+ matchedName = longer.name;
356
+ } else {
357
+ uid = memberMap!.get(name);
358
+ }
291
359
  } else if (hasUids && i < mention.uids!.length) {
292
360
  const candidate = mention.uids![i];
293
361
  uid = typeof candidate === "string" ? candidate : undefined;
@@ -297,10 +365,15 @@ export function convertContentForLLM(
297
365
  if (uid) {
298
366
  replacements.push({
299
367
  start: match.index,
300
- end: match.index + 1 + name.length,
301
- replacement: `@[${uid}:${name}]`,
368
+ end: match.index + 1 + matchedName.length,
369
+ replacement: `@[${uid}:${matchedName}]`,
302
370
  });
303
371
  }
372
+
373
+ // 跳过完整匹配长度
374
+ if (matchedName.length > name.length) {
375
+ pattern.lastIndex = match.index + 1 + matchedName.length;
376
+ }
304
377
  }
305
378
 
306
379
  for (let j = replacements.length - 1; j >= 0; j--) {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Tests for multi-bot accountId isolation fix.
3
+ *
4
+ * Verifies that when multiple bots share the same OpenClaw Gateway process,
5
+ * messages are sent from the correct bot account — not from whichever bot
6
+ * last processed a message in the same group.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+
10
+ // We need to reset module state between tests since _groupToAccount is module-level
11
+ beforeEach(() => {
12
+ vi.resetModules();
13
+ });
14
+
15
+ // ─── registerGroupToAccount / resolveAccountForGroup unit tests ─────────────
16
+
17
+ describe("registerGroupToAccount + resolveAccountForGroup", () => {
18
+ it("single bot — resolveAccountForGroup returns the registered accountId", async () => {
19
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
20
+
21
+ registerGroupToAccount("group-001", "botA");
22
+
23
+ expect(resolveAccountForGroup("group-001")).toBe("botA");
24
+ });
25
+
26
+ it("multi-bot same group — resolveAccountForGroup returns undefined", async () => {
27
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
28
+
29
+ registerGroupToAccount("group-001", "botA");
30
+ registerGroupToAccount("group-001", "botB");
31
+
32
+ expect(resolveAccountForGroup("group-001")).toBeUndefined();
33
+ });
34
+
35
+ it("unregistered group — resolveAccountForGroup returns undefined", async () => {
36
+ const { resolveAccountForGroup } = await import("./channel.js");
37
+
38
+ expect(resolveAccountForGroup("group-unknown")).toBeUndefined();
39
+ });
40
+
41
+ it("duplicate registration of same bot is idempotent", async () => {
42
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
43
+
44
+ registerGroupToAccount("group-001", "botA");
45
+ registerGroupToAccount("group-001", "botA");
46
+
47
+ // Still size 1 → should return the accountId
48
+ expect(resolveAccountForGroup("group-001")).toBe("botA");
49
+ });
50
+
51
+ it("different groups with different bots resolve independently", async () => {
52
+ const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");
53
+
54
+ registerGroupToAccount("group-001", "botA");
55
+ registerGroupToAccount("group-002", "botB");
56
+
57
+ expect(resolveAccountForGroup("group-001")).toBe("botA");
58
+ expect(resolveAccountForGroup("group-002")).toBe("botB");
59
+ });
60
+ });
61
+
62
+ // ─── handleAction correction logic tests ─────────────────────────────────────
63
+
64
+ // Mock dependencies that handleAction calls
65
+ vi.mock("./actions.js", () => ({
66
+ handleDmworkMessageAction: vi.fn(async () => ({ ok: true })),
67
+ parseTarget: vi.fn(() => ({ channelId: "test", channelType: 2 })),
68
+ }));
69
+
70
+ vi.mock("./agent-tools.js", () => ({
71
+ createDmworkManagementTools: vi.fn(() => []),
72
+ }));
73
+
74
+ vi.mock("./group-md.js", () => ({
75
+ getOrCreateGroupMdCache: vi.fn(() => new Map()),
76
+ registerBotGroupIds: vi.fn(),
77
+ getKnownGroupIds: vi.fn(() => new Set()),
78
+ }));
79
+
80
+ vi.mock("./api-fetch.js", () => ({
81
+ registerBot: vi.fn(),
82
+ sendMessage: vi.fn(),
83
+ sendHeartbeat: vi.fn(),
84
+ sendMediaMessage: vi.fn(),
85
+ inferContentType: vi.fn(),
86
+ ensureTextCharset: vi.fn((s: string) => s),
87
+ fetchBotGroups: vi.fn(async () => []),
88
+ getGroupMd: vi.fn(),
89
+ getGroupMembers: vi.fn(),
90
+ parseImageDimensions: vi.fn(),
91
+ parseImageDimensionsFromFile: vi.fn(),
92
+ getUploadCredentials: vi.fn(),
93
+ uploadFileToCOS: vi.fn(),
94
+ }));
95
+
96
+ describe("handleAction multi-bot isolation", () => {
97
+ it("single bot — corrects wrong accountId to the sole owner", async () => {
98
+ const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
99
+ const { handleDmworkMessageAction } = await import("./actions.js");
100
+
101
+ // Only botA is in group-001
102
+ registerGroupToAccount("group-001", "botA");
103
+
104
+ const ctx = {
105
+ accountId: "wrongBot",
106
+ action: "send" as const,
107
+ channel: "dmwork",
108
+ params: { target: "group:group-001", text: "hello" },
109
+ toolContext: { currentChannelId: "dmwork:group-001" },
110
+ cfg: {
111
+ channels: {
112
+ dmwork: {
113
+ accounts: {
114
+ botA: { botToken: "tokenA", apiUrl: "http://api" },
115
+ wrongBot: { botToken: "tokenWrong", apiUrl: "http://api" },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ log: { info: vi.fn() },
121
+ };
122
+
123
+ await dmworkPlugin.actions!.handleAction!(ctx as any);
124
+
125
+ // handleDmworkMessageAction should have been called with botA's token
126
+ expect(handleDmworkMessageAction).toHaveBeenCalledWith(
127
+ expect.objectContaining({ botToken: "tokenA" }),
128
+ );
129
+ // Correction log should have fired
130
+ expect(ctx.log.info).toHaveBeenCalledWith(
131
+ expect.stringContaining("accountId corrected"),
132
+ );
133
+ });
134
+
135
+ it("multi-bot same group — does NOT override ctx.accountId", async () => {
136
+ const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
137
+ const { handleDmworkMessageAction } = await import("./actions.js");
138
+
139
+ // Both botA and botB are in group-001
140
+ registerGroupToAccount("group-001", "botA");
141
+ registerGroupToAccount("group-001", "botB");
142
+
143
+ const ctx = {
144
+ accountId: "botA",
145
+ action: "send" as const,
146
+ channel: "dmwork",
147
+ params: { target: "group:group-001", text: "hello from A" },
148
+ toolContext: { currentChannelId: "dmwork:group-001" },
149
+ cfg: {
150
+ channels: {
151
+ dmwork: {
152
+ accounts: {
153
+ botA: { botToken: "tokenA", apiUrl: "http://api" },
154
+ botB: { botToken: "tokenB", apiUrl: "http://api" },
155
+ },
156
+ },
157
+ },
158
+ },
159
+ log: { info: vi.fn() },
160
+ };
161
+
162
+ await dmworkPlugin.actions!.handleAction!(ctx as any);
163
+
164
+ // Should use botA's token (the caller's original accountId), NOT botB's
165
+ expect(handleDmworkMessageAction).toHaveBeenCalledWith(
166
+ expect.objectContaining({ botToken: "tokenA" }),
167
+ );
168
+ // No correction log should have fired
169
+ expect(ctx.log.info).not.toHaveBeenCalledWith(
170
+ expect.stringContaining("accountId corrected"),
171
+ );
172
+ });
173
+
174
+ it("single bot — correct accountId is not re-corrected", async () => {
175
+ const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
176
+ const { handleDmworkMessageAction } = await import("./actions.js");
177
+
178
+ registerGroupToAccount("group-001", "botA");
179
+
180
+ const ctx = {
181
+ accountId: "botA", // already correct
182
+ action: "send" as const,
183
+ channel: "dmwork",
184
+ params: { target: "group:group-001", text: "hello" },
185
+ toolContext: { currentChannelId: "dmwork:group-001" },
186
+ cfg: {
187
+ channels: {
188
+ dmwork: {
189
+ accounts: {
190
+ botA: { botToken: "tokenA", apiUrl: "http://api" },
191
+ },
192
+ },
193
+ },
194
+ },
195
+ log: { info: vi.fn() },
196
+ };
197
+
198
+ await dmworkPlugin.actions!.handleAction!(ctx as any);
199
+
200
+ expect(handleDmworkMessageAction).toHaveBeenCalledWith(
201
+ expect.objectContaining({ botToken: "tokenA" }),
202
+ );
203
+ // No correction needed
204
+ expect(ctx.log.info).not.toHaveBeenCalledWith(
205
+ expect.stringContaining("accountId corrected"),
206
+ );
207
+ });
208
+ });