@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.10
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.9
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.3.8-beta.1
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
21
+ ## 2026.3.8
22
+
23
+ ### Changes
24
+
25
+ - Version alignment with core OpenClaw release numbers.
26
+
3
27
  ## 2026.3.7
4
28
 
5
29
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalouser",
3
- "version": "2026.3.7",
3
+ "version": "2026.3.10",
4
4
  "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -29,6 +29,11 @@
29
29
  "npmSpec": "@openclaw/zalouser",
30
30
  "localPath": "extensions/zalouser",
31
31
  "defaultChoice": "npm"
32
+ },
33
+ "releaseChecks": {
34
+ "rootDependencyMirrorAllowlist": [
35
+ "zca-js"
36
+ ]
32
37
  }
33
38
  }
34
39
  }
@@ -0,0 +1,72 @@
1
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => []));
5
+
6
+ vi.mock("./zalo-js.js", async (importOriginal) => {
7
+ const actual = (await importOriginal()) as Record<string, unknown>;
8
+ return {
9
+ ...actual,
10
+ listZaloGroupMembers: listZaloGroupMembersMock,
11
+ };
12
+ });
13
+
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
+ import { zalouserPlugin } from "./channel.js";
30
+
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
+ };
38
+
39
+ describe("zalouser directory group members", () => {
40
+ it("accepts prefixed group ids from directory groups list output", async () => {
41
+ await zalouserPlugin.directory!.listGroupMembers!({
42
+ cfg: {},
43
+ accountId: "default",
44
+ groupId: "group:1471383327500481391",
45
+ runtime: runtimeStub,
46
+ });
47
+
48
+ expect(listZaloGroupMembersMock).toHaveBeenCalledWith("default", "1471383327500481391");
49
+ });
50
+
51
+ it("keeps backward compatibility for raw group ids", async () => {
52
+ await zalouserPlugin.directory!.listGroupMembers!({
53
+ cfg: {},
54
+ accountId: "default",
55
+ groupId: "1471383327500481391",
56
+ runtime: runtimeStub,
57
+ });
58
+
59
+ expect(listZaloGroupMembersMock).toHaveBeenCalledWith("default", "1471383327500481391");
60
+ });
61
+
62
+ it("accepts provider-native g- group ids without stripping the prefix", async () => {
63
+ await zalouserPlugin.directory!.listGroupMembers!({
64
+ cfg: {},
65
+ accountId: "default",
66
+ groupId: "g-1471383327500481391",
67
+ runtime: runtimeStub,
68
+ });
69
+
70
+ expect(listZaloGroupMembersMock).toHaveBeenCalledWith("default", "g-1471383327500481391");
71
+ });
72
+ });
@@ -1,5 +1,9 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ installSendPayloadContractSuite,
5
+ primeSendMock,
6
+ } from "../../../src/test-utils/send-payload-contract.js";
3
7
  import { zalouserPlugin } from "./channel.js";
4
8
 
