@openclaw/zalouser 2026.3.12 → 2026.5.1-beta.2

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.
Files changed (67) hide show
  1. package/README.md +4 -3
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/doctor-contract-api.ts +1 -0
  6. package/index.ts +29 -24
  7. package/openclaw.plugin.json +288 -1
  8. package/package.json +38 -11
  9. package/runtime-api.ts +67 -0
  10. package/secret-contract-api.ts +4 -0
  11. package/setup-entry.ts +9 -0
  12. package/setup-plugin-api.ts +2 -0
  13. package/src/accounts.runtime.ts +1 -0
  14. package/src/accounts.test-mocks.ts +14 -0
  15. package/src/accounts.test.ts +53 -1
  16. package/src/accounts.ts +52 -37
  17. package/src/channel-api.ts +20 -0
  18. package/src/channel.adapters.ts +390 -0
  19. package/src/channel.directory.test.ts +48 -61
  20. package/src/channel.runtime.ts +12 -0
  21. package/src/channel.sendpayload.test.ts +42 -37
  22. package/src/channel.setup.test.ts +33 -0
  23. package/src/channel.setup.ts +12 -0
  24. package/src/channel.test.ts +258 -56
  25. package/src/channel.ts +176 -692
  26. package/src/config-schema.ts +5 -5
  27. package/src/directory.ts +54 -0
  28. package/src/doctor-contract.ts +156 -0
  29. package/src/doctor.test.ts +77 -0
  30. package/src/doctor.ts +37 -0
  31. package/src/group-policy.test.ts +4 -4
  32. package/src/group-policy.ts +4 -2
  33. package/src/monitor.account-scope.test.ts +4 -10
  34. package/src/monitor.group-gating.test.ts +319 -190
  35. package/src/monitor.ts +233 -182
  36. package/src/probe.ts +3 -2
  37. package/src/qr-temp-file.ts +1 -1
  38. package/src/reaction.ts +5 -2
  39. package/src/runtime.ts +6 -3
  40. package/src/security-audit.test.ts +80 -0
  41. package/src/security-audit.ts +71 -0
  42. package/src/send.test.ts +2 -2
  43. package/src/send.ts +3 -3
  44. package/src/session-route.ts +121 -0
  45. package/src/setup-core.ts +33 -0
  46. package/src/setup-surface.test.ts +363 -0
  47. package/src/setup-surface.ts +470 -0
  48. package/src/setup-test-helpers.ts +42 -0
  49. package/src/shared.ts +92 -0
  50. package/src/status-issues.test.ts +5 -17
  51. package/src/status-issues.ts +18 -30
  52. package/src/test-helpers.ts +26 -0
  53. package/src/text-styles.test.ts +1 -1
  54. package/src/text-styles.ts +5 -2
  55. package/src/tool.test.ts +66 -3
  56. package/src/tool.ts +76 -14
  57. package/src/types.ts +3 -3
  58. package/src/zalo-js.credentials.test.ts +465 -0
  59. package/src/zalo-js.test-mocks.ts +89 -0
  60. package/src/zalo-js.ts +491 -274
  61. package/src/zca-client.test.ts +24 -0
  62. package/src/zca-client.ts +24 -58
  63. package/src/zca-constants.ts +55 -0
  64. package/test-api.ts +21 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -101
  67. package/src/onboarding.ts +0 -340
@@ -1,6 +1,8 @@
1
- import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
2
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
3
3
  import "./monitor.send-mocks.js";
4
+ import "./zalo-js.test-mocks.js";
5
+ import { resolveZalouserAccountSync } from "./accounts.js";
4
6
  import { __testing } from "./monitor.js";
