@openclaw/zalouser 2026.3.1 → 2026.3.7

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,25 +1,33 @@
1
+ import {
2
+ buildAccountScopedDmSecurityPolicy,
3
+ mapAllowFromEntries,
4
+ } from "openclaw/plugin-sdk/compat";
1
5
  import type {
2
6
  ChannelAccountSnapshot,
3
7
  ChannelDirectoryEntry,
4
8
  ChannelDock,
5
9
  ChannelGroupContext,
10
+ ChannelMessageActionAdapter,
6
11
  ChannelPlugin,
7
12
  OpenClawConfig,
8
13
  GroupToolPolicyConfig,
9
- } from "openclaw/plugin-sdk";
14
+ } from "openclaw/plugin-sdk/zalouser";
10
15
  import {
11
16
  applyAccountNameToChannelSection,
17
+ applySetupAccountConfigPatch,
18
+ buildChannelSendResult,
19
+ buildBaseAccountStatusSnapshot,
12
20
  buildChannelConfigSchema,
13
21
  DEFAULT_ACCOUNT_ID,
14
22
  chunkTextForOutbound,
15
23
  deleteAccountFromConfigSection,
16
24
  formatAllowFromLowercase,
17
- formatPairingApproveHint,
25
+ isNumericTargetId,
18
26
  migrateBaseNameToDefaultAccount,
19
27
  normalizeAccountId,
20
- resolveChannelAccountConfigBasePath,
28
+ sendPayloadWithChunkedTextAndMedia,
21
29
  setAccountEnabledInConfigSection,
22
- } from "openclaw/plugin-sdk";
30
+ } from "openclaw/plugin-sdk/zalouser";
23
31
  import {
24
32
  listZalouserAccountIds,
25
33
  resolveDefaultZalouserAccountId,
@@ -29,12 +37,22 @@ import {
29
37
  type ResolvedZalouserAccount,
30
38
  } from "./accounts.js";
31
39
  import { ZalouserConfigSchema } from "./config-schema.js";
40
+ import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
41
+ import { resolveZalouserReactionMessageIds } from "./message-sid.js";
32
42
  import { zalouserOnboardingAdapter } from "./onboarding.js";
33
43
  import { probeZalouser } from "./probe.js";
34
- import { sendMessageZalouser } from "./send.js";
44
+ import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
45
+ import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
35
46
  import { collectZalouserStatusIssues } from "./status-issues.js";
36
- import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
37
- import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
47
+ import {
48
+ listZaloFriendsMatching,
49
+ listZaloGroupMembers,
50
+ listZaloGroupsMatching,
51
+ logoutZaloProfile,
52
+ startZaloQrLogin,
53
+ waitForZaloQrLogin,
54
+ getZaloUserInfo,
55
+ } from "./zalo-js.js";
38
56
 
39
57
  const meta = {
40
58
  id: "zalouser",
@@ -51,7 +69,7 @@ const meta = {
51
69
  function resolveZalouserQrProfile(accountId?: string | null): string {
52
70
  const normalized = normalizeAccountId(accountId);
53
71
  if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
54
- return process.env.ZCA_PROFILE?.trim() || "default";
72
+ return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
55
73
  }
56
74
  return normalized;
57
75
  }
@@ -84,28 +102,105 @@ function mapGroup(params: {
84
102
  };
85
103
  }
86
104
 
87
- function resolveZalouserGroupToolPolicy(
88
- params: ChannelGroupContext,
89
- ): GroupToolPolicyConfig | undefined {
105
+ function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
90
106
  const account = resolveZalouserAccountSync({
91
107
  cfg: params.cfg,
92
108
  accountId: params.accountId ?? undefined,
93
109
  });
94
110
  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),
111
+ return findZalouserGroupEntry(
112
+ groups,
113
+ buildZalouserGroupCandidates({
114
+ groupId: params.groupId,
115
+ groupChannel: params.groupChannel,
116
+ includeWildcard: true,
117
+ }),
99
118
  );
100
- for (const key of candidates) {
101
- const entry = groups[key];
102
- if (entry?.tools) {
103
- return entry.tools;
104
- }
119
+ }
120
+
121
+ function resolveZalouserGroupToolPolicy(
122
+ params: ChannelGroupContext,
123
+ ): GroupToolPolicyConfig | undefined {
124
+ return resolveZalouserGroupPolicyEntry(params)?.tools;
125
+ }
126
+
127
+ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
128
+ const entry = resolveZalouserGroupPolicyEntry(params);
129
+ if (typeof entry?.requireMention === "boolean") {
130
+ return entry.requireMention;
105
131
  }
106
- return undefined;
132
+ return true;
107
133
  }
108
134
 
135
+ const zalouserMessageActions: ChannelMessageActionAdapter = {
136
+ listActions: ({ cfg }) => {
137
+ const accounts = listZalouserAccountIds(cfg)
138
+ .map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
139
+ .filter((account) => account.enabled);
140
+ if (accounts.length === 0) {
141
+ return [];
142
+ }
143
+ return ["react"];
144
+ },
145
+ supportsAction: ({ action }) => action === "react",
146
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
147
+ if (action !== "react") {
148
+ throw new Error(`Zalouser action ${action} not supported`);
149
+ }
150
+ const account = resolveZalouserAccountSync({ cfg, accountId });
151
+ const threadId =
152
+ (typeof params.threadId === "string" ? params.threadId.trim() : "") ||
153
+ (typeof params.to === "string" ? params.to.trim() : "") ||
154
+ (typeof params.chatId === "string" ? params.chatId.trim() : "") ||
155
+ (toolContext?.currentChannelId?.trim() ?? "");
156
+ if (!threadId) {
157
+ throw new Error("Zalouser react requires threadId (or to/chatId).");
158
+ }
159
+ const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
160
+ if (!emoji) {
161
+ throw new Error("Zalouser react requires emoji.");
162
+ }
163
+ const ids = resolveZalouserReactionMessageIds({
164
+ messageId: typeof params.messageId === "string" ? params.messageId : undefined,
165
+ cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
166
+ currentMessageId: toolContext?.currentMessageId,
167
+ });
168
+ if (!ids) {
169
+ throw new Error(
170
+ "Zalouser react requires messageId + cliMsgId (or a current message context id).",
171
+ );
172
+ }
173
+ const result = await sendReactionZalouser({
174
+ profile: account.profile,
175
+ threadId,
176
+ isGroup: params.isGroup === true,
177
+ msgId: ids.msgId,
178
+ cliMsgId: ids.cliMsgId,
179
+ emoji,
180
+ remove: params.remove === true,
181
+ });
182
+ if (!result.ok) {
183
+ throw new Error(result.error || "Failed to react on Zalo message");
184
+ }
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text" as const,
189
+ text:
190
+ params.remove === true
191
+ ? `Removed reaction ${emoji} from ${ids.msgId}`
192
+ : `Reacted ${emoji} on ${ids.msgId}`,
193
+ },
194
+ ],
195
+ details: {
196
+ messageId: ids.msgId,
197
+ cliMsgId: ids.cliMsgId,
198
+ threadId,
199
+ },
200
+ };
201
+ },
202
+ };
203
+
109
204
  export const zalouserDock: ChannelDock = {
110
205
  id: "zalouser",
111
206
  capabilities: {
@@ -116,14 +211,12 @@ export const zalouserDock: ChannelDock = {
116
211
  outbound: { textChunkLimit: 2000 },
117
212
  config: {
118
213
  resolveAllowFrom: ({ cfg, accountId }) =>
119
- (resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
120
- String(entry),
121
- ),
214
+ mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom),
122
215
  formatAllowFrom: ({ allowFrom }) =>
123
216
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
124
217
  },
125
218
  groups: {
126
- resolveRequireMention: () => true,
219
+ resolveRequireMention: resolveZalouserRequireMention,
127
220
  resolveToolPolicy: resolveZalouserGroupToolPolicy,
128
221
  },
129
222
  threading: {
@@ -173,14 +266,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
173
266
  "messagePrefix",
174
267
  ],
175
268
  }),
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
- },
269
+ isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
184
270
  describeAccount: (account): ChannelAccountSnapshot => ({
185
271
  accountId: account.accountId,
186
272
  name: account.name,
@@ -188,37 +274,32 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
188
274
  configured: undefined,
189
275
  }),