5
9
  vi.mock("./send.js", () => ({
@@ -24,7 +28,7 @@ vi.mock("./accounts.js", async (importOriginal) => {
24
28
  function baseCtx(payload: ReplyPayload) {
25
29
  return {
26
30
  cfg: {},
27
- to: "987654321",
31
+ to: "user:987654321",
28
32
  text: "",
29
33
  payload,
30
34
  };
@@ -40,78 +44,92 @@ describe("zalouserPlugin outbound sendPayload", () => {
40
44
  mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
41
45
  });
42
46
 
43
- it("text-only delegates to sendText", async () => {
44
- mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" });
47
+ it("group target delegates with isGroup=true and stripped threadId", async () => {
48
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" });
45
49
 
46
- const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
50
+ const result = await zalouserPlugin.outbound!.sendPayload!({
51
+ ...baseCtx({ text: "hello group" }),
52
+ to: "group:1471383327500481391",
53
+ });
47
54
 
48
- expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object));
49
- expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" });
55
+ expect(mockedSend).toHaveBeenCalledWith(
56
+ "1471383327500481391",
57
+ "hello group",
58
+ expect.objectContaining({ isGroup: true }),
59
+ );
60
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
50
61
  });
51
62
 
52
- it("single media delegates to sendMedia", async () => {
53
- mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" });
63
+ it("treats bare numeric targets as direct chats for backward compatibility", async () => {
64
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" });
54
65
 
55
- const result = await zalouserPlugin.outbound!.sendPayload!(
56
- baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
57
- );
66
+ const result = await zalouserPlugin.outbound!.sendPayload!({
67
+ ...baseCtx({ text: "hello" }),
68
+ to: "987654321",
69
+ });
58
70
 
59
71
  expect(mockedSend).toHaveBeenCalledWith(
60
72
  "987654321",
61
- "cap",
62
- expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
73
+ "hello",
74
+ expect.objectContaining({ isGroup: false }),
63
75
  );
64
- expect(result).toMatchObject({ channel: "zalouser" });
76
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
65
77
  });
66
78
 
67
- it("multi-media iterates URLs with caption on first", async () => {
68
- mockedSend
69
- .mockResolvedValueOnce({ ok: true, messageId: "zlu-1" })
70
- .mockResolvedValueOnce({ ok: true, messageId: "zlu-2" });
79
+ it("preserves provider-native group ids when sending to raw g- targets", async () => {
80
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" });
71
81
 
72
- const result = await zalouserPlugin.outbound!.sendPayload!(
73
- baseCtx({
74
- text: "caption",
75
- mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
76
- }),
77
- );
82
+ const result = await zalouserPlugin.outbound!.sendPayload!({
83
+ ...baseCtx({ text: "hello native group" }),
84
+ to: "g-1471383327500481391",
85
+ });
78
86
 
79
- expect(mockedSend).toHaveBeenCalledTimes(2);
80
- expect(mockedSend).toHaveBeenNthCalledWith(
81
- 1,
82
- "987654321",
83
- "caption",
84
- expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
85
- );
86
- expect(mockedSend).toHaveBeenNthCalledWith(
87
- 2,
88
- "987654321",
89
- "",
90
- expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
87
+ expect(mockedSend).toHaveBeenCalledWith(
88
+ "g-1471383327500481391",
89
+ "hello native group",
90
+ expect.objectContaining({ isGroup: true }),
91
91
  );
92
- expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" });
92
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
93
93
  });
94
94
 
95
- it("empty payload returns no-op", async () => {
96
- const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({}));
97
-
98
- expect(mockedSend).not.toHaveBeenCalled();
99
- expect(result).toEqual({ channel: "zalouser", messageId: "" });
95
+ installSendPayloadContractSuite({
96
+ channel: "zalouser",
97
+ chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
98
+ createHarness: ({ payload, sendResults }) => {
99
+ primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
100
+ return {
101
+ run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
102
+ sendMock: mockedSend,
103
+ to: "987654321",
104
+ };
105
+ },
100
106
  });
107
+ });
101
108
 
102
- it("chunking splits long text", async () => {
103
- mockedSend
104
- .mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" })
105
- .mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" });
106
-
107
- const longText = "a".repeat(3000);
108
- const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
109
+ describe("zalouserPlugin messaging target normalization", () => {
110
+ it("normalizes user/group aliases to canonical targets", () => {
111
+ const normalize = zalouserPlugin.messaging?.normalizeTarget;
112
+ expect(normalize).toBeTypeOf("function");
113
+ if (!normalize) {
114
+ return;
115
+ }
116
+ expect(normalize("zlu:g:30003")).toBe("group:30003");
117
+ expect(normalize("zalouser:u:20002")).toBe("user:20002");
118
+ expect(normalize("zlu:g-30003")).toBe("group:g-30003");
119
+ expect(normalize("zalouser:u-20002")).toBe("user:u-20002");
120
+ expect(normalize("20002")).toBe("20002");
121
+ });
109
122
 
110
- // textChunkLimit is 2000 with chunkTextForOutbound, so it should split
111
- expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
112
- for (const call of mockedSend.mock.calls) {
113
- expect((call[1] as string).length).toBeLessThanOrEqual(2000);
123
+ it("treats canonical and provider-native user/group targets as ids", () => {
124
+ const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
125
+ expect(looksLikeId).toBeTypeOf("function");
126
+ if (!looksLikeId) {
127
+ return;
114
128
  }
115
- expect(result).toMatchObject({ channel: "zalouser" });
129
+ expect(looksLikeId("user:20002")).toBe(true);
130
+ expect(looksLikeId("group:30003")).toBe(true);
131
+ expect(looksLikeId("g-30003")).toBe(true);
132
+ expect(looksLikeId("u-20002")).toBe(true);
133
+ expect(looksLikeId("Alice Nguyen")).toBe(false);
116
134
  });
117
135
  });
package/src/channel.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  buildAccountScopedDmSecurityPolicy,
3
+ createAccountStatusSink,
3
4
  mapAllowFromEntries,
4
5
  } from "openclaw/plugin-sdk/compat";
5
6
  import type {
@@ -66,6 +67,97 @@ const meta = {
66
67
  quickstartAllowFrom: true,
67
68
  };
68
69
 
70
+ function stripZalouserTargetPrefix(raw: string): string {
71
+ return raw
72
+ .trim()
73
+ .replace(/^(zalouser|zlu):/i, "")
74
+ .trim();
75
+ }
76
+
77
+ function normalizePrefixedTarget(raw: string): string | undefined {
78
+ const trimmed = stripZalouserTargetPrefix(raw);
79
+ if (!trimmed) {
80
+ return undefined;
81
+ }
82
+
83
+ const lower = trimmed.toLowerCase();
84
+ if (lower.startsWith("group:")) {
85
+ const id = trimmed.slice("group:".length).trim();
86
+ return id ? `group:${id}` : undefined;
87
+ }
88
+ if (lower.startsWith("g:")) {
89
+ const id = trimmed.slice("g:".length).trim();
90
+ return id ? `group:${id}` : undefined;
91
+ }
92
+ if (lower.startsWith("user:")) {
93
+ const id = trimmed.slice("user:".length).trim();
94
+ return id ? `user:${id}` : undefined;
95
+ }
96
+ if (lower.startsWith("dm:")) {
97
+ const id = trimmed.slice("dm:".length).trim();
98
+ return id ? `user:${id}` : undefined;
99
+ }
100
+ if (lower.startsWith("u:")) {
101
+ const id = trimmed.slice("u:".length).trim();
102
+ return id ? `user:${id}` : undefined;
103
+ }
104
+ if (/^g-\S+$/i.test(trimmed)) {
105
+ return `group:${trimmed}`;
106
+ }
107
+ if (/^u-\S+$/i.test(trimmed)) {
108
+ return `user:${trimmed}`;
109
+ }
110
+
111
+ return trimmed;
112
+ }
113
+
114
+ function parseZalouserOutboundTarget(raw: string): {
115
+ threadId: string;
116
+ isGroup: boolean;
117
+ } {
118
+ const normalized = normalizePrefixedTarget(raw);
119
+ if (!normalized) {
120
+ throw new Error("Zalouser target is required");
121
+ }
122
+ const lowered = normalized.toLowerCase();
123
+ if (lowered.startsWith("group:")) {
124
+ const threadId = normalized.slice("group:".length).trim();
125
+ if (!threadId) {
126
+ throw new Error("Zalouser group target is missing group id");
127
+ }
128
+ return { threadId, isGroup: true };
129
+ }
130
+ if (lowered.startsWith("user:")) {
131
+ const threadId = normalized.slice("user:".length).trim();
132
+ if (!threadId) {
133
+ throw new Error("Zalouser user target is missing user id");
134
+ }
135
+ return { threadId, isGroup: false };
136
+ }
137
+ // Backward-compatible fallback for bare IDs.
138
+ // Group sends should use explicit `group:<id>` targets.
139
+ return { threadId: normalized, isGroup: false };
140
+ }
141
+
142
+ function parseZalouserDirectoryGroupId(raw: string): string {
143
+ const normalized = normalizePrefixedTarget(raw);
144
+ if (!normalized) {
145
+ throw new Error("Zalouser group target is required");
146
+ }
147
+ const lowered = normalized.toLowerCase();
148
+ if (lowered.startsWith("group:")) {
149
+ const groupId = normalized.slice("group:".length).trim();
150
+ if (!groupId) {
151
+ throw new Error("Zalouser group target is missing group id");
152
+ }
153
+ return groupId;
154
+ }
155
+ if (lowered.startsWith("user:")) {
156
+ throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
157
+ }
158
+ return normalized;
159
+ }
160
+
69
161
  function resolveZalouserQrProfile(accountId?: string | null): string {
70
162
  const normalized = normalizeAccountId(accountId);
71
163
  if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
@@ -261,6 +353,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
261
353
  "name",
262
354
  "dmPolicy",
263
355
  "allowFrom",
356
+ "historyLimit",
357
+ "groupAllowFrom",
264
358
  "groupPolicy",
265
359
  "groups",
266
360
  "messagePrefix",
@@ -333,16 +427,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
333
427
  },
334
428
  },
335
429
  messaging: {
336
- normalizeTarget: (raw) => {
337
- const trimmed = raw?.trim();
338
- if (!trimmed) {
339
- return undefined;
340
- }
341
- return trimmed.replace(/^(zalouser|zlu):/i, "");
342
- },
430
+ normalizeTarget: (raw) => normalizePrefixedTarget(raw),
343
431
  targetResolver: {
344
- looksLikeId: isNumericTargetId,
345
- hint: "<threadId>",
432
+ looksLikeId: (raw) => {
433
+ const normalized = normalizePrefixedTarget(raw);
434
+ if (!normalized) {
435
+ return false;
436
+ }
437
+ if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
438
+ return true;
439
+ }
440
+ return isNumericTargetId(normalized);
441
+ },
442
+ hint: "<user:id|group:id>",
346
443
  },
347
444
  },
348
445
  directory: {
@@ -377,7 +474,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
377
474
  const groups = await listZaloGroupsMatching(account.profile, query);
378
475
  const rows = groups.map((group) =>
379
476
  mapGroup({
380
- id: String(group.groupId),
477
+ id: `group:${String(group.groupId)}`,
381
478
  name: group.name ?? null,
382
479
  raw: group,
383
480
  }),
@@ -386,7 +483,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
386
483
  },
387
484
  listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
388
485
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
389
- const members = await listZaloGroupMembers(account.profile, groupId);
486
+ const normalizedGroupId = parseZalouserDirectoryGroupId(groupId);
487
+ const members = await listZaloGroupMembers(account.profile, normalizedGroupId);
390
488
  const rows = members.map((member) =>
391
489
  mapUser({
392
490
  id: member.userId,
@@ -511,13 +609,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
511
609
  }),
512
610
  sendText: async ({ to, text, accountId, cfg }) => {
513
611
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
514
- const result = await sendMessageZalouser(to, text, { profile: account.profile });
612
+ const target = parseZalouserOutboundTarget(to);
613
+ const result = await sendMessageZalouser(target.threadId, text, {
614
+ profile: account.profile,
615
+ isGroup: target.isGroup,
616
+ });
515
617
  return buildChannelSendResult("zalouser", result);
516
618
  },
517
619
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
518
620
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
519
- const result = await sendMessageZalouser(to, text, {
621
+ const target = parseZalouserOutboundTarget(to);
622
+ const result = await sendMessageZalouser(target.threadId, text, {
520
623
  profile: account.profile,
624
+ isGroup: target.isGroup,
521
625
  mediaUrl,
522
626
  mediaLocalRoots,
523
627
  });
@@ -579,6 +683,10 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
579
683
  } catch {
580
684
  // ignore probe errors
581
685
  }
686
+ const statusSink = createAccountStatusSink({
687
+ accountId: ctx.accountId,
688
+ setStatus: ctx.setStatus,
689
+ });
582
690
  ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
583
691
  const { monitorZalouserProvider } = await import("./monitor.js");
584
692
  return monitorZalouserProvider({
@@ -586,7 +694,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
586
694
  config: ctx.cfg,
587
695
  runtime: ctx.runtime,
588
696
  abortSignal: ctx.abortSignal,
589
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
697
+ statusSink,
590
698
  });
591
699
  },
592
700
  loginWithQrStart: async (params) => {
@@ -1,8 +1,12 @@
1
+ import {
2
+ AllowFromListSchema,
3
+ buildCatchallMultiAccountChannelSchema,
4
+ DmPolicySchema,
5
+ GroupPolicySchema,
6
+ } from "openclaw/plugin-sdk/compat";
1
7
  import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
2
8
  import { z } from "zod";
3
9
 
4
- const allowFromEntry = z.union([z.string(), z.number()]);
5
-
6
10
  const groupConfigSchema = z.object({
7
11
  allow: z.boolean().optional(),
8
12
  enabled: z.boolean().optional(),
@@ -15,15 +19,14 @@ const zalouserAccountSchema = z.object({
15
19
  enabled: z.boolean().optional(),
16
20
  markdown: MarkdownConfigSchema,
17
21
  profile: z.string().optional(),
18
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
19
- allowFrom: z.array(allowFromEntry).optional(),
20
- groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
22
+ dmPolicy: DmPolicySchema.optional(),
23
+ allowFrom: AllowFromListSchema,
24
+ historyLimit: z.number().int().min(0).optional(),
25
+ groupAllowFrom: AllowFromListSchema,
26
+ groupPolicy: GroupPolicySchema.optional(),
21
27
  groups: z.object({}).catchall(groupConfigSchema).optional(),
22
28
  messagePrefix: z.string().optional(),
23
29
  responsePrefix: z.string().optional(),
24
30
  });
25
31
 
26
- export const ZalouserConfigSchema = zalouserAccountSchema.extend({
27
- accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
28
- defaultAccount: z.string().optional(),
29
- });
32
+ export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);