5
7
  import {
6
8
  sendDeliveredZalouserMock,
@@ -9,6 +11,7 @@ import {
9
11
  sendTypingZalouserMock,
10
12
  } from "./monitor.send-mocks.js";
11
13
  import { setZalouserRuntime } from "./runtime.js";
14
+ import { createZalouserRuntimeEnv } from "./test-helpers.js";
12
15
  import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
13
16
 
14
17
  function createAccount(): ResolvedZalouserAccount {
@@ -18,6 +21,8 @@ function createAccount(): ResolvedZalouserAccount {
18
21
  profile: "default",
19
22
  authenticated: true,
20
23
  config: {
24
+ dmPolicy: "open",
25
+ allowFrom: ["*"],
21
26
  groupPolicy: "open",
22
27
  groups: {
23
28
  "*": { requireMention: true },
@@ -31,6 +36,8 @@ function createConfig(): OpenClawConfig {
31
36
  channels: {
32
37
  zalouser: {
33
38
  enabled: true,
39
+ dmPolicy: "open",
40
+ allowFrom: ["*"],
34
41
  groups: {
35
42
  "*": { requireMention: true },
36
43
  },
@@ -39,15 +46,7 @@ function createConfig(): OpenClawConfig {
39
46
  };
40
47
  }
41
48
 
42
- function createRuntimeEnv(): RuntimeEnv {
43
- return {
44
- log: vi.fn(),
45
- error: vi.fn(),
46
- exit: ((code: number): never => {
47
- throw new Error(`exit ${code}`);
48
- }) as RuntimeEnv["exit"],
49
- };
50
- }
49
+ const createRuntimeEnv = () => createZalouserRuntimeEnv();
51
50
 
52
51
  function installRuntime(params: {
53
52
  commandAuthorized?: boolean;
@@ -90,6 +89,91 @@ function installRuntime(params: {
90
89
  const readSessionUpdatedAt = vi.fn(
91
90
  (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
92
91
  );
92
+ type ResolvedTurn = Awaited<
93
+ ReturnType<Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]["adapter"]["resolveTurn"]>
94
+ >;
95
+ const dispatchAssembled = vi.fn(async (turn: ResolvedTurn) => {
96
+ await turn.recordInboundSession({
97
+ storePath: turn.storePath,
98
+ sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
99
+ ctx: turn.ctxPayload,
100
+ groupResolution: turn.record?.groupResolution,
101
+ createIfMissing: turn.record?.createIfMissing,
102
+ updateLastRoute: turn.record?.updateLastRoute,
103
+ onRecordError: turn.record?.onRecordError ?? (() => undefined),
104
+ });
105
+ if ("runDispatch" in turn) {
106
+ const dispatchResult = await turn.runDispatch();
107
+ return {
108
+ admission: { kind: "dispatch" as const },
109
+ dispatched: true,
110
+ ctxPayload: turn.ctxPayload,
111
+ routeSessionKey: turn.routeSessionKey,
112
+ dispatchResult,
113
+ };
114
+ }
115
+ const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
116
+ ctx: turn.ctxPayload,
117
+ cfg: turn.cfg,
118
+ dispatcherOptions: {
119
+ ...turn.dispatcherOptions,
120
+ deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
121
+ await turn.delivery.deliver(...args);
122
+ },
123
+ onError: turn.delivery.onError,
124
+ },
125
+ replyOptions: turn.replyOptions,
126
+ replyResolver: turn.replyResolver,
127
+ });
128
+ return {
129
+ admission: { kind: "dispatch" as const },
130
+ dispatched: true,
131
+ ctxPayload: turn.ctxPayload,
132
+ routeSessionKey: turn.routeSessionKey,
133
+ dispatchResult,
134
+ };
135
+ });
136
+ const runTurn = vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
137
+ const input = await params.adapter.ingest(params.raw);
138
+ if (!input) {
139
+ return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
140
+ }
141
+ const resolved = await params.adapter.resolveTurn(
142
+ input,
143
+ {
144
+ kind: "message",
145
+ canStartAgentTurn: true,
146
+ },
147
+ {},
148
+ );
149
+ return await dispatchAssembled(resolved);
150
+ });
151
+ const buildContext = vi.fn(
152
+ (params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) =>
153
+ ({
154
+ Body: params.message.body ?? params.message.rawBody,
155
+ BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
156
+ InboundHistory: params.message.inboundHistory,
157
+ RawBody: params.message.rawBody,
158
+ CommandBody: params.message.commandBody ?? params.message.rawBody,
159
+ BodyForCommands: params.message.commandBody ?? params.message.rawBody,
160
+ From: params.from,
161
+ To: params.reply.to,
162
+ SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
163
+ AccountId: params.route.accountId ?? params.accountId,
164
+ ChatType: params.conversation.kind,
165
+ ConversationLabel: params.conversation.label,
166
+ SenderName: params.sender.name,
167
+ SenderId: params.sender.id,
168
+ Provider: params.provider ?? params.channel,
169
+ Surface: params.surface ?? params.provider ?? params.channel,
170
+ MessageSid: params.messageId,
171
+ MessageSidFull: params.messageIdFull,
172
+ OriginatingChannel: params.channel,
173
+ OriginatingTo: params.reply.originatingTo,
174
+ ...params.extra,
175
+ }) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
176
+ );
93
177
  const buildAgentSessionKey = vi.fn(
94
178
  (input: {
95
179
  agentId: string;
@@ -141,8 +225,9 @@ function installRuntime(params: {
141
225
  resolveRequireMention: vi.fn((input) => {
142
226
  const cfg = input.cfg as OpenClawConfig;
143
227
  const groupCfg = cfg.channels?.zalouser?.groups ?? {};
144
- const groupEntry = input.groupId ? groupCfg[input.groupId] : undefined;
145
- const defaultEntry = groupCfg["*"];
228
+ const typedGroupCfg = groupCfg as Record<string, { requireMention?: boolean }>;
229
+ const groupEntry = input.groupId ? typedGroupCfg[input.groupId] : undefined;
230
+ const defaultEntry = typedGroupCfg["*"];
146
231
  if (typeof groupEntry?.requireMention === "boolean") {
147
232
  return groupEntry.requireMention;
148
233
  }
@@ -167,6 +252,10 @@ function installRuntime(params: {
167
252
  finalizeInboundContext: vi.fn((ctx) => ctx),
168
253
  dispatchReplyWithBufferedBlockDispatcher,
169
254
  },
255
+ turn: {
256
+ run: runTurn as unknown as PluginRuntime["channel"]["turn"]["run"],
257
+ buildContext: buildContext as unknown as PluginRuntime["channel"]["turn"]["buildContext"],
258
+ },
170
259
  text: {
171
260
  resolveMarkdownTableMode: vi.fn(() => "code"),
172
261
  convertMarkdownTables: vi.fn((text: string) => text),
@@ -187,6 +276,31 @@ function installRuntime(params: {
187
276
  };
188
277
  }
189
278
 
279
+ function installGroupCommandAuthRuntime() {
280
+ return installRuntime({
281
+ resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
282
+ useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
283
+ });
284
+ }
285
+
286
+ async function processGroupControlCommand(params: {
287
+ account: ResolvedZalouserAccount;
288
+ content?: string;
289
+ commandContent?: string;
290
+ }) {
291
+ await __testing.processMessage({
292
+ message: createGroupMessage({
293
+ content: params.content ?? "/new",
294
+ commandContent: params.commandContent ?? "/new",
295
+ hasAnyMention: true,
296
+ wasExplicitlyMentioned: true,
297
+ }),
298
+ account: params.account,
299
+ config: createConfig(),
300
+ runtime: createRuntimeEnv(),
301
+ });
302
+ }
303
+
190
304
  function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
191
305
  return {
192
306
  threadId: "g-1",
@@ -229,57 +343,180 @@ describe("zalouser monitor group mention gating", () => {
229
343
  sendSeenZalouserMock.mockClear();
230
344
  });
231
345
 
232
- it("skips unmentioned group messages when requireMention=true", async () => {
233
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
234
- commandAuthorized: false,
235
- });
346
+ async function processMessageWithDefaults(params: {
347
+ message: ZaloInboundMessage;
348
+ account?: ResolvedZalouserAccount;
349
+ historyState?: {
350
+ historyLimit: number;
351
+ groupHistories: Map<
352
+ string,
353
+ Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
354
+ >;
355
+ };
356
+ }) {
236
357
  await __testing.processMessage({
237
- message: createGroupMessage(),
238
- account: createAccount(),
358
+ message: params.message,
359
+ account: params.account ?? createAccount(),
239
360
  config: createConfig(),
240
- runtime: createRuntimeEnv(),
361
+ runtime: createZalouserRuntimeEnv(),
362
+ historyState: params.historyState,
241
363
  });
364
+ }
242
365
 
366
+ async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
367
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
368
+ commandAuthorized: false,
369
+ });
370
+ await processMessageWithDefaults({
371
+ message: createGroupMessage(message),
372
+ });
243
373
  expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
244
374
  expect(sendTypingZalouserMock).not.toHaveBeenCalled();
245
- });
375
+ }
246
376
 
247
- it("fails closed when requireMention=true but mention detection is unavailable", async () => {
377
+ async function expectGroupCommandAuthorizers(params: {
378
+ accountConfig: ResolvedZalouserAccount["config"];
379
+ expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>;
380
+ }) {
381
+ const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
382
+ installGroupCommandAuthRuntime();
383
+ await processGroupControlCommand({
384
+ account: {
385
+ ...createAccount(),
386
+ config: params.accountConfig,
387
+ },
388
+ });
389
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
390
+ const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
391
+ expect(authCall?.authorizers).toEqual(params.expectedAuthorizers);
392
+ }
393
+
394
+ async function processOpenDmMessage(params?: {
395
+ message?: Partial<ZaloInboundMessage>;
396
+ readSessionUpdatedAt?: (input?: {
397
+ storePath: string;
398
+ sessionKey: string;
399
+ }) => number | undefined;
400
+ }) {
401
+ const runtime = installRuntime({
402
+ commandAuthorized: false,
403
+ });
404
+ if (params?.readSessionUpdatedAt) {
405
+ runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
406
+ }
407
+ const account = createAccount();
408
+ await processMessageWithDefaults({
409
+ message: createDmMessage(params?.message),
410
+ account: {
411
+ ...account,
412
+ config: {
413
+ ...account.config,
414
+ dmPolicy: "open",
415
+ },
416
+ },
417
+ });
418
+ return runtime;
419
+ }
420
+
421
+ async function expectDangerousNameMatching(params: {
422
+ dangerouslyAllowNameMatching?: boolean;
423
+ expectedDispatches: number;
424
+ }) {
248
425
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
249
426
  commandAuthorized: false,
250
427
  });
251
- await __testing.processMessage({
428
+ await processMessageWithDefaults({
252
429
  message: createGroupMessage({
253
- canResolveExplicitMention: false,
254
- hasAnyMention: false,
255
- wasExplicitlyMentioned: false,
430
+ threadId: "g-attacker-001",
431
+ groupName: "Trusted Team",
432
+ senderId: "666",
433
+ hasAnyMention: true,
434
+ wasExplicitlyMentioned: true,
435
+ content: "ping @bot",
256
436
  }),
257
- account: createAccount(),
258
- config: createConfig(),
259
- runtime: createRuntimeEnv(),
437
+ account: {
438
+ ...createAccount(),
439
+ config: {
440
+ ...createAccount().config,
441
+ ...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
442
+ groupPolicy: "allowlist",
443
+ groupAllowFrom: ["*"],
444
+ groups: {
445
+ "group:g-trusted-001": { enabled: true },
446
+ "Trusted Team": { enabled: true },
447
+ },
448
+ },
449
+ },
260
450
  });
451
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
452
+ params.expectedDispatches,
453
+ );
454
+ return dispatchReplyWithBufferedBlockDispatcher;
455
+ }
261
456
 
262
- expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
263
- expect(sendTypingZalouserMock).not.toHaveBeenCalled();
457
+ async function dispatchGroupMessage(params: {
458
+ commandAuthorized: boolean;
459
+ message: Partial<ZaloInboundMessage>;
460
+ }) {
461
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
462
+ commandAuthorized: params.commandAuthorized,
463
+ });
464
+ await processMessageWithDefaults({
465
+ message: createGroupMessage(params.message),
466
+ });
467
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
468
+ return dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
469
+ }
470
+
471
+ it("skips unmentioned group messages when requireMention=true", async () => {
472
+ await expectSkippedGroupMessage();
264
473
  });
265
474
 
266
- it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
475
+ it("blocks mentioned group messages by default when groupPolicy is omitted", async () => {
267
476
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
268
477
  commandAuthorized: false,
269
478
  });
479
+ const cfg: OpenClawConfig = {
480
+ channels: {
481
+ zalouser: {
482
+ enabled: true,
483
+ },
484
+ },
485
+ };
486
+ const account = resolveZalouserAccountSync({ cfg, accountId: "default" });
487
+
270
488
  await __testing.processMessage({
271
489
  message: createGroupMessage({
490
+ content: "ping @bot",
272
491
  hasAnyMention: true,
273
492
  wasExplicitlyMentioned: true,
274
- content: "ping @bot",
275
493
  }),
276
- account: createAccount(),
277
- config: createConfig(),
494
+ account,
495
+ config: cfg,
278
496
  runtime: createRuntimeEnv(),
279
497
  });
280
498
 
281
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
282
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
499
+ expect(account.config.groupPolicy).toBe("allowlist");
500
+ expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("fails closed when requireMention=true but mention detection is unavailable", async () => {
504
+ await expectSkippedGroupMessage({
505
+ canResolveExplicitMention: false,
506
+ hasAnyMention: false,
507
+ wasExplicitlyMentioned: false,
508
+ });
509
+ });
510
+
511
+ it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
512
+ const callArg = await dispatchGroupMessage({
513
+ commandAuthorized: false,
514
+ message: {
515
+ hasAnyMention: true,
516
+ wasExplicitlyMentioned: true,
517
+ content: "ping @bot",
518
+ },
519
+ });
283
520
  expect(callArg?.ctx?.WasMentioned).toBe(true);
284
521
  expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
285
522
  expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
@@ -290,22 +527,14 @@ describe("zalouser monitor group mention gating", () => {
290
527
  });
291
528
 
292
529
  it("allows authorized control commands to bypass mention gating", async () => {
293
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
530
+ const callArg = await dispatchGroupMessage({
294
531
  commandAuthorized: true,
295
- });
296
- await __testing.processMessage({
297
- message: createGroupMessage({
532
+ message: {
298
533
  content: "/status",
299
534
  hasAnyMention: false,
300
535
  wasExplicitlyMentioned: false,
301
- }),
302
- account: createAccount(),
303
- config: createConfig(),
304
- runtime: createRuntimeEnv(),
536
+ },
305
537
  });
306
-
307
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
308
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
309
538
  expect(callArg?.ctx?.WasMentioned).toBe(true);
310
539
  });
311
540
 
@@ -346,60 +575,33 @@ describe("zalouser monitor group mention gating", () => {
346
575
  });
347
576
 
348
577
  it("uses commandContent for mention-prefixed control commands", async () => {
349
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
578
+ const callArg = await dispatchGroupMessage({
350
579
  commandAuthorized: true,
351
- });
352
- await __testing.processMessage({
353
- message: createGroupMessage({
580
+ message: {
354
581
  content: "@Bot /new",
355
582
  commandContent: "/new",
356
583
  hasAnyMention: true,
357
584
  wasExplicitlyMentioned: true,
358
- }),
359
- account: createAccount(),
360
- config: createConfig(),
361
- runtime: createRuntimeEnv(),
585
+ },
362
586
  });
363
-
364
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
365
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
366
587
  expect(callArg?.ctx?.CommandBody).toBe("/new");
367
588
  expect(callArg?.ctx?.BodyForCommands).toBe("/new");
368
589
  });
369
590
 
370
591
  it("allows group control commands when only allowFrom is configured", async () => {
371
- const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
372
- installRuntime({
373
- resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
374
- useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
375
- });
376
- await __testing.processMessage({
377
- message: createGroupMessage({
378
- content: "/new",
379
- commandContent: "/new",
380
- hasAnyMention: true,
381
- wasExplicitlyMentioned: true,
382
- }),
383
- account: {
384
- ...createAccount(),
385
- config: {
386
- ...createAccount().config,
387
- allowFrom: ["123"],
388
- },
592
+ await expectGroupCommandAuthorizers({
593
+ accountConfig: {
594
+ ...createAccount().config,
595
+ allowFrom: ["123"],
389
596
  },
390
- config: createConfig(),
391
- runtime: createRuntimeEnv(),
597
+ expectedAuthorizers: [
598
+ { configured: true, allowed: true },
599
+ { configured: true, allowed: true },
600
+ ],
392
601
  });
393
-
394
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
395
- const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
396
- expect(authCall?.authorizers).toEqual([
397
- { configured: true, allowed: true },
398
- { configured: true, allowed: true },
399
- ]);
400
602
  });
401
603
 
402
- it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
604
+ it("blocks routed allowlist groups without an explicit group sender allowlist", async () => {
403
605
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
404
606
  commandAuthorized: false,
405
607
  });
@@ -408,13 +610,17 @@ describe("zalouser monitor group mention gating", () => {
408
610
  content: "ping @bot",
409
611
  hasAnyMention: true,
410
612
  wasExplicitlyMentioned: true,
613
+ senderId: "456",
411
614
  }),
412
615
  account: {
413
616
  ...createAccount(),
414
617
  config: {
415
618
  ...createAccount().config,
416
619
  groupPolicy: "allowlist",
417
- allowFrom: ["999"],
620
+ allowFrom: ["123"],
621
+ groups: {
622
+ "group:g-1": { enabled: true, requireMention: true },
623
+ },
418
624
  },
419
625
  },
420
626
  config: createConfig(),
@@ -424,29 +630,23 @@ describe("zalouser monitor group mention gating", () => {
424
630
  expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
425
631
  });
426
632
 
427
- it("does not accept a different group id by matching only the mutable group name by default", async () => {
633
+ it("blocks group messages when sender is not in groupAllowFrom", async () => {
428
634
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
429
635
  commandAuthorized: false,
430
636
  });
431
637
  await __testing.processMessage({
432
638
  message: createGroupMessage({
433
- threadId: "g-attacker-001",
434
- groupName: "Trusted Team",
435
- senderId: "666",
639
+ content: "ping @bot",
436
640
  hasAnyMention: true,
437
641
  wasExplicitlyMentioned: true,
438
- content: "ping @bot",
439
642
  }),
440
643
  account: {
441
644
  ...createAccount(),
442
645
  config: {
443
646
  ...createAccount().config,
444
647
  groupPolicy: "allowlist",
445
- groupAllowFrom: ["*"],
446
- groups: {
447
- "group:g-trusted-001": { allow: true },
448
- "Trusted Team": { allow: true },
449
- },
648
+ allowFrom: ["999"],
649
+ groupAllowFrom: ["999"],
450
650
  },
451
651
  },
452
652
  config: createConfig(),
@@ -456,92 +656,36 @@ describe("zalouser monitor group mention gating", () => {
456
656
  expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
457
657
  });
458
658
 
659
+ it("does not accept a different group id by matching only the mutable group name by default", async () => {
660
+ await expectDangerousNameMatching({ expectedDispatches: 0 });
661
+ });
662
+
459
663
  it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
460
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
461
- commandAuthorized: false,
664
+ const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
665
+ dangerouslyAllowNameMatching: true,
666
+ expectedDispatches: 1,
462
667
  });
463
- await __testing.processMessage({
464
- message: createGroupMessage({
465
- threadId: "g-attacker-001",
466
- groupName: "Trusted Team",
467
- senderId: "666",
468
- hasAnyMention: true,
469
- wasExplicitlyMentioned: true,
470
- content: "ping @bot",
471
- }),
472
- account: {
473
- ...createAccount(),
474
- config: {
475
- ...createAccount().config,
476
- dangerouslyAllowNameMatching: true,
477
- groupPolicy: "allowlist",
478
- groupAllowFrom: ["*"],
479
- groups: {
480
- "group:g-trusted-001": { allow: true },
481
- "Trusted Team": { allow: true },
482
- },
483
- },
484
- },
485
- config: createConfig(),
486
- runtime: createRuntimeEnv(),
487
- });
488
-
489
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
490
668
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
491
669
  expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
492
670
  });
493
671
 
494
672
  it("allows group control commands when sender is in groupAllowFrom", async () => {
495
- const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
496
- installRuntime({
497
- resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
498
- useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
499
- });
500
- await __testing.processMessage({
501
- message: createGroupMessage({
502
- content: "/new",
503
- commandContent: "/new",
504
- hasAnyMention: true,
505
- wasExplicitlyMentioned: true,
506
- }),
507
- account: {
508
- ...createAccount(),
509
- config: {
510
- ...createAccount().config,
511
- allowFrom: ["999"],
512
- groupAllowFrom: ["123"],
513
- },
673
+ await expectGroupCommandAuthorizers({
674
+ accountConfig: {
675
+ ...createAccount().config,
676
+ allowFrom: ["999"],
677
+ groupAllowFrom: ["123"],
514
678
  },
515
- config: createConfig(),
516
- runtime: createRuntimeEnv(),
679
+ expectedAuthorizers: [
680
+ { configured: true, allowed: false },
681
+ { configured: true, allowed: true },
682
+ ],
517
683
  });
518
-
519
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
520
- const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
521
- expect(authCall?.authorizers).toEqual([
522
- { configured: true, allowed: false },
523
- { configured: true, allowed: true },
524
- ]);
525
684
  });
526
685
 
527
686
  it("routes DM messages with direct peer kind", async () => {
528
687
  const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
529
- installRuntime({
530
- commandAuthorized: false,
531
- });
532
- const account = createAccount();
533
- await __testing.processMessage({
534
- message: createDmMessage(),
535
- account: {
536
- ...account,
537
- config: {
538
- ...account.config,
539
- dmPolicy: "open",
540
- },
541
- },
542
- config: createConfig(),
543
- runtime: createRuntimeEnv(),
544
- });
688
+ await processOpenDmMessage();
545
689
 
546
690
  expect(resolveAgentRoute).toHaveBeenCalledWith(
547
691
  expect.objectContaining({
@@ -559,31 +703,16 @@ describe("zalouser monitor group mention gating", () => {
559
703
  });
560
704
 
561
705
  it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
562
- const { dispatchReplyWithBufferedBlockDispatcher, readSessionUpdatedAt } = installRuntime({
563
- commandAuthorized: false,
564
- });
565
- readSessionUpdatedAt.mockImplementation((input?: { storePath: string; sessionKey: string }) =>
566
- input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
567
- );
568
- const account = createAccount();
569
- await __testing.processMessage({
570
- message: createDmMessage(),
571
- account: {
572
- ...account,
573
- config: {
574
- ...account.config,
575
- dmPolicy: "open",
576
- },
577
- },
578
- config: createConfig(),
579
- runtime: createRuntimeEnv(),
706
+ const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
707
+ readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
708
+ input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
580
709
  });
581
710
 
582
711
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
583
712
  expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321");
584
713
  });
585
714
 
586
- it("reads pairing store for open DM control commands", async () => {
715
+ it("skips pairing store read for open DM control commands", async () => {
587
716
  const { readAllowFromStore } = installRuntime({
588
717
  commandAuthorized: false,
589
718
  });
@@ -601,7 +730,7 @@ describe("zalouser monitor group mention gating", () => {
601
730
  runtime: createRuntimeEnv(),
602
731
  });
603
732
 
604
- expect(readAllowFromStore).toHaveBeenCalledTimes(1);
733
+ expect(readAllowFromStore).not.toHaveBeenCalled();
605
734
  });
606
735
 
607
736
  it("skips pairing store read for open DM non-command messages", async () => {