190
276
  resolveAllowFrom: ({ cfg, accountId }) =>
191
- (resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
192
- String(entry),
193
- ),
277
+ mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom),
194
278
  formatAllowFrom: ({ allowFrom }) =>
195
279
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
196
280
  },
197
281
  security: {
198
282
  resolveDmPolicy: ({ cfg, accountId, account }) => {
199
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
200
- const basePath = resolveChannelAccountConfigBasePath({
283
+ return buildAccountScopedDmSecurityPolicy({
201
284
  cfg,
202
285
  channelKey: "zalouser",
203
- accountId: resolvedAccountId,
204
- });
205
- return {
206
- policy: account.config.dmPolicy ?? "pairing",
286
+ accountId,
287
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
288
+ policy: account.config.dmPolicy,
207
289
  allowFrom: account.config.allowFrom ?? [],
208
- policyPath: `${basePath}dmPolicy`,
209
- allowFromPath: basePath,
210
- approveHint: formatPairingApproveHint("zalouser"),
290
+ policyPathSuffix: "dmPolicy",
211
291
  normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
212
- };
292
+ });
213
293
  },
214
294
  },
215
295
  groups: {
216
- resolveRequireMention: () => true,
296
+ resolveRequireMention: resolveZalouserRequireMention,
217
297
  resolveToolPolicy: resolveZalouserGroupToolPolicy,
218
298
  },
