@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/channel.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import type {
2
4
  ChannelAccountSnapshot,
3
5
  ChannelDirectoryEntry,
4
6
  ChannelDock,
5
7
  ChannelGroupContext,
8
+ ChannelMessageActionAdapter,
6
9
  ChannelPlugin,
7
10
  OpenClawConfig,
8
11
  GroupToolPolicyConfig,
@@ -17,6 +20,7 @@ import {
17
20
  formatPairingApproveHint,
18
21
  migrateBaseNameToDefaultAccount,
19
22
  normalizeAccountId,
23
+ resolvePreferredOpenClawTmpDir,
20
24
  resolveChannelAccountConfigBasePath,
21
25
  setAccountEnabledInConfigSection,
22
26
  } from "openclaw/plugin-sdk";
@@ -29,12 +33,21 @@ import {
29
33
  type ResolvedZalouserAccount,
30
34
  } from "./accounts.js";
31
35
  import { ZalouserConfigSchema } from "./config-schema.js";
36
+ import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
37
+ import { resolveZalouserReactionMessageIds } from "./message-sid.js";
32
38
  import { zalouserOnboardingAdapter } from "./onboarding.js";
33
39
  import { probeZalouser } from "./probe.js";
34
- import { sendMessageZalouser } from "./send.js";
40
+ import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
35
41
  import { collectZalouserStatusIssues } from "./status-issues.js";
36
- import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
37
- import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
42
+ import {
43
+ listZaloFriendsMatching,
44
+ listZaloGroupMembers,
45
+ listZaloGroupsMatching,
46
+ logoutZaloProfile,
47
+ startZaloQrLogin,
48
+ waitForZaloQrLogin,
49
+ getZaloUserInfo,
50
+ } from "./zalo-js.js";
38
51
 
39
52
  const meta = {
40
53
  id: "zalouser",
@@ -51,11 +64,30 @@ const meta = {
51
64
  function resolveZalouserQrProfile(accountId?: string | null): string {
52
65
  const normalized = normalizeAccountId(accountId);
53
66
  if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
54
- return process.env.ZCA_PROFILE?.trim() || "default";
67
+ return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
55
68
  }
56
69
  return normalized;
57
70
  }
58
71
 
72
+ async function writeQrDataUrlToTempFile(
73
+ qrDataUrl: string,
74
+ profile: string,
75
+ ): Promise<string | null> {
76
+ const trimmed = qrDataUrl.trim();
77
+ const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
78
+ const base64 = (match?.[1] ?? "").trim();
79
+ if (!base64) {
80
+ return null;
81
+ }
82
+ const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
83
+ const filePath = path.join(
84
+ resolvePreferredOpenClawTmpDir(),
85
+ `openclaw-zalouser-qr-${safeProfile}.png`,
86
+ );
87
+ await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
88
+ return filePath;
89
+ }
90
+
59
91
  function mapUser(params: {
60
92
  id: string;
61
93
  name?: string | null;
@@ -92,20 +124,106 @@ function resolveZalouserGroupToolPolicy(
92
124
  accountId: params.accountId ?? undefined,
93
125
  });
94
126
  const groups = account.config.groups ?? {};
95
- const groupId = params.groupId?.trim();
96
- const groupChannel = params.groupChannel?.trim();
97
- const candidates = [groupId, groupChannel, "*"].filter((value): value is string =>
98
- Boolean(value),
127
+ const entry = findZalouserGroupEntry(
128
+ groups,
129
+ buildZalouserGroupCandidates({
130
+ groupId: params.groupId,
131
+ groupChannel: params.groupChannel,
132
+ includeWildcard: true,
133
+ }),
99
134
  );
100
- for (const key of candidates) {
101
- const entry = groups[key];
102
- if (entry?.tools) {
103
- return entry.tools;
104
- }
135
+ return entry?.tools;
136
+ }
137
+
138
+ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
139
+ const account = resolveZalouserAccountSync({
140
+ cfg: params.cfg,
141
+ accountId: params.accountId ?? undefined,
142
+ });
143
+ const groups = account.config.groups ?? {};
144
+ const entry = findZalouserGroupEntry(
145
+ groups,
146
+ buildZalouserGroupCandidates({
147
+ groupId: params.groupId,
148
+ groupChannel: params.groupChannel,
149
+ includeWildcard: true,
150
+ }),
151
+ );
152
+ if (typeof entry?.requireMention === "boolean") {
153
+ return entry.requireMention;
105
154
  }
106
- return undefined;
155
+ return true;
107
156
  }
108
157
 
158
+ const zalouserMessageActions: ChannelMessageActionAdapter = {
159
+ listActions: ({ cfg }) => {
160
+ const accounts = listZalouserAccountIds(cfg)
161
+ .map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
162
+ .filter((account) => account.enabled);
163
+ if (accounts.length === 0) {
164
+ return [];
165
+ }
166
+ return ["react"];
167
+ },
168
+ supportsAction: ({ action }) => action === "react",
169
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
170
+ if (action !== "react") {
171
+ throw new Error(`Zalouser action ${action} not supported`);
172
+ }
173
+ const account = resolveZalouserAccountSync({ cfg, accountId });
174
+ const threadId =
175
+ (typeof params.threadId === "string" ? params.threadId.trim() : "") ||
176
+ (typeof params.to === "string" ? params.to.trim() : "") ||
177
+ (typeof params.chatId === "string" ? params.chatId.trim() : "") ||
178
+ (toolContext?.currentChannelId?.trim() ?? "");
179
+ if (!threadId) {
180
+ throw new Error("Zalouser react requires threadId (or to/chatId).");
181
+ }
182
+ const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
183
+ if (!emoji) {
184
+ throw new Error("Zalouser react requires emoji.");
185
+ }
186
+ const ids = resolveZalouserReactionMessageIds({
187
+ messageId: typeof params.messageId === "string" ? params.messageId : undefined,
188
+ cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
189
+ currentMessageId: toolContext?.currentMessageId,
190
+ });
191
+ if (!ids) {
192
+ throw new Error(
193
+ "Zalouser react requires messageId + cliMsgId (or a current message context id).",
194
+ );
195
+ }
196
+ const result = await sendReactionZalouser({
197
+ profile: account.profile,
198
+ threadId,
199
+ isGroup: params.isGroup === true,
200
+ msgId: ids.msgId,
201
+ cliMsgId: ids.cliMsgId,
202
+ emoji,
203
+ remove: params.remove === true,
204
+ });
205
+ if (!result.ok) {
206
+ throw new Error(result.error || "Failed to react on Zalo message");
207
+ }
208
+ return {
209
+ content: [
210
+ {
211
+ type: "text" as const,
212
+ text:
213
+ params.remove === true
214
+ ? `Removed reaction ${emoji} from ${ids.msgId}`
215
+ : `Reacted ${emoji} on ${ids.msgId}`,
216
+ },
217
+ ],
218
+ details: {
219
+ messageId: ids.msgId,
220
+ cliMsgId: ids.cliMsgId,
221
+ threadId,
222
+ },
223
+ };
224
+ },
225
+ };
226
+
109
227
  export const zalouserDock: ChannelDock = {
110
228
  id: "zalouser",
111
229
  capabilities: {
@@ -123,7 +241,7 @@ export const zalouserDock: ChannelDock = {
123
241
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
124
242
  },
125
243
  groups: {
126
- resolveRequireMention: () => true,
244
+ resolveRequireMention: resolveZalouserRequireMention,
127
245
  resolveToolPolicy: resolveZalouserGroupToolPolicy,
128
246
  },
129
247
  threading: {
@@ -173,14 +291,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
173
291
  "messagePrefix",
174
292
  ],
175
293
  }),
176
- isConfigured: async (account) => {
177
- // Check if zca auth status is OK for this profile
178
- const result = await runZca(["auth", "status"], {
179
- profile: account.profile,
180
- timeout: 5000,
181
- });
182
- return result.ok;
183
- },
294
+ isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
184
295
  describeAccount: (account): ChannelAccountSnapshot => ({
185
296
  accountId: account.accountId,
186
297
  name: account.name,
@@ -213,12 +324,13 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
213
324
  },
214
325
  },
215
326
  groups: {
216
- resolveRequireMention: () => true,
327
+ resolveRequireMention: resolveZalouserRequireMention,
217
328
  resolveToolPolicy: resolveZalouserGroupToolPolicy,
218
329
  },
219
330
  threading: {
220
331
  resolveReplyToMode: () => "off",
221
332
  },
333
+ actions: zalouserMessageActions,
222
334
  setup: {
223
335
  resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
224
336
  applyAccountName: ({ cfg, accountId, name }) =>
@@ -294,21 +406,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
294
406
  },
295
407
  },
296
408
  directory: {
297
- self: async ({ cfg, accountId, runtime }) => {
298
- const ok = await checkZcaInstalled();
299
- if (!ok) {
300
- throw new Error("Missing dependency: `zca` not found in PATH");
301
- }
409
+ self: async ({ cfg, accountId }) => {
302
410
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
303
- const result = await runZca(["me", "info", "-j"], {
304
- profile: account.profile,
305
- timeout: 10000,
306
- });
307
- if (!result.ok) {
308
- runtime.error(result.stderr || "Failed to fetch profile");
309
- return null;
310
- }
311
- const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
411
+ const parsed = await getZaloUserInfo(account.profile);
312
412
  if (!parsed?.userId) {
313
413
  return null;
314
414
  }
@@ -320,92 +420,42 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
320
420
  });
321
421
  },
322
422
  listPeers: async ({ cfg, accountId, query, limit }) => {
323
- const ok = await checkZcaInstalled();
324
- if (!ok) {
325
- throw new Error("Missing dependency: `zca` not found in PATH");
326
- }
327
423
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
328
- const args = query?.trim() ? ["friend", "find", query.trim()] : ["friend", "list", "-j"];
329
- const result = await runZca(args, { profile: account.profile, timeout: 15000 });
330
- if (!result.ok) {
331
- throw new Error(result.stderr || "Failed to list peers");
332
- }
333
- const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
334
- const rows = Array.isArray(parsed)
335
- ? parsed.map((f) =>
336
- mapUser({
337
- id: String(f.userId),
338
- name: f.displayName ?? null,
339
- avatarUrl: f.avatar ?? null,
340
- raw: f,
341
- }),
342
- )
343
- : [];
424
+ const friends = await listZaloFriendsMatching(account.profile, query);
425
+ const rows = friends.map((friend) =>
426
+ mapUser({
427
+ id: String(friend.userId),
428
+ name: friend.displayName ?? null,
429
+ avatarUrl: friend.avatar ?? null,
430
+ raw: friend,
431
+ }),
432
+ );
344
433
  return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
345
434
  },
346
435
  listGroups: async ({ cfg, accountId, query, limit }) => {
347
- const ok = await checkZcaInstalled();
348
- if (!ok) {
349
- throw new Error("Missing dependency: `zca` not found in PATH");
350
- }
351
436
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
352
- const result = await runZca(["group", "list", "-j"], {
353
- profile: account.profile,
354
- timeout: 15000,
355
- });
356
- if (!result.ok) {
357
- throw new Error(result.stderr || "Failed to list groups");
358
- }
359
- const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
360
- let rows = Array.isArray(parsed)
361
- ? parsed.map((g) =>
362
- mapGroup({
363
- id: String(g.groupId),
364
- name: g.name ?? null,
365
- raw: g,
366
- }),
367
- )
368
- : [];
369
- const q = query?.trim().toLowerCase();
370
- if (q) {
371
- rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
372
- }
437
+ const groups = await listZaloGroupsMatching(account.profile, query);
438
+ const rows = groups.map((group) =>
439
+ mapGroup({
440
+ id: String(group.groupId),
441
+ name: group.name ?? null,
442
+ raw: group,
443
+ }),
444
+ );
373
445
  return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
374
446
  },
375
447
  listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
376
- const ok = await checkZcaInstalled();
377
- if (!ok) {
378
- throw new Error("Missing dependency: `zca` not found in PATH");
379
- }
380
448
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
381
- const result = await runZca(["group", "members", groupId, "-j"], {
382
- profile: account.profile,
383
- timeout: 20000,
384
- });
385
- if (!result.ok) {
386
- throw new Error(result.stderr || "Failed to list group members");
387
- }
388
- const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(
389
- result.stdout,
449
+ const members = await listZaloGroupMembers(account.profile, groupId);
450
+ const rows = members.map((member) =>
451
+ mapUser({
452
+ id: member.userId,
453
+ name: member.displayName,
454
+ avatarUrl: member.avatar ?? null,
455
+ raw: member,
456
+ }),
390
457
  );
391
- const rows = Array.isArray(parsed)
392
- ? parsed
393
- .map((m) => {
394
- const id = m.userId ?? (m as { id?: string | number }).id;
395
- if (!id) {
396
- return null;
397
- }
398
- return mapUser({
399
- id: String(id),
400
- name: (m as { displayName?: string }).displayName ?? null,
401
- avatarUrl: (m as { avatar?: string }).avatar ?? null,
402
- raw: m,
403
- });
404
- })
405
- .filter(Boolean)
406
- : [];
407
- const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
408
- return sliced as ChannelDirectoryEntry[];
458
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
409
459
  },
410
460
  },
