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 +1 -1
- package/src/api-fetch.test.ts +64 -0
- package/src/api-fetch.ts +1 -1
- package/src/channel.test.ts +257 -0
- package/src/channel.ts +80 -14
- package/src/inbound.ts +4 -0
- package/src/mention-utils.test.ts +128 -0
- package/src/mention-utils.ts +78 -5
- package/src/multi-bot-isolation.test.ts +208 -0
package/package.json
CHANGED
package/src/api-fetch.test.ts
CHANGED
|
@@ -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
package/src/channel.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
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
|
|
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
|
+
});
|
package/src/mention-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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 = `@${
|
|
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
|
-
|
|
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 +
|
|
301
|
-
replacement: `@[${uid}:${
|
|
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
|
+
});
|