219
299
  threading: {
220
300
  resolveReplyToMode: () => "off",
221
301
  },
302
+ actions: zalouserMessageActions,
222
303
  setup: {
223
304
  resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
224
305
  applyAccountName: ({ cfg, accountId, name }) =>
@@ -243,35 +324,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
243
324
  channelKey: "zalouser",
244
325
  })
245
326
  : namedConfig;
246
- if (accountId === DEFAULT_ACCOUNT_ID) {
247
- return {
248
- ...next,
249
- channels: {
250
- ...next.channels,
251
- zalouser: {
252
- ...next.channels?.zalouser,
253
- enabled: true,
254
- },
255
- },
256
- } as OpenClawConfig;
257
- }
258
- return {
259
- ...next,
260
- channels: {
261
- ...next.channels,
262
- zalouser: {
263
- ...next.channels?.zalouser,
264
- enabled: true,
265
- accounts: {
266
- ...next.channels?.zalouser?.accounts,
267
- [accountId]: {
268
- ...next.channels?.zalouser?.accounts?.[accountId],
269
- enabled: true,
270
- },
271
- },
272
- },
273
- },
274
- } as OpenClawConfig;
327
+ return applySetupAccountConfigPatch({
328
+ cfg: next,
329
+ channelKey: "zalouser",
330
+ accountId,
331
+ patch: {},
332
+ });
275
333
  },
276
334
  },
277
335
  messaging: {
@@ -283,32 +341,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
283
341
  return trimmed.replace(/^(zalouser|zlu):/i, "");
284
342
  },
285
343
  targetResolver: {
286
- looksLikeId: (raw) => {
287
- const trimmed = raw.trim();
288
- if (!trimmed) {
289
- return false;
290
- }
291
- return /^\d{3,}$/.test(trimmed);
292
- },
344
+ looksLikeId: isNumericTargetId,
293
345
  hint: "<threadId>",
294
346
  },
295
347
  },
296
348
  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
- }
349
+ self: async ({ cfg, accountId }) => {
302
350
  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);