411
461
  resolver: {
@@ -426,48 +476,27 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
426
476
  cfg: cfg,
427
477
  accountId: accountId ?? DEFAULT_ACCOUNT_ID,
428
478
  });
429
- const args =
430
- kind === "user"
431
- ? trimmed
432
- ? ["friend", "find", trimmed]
433
- : ["friend", "list", "-j"]
434
- : ["group", "list", "-j"];
435
- const result = await runZca(args, { profile: account.profile, timeout: 15000 });
436
- if (!result.ok) {
437
- throw new Error(result.stderr || "zca lookup failed");
438
- }
439
479
  if (kind === "user") {
440
- const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
441
- const matches = Array.isArray(parsed)
442
- ? parsed.map((f) => ({
443
- id: String(f.userId),
444
- name: f.displayName ?? undefined,
445
- }))
446
- : [];
447
- const best = matches[0];
480
+ const friends = await listZaloFriendsMatching(account.profile, trimmed);
481
+ const best = friends[0];
448
482
  results.push({
449
483
  input,
450
- resolved: Boolean(best?.id),
451
- id: best?.id,
452
- name: best?.name,
453
- note: matches.length > 1 ? "multiple matches; chose first" : undefined,
484
+ resolved: Boolean(best?.userId),
485
+ id: best?.userId,
486
+ name: best?.displayName,
487
+ note: friends.length > 1 ? "multiple matches; chose first" : undefined,
454
488
  });
455
489
  } else {
456
- const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
457
- const matches = Array.isArray(parsed)
458
- ? parsed.map((g) => ({
459
- id: String(g.groupId),
460
- name: g.name ?? undefined,
461
- }))
462
- : [];
490
+ const groups = await listZaloGroupsMatching(account.profile, trimmed);
463
491
  const best =
464
- matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
492
+ groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ??
493
+ groups[0];
465
494
  results.push({
466
495
  input,
467
- resolved: Boolean(best?.id),
468
- id: best?.id,
496
+ resolved: Boolean(best?.groupId),
497
+ id: best?.groupId,
469
498
  name: best?.name,
470
- note: matches.length > 1 ? "multiple matches; chose first" : undefined,
499
+ note: groups.length > 1 ? "multiple matches; chose first" : undefined,
471
500
  });
472
501
  }
