@openclaw/zalouser 2026.2.25 → 2026.3.2

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/src/onboarding.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import type {
2
4
  ChannelOnboardingAdapter,
3
5
  ChannelOnboardingDmPolicy,
@@ -7,10 +9,12 @@ import type {
7
9
  import {
8
10
  addWildcardAllowFrom,
9
11
  DEFAULT_ACCOUNT_ID,
12
+ formatResolvedUnresolvedNote,
10
13
  mergeAllowFromEntries,
11
14
  normalizeAccountId,
12
15
  promptAccountId,
13
16
  promptChannelAccessConfig,
17
+ resolvePreferredOpenClawTmpDir,
14
18
  } from "openclaw/plugin-sdk";
15
19
  import {
16
20
  listZalouserAccountIds,
@@ -18,8 +22,13 @@ import {
18
22
  resolveZalouserAccountSync,
19
23
  checkZcaAuthenticated,
20
24
  } from "./accounts.js";
21
- import type { ZcaFriend, ZcaGroup } from "./types.js";
22
- import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
25
+ import {
26
+ logoutZaloProfile,
27
+ resolveZaloAllowFromEntries,
28
+ resolveZaloGroupsByEntries,
29
+ startZaloQrLogin,
30
+ waitForZaloQrLogin,
31
+ } from "./zalo-js.js";
23
32
 
24
33
  const channel = "zalouser" as const;
25
34
 
@@ -86,9 +95,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
86
95
  [
87
96
  "Zalo Personal Account login via QR code.",
88
97
  "",
89
- "Prerequisites:",
90
- "1) Install zca-cli",
91
- "2) You'll scan a QR code with your Zalo app",
98
+ "This plugin uses zca-js directly (no external CLI dependency).",
92
99
  "",
93
100
  "Docs: https://docs.openclaw.ai/channels/zalouser",
94
101
  ].join("\n"),
@@ -96,6 +103,25 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
96
103
  );
97
104
  }
98
105
 
106
+ async function writeQrDataUrlToTempFile(
107
+ qrDataUrl: string,
108
+ profile: string,
109
+ ): Promise<string | null> {
110
+ const trimmed = qrDataUrl.trim();
111
+ const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
112
+ const base64 = (match?.[1] ?? "").trim();
113
+ if (!base64) {
114
+ return null;
115
+ }
116
+ const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
117
+ const filePath = path.join(
118
+ resolvePreferredOpenClawTmpDir(),
119
+ `openclaw-zalouser-qr-${safeProfile}.png`,
120
+ );
121
+ await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
122
+ return filePath;
123
+ }
124
+
99
125
  async function promptZalouserAllowFrom(params: {
100
126
  cfg: OpenClawConfig;
101
127
  prompter: WizardPrompter;
@@ -110,58 +136,40 @@ async function promptZalouserAllowFrom(params: {
110
136
  .map((entry) => entry.trim())
111
137
  .filter(Boolean);
112
138
 
113
- const resolveUserId = async (input: string): Promise<string | null> => {
114
- const trimmed = input.trim();
115
- if (!trimmed) {
116
- return null;
117
- }
118
- if (/^\d+$/.test(trimmed)) {
119
- return trimmed;
120
- }
121
- const ok = await checkZcaInstalled();
122
- if (!ok) {
123
- return null;
124
- }
125
- const result = await runZca(["friend", "find", trimmed], {
126
- profile: resolved.profile,
127
- timeout: 15000,
128
- });
129
- if (!result.ok) {
130
- return null;
131
- }
132
- const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
133
- const rows = Array.isArray(parsed) ? parsed : [];
134
- const match = rows[0];
135
- if (!match?.userId) {
136
- return null;
137
- }
138
- if (rows.length > 1) {
139
- await prompter.note(
140
- `Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
141
- "Zalo Personal allowlist",
142
- );
143
- }
144
- return String(match.userId);
145
- };
146
-
147
139
  while (true) {
148
140
  const entry = await prompter.text({
149
- message: "Zalouser allowFrom (username or user id)",
141
+ message: "Zalouser allowFrom (name or user id)",
150
142
  placeholder: "Alice, 123456789",
151
143
  initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
152
144
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
153
145
  });
154
146
  const parts = parseInput(String(entry));
155
- const results = await Promise.all(parts.map((part) => resolveUserId(part)));
156
- const unresolved = parts.filter((_, idx) => !results[idx]);
147
+ const resolvedEntries = await resolveZaloAllowFromEntries({
148
+ profile: resolved.profile,
149
+ entries: parts,
150
+ });
151
+
152
+ const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
157
153
  if (unresolved.length > 0) {
158
154
  await prompter.note(
159
- `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
155
+ `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
160
156
  "Zalo Personal allowlist",
161
157
  );
162
158
  continue;
163
159
  }
164
- const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]);
160
+
161
+ const resolvedIds = resolvedEntries
162
+ .filter((item) => item.resolved && item.id)
163
+ .map((item) => item.id as string);
164
+ const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
165
+
166
+ const notes = resolvedEntries
167
+ .filter((item) => item.note)
168
+ .map((item) => `${item.input} -> ${item.id} (${item.note})`);
169
+ if (notes.length > 0) {
170
+ await prompter.note(notes.join("\n"), "Zalo Personal allowlist");
171
+ }
172
+
165
173
  return setZalouserAccountScopedConfig(cfg, accountId, {
166
174
  dmPolicy: "allowlist",
167
175
  allowFrom: unique,
@@ -190,49 +198,6 @@ function setZalouserGroupAllowlist(
190
198
  });
191
199
  }
192
200
 
193
- async function resolveZalouserGroups(params: {
194
- cfg: OpenClawConfig;
195
- accountId: string;
196
- entries: string[];
197
- }): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
198
- const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
199
- const result = await runZca(["group", "list", "-j"], {
200
- profile: account.profile,
201
- timeout: 15000,
202
- });
203
- if (!result.ok) {
204
- throw new Error(result.stderr || "Failed to list groups");
205
- }
206
- const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter((group) =>
207
- Boolean(group.groupId),
208
- );
209
- const byName = new Map<string, ZcaGroup[]>();
210
- for (const group of groups) {
211
- const name = group.name?.trim().toLowerCase();
212
- if (!name) {
213
- continue;
214
- }
215
- const list = byName.get(name) ?? [];
216
- list.push(group);
217
- byName.set(name, list);
218
- }
219
-
220
- return params.entries.map((input) => {
221
- const trimmed = input.trim();
222
- if (!trimmed) {
223
- return { input, resolved: false };
224
- }
225
- if (/^\d+$/.test(trimmed)) {
226
- return { input, resolved: true, id: trimmed };
227
- }
228
- const matches = byName.get(trimmed.toLowerCase()) ?? [];
229
- const match = matches[0];
230
- return match?.groupId
231
- ? { input, resolved: true, id: String(match.groupId) }
232
- : { input, resolved: false };
233
- });
234
- }
235
-
236
201
  const dmPolicy: ChannelOnboardingDmPolicy = {
237
202
  label: "Zalo Personal",
238
203
  channel,
@@ -246,7 +211,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
246
211
  ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
247
212
  : resolveDefaultZalouserAccountId(cfg);
248
213
  return promptZalouserAllowFrom({
249
- cfg: cfg,
214
+ cfg,
250
215
  prompter,
251
216
  accountId: id,
252
217
  });
@@ -260,7 +225,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
260
225
  const ids = listZalouserAccountIds(cfg);
261
226
  let configured = false;
262
227
  for (const accountId of ids) {
263
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
228
+ const account = resolveZalouserAccountSync({ cfg, accountId });
264
229
  const isAuth = await checkZcaAuthenticated(account.profile);
265
230
  if (isAuth) {
266
231
  configured = true;
@@ -282,28 +247,13 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
282
247
  shouldPromptAccountIds,
283
248
  forceAllowFrom,
284
249
  }) => {
285
- // Check zca is installed
286
- const zcaInstalled = await checkZcaInstalled();
287
- if (!zcaInstalled) {
288
- await prompter.note(
289
- [
290
- "The `zca` binary was not found in PATH.",
291
- "",
292
- "Install zca-cli, then re-run onboarding:",
293
- "Docs: https://docs.openclaw.ai/channels/zalouser",
294
- ].join("\n"),
295
- "Missing Dependency",
296
- );
297
- return { cfg, accountId: DEFAULT_ACCOUNT_ID };
298
- }
299
-
300
250
  const zalouserOverride = accountOverrides.zalouser?.trim();
301
251
  const defaultAccountId = resolveDefaultZalouserAccountId(cfg);
302
252
  let accountId = zalouserOverride ? normalizeAccountId(zalouserOverride) : defaultAccountId;
303
253
 
304
254
  if (shouldPromptAccountIds && !zalouserOverride) {
305
255
  accountId = await promptAccountId({
306
- cfg: cfg,
256
+ cfg,
307
257
  prompter,
308
258
  label: "Zalo Personal",
309
259
  currentId: accountId,
@@ -325,23 +275,32 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
325
275
  });
326
276
 
327
277
  if (wantsLogin) {
328
- await prompter.note(
329
- "A QR code will appear in your terminal.\nScan it with your Zalo app to login.",
330
- "QR Login",
331
- );
332
-
333
- // Run interactive login
334
- const result = await runZcaInteractive(["auth", "login"], {
335
- profile: account.profile,
336
- });
337
-
338
- if (!result.ok) {
339
- await prompter.note(`Login failed: ${result.stderr || "Unknown error"}`, "Error");
340
- } else {
341
- const isNowAuth = await checkZcaAuthenticated(account.profile);
342
- if (isNowAuth) {
343
- await prompter.note("Login successful!", "Success");
278
+ const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
279
+ if (start.qrDataUrl) {
280
+ const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
281
+ await prompter.note(
282
+ [
283
+ start.message,
284
+ qrPath
285
+ ? `QR image saved to: ${qrPath}`
286
+ : "Could not write QR image file; use gateway web login UI instead.",
287
+ "Scan + approve on phone, then continue.",
288
+ ].join("\n"),
289
+ "QR Login",
290
+ );
291
+ const scanned = await prompter.confirm({
292
+ message: "Did you scan and approve the QR on your phone?",
293
+ initialValue: true,
294
+ });
295
+ if (scanned) {
296
+ const waited = await waitForZaloQrLogin({
297
+ profile: account.profile,
298
+ timeoutMs: 120_000,
299
+ });
300
+ await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
344
301
  }
302
+ } else {
303
+ await prompter.note(start.message, "Login pending");
345
304
  }
346
305
  }
347
306
  } else {
@@ -350,12 +309,26 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
350
309
  initialValue: true,
351
310
  });
352
311
  if (!keepSession) {
353
- await runZcaInteractive(["auth", "logout"], { profile: account.profile });
354
- await runZcaInteractive(["auth", "login"], { profile: account.profile });
312
+ await logoutZaloProfile(account.profile);
313
+ const start = await startZaloQrLogin({
314
+ profile: account.profile,
315
+ force: true,
316
+ timeoutMs: 35_000,
317
+ });
318
+ if (start.qrDataUrl) {
319
+ const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
320
+ await prompter.note(
321
+ [start.message, qrPath ? `QR image saved to: ${qrPath}` : undefined]
322
+ .filter(Boolean)
323
+ .join("\n"),
324
+ "QR Login",
325
+ );
326
+ const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
327
+ await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
328
+ }
355
329
  }
356
330
  }
357
331
 
358
- // Enable the channel
359
332
  next = setZalouserAccountScopedConfig(
360
333
  next,
361
334
  accountId,
@@ -371,14 +344,16 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
371
344
  });
372
345
  }
373
346
 
347
+ const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId });
374
348
  const accessConfig = await promptChannelAccessConfig({
375
349
  prompter,
376
350
  label: "Zalo groups",
377
- currentPolicy: account.config.groupPolicy ?? "allowlist",
378
- currentEntries: Object.keys(account.config.groups ?? {}),
351
+ currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist",
352
+ currentEntries: Object.keys(updatedAccount.config.groups ?? {}),
379
353
  placeholder: "Family, Work, 123456789",
380
- updatePrompt: Boolean(account.config.groups),
354
+ updatePrompt: Boolean(updatedAccount.config.groups),
381
355
  });
356
+
382
357
  if (accessConfig) {
383
358
  if (accessConfig.policy !== "allowlist") {
384
359
  next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
@@ -386,9 +361,8 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
386
361
  let keys = accessConfig.entries;
387
362
  if (accessConfig.entries.length > 0) {
388
363
  try {
389
- const resolved = await resolveZalouserGroups({
390
- cfg: next,
391
- accountId,
364
+ const resolved = await resolveZaloGroupsByEntries({
365
+ profile: updatedAccount.profile,
392
366
  entries: accessConfig.entries,
393
367
  });
394
368
  const resolvedIds = resolved
@@ -398,18 +372,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
398
372
  .filter((entry) => !entry.resolved)
399
373
  .map((entry) => entry.input);
400
374
  keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
401
- if (resolvedIds.length > 0 || unresolved.length > 0) {
402
- await prompter.note(
403
- [
404
- resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
405
- unresolved.length > 0
406
- ? `Unresolved (kept as typed): ${unresolved.join(", ")}`
407
- : undefined,
408
- ]
409
- .filter(Boolean)
410
- .join("\n"),
411
- "Zalo groups",
412
- );
375
+ const resolution = formatResolvedUnresolvedNote({
376
+ resolved: resolvedIds,
377
+ unresolved,
378
+ });
379
+ if (resolution) {
380
+ await prompter.note(resolution, "Zalo groups");
413
381
  }
414
382
  } catch (err) {
415
383
  await prompter.note(
@@ -0,0 +1,60 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { probeZalouser } from "./probe.js";
3
+ import { getZaloUserInfo } from "./zalo-js.js";
4
+
5
+ vi.mock("./zalo-js.js", () => ({
6
+ getZaloUserInfo: vi.fn(),
7
+ }));
8
+
9
+ const mockGetUserInfo = vi.mocked(getZaloUserInfo);
10
+
11
+ describe("probeZalouser", () => {
12
+ beforeEach(() => {
13
+ mockGetUserInfo.mockReset();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("returns ok=true with user when authenticated", async () => {
21
+ mockGetUserInfo.mockResolvedValueOnce({
22
+ userId: "123",
23
+ displayName: "Alice",
24
+ });
25
+
26
+ await expect(probeZalouser("default")).resolves.toEqual({
27
+ ok: true,
28
+ user: { userId: "123", displayName: "Alice" },
29
+ });
30
+ });
31
+
32
+ it("returns not authenticated when no user info is returned", async () => {
33
+ mockGetUserInfo.mockResolvedValueOnce(null);
34
+ await expect(probeZalouser("default")).resolves.toEqual({
35
+ ok: false,
36
+ error: "Not authenticated",
37
+ });
38
+ });
39
+
40
+ it("returns error when user lookup throws", async () => {
41
+ mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
42
+ await expect(probeZalouser("default")).resolves.toEqual({
43
+ ok: false,
44
+ error: "network down",
45
+ });
46
+ });
47
+
48
+ it("times out when lookup takes too long", async () => {
49
+ vi.useFakeTimers();
50
+ mockGetUserInfo.mockReturnValueOnce(new Promise(() => undefined));
51
+
52
+ const pending = probeZalouser("default", 10);
53
+ await vi.advanceTimersByTimeAsync(1000);
54
+
55
+ await expect(pending).resolves.toEqual({
56
+ ok: false,
57
+ error: "Not authenticated",
58
+ });
59
+ });
60
+ });
package/src/probe.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { BaseProbeResult } from "openclaw/plugin-sdk";
2
2
  import type { ZcaUserInfo } from "./types.js";
3
- import { runZca, parseJsonOutput } from "./zca.js";
3
+ import { getZaloUserInfo } from "./zalo-js.js";
4
4
 
5
5
  export type ZalouserProbeResult = BaseProbeResult<string> & {
6
6
  user?: ZcaUserInfo;
@@ -10,18 +10,25 @@ export async function probeZalouser(
10
10
  profile: string,
11
11
  timeoutMs?: number,
12
12
  ): Promise<ZalouserProbeResult> {
13
- const result = await runZca(["me", "info", "-j"], {
14
- profile,
15
- timeout: timeoutMs,
16
- });
13
+ try {
14
+ const user = timeoutMs
15
+ ? await Promise.race([
16
+ getZaloUserInfo(profile),
17
+ new Promise<null>((resolve) =>
18
+ setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
19
+ ),
20
+ ])
21
+ : await getZaloUserInfo(profile);
17
22
 
18
- if (!result.ok) {
19
- return { ok: false, error: result.stderr || "Failed to probe" };
20
- }
23
+ if (!user) {
24
+ return { ok: false, error: "Not authenticated" };
25
+ }
21
26
 
22
- const user = parseJsonOutput<ZcaUserInfo>(result.stdout);
23
- if (!user) {
24
- return { ok: false, error: "Failed to parse user info" };
27
+ return { ok: true, user };
28
+ } catch (error) {
29
+ return {
30
+ ok: false,
31
+ error: error instanceof Error ? error.message : String(error),
32
+ };
25
33
  }
26
- return { ok: true, user };
27
34
  }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeZaloReactionIcon } from "./reaction.js";
3
+
4
+ describe("zalouser reaction alias normalization", () => {
5
+ it("maps common aliases", () => {
6
+ expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
7
+ expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
8
+ expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
9
+ expect(normalizeZaloReactionIcon("😂")).toBe(":>");
10
+ });
11
+
12
+ it("defaults empty icon to like", () => {
13
+ expect(normalizeZaloReactionIcon("")).toBe("/-strong");
14
+ });
15
+
16
+ it("passes through unknown custom reactions", () => {
17
+ expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
18
+ });
19
+ });
@@ -0,0 +1,29 @@
1
+ import { Reactions } from "./zca-client.js";
2
+
3
+ const REACTION_ALIAS_MAP = new Map<string, string>([
4
+ ["like", Reactions.LIKE],
5
+ ["👍", Reactions.LIKE],
6
+ [":+1:", Reactions.LIKE],
7
+ ["heart", Reactions.HEART],
8
+ ["❤️", Reactions.HEART],
9
+ ["<3", Reactions.HEART],
10
+ ["haha", Reactions.HAHA],
11
+ ["laugh", Reactions.HAHA],
12
+ ["😂", Reactions.HAHA],
13
+ ["wow", Reactions.WOW],
14
+ ["😮", Reactions.WOW],
15
+ ["cry", Reactions.CRY],
16
+ ["😢", Reactions.CRY],
17
+ ["angry", Reactions.ANGRY],
18
+ ["😡", Reactions.ANGRY],
19
+ ]);
20
+
21
+ export function normalizeZaloReactionIcon(raw: string): string {
22
+ const trimmed = raw.trim();
23
+ if (!trimmed) {
24
+ return Reactions.LIKE;
25
+ }
26
+ return (
27
+ REACTION_ALIAS_MAP.get(trimmed.toLowerCase()) ?? REACTION_ALIAS_MAP.get(trimmed) ?? trimmed
28
+ );
29
+ }