351
+ const parsed = await getZaloUserInfo(account.profile);
312
352
  if (!parsed?.userId) {
313
353
  return null;
314
354
  }
@@ -320,92 +360,42 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
320
360
  });
321
361
  },
322
362
  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
363
  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
- : [];
364
+ const friends = await listZaloFriendsMatching(account.profile, query);
365
+ const rows = friends.map((friend) =>
366
+ mapUser({
367
+ id: String(friend.userId),
368
+ name: friend.displayName ?? null,
369
+ avatarUrl: friend.avatar ?? null,
370
+ raw: friend,
371
+ }),
372
+ );
344
373
  return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
345
374
  },
346
375
  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
376
  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
- }
377
+ const groups = await listZaloGroupsMatching(account.profile, query);
378
+ const rows = groups.map((group) =>
379
+ mapGroup({
380
+ id: String(group.groupId),
381
+ name: group.name ?? null,
382
+ raw: group,
383
+ }),
384
+ );
373
385
  return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
374
386
  },
375
387
  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
388
  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,
389
+ const members = await listZaloGroupMembers(account.profile, groupId);
390
+ const rows = members.map((member) =>
391
+ mapUser({
392
+ id: member.userId,
393
+ name: member.displayName,
394
+ avatarUrl: member.avatar ?? null,
395
+ raw: member,
396
+ }),
390
397
  );
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[];
398
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
409
399
  },
410
400
  },
411
401
  resolver: {
@@ -426,48 +416,27 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
426
416
  cfg: cfg,
427
417
  accountId: accountId ?? DEFAULT_ACCOUNT_ID,
428
418
  });
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
419
  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];
420
+ const friends = await listZaloFriendsMatching(account.profile, trimmed);
421
+ const best = friends[0];
448
422
  results.push({
449
423
  input,
450
- resolved: Boolean(best?.id),
451
- id: best?.id,
452
- name: best?.name,
453
- note: matches.length > 1 ? "multiple matches; chose first" : undefined,
424
+ resolved: Boolean(best?.userId),
425
+ id: best?.userId,
426
+ name: best?.displayName,
427
+ note: friends.length > 1 ? "multiple matches; chose first" : undefined,
454
428
  });
455
429
  } 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
- : [];
430
+ const groups = await listZaloGroupsMatching(account.profile, trimmed);
463
431
  const best =
464
- matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
432
+ groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ??
433
+ groups[0];
465
434
  results.push({
466
435
  input,
467
- resolved: Boolean(best?.id),
468
- id: best?.id,
436
+ resolved: Boolean(best?.groupId),
437
+ id: best?.groupId,
469
438
  name: best?.name,
470
- note: matches.length > 1 ? "multiple matches; chose first" : undefined,
439
+ note: groups.length > 1 ? "multiple matches; chose first" : undefined,
471
440
  });
472
441
  }
473
442
  } catch (err) {
@@ -498,19 +467,32 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
498
467
  cfg: cfg,
499
468
  accountId: accountId ?? DEFAULT_ACCOUNT_ID,
500
469
  });
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
- }
470
+
507
471
  runtime.log(
508
- `Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
472
+ `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
509
473
  );
510
- const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
511
- if (!result.ok) {
512
- throw new Error(result.stderr || "Zalouser login failed");
474
+
475
+ const started = await startZaloQrLogin({
476
+ profile: account.profile,
477
+ timeoutMs: 35_000,
478
+ });
479
+ if (!started.qrDataUrl) {
480
+ throw new Error(started.message || "Failed to start QR login");
481
+ }
482
+
483
+ const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
484
+ if (qrPath) {
485
+ runtime.log(`Scan QR image: ${qrPath}`);
486
+ } else {
487
+ runtime.log("QR generated but could not be written to a temp file.");
488
+ }
489
+
490
+ const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
491
+ if (!waited.connected) {
492
+ throw new Error(waited.message || "Zalouser login failed");
513
493
  }
494
+
495
+ runtime.log(waited.message);
514
496
  },