473
502
  } catch (err) {
@@ -498,19 +527,32 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
498
527
  cfg: cfg,
499
528
  accountId: accountId ?? DEFAULT_ACCOUNT_ID,
500
529
  });
501
- const ok = await checkZcaInstalled();
502
- if (!ok) {
503
- throw new Error(
504
- "Missing dependency: `zca` not found in PATH. See docs.openclaw.ai/channels/zalouser",
505
- );
506
- }
530
+
507
531
  runtime.log(
508
- `Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
532
+ `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
509
533
  );
510
- const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
511
- if (!result.ok) {
512
- throw new Error(result.stderr || "Zalouser login failed");
534
+
535
+ const started = await startZaloQrLogin({
536
+ profile: account.profile,
537
+ timeoutMs: 35_000,
538
+ });
539
+ if (!started.qrDataUrl) {
540
+ throw new Error(started.message || "Failed to start QR login");
513
541
  }
542
+
543
+ const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
544
+ if (qrPath) {
545
+ runtime.log(`Scan QR image: ${qrPath}`);
546
+ } else {
547
+ runtime.log("QR generated but could not be written to a temp file.");
548
+ }
549
+
550
+ const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
551
+ if (!waited.connected) {
552
+ throw new Error(waited.message || "Zalouser login failed");
553
+ }
554
+
555
+ runtime.log(waited.message);
514
556
  },
