@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 +12 -0
- package/package.json +6 -1
- package/src/channel.directory.test.ts +72 -0
- package/src/channel.sendpayload.test.ts +77 -1
- package/src/channel.ts +116 -13
- package/src/config-schema.ts +8 -7
- package/src/monitor.group-gating.test.ts +379 -11
- package/src/monitor.ts +412 -114
- package/src/runtime.ts +4 -12
- package/src/types.ts +3 -0
- package/src/zalo-js.ts +269 -22
- package/src/zca-client.ts +45 -1
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/zalouser",
|
|
3
|
-
"version": "2026.3.
|
|
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:
|
|
345
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/config-schema.ts
CHANGED
|
@@ -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(
|
|
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
|
|
27
|
-
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
|
|
28
|
-
defaultAccount: z.string().optional(),
|
|
29
|
-
});
|
|
30
|
+
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
|