@openclaw/zalouser 2026.3.7 → 2026.3.8-beta.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.8-beta.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.8
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.7
4
16
 
5
17
  ### 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.8-beta.1",
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
+ });
@@ -24,7 +24,7 @@ vi.mock("./accounts.js", async (importOriginal) => {
24
24
  function baseCtx(payload: ReplyPayload) {
25
25
  return {
26
26
  cfg: {},
27
- to: "987654321",
27
+ to: "user:987654321",
28
28
  text: "",
29
29
  payload,
30
30
  };
@@ -49,6 +49,22 @@ describe("zalouserPlugin outbound sendPayload", () => {
49
49
  expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" });
50
50
  });
51
51
 
52
+ it("group target delegates with isGroup=true and stripped threadId", async () => {
53
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" });
54
+
55
+ const result = await zalouserPlugin.outbound!.sendPayload!({
56
+ ...baseCtx({ text: "hello group" }),
57
+ to: "group:1471383327500481391",
58
+ });
59
+
60
+ expect(mockedSend).toHaveBeenCalledWith(
61
+ "1471383327500481391",
62
+ "hello group",
63
+ expect.objectContaining({ isGroup: true }),
64
+ );
65
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
66
+ });
67
+
52
68
  it("single media delegates to sendMedia", async () => {
53
69
  mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" });
54
70
 
@@ -64,6 +80,38 @@ describe("zalouserPlugin outbound sendPayload", () => {
64
80
  expect(result).toMatchObject({ channel: "zalouser" });
65
81
  });
66
82
 
83
+ it("treats bare numeric targets as direct chats for backward compatibility", async () => {
84
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" });
85
+
86
+ const result = await zalouserPlugin.outbound!.sendPayload!({
87
+ ...baseCtx({ text: "hello" }),
88
+ to: "987654321",
89
+ });
90
+
91
+ expect(mockedSend).toHaveBeenCalledWith(
92
+ "987654321",
93
+ "hello",
94
+ expect.objectContaining({ isGroup: false }),
95
+ );
96
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
97
+ });
98
+
99
+ it("preserves provider-native group ids when sending to raw g- targets", async () => {
100
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" });
101
+
102
+ const result = await zalouserPlugin.outbound!.sendPayload!({
103
+ ...baseCtx({ text: "hello native group" }),
104
+ to: "g-1471383327500481391",
105
+ });
106
+
107
+ expect(mockedSend).toHaveBeenCalledWith(
108
+ "g-1471383327500481391",
109
+ "hello native group",
110
+ expect.objectContaining({ isGroup: true }),
111
+ );
112
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
113
+ });
114
+
67
115
  it("multi-media iterates URLs with caption on first", async () => {
68
116
  mockedSend
69
117
  .mockResolvedValueOnce({ ok: true, messageId: "zlu-1" })
@@ -115,3 +163,31 @@ describe("zalouserPlugin outbound sendPayload", () => {
115
163
  expect(result).toMatchObject({ channel: "zalouser" });
116
164
  });
117
165
  });
166
+
167
+ describe("zalouserPlugin messaging target normalization", () => {
168
+ it("normalizes user/group aliases to canonical targets", () => {
169
+ const normalize = zalouserPlugin.messaging?.normalizeTarget;
170
+ expect(normalize).toBeTypeOf("function");
171
+ if (!normalize) {
172
+ return;
173
+ }
174
+ expect(normalize("zlu:g:30003")).toBe("group:30003");
175
+ expect(normalize("zalouser:u:20002")).toBe("user:20002");
176
+ expect(normalize("zlu:g-30003")).toBe("group:g-30003");
177
+ expect(normalize("zalouser:u-20002")).toBe("user:u-20002");
178
+ expect(normalize("20002")).toBe("20002");
179
+ });
180
+
181
+ it("treats canonical and provider-native user/group targets as ids", () => {
182
+ const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
183
+ expect(looksLikeId).toBeTypeOf("function");
184
+ if (!looksLikeId) {
185
+ return;
186
+ }
187
+ expect(looksLikeId("user:20002")).toBe(true);
188
+ expect(looksLikeId("group:30003")).toBe(true);
189
+ expect(looksLikeId("g-30003")).toBe(true);
190
+ expect(looksLikeId("u-20002")).toBe(true);
191
+ expect(looksLikeId("Alice Nguyen")).toBe(false);
192
+ });
193
+ });
package/src/channel.ts CHANGED
@@ -66,6 +66,97 @@ const meta = {
66
66
  quickstartAllowFrom: true,
67
67
  };
68
68
 
69
+ function stripZalouserTargetPrefix(raw: string): string {
70
+ return raw
71
+ .trim()
72
+ .replace(/^(zalouser|zlu):/i, "")
73
+ .trim();
74
+ }
75
+
76
+ function normalizePrefixedTarget(raw: string): string | undefined {
77
+ const trimmed = stripZalouserTargetPrefix(raw);
78
+ if (!trimmed) {
79
+ return undefined;
80
+ }
81
+
82
+ const lower = trimmed.toLowerCase();
83
+ if (lower.startsWith("group:")) {
84
+ const id = trimmed.slice("group:".length).trim();
85
+ return id ? `group:${id}` : undefined;
86
+ }
87
+ if (lower.startsWith("g:")) {
88
+ const id = trimmed.slice("g:".length).trim();
89
+ return id ? `group:${id}` : undefined;
90
+ }
91
+ if (lower.startsWith("user:")) {
92
+ const id = trimmed.slice("user:".length).trim();
93
+ return id ? `user:${id}` : undefined;
94
+ }
95
+ if (lower.startsWith("dm:")) {
96
+ const id = trimmed.slice("dm:".length).trim();
97
+ return id ? `user:${id}` : undefined;
98
+ }
99
+ if (lower.startsWith("u:")) {
100
+ const id = trimmed.slice("u:".length).trim();
101
+ return id ? `user:${id}` : undefined;
102
+ }
103
+ if (/^g-\S+$/i.test(trimmed)) {
104
+ return `group:${trimmed}`;
105
+ }
106
+ if (/^u-\S+$/i.test(trimmed)) {
107
+ return `user:${trimmed}`;
108
+ }
109
+
110
+ return trimmed;
111
+ }
112
+
113
+ function parseZalouserOutboundTarget(raw: string): {
114
+ threadId: string;
115
+ isGroup: boolean;
116
+ } {
117
+ const normalized = normalizePrefixedTarget(raw);
118
+ if (!normalized) {
119
+ throw new Error("Zalouser target is required");
120
+ }
121
+ const lowered = normalized.toLowerCase();
122
+ if (lowered.startsWith("group:")) {
123
+ const threadId = normalized.slice("group:".length).trim();
124
+ if (!threadId) {
125
+ throw new Error("Zalouser group target is missing group id");
126
+ }
127
+ return { threadId, isGroup: true };
128
+ }
129
+ if (lowered.startsWith("user:")) {
130
+ const threadId = normalized.slice("user:".length).trim();
131
+ if (!threadId) {
132
+ throw new Error("Zalouser user target is missing user id");
133
+ }
134
+ return { threadId, isGroup: false };
135
+ }
136
+ // Backward-compatible fallback for bare IDs.
137
+ // Group sends should use explicit `group:<id>` targets.
138
+ return { threadId: normalized, isGroup: false };
139
+ }
140
+
141
+ function parseZalouserDirectoryGroupId(raw: string): string {
142
+ const normalized = normalizePrefixedTarget(raw);
143
+ if (!normalized) {
144
+ throw new Error("Zalouser group target is required");
145
+ }
146
+ const lowered = normalized.toLowerCase();
147
+ if (lowered.startsWith("group:")) {
148
+ const groupId = normalized.slice("group:".length).trim();
149
+ if (!groupId) {
150
+ throw new Error("Zalouser group target is missing group id");
151
+ }
152
+ return groupId;
153
+ }
154
+ if (lowered.startsWith("user:")) {
155
+ throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
156
+ }
157
+ return normalized;
158
+ }
159
+
69
160
  function resolveZalouserQrProfile(accountId?: string | null): string {
70
161
  const normalized = normalizeAccountId(accountId);
71
162
  if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
@@ -261,6 +352,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
261
352
  "name",
262
353
  "dmPolicy",
263
354
  "allowFrom",
355
+ "historyLimit",
356
+ "groupAllowFrom",
264
357
  "groupPolicy",
265
358
  "groups",
266
359
  "messagePrefix",
@@ -333,16 +426,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
333
426
  },
334
427
  },
335
428
  messaging: {
336
- normalizeTarget: (raw) => {
337
- const trimmed = raw?.trim();
338
- if (!trimmed) {
339
- return undefined;
340
- }
341
- return trimmed.replace(/^(zalouser|zlu):/i, "");
342
- },
429
+ normalizeTarget: (raw) => normalizePrefixedTarget(raw),
343
430
  targetResolver: {
344
- looksLikeId: isNumericTargetId,
345
- hint: "<threadId>",
431
+ looksLikeId: (raw) => {
432
+ const normalized = normalizePrefixedTarget(raw);
433
+ if (!normalized) {
434
+ return false;
435
+ }
436
+ if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
437
+ return true;
438
+ }
439
+ return isNumericTargetId(normalized);
440
+ },
441
+ hint: "<user:id|group:id>",
346
442
  },
347
443
  },
348
444
  directory: {
@@ -377,7 +473,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
377
473
  const groups = await listZaloGroupsMatching(account.profile, query);
378
474
  const rows = groups.map((group) =>
379
475
  mapGroup({
380
- id: String(group.groupId),
476
+ id: `group:${String(group.groupId)}`,
381
477
  name: group.name ?? null,
382
478
  raw: group,
383
479
  }),
@@ -386,7 +482,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
386
482
  },
387
483
  listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
388
484
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
389
- const members = await listZaloGroupMembers(account.profile, groupId);
485
+ const normalizedGroupId = parseZalouserDirectoryGroupId(groupId);
486
+ const members = await listZaloGroupMembers(account.profile, normalizedGroupId);
390
487
  const rows = members.map((member) =>
391
488
  mapUser({
392
489
  id: member.userId,
@@ -511,13 +608,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
511
608
  }),
512
609
  sendText: async ({ to, text, accountId, cfg }) => {
513
610
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
514
- const result = await sendMessageZalouser(to, text, { profile: account.profile });
611
+ const target = parseZalouserOutboundTarget(to);
612
+ const result = await sendMessageZalouser(target.threadId, text, {
613
+ profile: account.profile,
614
+ isGroup: target.isGroup,
615
+ });
515
616
  return buildChannelSendResult("zalouser", result);
516
617
  },
517
618
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
518
619
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
519
- const result = await sendMessageZalouser(to, text, {
620
+ const target = parseZalouserOutboundTarget(to);
621
+ const result = await sendMessageZalouser(target.threadId, text, {
520
622
  profile: account.profile,
623
+ isGroup: target.isGroup,
521
624
  mediaUrl,
522
625
  mediaLocalRoots,
523
626
  });
@@ -1,8 +1,10 @@
1
+ import {
2
+ AllowFromEntrySchema,
3
+ buildCatchallMultiAccountChannelSchema,
4
+ } from "openclaw/plugin-sdk/compat";
1
5
  import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
2
6
  import { z } from "zod";
3
7
 
4
- const allowFromEntry = z.union([z.string(), z.number()]);
5
-
6
8
  const groupConfigSchema = z.object({
7
9
  allow: z.boolean().optional(),
8
10
  enabled: z.boolean().optional(),
@@ -16,14 +18,13 @@ const zalouserAccountSchema = z.object({
16
18
  markdown: MarkdownConfigSchema,
17
19
  profile: z.string().optional(),
18
20
  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
19
- allowFrom: z.array(allowFromEntry).optional(),
21
+ allowFrom: z.array(AllowFromEntrySchema).optional(),
22
+ historyLimit: z.number().int().min(0).optional(),
23
+ groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
20
24
  groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
21
25
  groups: z.object({}).catchall(groupConfigSchema).optional(),
22
26
  messagePrefix: z.string().optional(),
23
27
  responsePrefix: z.string().optional(),
24
28
  });
25
29
 
26
- export const ZalouserConfigSchema = zalouserAccountSchema.extend({
27
- accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
28
- defaultAccount: z.string().optional(),
29
- });
30
+ export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);