515
557
  },
516
558
  outbound: {
@@ -518,6 +560,40 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
518
560
  chunker: chunkTextForOutbound,
519
561
  chunkerMode: "text",
520
562
  textChunkLimit: 2000,
563
+ sendPayload: async (ctx) => {
564
+ const text = ctx.payload.text ?? "";
565
+ const urls = ctx.payload.mediaUrls?.length
566
+ ? ctx.payload.mediaUrls
567
+ : ctx.payload.mediaUrl
568
+ ? [ctx.payload.mediaUrl]
569
+ : [];
570
+ if (!text && urls.length === 0) {
571
+ return { channel: "zalouser", messageId: "" };
572
+ }
573
+ if (urls.length > 0) {
574
+ let lastResult = await zalouserPlugin.outbound!.sendMedia!({
575
+ ...ctx,
576
+ text,
577
+ mediaUrl: urls[0],
578
+ });
579
+ for (let i = 1; i < urls.length; i++) {
580
+ lastResult = await zalouserPlugin.outbound!.sendMedia!({
581
+ ...ctx,
582
+ text: "",
583
+ mediaUrl: urls[i],
584
+ });
585
+ }
586
+ return lastResult;
587
+ }
588
+ const outbound = zalouserPlugin.outbound!;
589
+ const limit = outbound.textChunkLimit;
590
+ const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
591
+ let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
592
+ for (const chunk of chunks) {
593
+ lastResult = await outbound.sendText!({ ...ctx, text: chunk });
594
+ }
595
+ return lastResult!;
596
+ },
521
597
  sendText: async ({ to, text, accountId, cfg }) => {
522
598
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
523
599
  const result = await sendMessageZalouser(to, text, { profile: account.profile });
@@ -528,11 +604,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
528
604
  error: result.error ? new Error(result.error) : undefined,
529
605
  };
530
606
  },
