@openclaw/zalouser 2026.3.7 → 2026.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,11 +49,67 @@ function createRuntimeEnv(): RuntimeEnv {
49
49
  };
50
50
  }
51
51
 
52
- function installRuntime(params: { commandAuthorized: boolean }) {
52
+ function installRuntime(params: {
53
+ commandAuthorized?: boolean;
54
+ resolveCommandAuthorizedFromAuthorizers?: (params: {
55
+ useAccessGroups: boolean;
56
+ authorizers: Array<{ configured: boolean; allowed: boolean }>;
57
+ }) => boolean;
58
+ }) {
53
59
  const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
54
60
  await dispatcherOptions.typingCallbacks?.onReplyStart?.();
55
61
  return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
56
62
  });
63
+ const resolveCommandAuthorizedFromAuthorizers = vi.fn(
64
+ (input: {
65
+ useAccessGroups: boolean;
66
+ authorizers: Array<{ configured: boolean; allowed: boolean }>;
67
+ }) => {
68
+ if (params.resolveCommandAuthorizedFromAuthorizers) {
69
+ return params.resolveCommandAuthorizedFromAuthorizers(input);
70
+ }
71
+ return params.commandAuthorized ?? false;
72
+ },
73
+ );
74
+ const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => {
75
+ const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
76
+ const peerId = input.peer?.id ?? "1";
77
+ return {
78
+ agentId: "main",
79
+ sessionKey:
80
+ peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`,
81
+ accountId: "default",
82
+ mainSessionKey: "agent:main:main",
83
+ };
84
+ });
85
+ const readAllowFromStore = vi.fn(async () => []);
86
+ const readSessionUpdatedAt = vi.fn(
87
+ (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
88
+ );
89
+ const buildAgentSessionKey = vi.fn(
90
+ (input: {
91
+ agentId: string;
92
+ channel: string;
93
+ accountId?: string;
94
+ peer?: { kind?: string; id?: string };
95
+ dmScope?: string;
96
+ }) => {
97
+ const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
98
+ const peerId = input.peer?.id ?? "1";
99
+ if (peerKind === "direct") {
100
+ if (input.dmScope === "per-account-channel-peer") {
101
+ return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`;
102
+ }
103
+ if (input.dmScope === "per-peer") {
104
+ return `agent:${input.agentId}:direct:${peerId}`;
105
+ }
106
+ if (input.dmScope === "main" || !input.dmScope) {
107
+ return "agent:main:main";
108
+ }
109
+ }
110
+ return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`;
111
+ },
112
+ );
57
113
 
58
114
  setZalouserRuntime({
59
115
  logging: {
@@ -61,13 +117,13 @@ function installRuntime(params: { commandAuthorized: boolean }) {
61
117
  },
62
118
  channel: {
63
119
  pairing: {
64
- readAllowFromStore: vi.fn(async () => []),
120
+ readAllowFromStore,
65
121
  upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
66
122
  buildPairingReply: vi.fn(() => "pair"),
67
123
  },
68
124
  commands: {
69
125
  shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
70
- resolveCommandAuthorizedFromAuthorizers: vi.fn(() => params.commandAuthorized),
126
+ resolveCommandAuthorizedFromAuthorizers,
71
127
  isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
72
128
  shouldHandleTextCommands: vi.fn(() => true),
73
129
  },
@@ -93,16 +149,12 @@ function installRuntime(params: { commandAuthorized: boolean }) {
93
149
  }),
94
150
  },
95
151
  routing: {
96
- resolveAgentRoute: vi.fn(() => ({
97
- agentId: "main",
98
- sessionKey: "agent:main:zalouser:group:1",
99
- accountId: "default",
100
- mainSessionKey: "agent:main:main",
101
- })),
152
+ buildAgentSessionKey,
153
+ resolveAgentRoute,
102
154
  },
103
155
  session: {
104
156
  resolveStorePath: vi.fn(() => "/tmp"),
105
- readSessionUpdatedAt: vi.fn(() => undefined),
157
+ readSessionUpdatedAt,
106
158
  recordInboundSession: vi.fn(async () => {}),
107
159
  },
108
160
  reply: {
@@ -120,7 +172,14 @@ function installRuntime(params: { commandAuthorized: boolean }) {
120
172
  },
121
173
  } as unknown as PluginRuntime);
122
174
 
123
- return { dispatchReplyWithBufferedBlockDispatcher };
175
+ return {
176
+ dispatchReplyWithBufferedBlockDispatcher,
177
+ resolveAgentRoute,
178
+ resolveCommandAuthorizedFromAuthorizers,
179
+ readAllowFromStore,
180
+ readSessionUpdatedAt,
181
+ buildAgentSessionKey,
182
+ };
124
183
  }
125
184
 
126
185
  function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
@@ -142,6 +201,21 @@ function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloIn
142
201
  };
143
202
  }
144
203
 
204
+ function createDmMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
205
+ return {
206
+ threadId: "u-1",
207
+ isGroup: false,
208
+ senderId: "321",
209
+ senderName: "Bob",
210
+ groupName: undefined,
211
+ content: "hello",
212
+ timestampMs: Date.now(),
213
+ msgId: "dm-1",
214
+ raw: { source: "test" },
215
+ ...overrides,
216
+ };
217
+ }
218
+
145
219
  describe("zalouser monitor group mention gating", () => {
146
220
  beforeEach(() => {
147
221
  sendMessageZalouserMock.mockClear();
@@ -165,6 +239,25 @@ describe("zalouser monitor group mention gating", () => {
165
239
  expect(sendTypingZalouserMock).not.toHaveBeenCalled();
166
240
  });
167
241
 
242
+ it("fails closed when requireMention=true but mention detection is unavailable", async () => {
243
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
244
+ commandAuthorized: false,
245
+ });
246
+ await __testing.processMessage({
247
+ message: createGroupMessage({
248
+ canResolveExplicitMention: false,
249
+ hasAnyMention: false,
250
+ wasExplicitlyMentioned: false,
251
+ }),
252
+ account: createAccount(),
253
+ config: createConfig(),
254
+ runtime: createRuntimeEnv(),
255
+ });
256
+
257
+ expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
258
+ expect(sendTypingZalouserMock).not.toHaveBeenCalled();
259
+ });
260
+
168
261
  it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
169
262
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
170
263
  commandAuthorized: false,
@@ -183,6 +276,8 @@ describe("zalouser monitor group mention gating", () => {
183
276
  expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
184
277
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
185
278
  expect(callArg?.ctx?.WasMentioned).toBe(true);
279
+ expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
280
+ expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
186
281
  expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
187
282
  profile: "default",
188
283
  isGroup: true,
@@ -208,4 +303,277 @@ describe("zalouser monitor group mention gating", () => {
208
303
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
209
304
  expect(callArg?.ctx?.WasMentioned).toBe(true);
210
305
  });
306
+
307
+ it("uses commandContent for mention-prefixed control commands", async () => {
308
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
309
+ commandAuthorized: true,
310
+ });
311
+ await __testing.processMessage({
312
+ message: createGroupMessage({
313
+ content: "@Bot /new",
314
+ commandContent: "/new",
315
+ hasAnyMention: true,
316
+ wasExplicitlyMentioned: true,
317
+ }),
318
+ account: createAccount(),
319
+ config: createConfig(),
320
+ runtime: createRuntimeEnv(),
321
+ });
322
+
323
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
324
+ const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
325
+ expect(callArg?.ctx?.CommandBody).toBe("/new");
326
+ expect(callArg?.ctx?.BodyForCommands).toBe("/new");
327
+ });
328
+
329
+ it("allows group control commands when only allowFrom is configured", async () => {
330
+ const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
331
+ installRuntime({
332
+ resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
333
+ useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
334
+ });
335
+ await __testing.processMessage({
336
+ message: createGroupMessage({
337
+ content: "/new",
338
+ commandContent: "/new",
339
+ hasAnyMention: true,
340
+ wasExplicitlyMentioned: true,
341
+ }),
342
+ account: {
343
+ ...createAccount(),
344
+ config: {
345
+ ...createAccount().config,
346
+ allowFrom: ["123"],
347
+ },
348
+ },
349
+ config: createConfig(),
350
+ runtime: createRuntimeEnv(),
351
+ });
352
+
353
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
354
+ const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
355
+ expect(authCall?.authorizers).toEqual([
356
+ { configured: true, allowed: true },
357
+ { configured: true, allowed: true },
358
+ ]);
359
+ });
360
+
361
+ it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
362
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
363
+ commandAuthorized: false,
364
+ });
365
+ await __testing.processMessage({
366
+ message: createGroupMessage({
367
+ content: "ping @bot",
368
+ hasAnyMention: true,
369
+ wasExplicitlyMentioned: true,
370
+ }),
371
+ account: {
372
+ ...createAccount(),
373
+ config: {
374
+ ...createAccount().config,
375
+ groupPolicy: "allowlist",
376
+ allowFrom: ["999"],
377
+ },
378
+ },
379
+ config: createConfig(),
380
+ runtime: createRuntimeEnv(),
381
+ });
382
+
383
+ expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
384
+ });
385
+
386
+ it("allows group control commands when sender is in groupAllowFrom", async () => {
387
+ const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
388
+ installRuntime({
389
+ resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
390
+ useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
391
+ });
392
+ await __testing.processMessage({
393
+ message: createGroupMessage({
394
+ content: "/new",
395
+ commandContent: "/new",
396
+ hasAnyMention: true,
397
+ wasExplicitlyMentioned: true,
398
+ }),
399
+ account: {
400
+ ...createAccount(),
401
+ config: {
402
+ ...createAccount().config,
403
+ allowFrom: ["999"],
404
+ groupAllowFrom: ["123"],
405
+ },
406
+ },
407
+ config: createConfig(),
408
+ runtime: createRuntimeEnv(),
409
+ });
410
+
411
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
412
+ const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
413
+ expect(authCall?.authorizers).toEqual([
414
+ { configured: true, allowed: false },
415
+ { configured: true, allowed: true },
416
+ ]);
417
+ });
418
+
419
+ it("routes DM messages with direct peer kind", async () => {
420
+ const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
421
+ installRuntime({
422
+ commandAuthorized: false,
423
+ });
424
+ const account = createAccount();
425
+ await __testing.processMessage({
426
+ message: createDmMessage(),
427
+ account: {
428
+ ...account,
429
+ config: {
430
+ ...account.config,
431
+ dmPolicy: "open",
432
+ },
433
+ },
434
+ config: createConfig(),
435
+ runtime: createRuntimeEnv(),
436
+ });
437
+
438
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
439
+ expect.objectContaining({
440
+ peer: { kind: "direct", id: "321" },
441
+ }),
442
+ );
443
+ expect(buildAgentSessionKey).toHaveBeenCalledWith(
444
+ expect.objectContaining({
445
+ peer: { kind: "direct", id: "321" },
446
+ dmScope: "per-channel-peer",
447
+ }),
448
+ );
449
+ const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
450
+ expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321");
451
+ });
452
+
453
+ it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
454
+ const { dispatchReplyWithBufferedBlockDispatcher, readSessionUpdatedAt } = installRuntime({
455
+ commandAuthorized: false,
456
+ });
457
+ readSessionUpdatedAt.mockImplementation((input?: { storePath: string; sessionKey: string }) =>
458
+ input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
459
+ );
460
+ const account = createAccount();
461
+ await __testing.processMessage({
462
+ message: createDmMessage(),
463
+ account: {
464
+ ...account,
465
+ config: {
466
+ ...account.config,
467
+ dmPolicy: "open",
468
+ },
469
+ },
470
+ config: createConfig(),
471
+ runtime: createRuntimeEnv(),
472
+ });
473
+
474
+ const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
475
+ expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321");
476
+ });
477
+
478
+ it("reads pairing store for open DM control commands", async () => {
479
+ const { readAllowFromStore } = installRuntime({
480
+ commandAuthorized: false,
481
+ });
482
+ const account = createAccount();
483
+ await __testing.processMessage({
484
+ message: createDmMessage({ content: "/new", commandContent: "/new" }),
485
+ account: {
486
+ ...account,
487
+ config: {
488
+ ...account.config,
489
+ dmPolicy: "open",
490
+ },
491
+ },
492
+ config: createConfig(),
493
+ runtime: createRuntimeEnv(),
494
+ });
495
+
496
+ expect(readAllowFromStore).toHaveBeenCalledTimes(1);
497
+ });
498
+
499
+ it("skips pairing store read for open DM non-command messages", async () => {
500
+ const { readAllowFromStore } = installRuntime({
501
+ commandAuthorized: false,
502
+ });
503
+ const account = createAccount();
504
+ await __testing.processMessage({
505
+ message: createDmMessage({ content: "hello there" }),
506
+ account: {
507
+ ...account,
508
+ config: {
509
+ ...account.config,
510
+ dmPolicy: "open",
511
+ },
512
+ },
513
+ config: createConfig(),
514
+ runtime: createRuntimeEnv(),
515
+ });
516
+
517
+ expect(readAllowFromStore).not.toHaveBeenCalled();
518
+ });
519
+
520
+ it("includes skipped group messages as InboundHistory on the next processed message", async () => {
521
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
522
+ commandAuthorized: false,
523
+ });
524
+ const historyState = {
525
+ historyLimit: 5,
526
+ groupHistories: new Map<
527
+ string,
528
+ Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
529
+ >(),
530
+ };
531
+ const account = createAccount();
532
+ const config = createConfig();
533
+ await __testing.processMessage({
534
+ message: createGroupMessage({
535
+ content: "first unmentioned line",
536
+ hasAnyMention: false,
537
+ wasExplicitlyMentioned: false,
538
+ }),
539
+ account,
540
+ config,
541
+ runtime: createRuntimeEnv(),
542
+ historyState,
543
+ });
544
+ expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
545
+
546
+ await __testing.processMessage({
547
+ message: createGroupMessage({
548
+ content: "second line @bot",
549
+ hasAnyMention: true,
550
+ wasExplicitlyMentioned: true,
551
+ }),
552
+ account,
553
+ config,
554
+ runtime: createRuntimeEnv(),
555
+ historyState,
556
+ });
557
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
558
+ const firstDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
559
+ expect(firstDispatch?.ctx?.InboundHistory).toEqual([
560
+ expect.objectContaining({ sender: "Alice", body: "first unmentioned line" }),
561
+ ]);
562
+ expect(String(firstDispatch?.ctx?.Body ?? "")).toContain("first unmentioned line");
563
+
564
+ await __testing.processMessage({
565
+ message: createGroupMessage({
566
+ content: "third line @bot",
567
+ hasAnyMention: true,
568
+ wasExplicitlyMentioned: true,
569
+ }),
570
+ account,
571
+ config,
572
+ runtime: createRuntimeEnv(),
573
+ historyState,
574
+ });
575
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
576
+ const secondDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
577
+ expect(secondDispatch?.ctx?.InboundHistory).toEqual([]);
578
+ });
211
579
  });