@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.
package/src/channel.ts ADDED
@@ -0,0 +1,641 @@
1
+ import type {
2
+ ChannelAccountSnapshot,
3
+ ChannelDirectoryEntry,
4
+ ChannelDock,
5
+ ChannelGroupContext,
6
+ ChannelPlugin,
7
+ OpenClawConfig,
8
+ GroupToolPolicyConfig,
9
+ } from "openclaw/plugin-sdk";
10
+ import {
11
+ applyAccountNameToChannelSection,
12
+ buildChannelConfigSchema,
13
+ DEFAULT_ACCOUNT_ID,
14
+ deleteAccountFromConfigSection,
15
+ formatPairingApproveHint,
16
+ migrateBaseNameToDefaultAccount,
17
+ normalizeAccountId,
18
+ setAccountEnabledInConfigSection,
19
+ } from "openclaw/plugin-sdk";
20
+ import {
21
+ listZalouserAccountIds,
22
+ resolveDefaultZalouserAccountId,
23
+ resolveZalouserAccountSync,
24
+ getZcaUserInfo,
25
+ checkZcaAuthenticated,
26
+ type ResolvedZalouserAccount,
27
+ } from "./accounts.js";
28
+ import { zalouserOnboardingAdapter } from "./onboarding.js";
29
+ import { sendMessageZalouser } from "./send.js";
30
+ import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
31
+ import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
32
+ import { ZalouserConfigSchema } from "./config-schema.js";
33
+ import { collectZalouserStatusIssues } from "./status-issues.js";
34
+ import { probeZalouser } from "./probe.js";
35
+
36
+ const meta = {
37
+ id: "zalouser",
38
+ label: "Zalo Personal",
39
+ selectionLabel: "Zalo (Personal Account)",
40
+ docsPath: "/channels/zalouser",
41
+ docsLabel: "zalouser",
42
+ blurb: "Zalo personal account via QR code login.",
43
+ aliases: ["zlu"],
44
+ order: 85,
45
+ quickstartAllowFrom: true,
46
+ };
47
+
48
+ function resolveZalouserQrProfile(accountId?: string | null): string {
49
+ const normalized = normalizeAccountId(accountId);
50
+ if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
51
+ return process.env.ZCA_PROFILE?.trim() || "default";
52
+ }
53
+ return normalized;
54
+ }
55
+
56
+ function mapUser(params: {
57
+ id: string;
58
+ name?: string | null;
59
+ avatarUrl?: string | null;
60
+ raw?: unknown;
61
+ }): ChannelDirectoryEntry {
62
+ return {
63
+ kind: "user",
64
+ id: params.id,
65
+ name: params.name ?? undefined,
66
+ avatarUrl: params.avatarUrl ?? undefined,
67
+ raw: params.raw,
68
+ };
69
+ }
70
+
71
+ function mapGroup(params: {
72
+ id: string;
73
+ name?: string | null;
74
+ raw?: unknown;
75
+ }): ChannelDirectoryEntry {
76
+ return {
77
+ kind: "group",
78
+ id: params.id,
79
+ name: params.name ?? undefined,
80
+ raw: params.raw,
81
+ };
82
+ }
83
+
84
+ function resolveZalouserGroupToolPolicy(
85
+ params: ChannelGroupContext,
86
+ ): GroupToolPolicyConfig | undefined {
87
+ const account = resolveZalouserAccountSync({
88
+ cfg: params.cfg as OpenClawConfig,
89
+ accountId: params.accountId ?? undefined,
90
+ });
91
+ const groups = account.config.groups ?? {};
92
+ const groupId = params.groupId?.trim();
93
+ const groupChannel = params.groupChannel?.trim();
94
+ const candidates = [groupId, groupChannel, "*"].filter(
95
+ (value): value is string => Boolean(value),
96
+ );
97
+ for (const key of candidates) {
98
+ const entry = groups[key];
99
+ if (entry?.tools) return entry.tools;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ export const zalouserDock: ChannelDock = {
105
+ id: "zalouser",
106
+ capabilities: {
107
+ chatTypes: ["direct", "group"],
108
+ media: true,
109
+ blockStreaming: true,
110
+ },
111
+ outbound: { textChunkLimit: 2000 },
112
+ config: {
113
+ resolveAllowFrom: ({ cfg, accountId }) =>
114
+ (resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ?? []).map(
115
+ (entry) => String(entry),
116
+ ),
117
+ formatAllowFrom: ({ allowFrom }) =>
118
+ allowFrom
119
+ .map((entry) => String(entry).trim())
120
+ .filter(Boolean)
121
+ .map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
122
+ .map((entry) => entry.toLowerCase()),
123
+ },
124
+ groups: {
125
+ resolveRequireMention: () => true,
126
+ resolveToolPolicy: resolveZalouserGroupToolPolicy,
127
+ },
128
+ threading: {
129
+ resolveReplyToMode: () => "off",
130
+ },
131
+ };
132
+
133
+ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
134
+ id: "zalouser",
135
+ meta,
136
+ onboarding: zalouserOnboardingAdapter,
137
+ capabilities: {
138
+ chatTypes: ["direct", "group"],
139
+ media: true,
140
+ reactions: true,
141
+ threads: false,
142
+ polls: false,
143
+ nativeCommands: false,
144
+ blockStreaming: true,
145
+ },
146
+ reload: { configPrefixes: ["channels.zalouser"] },
147
+ configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
148
+ config: {
149
+ listAccountIds: (cfg) => listZalouserAccountIds(cfg as OpenClawConfig),
150
+ resolveAccount: (cfg, accountId) =>
151
+ resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }),
152
+ defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as OpenClawConfig),
153
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
154
+ setAccountEnabledInConfigSection({
155
+ cfg: cfg as OpenClawConfig,
156
+ sectionKey: "zalouser",
157
+ accountId,
158
+ enabled,
159
+ allowTopLevel: true,
160
+ }),
161
+ deleteAccount: ({ cfg, accountId }) =>
162
+ deleteAccountFromConfigSection({
163
+ cfg: cfg as OpenClawConfig,
164
+ sectionKey: "zalouser",
165
+ accountId,
166
+ clearBaseFields: ["profile", "name", "dmPolicy", "allowFrom", "groupPolicy", "groups", "messagePrefix"],
167
+ }),
168
+ isConfigured: async (account) => {
169
+ // Check if zca auth status is OK for this profile
170
+ const result = await runZca(["auth", "status"], {
171
+ profile: account.profile,
172
+ timeout: 5000,
173
+ });
174
+ return result.ok;
175
+ },
176
+ describeAccount: (account): ChannelAccountSnapshot => ({
177
+ accountId: account.accountId,
178
+ name: account.name,
179
+ enabled: account.enabled,
180
+ configured: undefined,
181
+ }),
182
+ resolveAllowFrom: ({ cfg, accountId }) =>
183
+ (resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ?? []).map(
184
+ (entry) => String(entry),
185
+ ),
186
+ formatAllowFrom: ({ allowFrom }) =>
187
+ allowFrom
188
+ .map((entry) => String(entry).trim())
189
+ .filter(Boolean)
190
+ .map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
191
+ .map((entry) => entry.toLowerCase()),
192
+ },
193
+ security: {
194
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
195
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
196
+ const useAccountPath = Boolean(
197
+ (cfg as OpenClawConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
198
+ );
199
+ const basePath = useAccountPath
200
+ ? `channels.zalouser.accounts.${resolvedAccountId}.`
201
+ : "channels.zalouser.";
202
+ return {
203
+ policy: account.config.dmPolicy ?? "pairing",
204
+ allowFrom: account.config.allowFrom ?? [],
205
+ policyPath: `${basePath}dmPolicy`,
206
+ allowFromPath: basePath,
207
+ approveHint: formatPairingApproveHint("zalouser"),
208
+ normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
209
+ };
210
+ },
211
+ },
212
+ groups: {
213
+ resolveRequireMention: () => true,
214
+ resolveToolPolicy: resolveZalouserGroupToolPolicy,
215
+ },
216
+ threading: {
217
+ resolveReplyToMode: () => "off",
218
+ },
219
+ setup: {
220
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
221
+ applyAccountName: ({ cfg, accountId, name }) =>
222
+ applyAccountNameToChannelSection({
223
+ cfg: cfg as OpenClawConfig,
224
+ channelKey: "zalouser",
225
+ accountId,
226
+ name,
227
+ }),
228
+ validateInput: () => null,
229
+ applyAccountConfig: ({ cfg, accountId, input }) => {
230
+ const namedConfig = applyAccountNameToChannelSection({
231
+ cfg: cfg as OpenClawConfig,
232
+ channelKey: "zalouser",
233
+ accountId,
234
+ name: input.name,
235
+ });
236
+ const next =
237
+ accountId !== DEFAULT_ACCOUNT_ID
238
+ ? migrateBaseNameToDefaultAccount({
239
+ cfg: namedConfig,
240
+ channelKey: "zalouser",
241
+ })
242
+ : namedConfig;
243
+ if (accountId === DEFAULT_ACCOUNT_ID) {
244
+ return {
245
+ ...next,
246
+ channels: {
247
+ ...next.channels,
248
+ zalouser: {
249
+ ...next.channels?.zalouser,
250
+ enabled: true,
251
+ },
252
+ },
253
+ } as OpenClawConfig;
254
+ }
255
+ return {
256
+ ...next,
257
+ channels: {
258
+ ...next.channels,
259
+ zalouser: {
260
+ ...next.channels?.zalouser,
261
+ enabled: true,
262
+ accounts: {
263
+ ...(next.channels?.zalouser?.accounts ?? {}),
264
+ [accountId]: {
265
+ ...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
266
+ enabled: true,
267
+ },
268
+ },
269
+ },
270
+ },
271
+ } as OpenClawConfig;
272
+ },
273
+ },
274
+ messaging: {
275
+ normalizeTarget: (raw) => {
276
+ const trimmed = raw?.trim();
277
+ if (!trimmed) return undefined;
278
+ return trimmed.replace(/^(zalouser|zlu):/i, "");
279
+ },
280
+ targetResolver: {
281
+ looksLikeId: (raw) => {
282
+ const trimmed = raw.trim();
283
+ if (!trimmed) return false;
284
+ return /^\d{3,}$/.test(trimmed);
285
+ },
286
+ hint: "<threadId>",
287
+ },
288
+ },
289
+ directory: {
290
+ self: async ({ cfg, accountId, runtime }) => {
291
+ const ok = await checkZcaInstalled();
292
+ if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
293
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
294
+ const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
295
+ if (!result.ok) {
296
+ runtime.error(result.stderr || "Failed to fetch profile");
297
+ return null;
298
+ }
299
+ const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
300
+ if (!parsed?.userId) return null;
301
+ return mapUser({
302
+ id: String(parsed.userId),
303
+ name: parsed.displayName ?? null,
304
+ avatarUrl: parsed.avatar ?? null,
305
+ raw: parsed,
306
+ });
307
+ },
308
+ listPeers: async ({ cfg, accountId, query, limit }) => {
309
+ const ok = await checkZcaInstalled();
310
+ if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
311
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
312
+ const args = query?.trim()
313
+ ? ["friend", "find", query.trim()]
314
+ : ["friend", "list", "-j"];
315
+ const result = await runZca(args, { profile: account.profile, timeout: 15000 });
316
+ if (!result.ok) {
317
+ throw new Error(result.stderr || "Failed to list peers");
318
+ }
319
+ const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
320
+ const rows = Array.isArray(parsed)
321
+ ? parsed.map((f) =>
322
+ mapUser({
323
+ id: String(f.userId),
324
+ name: f.displayName ?? null,
325
+ avatarUrl: f.avatar ?? null,
326
+ raw: f,
327
+ }),
328
+ )
329
+ : [];
330
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
331
+ },
332
+ listGroups: async ({ cfg, accountId, query, limit }) => {
333
+ const ok = await checkZcaInstalled();
334
+ if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
335
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
336
+ const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
337
+ if (!result.ok) {
338
+ throw new Error(result.stderr || "Failed to list groups");
339
+ }
340
+ const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
341
+ let rows = Array.isArray(parsed)
342
+ ? parsed.map((g) =>
343
+ mapGroup({
344
+ id: String(g.groupId),
345
+ name: g.name ?? null,
346
+ raw: g,
347
+ }),
348
+ )
349
+ : [];
350
+ const q = query?.trim().toLowerCase();
351
+ if (q) {
352
+ rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
353
+ }
354
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
355
+ },
356
+ listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
357
+ const ok = await checkZcaInstalled();
358
+ if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
359
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
360
+ const result = await runZca(["group", "members", groupId, "-j"], {
361
+ profile: account.profile,
362
+ timeout: 20000,
363
+ });
364
+ if (!result.ok) {
365
+ throw new Error(result.stderr || "Failed to list group members");
366
+ }
367
+ const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(result.stdout);
368
+ const rows = Array.isArray(parsed)
369
+ ? parsed
370
+ .map((m) => {
371
+ const id = m.userId ?? (m as { id?: string | number }).id;
372
+ if (!id) return null;
373
+ return mapUser({
374
+ id: String(id),
375
+ name: (m as { displayName?: string }).displayName ?? null,
376
+ avatarUrl: (m as { avatar?: string }).avatar ?? null,
377
+ raw: m,
378
+ });
379
+ })
380
+ .filter(Boolean)
381
+ : [];
382
+ const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
383
+ return sliced as ChannelDirectoryEntry[];
384
+ },
385
+ },
386
+ resolver: {
387
+ resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
388
+ const results = [];
389
+ for (const input of inputs) {
390
+ const trimmed = input.trim();
391
+ if (!trimmed) {
392
+ results.push({ input, resolved: false, note: "empty input" });
393
+ continue;
394
+ }
395
+ if (/^\d+$/.test(trimmed)) {
396
+ results.push({ input, resolved: true, id: trimmed });
397
+ continue;
398
+ }
399
+ try {
400
+ const account = resolveZalouserAccountSync({
401
+ cfg: cfg as OpenClawConfig,
402
+ accountId: accountId ?? DEFAULT_ACCOUNT_ID,
403
+ });
404
+ const args =
405
+ kind === "user"
406
+ ? trimmed
407
+ ? ["friend", "find", trimmed]
408
+ : ["friend", "list", "-j"]
409
+ : ["group", "list", "-j"];
410
+ const result = await runZca(args, { profile: account.profile, timeout: 15000 });
411
+ if (!result.ok) throw new Error(result.stderr || "zca lookup failed");
412
+ if (kind === "user") {
413
+ const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
414
+ const matches = Array.isArray(parsed)
415
+ ? parsed.map((f) => ({
416
+ id: String(f.userId),
417
+ name: f.displayName ?? undefined,
418
+ }))
419
+ : [];
420
+ const best = matches[0];
421
+ results.push({
422
+ input,
423
+ resolved: Boolean(best?.id),
424
+ id: best?.id,
425
+ name: best?.name,
426
+ note: matches.length > 1 ? "multiple matches; chose first" : undefined,
427
+ });
428
+ } else {
429
+ const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
430
+ const matches = Array.isArray(parsed)
431
+ ? parsed.map((g) => ({
432
+ id: String(g.groupId),
433
+ name: g.name ?? undefined,
434
+ }))
435
+ : [];
436
+ const best = matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
437
+ results.push({
438
+ input,
439
+ resolved: Boolean(best?.id),
440
+ id: best?.id,
441
+ name: best?.name,
442
+ note: matches.length > 1 ? "multiple matches; chose first" : undefined,
443
+ });
444
+ }
445
+ } catch (err) {
446
+ runtime.error?.(`zalouser resolve failed: ${String(err)}`);
447
+ results.push({ input, resolved: false, note: "lookup failed" });
448
+ }
449
+ }
450
+ return results;
451
+ },
452
+ },
453
+ pairing: {
454
+ idLabel: "zalouserUserId",
455
+ normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
456
+ notifyApproval: async ({ cfg, id }) => {
457
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig });
458
+ const authenticated = await checkZcaAuthenticated(account.profile);
459
+ if (!authenticated) throw new Error("Zalouser not authenticated");
460
+ await sendMessageZalouser(id, "Your pairing request has been approved.", {
461
+ profile: account.profile,
462
+ });
463
+ },
464
+ },
465
+ auth: {
466
+ login: async ({ cfg, accountId, runtime }) => {
467
+ const account = resolveZalouserAccountSync({
468
+ cfg: cfg as OpenClawConfig,
469
+ accountId: accountId ?? DEFAULT_ACCOUNT_ID,
470
+ });
471
+ const ok = await checkZcaInstalled();
472
+ if (!ok) {
473
+ throw new Error(
474
+ "Missing dependency: `zca` not found in PATH. See docs.openclaw.ai/channels/zalouser",
475
+ );
476
+ }
477
+ runtime.log(
478
+ `Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
479
+ );
480
+ const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
481
+ if (!result.ok) {
482
+ throw new Error(result.stderr || "Zalouser login failed");
483
+ }
484
+ },
485
+ },
486
+ outbound: {
487
+ deliveryMode: "direct",
488
+ chunker: (text, limit) => {
489
+ if (!text) return [];
490
+ if (limit <= 0 || text.length <= limit) return [text];
491
+ const chunks: string[] = [];
492
+ let remaining = text;
493
+ while (remaining.length > limit) {
494
+ const window = remaining.slice(0, limit);
495
+ const lastNewline = window.lastIndexOf("\n");
496
+ const lastSpace = window.lastIndexOf(" ");
497
+ let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
498
+ if (breakIdx <= 0) breakIdx = limit;
499
+ const rawChunk = remaining.slice(0, breakIdx);
500
+ const chunk = rawChunk.trimEnd();
501
+ if (chunk.length > 0) chunks.push(chunk);
502
+ const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
503
+ const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
504
+ remaining = remaining.slice(nextStart).trimStart();
505
+ }
506
+ if (remaining.length) chunks.push(remaining);
507
+ return chunks;
508
+ },
509
+ chunkerMode: "text",
510
+ textChunkLimit: 2000,
511
+ sendText: async ({ to, text, accountId, cfg }) => {
512
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
513
+ const result = await sendMessageZalouser(to, text, { profile: account.profile });
514
+ return {
515
+ channel: "zalouser",
516
+ ok: result.ok,
517
+ messageId: result.messageId ?? "",
518
+ error: result.error ? new Error(result.error) : undefined,
519
+ };
520
+ },
521
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
522
+ const account = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
523
+ const result = await sendMessageZalouser(to, text, {
524
+ profile: account.profile,
525
+ mediaUrl,
526
+ });
527
+ return {
528
+ channel: "zalouser",
529
+ ok: result.ok,
530
+ messageId: result.messageId ?? "",
531
+ error: result.error ? new Error(result.error) : undefined,
532
+ };
533
+ },
534
+ },
535
+ status: {
536
+ defaultRuntime: {
537
+ accountId: DEFAULT_ACCOUNT_ID,
538
+ running: false,
539
+ lastStartAt: null,
540
+ lastStopAt: null,
541
+ lastError: null,
542
+ },
543
+ collectStatusIssues: collectZalouserStatusIssues,
544
+ buildChannelSummary: ({ snapshot }) => ({
545
+ configured: snapshot.configured ?? false,
546
+ running: snapshot.running ?? false,
547
+ lastStartAt: snapshot.lastStartAt ?? null,
548
+ lastStopAt: snapshot.lastStopAt ?? null,
549
+ lastError: snapshot.lastError ?? null,
550
+ probe: snapshot.probe,
551
+ lastProbeAt: snapshot.lastProbeAt ?? null,
552
+ }),
553
+ probeAccount: async ({ account, timeoutMs }) =>
554
+ probeZalouser(account.profile, timeoutMs),
555
+ buildAccountSnapshot: async ({ account, runtime }) => {
556
+ const zcaInstalled = await checkZcaInstalled();
557
+ const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
558
+ const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
559
+ return {
560
+ accountId: account.accountId,
561
+ name: account.name,
562
+ enabled: account.enabled,
563
+ configured,
564
+ running: runtime?.running ?? false,
565
+ lastStartAt: runtime?.lastStartAt ?? null,
566
+ lastStopAt: runtime?.lastStopAt ?? null,
567
+ lastError: configured ? (runtime?.lastError ?? null) : runtime?.lastError ?? configError,
568
+ lastInboundAt: runtime?.lastInboundAt ?? null,
569
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
570
+ dmPolicy: account.config.dmPolicy ?? "pairing",
571
+ };
572
+ },
573
+ },
574
+ gateway: {
575
+ startAccount: async (ctx) => {
576
+ const account = ctx.account;
577
+ let userLabel = "";
578
+ try {
579
+ const userInfo = await getZcaUserInfo(account.profile);
580
+ if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
581
+ ctx.setStatus({
582
+ accountId: account.accountId,
583
+ user: userInfo,
584
+ });
585
+ } catch {
586
+ // ignore probe errors
587
+ }
588
+ ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
589
+ const { monitorZalouserProvider } = await import("./monitor.js");
590
+ return monitorZalouserProvider({
591
+ account,
592
+ config: ctx.cfg as OpenClawConfig,
593
+ runtime: ctx.runtime,
594
+ abortSignal: ctx.abortSignal,
595
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
596
+ });
597
+ },
598
+ loginWithQrStart: async (params) => {
599
+ const profile = resolveZalouserQrProfile(params.accountId);
600
+ // Start login and get QR code
601
+ const result = await runZca(["auth", "login", "--qr-base64"], {
602
+ profile,
603
+ timeout: params.timeoutMs ?? 30000,
604
+ });
605
+ if (!result.ok) {
606
+ return { message: result.stderr || "Failed to start QR login" };
607
+ }
608
+ // The stdout should contain the base64 QR data URL
609
+ const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
610
+ if (qrMatch) {
611
+ return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
612
+ }
613
+ return { message: result.stdout || "QR login started" };
614
+ },
615
+ loginWithQrWait: async (params) => {
616
+ const profile = resolveZalouserQrProfile(params.accountId);
617
+ // Check if already authenticated
618
+ const statusResult = await runZca(["auth", "status"], {
619
+ profile,
620
+ timeout: params.timeoutMs ?? 60000,
621
+ });
622
+ return {
623
+ connected: statusResult.ok,
624
+ message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
625
+ };
626
+ },
627
+ logoutAccount: async (ctx) => {
628
+ const result = await runZca(["auth", "logout"], {
629
+ profile: ctx.account.profile,
630
+ timeout: 10000,
631
+ });
632
+ return {
633
+ cleared: result.ok,
634
+ loggedOut: result.ok,
635
+ message: result.ok ? "Logged out" : result.stderr,
636
+ };
637
+ },
638
+ },
639
+ };
640
+
641
+ export type { ResolvedZalouserAccount };
@@ -0,0 +1,27 @@
1
+ import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+
4
+ const allowFromEntry = z.union([z.string(), z.number()]);
5
+
6
+ const groupConfigSchema = z.object({
7
+ allow: z.boolean().optional(),
8
+ enabled: z.boolean().optional(),
9
+ tools: ToolPolicySchema,
10
+ });
11
+
12
+ const zalouserAccountSchema = z.object({
13
+ name: z.string().optional(),
14
+ enabled: z.boolean().optional(),
15
+ markdown: MarkdownConfigSchema,
16
+ profile: z.string().optional(),
17
+ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
18
+ allowFrom: z.array(allowFromEntry).optional(),
19
+ groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
20
+ groups: z.object({}).catchall(groupConfigSchema).optional(),
21
+ messagePrefix: z.string().optional(),
22
+ });
23
+
24
+ export const ZalouserConfigSchema = zalouserAccountSchema.extend({
25
+ accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
26
+ defaultAccount: z.string().optional(),
27
+ });