531
- sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
607
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
532
608
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
533
609
  const result = await sendMessageZalouser(to, text, {
534
610
  profile: account.profile,
535
611
  mediaUrl,
612
+ mediaLocalRoots,
536
613
  });
537
614
  return {
538
615
  channel: "zalouser",
@@ -562,9 +639,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
562
639
  }),
563
640
  probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
564
641
  buildAccountSnapshot: async ({ account, runtime }) => {
565
- const zcaInstalled = await checkZcaInstalled();
566
- const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
567
- const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
642
+ const configured = await checkZcaAuthenticated(account.profile);
643
+ const configError = "not authenticated";
568
644
  return {
569
645
  accountId: account.accountId,
570
646
  name: account.name,
@@ -608,44 +684,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
608
684
  },
609
685
  loginWithQrStart: async (params) => {
610
686
  const profile = resolveZalouserQrProfile(params.accountId);
611
- // Start login and get QR code
612
- const result = await runZca(["auth", "login", "--qr-base64"], {
687
+ return await startZaloQrLogin({
613
688
  profile,
614
- timeout: params.timeoutMs ?? 30000,
689
+ force: params.force,
690
+ timeoutMs: params.timeoutMs,
615
691
  });
616
- if (!result.ok) {
617
- return { message: result.stderr || "Failed to start QR login" };
618
- }
619
- // The stdout should contain the base64 QR data URL
620
- const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
621
- if (qrMatch) {
622
- return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
623
- }
624
- return { message: result.stdout || "QR login started" };
625
692
  },
626
693
  loginWithQrWait: async (params) => {
627
694
  const profile = resolveZalouserQrProfile(params.accountId);
628
- // Check if already authenticated
629
- const statusResult = await runZca(["auth", "status"], {
695
+ return await waitForZaloQrLogin({
630
696
  profile,
631
- timeout: params.timeoutMs ?? 60000,
697
+ timeoutMs: params.timeoutMs,
632
698
  });
633
- return {
634
- connected: statusResult.ok,
635
- message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
636
- };
637
- },
638
- logoutAccount: async (ctx) => {
639
- const result = await runZca(["auth", "logout"], {
640
- profile: ctx.account.profile,
641
- timeout: 10000,
642
- });
643
- return {
644
- cleared: result.ok,
645
- loggedOut: result.ok,
646
- message: result.ok ? "Logged out" : result.stderr,
647
- };
648
699
  },
700
+ logoutAccount: async (ctx) =>
701
+ await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
649
702
  },
650
703
  };
651
704
 
@@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
6
6
  const groupConfigSchema = z.object({
7
7
  allow: z.boolean().optional(),
8
8
  enabled: z.boolean().optional(),
9
+ requireMention: z.boolean().optional(),
9
10
  tools: ToolPolicySchema,
10
11
  });
11
12
 
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildZalouserGroupCandidates,
4
+ findZalouserGroupEntry,
5
+ isZalouserGroupEntryAllowed,
6
+ normalizeZalouserGroupSlug,
7
+ } from "./group-policy.js";
8
+
9
+ describe("zalouser group policy helpers", () => {
10
+ it("normalizes group slug names", () => {
11
+ expect(normalizeZalouserGroupSlug(" Team Alpha ")).toBe("team-alpha");
12
+ expect(normalizeZalouserGroupSlug("#Roadmap Updates")).toBe("roadmap-updates");
13
+ });
14
+
15
+ it("builds ordered candidates with optional aliases", () => {
16
+ expect(
17
+ buildZalouserGroupCandidates({
18
+ groupId: "123",
19
+ groupChannel: "chan-1",
20
+ groupName: "Team Alpha",
21
+ includeGroupIdAlias: true,
22
+ }),
23
+ ).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
24
+ });
25
+
26
+ it("finds the first matching group entry", () => {
27
+ const groups = {
28
+ "group:123": { allow: true },
29
+ "team-alpha": { requireMention: false },
30
+ "*": { requireMention: true },
31
+ };
32
+ const entry = findZalouserGroupEntry(
33
+ groups,
34
+ buildZalouserGroupCandidates({
35
+ groupId: "123",
36
+ groupName: "Team Alpha",
37
+ includeGroupIdAlias: true,
38
+ }),
39
+ );
40
+ expect(entry).toEqual({ allow: true });
41
+ });
42
+
43
+ it("evaluates allow/enable flags", () => {
44
+ expect(isZalouserGroupEntryAllowed({ allow: true, enabled: true })).toBe(true);
45
+ expect(isZalouserGroupEntryAllowed({ allow: false })).toBe(false);
46
+ expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false);
47
+ expect(isZalouserGroupEntryAllowed(undefined)).toBe(false);
48
+ });
49
+ });