515
497
  },
516
498
  outbound: {
@@ -518,28 +500,28 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
518
500
  chunker: chunkTextForOutbound,
519
501
  chunkerMode: "text",
520
502
  textChunkLimit: 2000,
503
+ sendPayload: async (ctx) =>
504
+ await sendPayloadWithChunkedTextAndMedia({
505
+ ctx,
506
+ textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
507
+ chunker: zalouserPlugin.outbound!.chunker,
508
+ sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
509
+ sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
510
+ emptyResult: { channel: "zalouser", messageId: "" },
511
+ }),
521
512
  sendText: async ({ to, text, accountId, cfg }) => {
522
513
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
523
514
  const result = await sendMessageZalouser(to, text, { profile: account.profile });
524
- return {
525
- channel: "zalouser",
526
- ok: result.ok,
527
- messageId: result.messageId ?? "",
528
- error: result.error ? new Error(result.error) : undefined,
529
- };
515
+ return buildChannelSendResult("zalouser", result);
530
516
  },
531
- sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
517
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
532
518
  const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
533
519
  const result = await sendMessageZalouser(to, text, {
534
520
  profile: account.profile,
535
521
  mediaUrl,
522
+ mediaLocalRoots,
536
523
  });
537
- return {
538
- channel: "zalouser",
539
- ok: result.ok,
540
- messageId: result.messageId ?? "",
541
- error: result.error ? new Error(result.error) : undefined,
542
- };
524
+ return buildChannelSendResult("zalouser", result);
543
525
  },
544
526
  },
545
527
  status: {
@@ -562,20 +544,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
562
544
  }),
563
545
  probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
564
546
  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";
547
+ const configured = await checkZcaAuthenticated(account.profile);
548
+ const configError = "not authenticated";
549
+ const base = buildBaseAccountStatusSnapshot({
550
+ account: {
551
+ accountId: account.accountId,
552
+ name: account.name,
553
+ enabled: account.enabled,
554
+ configured,
555
+ },
556
+ runtime: configured
557
+ ? runtime
558
+ : { ...runtime, lastError: runtime?.lastError ?? configError },
559
+ });
568
560
  return {
569
- accountId: account.accountId,
570
- name: account.name,
571
- enabled: account.enabled,
572
- configured,
573
- running: runtime?.running ?? false,
574
- lastStartAt: runtime?.lastStartAt ?? null,
575
- lastStopAt: runtime?.lastStopAt ?? null,
576
- lastError: configured ? (runtime?.lastError ?? null) : (runtime?.lastError ?? configError),
577
- lastInboundAt: runtime?.lastInboundAt ?? null,
578
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
561
+ ...base,
579
562
  dmPolicy: account.config.dmPolicy ?? "pairing",
580
563
  };
581
564
  },
@@ -608,44 +591,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
608
591
  },
609
592
  loginWithQrStart: async (params) => {
610
593
  const profile = resolveZalouserQrProfile(params.accountId);
611
- // Start login and get QR code
612
- const result = await runZca(["auth", "login", "--qr-base64"], {
594
+ return await startZaloQrLogin({
613
595
  profile,
614
- timeout: params.timeoutMs ?? 30000,
596
+ force: params.force,
597
+ timeoutMs: params.timeoutMs,
615
598
  });
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
599
  },
626
600
  loginWithQrWait: async (params) => {
627
601
  const profile = resolveZalouserQrProfile(params.accountId);
628
- // Check if already authenticated
629
- const statusResult = await runZca(["auth", "status"], {
602
+ return await waitForZaloQrLogin({
630
603
  profile,
631
- timeout: params.timeoutMs ?? 60000,
604
+ timeoutMs: params.timeoutMs,
632
605
  });
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
606
  },
607
+ logoutAccount: async (ctx) =>
608
+ await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
649
609
  },
650
610
  };
651
611