@openclaw/zalouser 2026.3.12 → 2026.3.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.13
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.3.12
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalouser",
3
- "version": "2026.3.12",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,10 @@
1
+ import { vi } from "vitest";
2
+ import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
3
+
4
+ vi.mock("./accounts.js", async (importOriginal) => {
5
+ const actual = (await importOriginal()) as Record<string, unknown>;
6
+ return {
7
+ ...actual,
8
+ resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
9
+ };
10
+ });
package/src/accounts.ts CHANGED
@@ -43,17 +43,24 @@ function resolveProfile(config: ZalouserAccountConfig, accountId: string): strin
43
43
  return "default";
44
44
  }
45
45
 
46
- export async function resolveZalouserAccount(params: {
47
- cfg: OpenClawConfig;
48
- accountId?: string | null;
49
- }): Promise<ResolvedZalouserAccount> {
46
+ function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
50
47
  const accountId = normalizeAccountId(params.accountId);
51
48
  const baseEnabled =
52
49
  (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
53
50
  const merged = mergeZalouserAccountConfig(params.cfg, accountId);
54
- const accountEnabled = merged.enabled !== false;
55
- const enabled = baseEnabled && accountEnabled;
56
- const profile = resolveProfile(merged, accountId);
51
+ return {
52
+ accountId,
53
+ enabled: baseEnabled && merged.enabled !== false,
54
+ merged,
55
+ profile: resolveProfile(merged, accountId),
56
+ };
57
+ }
58
+
59
+ export async function resolveZalouserAccount(params: {
60
+ cfg: OpenClawConfig;
61
+ accountId?: string | null;
62
+ }): Promise<ResolvedZalouserAccount> {
63
+ const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
57
64
  const authenticated = await checkZaloAuthenticated(profile);
58
65
 
59
66
  return {
@@ -70,13 +77,7 @@ export function resolveZalouserAccountSync(params: {
70
77
  cfg: OpenClawConfig;
71
78
  accountId?: string | null;
72
79
  }): ResolvedZalouserAccount {
73
- const accountId = normalizeAccountId(params.accountId);
74
- const baseEnabled =
75
- (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
76
- const merged = mergeZalouserAccountConfig(params.cfg, accountId);
77
- const accountEnabled = merged.enabled !== false;
78
- const enabled = baseEnabled && accountEnabled;
79
- const profile = resolveProfile(merged, accountId);
80
+ const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
80
81
 
81
82
  return {
82
83
  accountId,
@@ -1,5 +1,6 @@
1
- import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
2
1
  import { describe, expect, it, vi } from "vitest";
2
+ import "./accounts.test-mocks.js";
3
+ import { createZalouserRuntimeEnv } from "./test-helpers.js";
3
4
 
4
5
  const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => []));
5
6
 
@@ -11,30 +12,9 @@ vi.mock("./zalo-js.js", async (importOriginal) => {
11
12
  };
12
13
  });
13
14
 
14
- vi.mock("./accounts.js", async (importOriginal) => {
15
- const actual = (await importOriginal()) as Record<string, unknown>;
16
- return {
17
- ...actual,
18
- resolveZalouserAccountSync: () => ({
19
- accountId: "default",
20
- profile: "default",
21
- name: "test",
22
- enabled: true,
23
- authenticated: true,
24
- config: {},
25
- }),
26
- };
27
- });
28
-
29
15
  import { zalouserPlugin } from "./channel.js";
30
16
 
31
- const runtimeStub: RuntimeEnv = {
32
- log: vi.fn(),
33
- error: vi.fn(),
34
- exit: ((code: number): never => {
35
- throw new Error(`exit ${code}`);
36
- }) as RuntimeEnv["exit"],
37
- };
17
+ const runtimeStub = createZalouserRuntimeEnv();
38
18
 
39
19
  describe("zalouser directory group members", () => {
40
20
  it("accepts prefixed group ids from directory groups list output", async () => {
@@ -1,5 +1,6 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import "./accounts.test-mocks.js";
3
4
  import {
4
5
  installSendPayloadContractSuite,
5
6
  primeSendMock,
@@ -12,20 +13,6 @@ vi.mock("./send.js", () => ({
12
13
  sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
13
14
  }));
14
15
 
15
- vi.mock("./accounts.js", async (importOriginal) => {
16
- const actual = (await importOriginal()) as Record<string, unknown>;
17
- return {
18
- ...actual,
19
- resolveZalouserAccountSync: () => ({
20
- accountId: "default",
21
- profile: "default",
22
- name: "test",
23
- enabled: true,
24
- config: {},
25
- }),
26
- };
27
- });
28
-
29
16
  function baseCtx(payload: ReplyPayload) {
30
17
  return {
31
18
  cfg: {},
@@ -15,6 +15,33 @@ vi.mock("./send.js", async (importOriginal) => {
15
15
  const mockSendMessage = vi.mocked(sendMessageZalouser);
16
16
  const mockSendReaction = vi.mocked(sendReactionZalouser);
17
17
 
18
+ function getResolveToolPolicy() {
19
+ const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
20
+ expect(resolveToolPolicy).toBeTypeOf("function");
21
+ if (!resolveToolPolicy) {
22
+ throw new Error("resolveToolPolicy unavailable");
23
+ }
24
+ return resolveToolPolicy;
25
+ }
26
+
27
+ function resolveGroupToolPolicy(
28
+ groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
29
+ groupId: string,
30
+ ) {
31
+ return getResolveToolPolicy()({
32
+ cfg: {
33
+ channels: {
34
+ zalouser: {
35
+ groups,
36
+ },
37
+ },
38
+ },
39
+ accountId: "default",
40
+ groupId,
41
+ groupChannel: groupId,
42
+ });
43
+ }
44
+
18
45
  describe("zalouser outbound", () => {
19
46
  beforeEach(() => {
20
47
  mockSendMessage.mockClear();
@@ -93,48 +120,12 @@ describe("zalouser channel policies", () => {
93
120
  });
94
121
 
95
122
  it("resolves group tool policy by explicit group id", () => {
96
- const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
97
- expect(resolveToolPolicy).toBeTypeOf("function");
98
- if (!resolveToolPolicy) {
99
- return;
100
- }
101
- const policy = resolveToolPolicy({
102
- cfg: {
103
- channels: {
104
- zalouser: {
105
- groups: {
106
- "123": { tools: { allow: ["search"] } },
107
- },
108
- },
109
- },
110
- },
111
- accountId: "default",
112
- groupId: "123",
113
- groupChannel: "123",
114
- });
123
+ const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
115
124
  expect(policy).toEqual({ allow: ["search"] });
116
125
  });
117
126
 
118
127
  it("falls back to wildcard group policy", () => {
119
- const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
120
- expect(resolveToolPolicy).toBeTypeOf("function");
121
- if (!resolveToolPolicy) {
122
- return;
123
- }
124
- const policy = resolveToolPolicy({
125
- cfg: {
126
- channels: {
127
- zalouser: {
128
- groups: {
129
- "*": { tools: { deny: ["system.run"] } },
130
- },
131
- },
132
- },
133
- },
134
- accountId: "default",
135
- groupId: "missing",
136
- groupChannel: "missing",
137
- });
128
+ const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
138
129
  expect(policy).toEqual({ deny: ["system.run"] });
139
130
  });
140
131
 
package/src/channel.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  sendPayloadWithChunkedTextAndMedia,
30
30
  setAccountEnabledInConfigSection,
31
31
  } from "openclaw/plugin-sdk/zalouser";
32
+ import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
32
33
  import {
33
34
  listZalouserAccountIds,
34
35
  resolveDefaultZalouserAccountId,
@@ -652,15 +653,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
652
653
  lastError: null,
653
654
  },
654
655
  collectStatusIssues: collectZalouserStatusIssues,
655
- buildChannelSummary: ({ snapshot }) => ({
656
- configured: snapshot.configured ?? false,
657
- running: snapshot.running ?? false,
658
- lastStartAt: snapshot.lastStartAt ?? null,
659
- lastStopAt: snapshot.lastStopAt ?? null,
660
- lastError: snapshot.lastError ?? null,
661
- probe: snapshot.probe,
662
- lastProbeAt: snapshot.lastProbeAt ?? null,
663
- }),
656
+ buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
664
657
  probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
665
658
  buildAccountSnapshot: async ({ account, runtime }) => {
666
659
  const configured = await checkZcaAuthenticated(account.profile);
@@ -4,6 +4,7 @@ import "./monitor.send-mocks.js";
4
4
  import { __testing } from "./monitor.js";
5
5
  import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
6
6
  import { setZalouserRuntime } from "./runtime.js";
7
+ import { createZalouserRuntimeEnv } from "./test-helpers.js";
7
8
  import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
8
9
 
9
10
  describe("zalouser monitor pairing account scoping", () => {
@@ -80,19 +81,11 @@ describe("zalouser monitor pairing account scoping", () => {
80
81
  raw: { source: "test" },
81
82
  };
82
83
 
83
- const runtime: RuntimeEnv = {
84
- log: vi.fn(),
85
- error: vi.fn(),
86
- exit: ((code: number): never => {
87
- throw new Error(`exit ${code}`);
88
- }) as RuntimeEnv["exit"],
89
- };
90
-
91
84
  await __testing.processMessage({
92
85
  message,
93
86
  account,
94
87
  config,
95
- runtime,
88
+ runtime: createZalouserRuntimeEnv(),
96
89
  });
97
90
 
98
91
  expect(readAllowFromStore).toHaveBeenCalledWith(
@@ -9,6 +9,7 @@ import {
9
9
  sendTypingZalouserMock,
10
10
  } from "./monitor.send-mocks.js";
11
11
  import { setZalouserRuntime } from "./runtime.js";
12
+ import { createZalouserRuntimeEnv } from "./test-helpers.js";
12
13
  import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
13
14
 
14
15
  function createAccount(): ResolvedZalouserAccount {
@@ -39,15 +40,7 @@ function createConfig(): OpenClawConfig {
39
40
  };
40
41
  }
41
42
 
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
- }
43
+ const createRuntimeEnv = () => createZalouserRuntimeEnv();
51
44
 
52
45
  function installRuntime(params: {
53
46
  commandAuthorized?: boolean;
@@ -187,6 +180,31 @@ function installRuntime(params: {
187
180
  };
188
181
  }
189
182
 
183
+ function installGroupCommandAuthRuntime() {
184
+ return installRuntime({
185
+ resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
186
+ useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
187
+ });
188
+ }
189
+
190
+ async function processGroupControlCommand(params: {
191
+ account: ResolvedZalouserAccount;
192
+ content?: string;
193
+ commandContent?: string;
194
+ }) {
195
+ await __testing.processMessage({
196
+ message: createGroupMessage({
197
+ content: params.content ?? "/new",
198
+ commandContent: params.commandContent ?? "/new",
199
+ hasAnyMention: true,
200
+ wasExplicitlyMentioned: true,
201
+ }),
202
+ account: params.account,
203
+ config: createConfig(),
204
+ runtime: createRuntimeEnv(),
205
+ });
206
+ }
207
+
190
208
  function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
191
209
  return {
192
210
  threadId: "g-1",
@@ -229,57 +247,152 @@ describe("zalouser monitor group mention gating", () => {
229
247
  sendSeenZalouserMock.mockClear();
230
248
  });
231
249
 
232
- it("skips unmentioned group messages when requireMention=true", async () => {
233
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
234
- commandAuthorized: false,
235
- });
250
+ async function processMessageWithDefaults(params: {
251
+ message: ZaloInboundMessage;
252
+ account?: ResolvedZalouserAccount;
253
+ historyState?: {
254
+ historyLimit: number;
255
+ groupHistories: Map<
256
+ string,
257
+ Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
258
+ >;
259
+ };
260
+ }) {
236
261
  await __testing.processMessage({
237
- message: createGroupMessage(),
238
- account: createAccount(),
262
+ message: params.message,
263
+ account: params.account ?? createAccount(),
239
264
  config: createConfig(),
240
- runtime: createRuntimeEnv(),
265
+ runtime: createZalouserRuntimeEnv(),
266
+ historyState: params.historyState,
241
267
  });
268
+ }
242
269
 
243
- expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
244
- expect(sendTypingZalouserMock).not.toHaveBeenCalled();
245
- });
246
-
247
- it("fails closed when requireMention=true but mention detection is unavailable", async () => {
270
+ async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
248
271
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
249
272
  commandAuthorized: false,
250
273
  });
251
- await __testing.processMessage({
252
- message: createGroupMessage({
253
- canResolveExplicitMention: false,
254
- hasAnyMention: false,
255
- wasExplicitlyMentioned: false,
256
- }),
257
- account: createAccount(),
258
- config: createConfig(),
259
- runtime: createRuntimeEnv(),
274
+ await processMessageWithDefaults({
275
+ message: createGroupMessage(message),
260
276
  });
261
-
262
277
  expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
263
278
  expect(sendTypingZalouserMock).not.toHaveBeenCalled();
264
- });
279
+ }
265
280
 
266
- it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
281
+ async function expectGroupCommandAuthorizers(params: {
282
+ accountConfig: ResolvedZalouserAccount["config"];
283
+ expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>;
284
+ }) {
285
+ const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
286
+ installGroupCommandAuthRuntime();
287
+ await processGroupControlCommand({
288
+ account: {
289
+ ...createAccount(),
290
+ config: params.accountConfig,
291
+ },
292
+ });
293
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
294
+ const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
295
+ expect(authCall?.authorizers).toEqual(params.expectedAuthorizers);
296
+ }
297
+
298
+ async function processOpenDmMessage(params?: {
299
+ message?: Partial<ZaloInboundMessage>;
300
+ readSessionUpdatedAt?: (input?: {
301
+ storePath: string;
302
+ sessionKey: string;
303
+ }) => number | undefined;
304
+ }) {
305
+ const runtime = installRuntime({
306
+ commandAuthorized: false,
307
+ });
308
+ if (params?.readSessionUpdatedAt) {
309
+ runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
310
+ }
311
+ const account = createAccount();
312
+ await processMessageWithDefaults({
313
+ message: createDmMessage(params?.message),
314
+ account: {
315
+ ...account,
316
+ config: {
317
+ ...account.config,
318
+ dmPolicy: "open",
319
+ },
320
+ },
321
+ });
322
+ return runtime;
323
+ }
324
+
325
+ async function expectDangerousNameMatching(params: {
326
+ dangerouslyAllowNameMatching?: boolean;
327
+ expectedDispatches: number;
328
+ }) {
267
329
  const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
268
330
  commandAuthorized: false,
269
331
  });
270
- await __testing.processMessage({
332
+ await processMessageWithDefaults({
271
333
  message: createGroupMessage({
334
+ threadId: "g-attacker-001",
335
+ groupName: "Trusted Team",
336
+ senderId: "666",
272
337
  hasAnyMention: true,
273
338
  wasExplicitlyMentioned: true,
274
339
  content: "ping @bot",
275
340
  }),
276
- account: createAccount(),
277
- config: createConfig(),
278
- runtime: createRuntimeEnv(),
341
+ account: {
342
+ ...createAccount(),
343
+ config: {
344
+ ...createAccount().config,
345
+ ...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
346
+ groupPolicy: "allowlist",
347
+ groupAllowFrom: ["*"],
348
+ groups: {
349
+ "group:g-trusted-001": { allow: true },
350
+ "Trusted Team": { allow: true },
351
+ },
352
+ },
353
+ },
279
354
  });
355
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
356
+ params.expectedDispatches,
357
+ );
358
+ return dispatchReplyWithBufferedBlockDispatcher;
359
+ }
280
360
 
361
+ async function dispatchGroupMessage(params: {
362
+ commandAuthorized: boolean;
363
+ message: Partial<ZaloInboundMessage>;
364
+ }) {
365
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
366
+ commandAuthorized: params.commandAuthorized,
367
+ });
368
+ await processMessageWithDefaults({
369
+ message: createGroupMessage(params.message),
370
+ });
281
371
  expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
282
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
372
+ return dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
373
+ }
374
+
375
+ it("skips unmentioned group messages when requireMention=true", async () => {
376
+ await expectSkippedGroupMessage();
377
+ });
378
+
379
+ it("fails closed when requireMention=true but mention detection is unavailable", async () => {
380
+ await expectSkippedGroupMessage({
381
+ canResolveExplicitMention: false,
382
+ hasAnyMention: false,
383
+ wasExplicitlyMentioned: false,
384
+ });
385
+ });
386
+
387
+ it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
388
+ const callArg = await dispatchGroupMessage({
389
+ commandAuthorized: false,
390
+ message: {
391
+ hasAnyMention: true,
392
+ wasExplicitlyMentioned: true,
393
+ content: "ping @bot",
394
+ },
395
+ });
283
396
  expect(callArg?.ctx?.WasMentioned).toBe(true);
284
397
  expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
285
398
  expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
@@ -290,22 +403,14 @@ describe("zalouser monitor group mention gating", () => {
290
403
  });
291
404
 
292
405
  it("allows authorized control commands to bypass mention gating", async () => {
293
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
406
+ const callArg = await dispatchGroupMessage({
294
407
  commandAuthorized: true,
295
- });
296
- await __testing.processMessage({
297
- message: createGroupMessage({
408
+ message: {
298
409
  content: "/status",
299
410
  hasAnyMention: false,
300
411
  wasExplicitlyMentioned: false,
301
- }),
302
- account: createAccount(),
303
- config: createConfig(),
304
- runtime: createRuntimeEnv(),
412
+ },
305
413
  });
306
-
307
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
308
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
309
414
  expect(callArg?.ctx?.WasMentioned).toBe(true);
310
415
  });
311
416
 
@@ -346,57 +451,30 @@ describe("zalouser monitor group mention gating", () => {
346
451
  });
347
452
 
348
453
  it("uses commandContent for mention-prefixed control commands", async () => {
349
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
454
+ const callArg = await dispatchGroupMessage({
350
455
  commandAuthorized: true,
351
- });
352
- await __testing.processMessage({
353
- message: createGroupMessage({
456
+ message: {
354
457
  content: "@Bot /new",
355
458
  commandContent: "/new",
356
459
  hasAnyMention: true,
357
460
  wasExplicitlyMentioned: true,
358
- }),
359
- account: createAccount(),
360
- config: createConfig(),
361
- runtime: createRuntimeEnv(),
461
+ },
362
462
  });
363
-
364
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
365
- const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
366
463
  expect(callArg?.ctx?.CommandBody).toBe("/new");
367
464
  expect(callArg?.ctx?.BodyForCommands).toBe("/new");
368
465
  });
369
466
 
370
467
  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
- },
468
+ await expectGroupCommandAuthorizers({
469
+ accountConfig: {
470
+ ...createAccount().config,
471
+ allowFrom: ["123"],
389
472
  },
390
- config: createConfig(),
391
- runtime: createRuntimeEnv(),
473
+ expectedAuthorizers: [
474
+ { configured: true, allowed: true },
475
+ { configured: true, allowed: true },
476
+ ],
392
477
  });
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
478
  });
401
479
 
402
480
  it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
@@ -425,123 +503,35 @@ describe("zalouser monitor group mention gating", () => {
425
503
  });
426
504
 
427
505
  it("does not accept a different group id by matching only the mutable group name by default", async () => {
428
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
429
- commandAuthorized: false,
430
- });
431
- await __testing.processMessage({
432
- message: createGroupMessage({
433
- threadId: "g-attacker-001",
434
- groupName: "Trusted Team",
435
- senderId: "666",
436
- hasAnyMention: true,
437
- wasExplicitlyMentioned: true,
438
- content: "ping @bot",
439
- }),
440
- account: {
441
- ...createAccount(),
442
- config: {
443
- ...createAccount().config,
444
- groupPolicy: "allowlist",
445
- groupAllowFrom: ["*"],
446
- groups: {
447
- "group:g-trusted-001": { allow: true },
448
- "Trusted Team": { allow: true },
449
- },
450
- },
451
- },
452
- config: createConfig(),
453
- runtime: createRuntimeEnv(),
454
- });
455
-
456
- expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
506
+ await expectDangerousNameMatching({ expectedDispatches: 0 });
457
507
  });
458
508
 
459
509
  it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
460
- const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
461
- commandAuthorized: false,
462
- });
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(),
510
+ const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
511
+ dangerouslyAllowNameMatching: true,
512
+ expectedDispatches: 1,
487
513
  });
488
-
489
- expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
490
514
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
491
515
  expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
492
516
  });
493
517
 
494
518
  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
- },
519
+ await expectGroupCommandAuthorizers({
520
+ accountConfig: {
521
+ ...createAccount().config,
522
+ allowFrom: ["999"],
523
+ groupAllowFrom: ["123"],
514
524
  },
515
- config: createConfig(),
516
- runtime: createRuntimeEnv(),
525
+ expectedAuthorizers: [
526
+ { configured: true, allowed: false },
527
+ { configured: true, allowed: true },
528
+ ],
517
529
  });
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
530
  });
526
531
 
527
532
  it("routes DM messages with direct peer kind", async () => {
528
533
  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
- });
534
+ await processOpenDmMessage();
545
535
 
546
536
  expect(resolveAgentRoute).toHaveBeenCalledWith(
547
537
  expect.objectContaining({
@@ -559,24 +549,9 @@ describe("zalouser monitor group mention gating", () => {
559
549
  });
560
550
 
561
551
  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(),
552
+ const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
553
+ readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
554
+ input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
580
555
  });
581
556
 
582
557
  const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
package/src/monitor.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  summarizeMapping,
32
32
  warnMissingProviderGroupPolicyFallbackOnce,
33
33
  } from "openclaw/plugin-sdk/zalouser";
34
+ import { createDeferred } from "../../shared/deferred.js";
34
35
  import {
35
36
  buildZalouserGroupCandidates,
36
37
  findZalouserGroupEntry,
@@ -129,16 +130,6 @@ function resolveInboundQueueKey(message: ZaloInboundMessage): string {
129
130
  return `direct:${senderId || threadId}`;
130
131
  }
131
132
 
132
- function createDeferred<T>() {
133
- let resolve!: (value: T | PromiseLike<T>) => void;
134
- let reject!: (reason?: unknown) => void;
135
- const promise = new Promise<T>((res, rej) => {
136
- resolve = res;
137
- reject = rej;
138
- });
139
- return { promise, resolve, reject };
140
- }
141
-
142
133
  function resolveZalouserDmSessionScope(config: OpenClawConfig) {
143
134
  const configured = config.session?.dmScope;
144
135
  return configured === "main" || !configured ? "per-channel-peer" : configured;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js";
2
3
  import { collectZalouserStatusIssues } from "./status-issues.js";
3
4
 
4
5
  describe("collectZalouserStatusIssues", () => {
@@ -17,16 +18,15 @@ describe("collectZalouserStatusIssues", () => {
17
18
  });
18
19
 
19
20
  it("warns when dmPolicy is open", () => {
20
- const issues = collectZalouserStatusIssues([
21
- {
21
+ expectOpenDmPolicyConfigIssue({
22
+ collectIssues: collectZalouserStatusIssues,
23
+ account: {
22
24
  accountId: "default",
23
25
  enabled: true,
24
26
  configured: true,
25
27
  dmPolicy: "open",
26
28
  },
27
- ]);
28
- expect(issues).toHaveLength(1);
29
- expect(issues[0]?.kind).toBe("config");
29
+ });
30
30
  });
31
31
 
32
32
  it("skips disabled accounts", () => {
@@ -1,42 +1,24 @@
1
1
  import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalouser";
2
+ import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js";
2
3
 
3
- type ZalouserAccountStatus = {
4
- accountId?: unknown;
5
- enabled?: unknown;
6
- configured?: unknown;
7
- dmPolicy?: unknown;
8
- lastError?: unknown;
9
- };
10
-
11
- const isRecord = (value: unknown): value is Record<string, unknown> =>
12
- Boolean(value && typeof value === "object");
13
-
14
- const asString = (value: unknown): string | undefined =>
15
- typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
16
-
17
- function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccountStatus | null {
18
- if (!isRecord(value)) {
19
- return null;
20
- }
21
- return {
22
- accountId: value.accountId,
23
- enabled: value.enabled,
24
- configured: value.configured,
25
- dmPolicy: value.dmPolicy,
26
- lastError: value.lastError,
27
- };
28
- }
4
+ const ZALOUSER_STATUS_FIELDS = [
5
+ "accountId",
6
+ "enabled",
7
+ "configured",
8
+ "dmPolicy",
9
+ "lastError",
10
+ ] as const;
29
11
 
30
12
  export function collectZalouserStatusIssues(
31
13
  accounts: ChannelAccountSnapshot[],
32
14
  ): ChannelStatusIssue[] {
33
15
  const issues: ChannelStatusIssue[] = [];
34
16
  for (const entry of accounts) {
35
- const account = readZalouserAccountStatus(entry);
17
+ const account = readStatusIssueFields(entry, ZALOUSER_STATUS_FIELDS);
36
18
  if (!account) {
37
19
  continue;
38
20
  }
39
- const accountId = asString(account.accountId) ?? "default";
21
+ const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
40
22
  const enabled = account.enabled !== false;
41
23
  if (!enabled) {
42
24
  continue;
@@ -0,0 +1,26 @@
1
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
2
+ import type { ResolvedZalouserAccount } from "./types.js";
3
+
4
+ export function createZalouserRuntimeEnv(): RuntimeEnv {
5
+ return {
6
+ log: () => {},
7
+ error: () => {},
8
+ exit: ((code: number): never => {
9
+ throw new Error(`exit ${code}`);
10
+ }) as RuntimeEnv["exit"],
11
+ };
12
+ }
13
+
14
+ export function createDefaultResolvedZalouserAccount(
15
+ overrides: Partial<ResolvedZalouserAccount> = {},
16
+ ): ResolvedZalouserAccount {
17
+ return {
18
+ accountId: "default",
19
+ profile: "default",
20
+ name: "test",
21
+ enabled: true,
22
+ authenticated: true,
23
+ config: {},
24
+ ...overrides,
25
+ };
26
+ }