@openclaw/zalouser 2026.1.29

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.
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { collectZalouserStatusIssues } from "./status-issues.js";
4
+
5
+ describe("collectZalouserStatusIssues", () => {
6
+ it("flags missing zca when configured is false", () => {
7
+ const issues = collectZalouserStatusIssues([
8
+ {
9
+ accountId: "default",
10
+ enabled: true,
11
+ configured: false,
12
+ lastError: "zca CLI not found in PATH",
13
+ },
14
+ ]);
15
+ expect(issues).toHaveLength(1);
16
+ expect(issues[0]?.kind).toBe("runtime");
17
+ expect(issues[0]?.message).toMatch(/zca CLI not found/i);
18
+ });
19
+
20
+ it("flags missing auth when configured is false", () => {
21
+ const issues = collectZalouserStatusIssues([
22
+ {
23
+ accountId: "default",
24
+ enabled: true,
25
+ configured: false,
26
+ lastError: "not authenticated",
27
+ },
28
+ ]);
29
+ expect(issues).toHaveLength(1);
30
+ expect(issues[0]?.kind).toBe("auth");
31
+ expect(issues[0]?.message).toMatch(/Not authenticated/i);
32
+ });
33
+
34
+ it("warns when dmPolicy is open", () => {
35
+ const issues = collectZalouserStatusIssues([
36
+ {
37
+ accountId: "default",
38
+ enabled: true,
39
+ configured: true,
40
+ dmPolicy: "open",
41
+ },
42
+ ]);
43
+ expect(issues).toHaveLength(1);
44
+ expect(issues[0]?.kind).toBe("config");
45
+ });
46
+
47
+ it("skips disabled accounts", () => {
48
+ const issues = collectZalouserStatusIssues([
49
+ {
50
+ accountId: "default",
51
+ enabled: false,
52
+ configured: false,
53
+ lastError: "zca CLI not found in PATH",
54
+ },
55
+ ]);
56
+ expect(issues).toHaveLength(0);
57
+ });
58
+ });
@@ -0,0 +1,81 @@
1
+ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk";
2
+
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)) return null;
19
+ return {
20
+ accountId: value.accountId,
21
+ enabled: value.enabled,
22
+ configured: value.configured,
23
+ dmPolicy: value.dmPolicy,
24
+ lastError: value.lastError,
25
+ };
26
+ }
27
+
28
+ function isMissingZca(lastError?: string): boolean {
29
+ if (!lastError) return false;
30
+ const lower = lastError.toLowerCase();
31
+ return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
32
+ }
33
+
34
+ export function collectZalouserStatusIssues(
35
+ accounts: ChannelAccountSnapshot[],
36
+ ): ChannelStatusIssue[] {
37
+ const issues: ChannelStatusIssue[] = [];
38
+ for (const entry of accounts) {
39
+ const account = readZalouserAccountStatus(entry);
40
+ if (!account) continue;
41
+ const accountId = asString(account.accountId) ?? "default";
42
+ const enabled = account.enabled !== false;
43
+ if (!enabled) continue;
44
+
45
+ const configured = account.configured === true;
46
+ const lastError = asString(account.lastError)?.trim();
47
+
48
+ if (!configured) {
49
+ if (isMissingZca(lastError)) {
50
+ issues.push({
51
+ channel: "zalouser",
52
+ accountId,
53
+ kind: "runtime",
54
+ message: "zca CLI not found in PATH.",
55
+ fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
56
+ });
57
+ } else {
58
+ issues.push({
59
+ channel: "zalouser",
60
+ accountId,
61
+ kind: "auth",
62
+ message: "Not authenticated (no zca session).",
63
+ fix: "Run: openclaw channels login --channel zalouser",
64
+ });
65
+ }
66
+ continue;
67
+ }
68
+
69
+ if (account.dmPolicy === "open") {
70
+ issues.push({
71
+ channel: "zalouser",
72
+ accountId,
73
+ kind: "config",
74
+ message:
75
+ 'Zalo Personal dmPolicy is "open", allowing any user to message the bot without pairing.',
76
+ fix: 'Set channels.zalouser.dmPolicy to "pairing" or "allowlist" to restrict access.',
77
+ });
78
+ }
79
+ }
80
+ return issues;
81
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ import { runZca, parseJsonOutput } from "./zca.js";
4
+
5
+ const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
6
+
7
+ function stringEnum<T extends readonly string[]>(
8
+ values: T,
9
+ options: { description?: string } = {},
10
+ ) {
11
+ return Type.Unsafe<T[number]>({
12
+ type: "string",
13
+ enum: [...values],
14
+ ...options,
15
+ });
16
+ }
17
+
18
+ // Tool schema - avoiding Type.Union per tool schema guardrails
19
+ export const ZalouserToolSchema = Type.Object({
20
+ action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
21
+ threadId: Type.Optional(
22
+ Type.String({ description: "Thread ID for messaging" }),
23
+ ),
24
+ message: Type.Optional(Type.String({ description: "Message text" })),
25
+ isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
26
+ profile: Type.Optional(Type.String({ description: "Profile name" })),
27
+ query: Type.Optional(Type.String({ description: "Search query" })),
28
+ url: Type.Optional(Type.String({ description: "URL for media/link" })),
29
+ }, { additionalProperties: false });
30
+
31
+ type ToolParams = {
32
+ action: (typeof ACTIONS)[number];
33
+ threadId?: string;
34
+ message?: string;
35
+ isGroup?: boolean;
36
+ profile?: string;
37
+ query?: string;
38
+ url?: string;
39
+ };
40
+
41
+ type ToolResult = {
42
+ content: Array<{ type: string; text: string }>;
43
+ details: unknown;
44
+ };
45
+
46
+ function json(payload: unknown): ToolResult {
47
+ return {
48
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
49
+ details: payload,
50
+ };
51
+ }
52
+
53
+ export async function executeZalouserTool(
54
+ _toolCallId: string,
55
+ params: ToolParams,
56
+ ): Promise<ToolResult> {
57
+ try {
58
+ switch (params.action) {
59
+ case "send": {
60
+ if (!params.threadId || !params.message) {
61
+ throw new Error("threadId and message required for send action");
62
+ }
63
+ const args = ["msg", "send", params.threadId, params.message];
64
+ if (params.isGroup) args.push("-g");
65
+ const result = await runZca(args, { profile: params.profile });
66
+ if (!result.ok) {
67
+ throw new Error(result.stderr || "Failed to send message");
68
+ }
69
+ return json({ success: true, output: result.stdout });
70
+ }
71
+
72
+ case "image": {
73
+ if (!params.threadId) {
74
+ throw new Error("threadId required for image action");
75
+ }
76
+ if (!params.url) {
77
+ throw new Error("url required for image action");
78
+ }
79
+ const args = ["msg", "image", params.threadId, "-u", params.url];
80
+ if (params.message) args.push("-m", params.message);
81
+ if (params.isGroup) args.push("-g");
82
+ const result = await runZca(args, { profile: params.profile });
83
+ if (!result.ok) {
84
+ throw new Error(result.stderr || "Failed to send image");
85
+ }
86
+ return json({ success: true, output: result.stdout });
87
+ }
88
+
89
+ case "link": {
90
+ if (!params.threadId || !params.url) {
91
+ throw new Error("threadId and url required for link action");
92
+ }
93
+ const args = ["msg", "link", params.threadId, params.url];
94
+ if (params.isGroup) args.push("-g");
95
+ const result = await runZca(args, { profile: params.profile });
96
+ if (!result.ok) {
97
+ throw new Error(result.stderr || "Failed to send link");
98
+ }
99
+ return json({ success: true, output: result.stdout });
100
+ }
101
+
102
+ case "friends": {
103
+ const args = params.query
104
+ ? ["friend", "find", params.query]
105
+ : ["friend", "list", "-j"];
106
+ const result = await runZca(args, { profile: params.profile });
107
+ if (!result.ok) {
108
+ throw new Error(result.stderr || "Failed to get friends");
109
+ }
110
+ const parsed = parseJsonOutput(result.stdout);
111
+ return json(parsed ?? { raw: result.stdout });
112
+ }
113
+
114
+ case "groups": {
115
+ const result = await runZca(["group", "list", "-j"], {
116
+ profile: params.profile,
117
+ });
118
+ if (!result.ok) {
119
+ throw new Error(result.stderr || "Failed to get groups");
120
+ }
121
+ const parsed = parseJsonOutput(result.stdout);
122
+ return json(parsed ?? { raw: result.stdout });
123
+ }
124
+
125
+ case "me": {
126
+ const result = await runZca(["me", "info", "-j"], {
127
+ profile: params.profile,
128
+ });
129
+ if (!result.ok) {
130
+ throw new Error(result.stderr || "Failed to get profile");
131
+ }
132
+ const parsed = parseJsonOutput(result.stdout);
133
+ return json(parsed ?? { raw: result.stdout });
134
+ }
135
+
136
+ case "status": {
137
+ const result = await runZca(["auth", "status"], {
138
+ profile: params.profile,
139
+ });
140
+ return json({
141
+ authenticated: result.ok,
142
+ output: result.stdout || result.stderr,
143
+ });
144
+ }
145
+
146
+ default:
147
+ throw new Error(
148
+ `Unknown action: ${params.action}. Valid actions: send, image, link, friends, groups, me, status`,
149
+ );
150
+ }
151
+ } catch (err) {
152
+ return json({
153
+ error: err instanceof Error ? err.message : String(err),
154
+ });
155
+ }
156
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ // zca-cli wrapper types
2
+ export type ZcaRunOptions = {
3
+ profile?: string;
4
+ cwd?: string;
5
+ timeout?: number;
6
+ };
7
+
8
+ export type ZcaResult = {
9
+ ok: boolean;
10
+ stdout: string;
11
+ stderr: string;
12
+ exitCode: number;
13
+ };
14
+
15
+ export type ZcaProfile = {
16
+ name: string;
17
+ label?: string;
18
+ isDefault?: boolean;
19
+ };
20
+
21
+ export type ZcaFriend = {
22
+ userId: string;
23
+ displayName: string;
24
+ avatar?: string;
25
+ };
26
+
27
+ export type ZcaGroup = {
28
+ groupId: string;
29
+ name: string;
30
+ memberCount?: number;
31
+ };
32
+
33
+ export type ZcaMessage = {
34
+ threadId: string;
35
+ msgId?: string;
36
+ cliMsgId?: string;
37
+ type: number;
38
+ content: string;
39
+ timestamp: number;
40
+ metadata?: {
41
+ isGroup: boolean;
42
+ threadName?: string;
43
+ senderName?: string;
44
+ fromId?: string;
45
+ };
46
+ };
47
+
48
+ export type ZcaUserInfo = {
49
+ userId: string;
50
+ displayName: string;
51
+ avatar?: string;
52
+ };
53
+
54
+ export type CommonOptions = {
55
+ profile?: string;
56
+ json?: boolean;
57
+ };
58
+
59
+ export type SendOptions = CommonOptions & {
60
+ group?: boolean;
61
+ };
62
+
63
+ export type ListenOptions = CommonOptions & {
64
+ raw?: boolean;
65
+ keepAlive?: boolean;
66
+ webhook?: string;
67
+ echo?: boolean;
68
+ prefix?: string;
69
+ };
70
+
71
+ export type ZalouserAccountConfig = {
72
+ enabled?: boolean;
73
+ name?: string;
74
+ profile?: string;
75
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
76
+ allowFrom?: Array<string | number>;
77
+ groupPolicy?: "open" | "allowlist" | "disabled";
78
+ groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
79
+ messagePrefix?: string;
80
+ };
81
+
82
+ export type ZalouserConfig = {
83
+ enabled?: boolean;
84
+ name?: string;
85
+ profile?: string;
86
+ defaultAccount?: string;
87
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
88
+ allowFrom?: Array<string | number>;
89
+ groupPolicy?: "open" | "allowlist" | "disabled";
90
+ groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
91
+ messagePrefix?: string;
92
+ accounts?: Record<string, ZalouserAccountConfig>;
93
+ };
94
+
95
+ export type ResolvedZalouserAccount = {
96
+ accountId: string;
97
+ name?: string;
98
+ enabled: boolean;
99
+ profile: string;
100
+ authenticated: boolean;
101
+ config: ZalouserAccountConfig;
102
+ };
package/src/zca.ts ADDED
@@ -0,0 +1,208 @@
1
+ import { spawn, type SpawnOptions } from "node:child_process";
2
+
3
+ import type { ZcaResult, ZcaRunOptions } from "./types.js";
4
+
5
+ const ZCA_BINARY = "zca";
6
+ const DEFAULT_TIMEOUT = 30000;
7
+
8
+ function buildArgs(args: string[], options?: ZcaRunOptions): string[] {
9
+ const result: string[] = [];
10
+ // Profile flag comes first (before subcommand)
11
+ const profile = options?.profile || process.env.ZCA_PROFILE;
12
+ if (profile) {
13
+ result.push("--profile", profile);
14
+ }
15
+ result.push(...args);
16
+ return result;
17
+ }
18
+
19
+ export async function runZca(
20
+ args: string[],
21
+ options?: ZcaRunOptions,
22
+ ): Promise<ZcaResult> {
23
+ const fullArgs = buildArgs(args, options);
24
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
25
+
26
+ return new Promise((resolve) => {
27
+ const spawnOpts: SpawnOptions = {
28
+ cwd: options?.cwd,
29
+ env: { ...process.env },
30
+ stdio: ["pipe", "pipe", "pipe"],
31
+ };
32
+
33
+ const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
34
+ let stdout = "";
35
+ let stderr = "";
36
+ let timedOut = false;
37
+
38
+ const timer = setTimeout(() => {
39
+ timedOut = true;
40
+ proc.kill("SIGTERM");
41
+ }, timeout);
42
+
43
+ proc.stdout?.on("data", (data: Buffer) => {
44
+ stdout += data.toString();
45
+ });
46
+
47
+ proc.stderr?.on("data", (data: Buffer) => {
48
+ stderr += data.toString();
49
+ });
50
+
51
+ proc.on("close", (code) => {
52
+ clearTimeout(timer);
53
+ if (timedOut) {
54
+ resolve({
55
+ ok: false,
56
+ stdout,
57
+ stderr: stderr || "Command timed out",
58
+ exitCode: code ?? 124,
59
+ });
60
+ return;
61
+ }
62
+ resolve({
63
+ ok: code === 0,
64
+ stdout: stdout.trim(),
65
+ stderr: stderr.trim(),
66
+ exitCode: code ?? 1,
67
+ });
68
+ });
69
+
70
+ proc.on("error", (err) => {
71
+ clearTimeout(timer);
72
+ resolve({
73
+ ok: false,
74
+ stdout: "",
75
+ stderr: err.message,
76
+ exitCode: 1,
77
+ });
78
+ });
79
+ });
80
+ }
81
+
82
+ export function runZcaInteractive(
83
+ args: string[],
84
+ options?: ZcaRunOptions,
85
+ ): Promise<ZcaResult> {
86
+ const fullArgs = buildArgs(args, options);
87
+
88
+ return new Promise((resolve) => {
89
+ const spawnOpts: SpawnOptions = {
90
+ cwd: options?.cwd,
91
+ env: { ...process.env },
92
+ stdio: "inherit",
93
+ };
94
+
95
+ const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
96
+
97
+ proc.on("close", (code) => {
98
+ resolve({
99
+ ok: code === 0,
100
+ stdout: "",
101
+ stderr: "",
102
+ exitCode: code ?? 1,
103
+ });
104
+ });
105
+
106
+ proc.on("error", (err) => {
107
+ resolve({
108
+ ok: false,
109
+ stdout: "",
110
+ stderr: err.message,
111
+ exitCode: 1,
112
+ });
113
+ });
114
+ });
115
+ }
116
+
117
+ function stripAnsi(str: string): string {
118
+ return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
119
+ }
120
+
121
+ export function parseJsonOutput<T>(stdout: string): T | null {
122
+ try {
123
+ return JSON.parse(stdout) as T;
124
+ } catch {
125
+ const cleaned = stripAnsi(stdout);
126
+
127
+ try {
128
+ return JSON.parse(cleaned) as T;
129
+ } catch {
130
+ // zca may prefix output with INFO/log lines, try to find JSON
131
+ const lines = cleaned.split("\n");
132
+
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i].trim();
135
+ if (line.startsWith("{") || line.startsWith("[")) {
136
+ // Try parsing from this line to the end
137
+ const jsonCandidate = lines.slice(i).join("\n").trim();
138
+ try {
139
+ return JSON.parse(jsonCandidate) as T;
140
+ } catch {
141
+ continue;
142
+ }
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ }
148
+ }
149
+
150
+ export async function checkZcaInstalled(): Promise<boolean> {
151
+ const result = await runZca(["--version"], { timeout: 5000 });
152
+ return result.ok;
153
+ }
154
+
155
+ export type ZcaStreamingOptions = ZcaRunOptions & {
156
+ onData?: (data: string) => void;
157
+ onError?: (err: Error) => void;
158
+ };
159
+
160
+ export function runZcaStreaming(
161
+ args: string[],
162
+ options?: ZcaStreamingOptions,
163
+ ): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
164
+ const fullArgs = buildArgs(args, options);
165
+
166
+ const spawnOpts: SpawnOptions = {
167
+ cwd: options?.cwd,
168
+ env: { ...process.env },
169
+ stdio: ["pipe", "pipe", "pipe"],
170
+ };
171
+
172
+ const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
173
+ let stdout = "";
174
+ let stderr = "";
175
+
176
+ proc.stdout?.on("data", (data: Buffer) => {
177
+ const text = data.toString();
178
+ stdout += text;
179
+ options?.onData?.(text);
180
+ });
181
+
182
+ proc.stderr?.on("data", (data: Buffer) => {
183
+ stderr += data.toString();
184
+ });
185
+
186
+ const promise = new Promise<ZcaResult>((resolve) => {
187
+ proc.on("close", (code) => {
188
+ resolve({
189
+ ok: code === 0,
190
+ stdout: stdout.trim(),
191
+ stderr: stderr.trim(),
192
+ exitCode: code ?? 1,
193
+ });
194
+ });
195
+
196
+ proc.on("error", (err) => {
197
+ options?.onError?.(err);
198
+ resolve({
199
+ ok: false,
200
+ stdout: "",
201
+ stderr: err.message,
202
+ exitCode: 1,
203
+ });
204
+ });
205
+ });
206
+
207
+ return { proc, promise };
208
+ }