@openclaw/zalouser 2026.3.13 → 2026.5.1-beta.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.
Files changed (67) hide show
  1. package/README.md +4 -3
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/doctor-contract-api.ts +1 -0
  6. package/index.ts +29 -24
  7. package/openclaw.plugin.json +288 -1
  8. package/package.json +38 -11
  9. package/runtime-api.ts +67 -0
  10. package/secret-contract-api.ts +4 -0
  11. package/setup-entry.ts +9 -0
  12. package/setup-plugin-api.ts +2 -0
  13. package/src/accounts.runtime.ts +1 -0
  14. package/src/accounts.test-mocks.ts +7 -3
  15. package/src/accounts.test.ts +53 -1
  16. package/src/accounts.ts +38 -24
  17. package/src/channel-api.ts +20 -0
  18. package/src/channel.adapters.ts +390 -0
  19. package/src/channel.directory.test.ts +47 -40
  20. package/src/channel.runtime.ts +12 -0
  21. package/src/channel.sendpayload.test.ts +41 -23
  22. package/src/channel.setup.test.ts +33 -0
  23. package/src/channel.setup.ts +12 -0
  24. package/src/channel.test.ts +231 -20
  25. package/src/channel.ts +176 -685
  26. package/src/config-schema.ts +5 -5
  27. package/src/directory.ts +54 -0
  28. package/src/doctor-contract.ts +156 -0
  29. package/src/doctor.test.ts +77 -0
  30. package/src/doctor.ts +37 -0
  31. package/src/group-policy.test.ts +4 -4
  32. package/src/group-policy.ts +4 -2
  33. package/src/monitor.account-scope.test.ts +2 -1
  34. package/src/monitor.group-gating.test.ts +162 -8
  35. package/src/monitor.ts +233 -173
  36. package/src/probe.ts +3 -2
  37. package/src/qr-temp-file.ts +1 -1
  38. package/src/reaction.ts +5 -2
  39. package/src/runtime.ts +6 -3
  40. package/src/security-audit.test.ts +80 -0
  41. package/src/security-audit.ts +71 -0
  42. package/src/send.test.ts +2 -2
  43. package/src/send.ts +3 -3
  44. package/src/session-route.ts +121 -0
  45. package/src/setup-core.ts +33 -0
  46. package/src/setup-surface.test.ts +363 -0
  47. package/src/setup-surface.ts +470 -0
  48. package/src/setup-test-helpers.ts +42 -0
  49. package/src/shared.ts +92 -0
  50. package/src/status-issues.test.ts +1 -13
  51. package/src/status-issues.ts +8 -2
  52. package/src/test-helpers.ts +1 -1
  53. package/src/text-styles.test.ts +1 -1
  54. package/src/text-styles.ts +5 -2
  55. package/src/tool.test.ts +66 -3
  56. package/src/tool.ts +76 -14
  57. package/src/types.ts +3 -3
  58. package/src/zalo-js.credentials.test.ts +465 -0
  59. package/src/zalo-js.test-mocks.ts +89 -0
  60. package/src/zalo-js.ts +491 -274
  61. package/src/zca-client.test.ts +24 -0
  62. package/src/zca-client.ts +24 -58
  63. package/src/zca-constants.ts +55 -0
  64. package/test-api.ts +21 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -107
  67. package/src/onboarding.ts +0 -340
package/src/channel.ts CHANGED
@@ -1,182 +1,40 @@
1
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
2
+ import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
3
+ import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
4
+ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
1
5
  import {
2
- buildAccountScopedDmSecurityPolicy,
3
- createAccountStatusSink,
4
- mapAllowFromEntries,
5
- } from "openclaw/plugin-sdk/compat";
6
- import type {
7
- ChannelAccountSnapshot,
8
- ChannelDirectoryEntry,
9
- ChannelDock,
10
- ChannelGroupContext,
11
- ChannelMessageActionAdapter,
12
- ChannelPlugin,
13
- OpenClawConfig,
14
- GroupToolPolicyConfig,
15
- } from "openclaw/plugin-sdk/zalouser";
6
+ createAsyncComputedAccountStatusAdapter,
7
+ createDefaultChannelRuntimeState,
8
+ } from "openclaw/plugin-sdk/status-helpers";
16
9
  import {
17
- applyAccountNameToChannelSection,
18
- applySetupAccountConfigPatch,
19
- buildChannelSendResult,
20
- buildBaseAccountStatusSnapshot,
21
- buildChannelConfigSchema,
22
- DEFAULT_ACCOUNT_ID,
23
- deleteAccountFromConfigSection,
24
- formatAllowFromLowercase,
25
- isDangerousNameMatchingEnabled,
26
- isNumericTargetId,
27
- migrateBaseNameToDefaultAccount,
28
- normalizeAccountId,
29
- sendPayloadWithChunkedTextAndMedia,
30
- setAccountEnabledInConfigSection,
31
- } from "openclaw/plugin-sdk/zalouser";
32
- import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
33
- import {
34
- listZalouserAccountIds,
35
- resolveDefaultZalouserAccountId,
36
- resolveZalouserAccountSync,
37
- getZcaUserInfo,
38
10
  checkZcaAuthenticated,
11
+ resolveZalouserAccountSync,
39
12
  type ResolvedZalouserAccount,
40
13
  } from "./accounts.js";
41
- import { ZalouserConfigSchema } from "./config-schema.js";
42
- import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
43
- import { resolveZalouserReactionMessageIds } from "./message-sid.js";
44
- import { zalouserOnboardingAdapter } from "./onboarding.js";
45
- import { probeZalouser } from "./probe.js";
46
- import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
47
- import { getZalouserRuntime } from "./runtime.js";
48
- import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
49
- import { collectZalouserStatusIssues } from "./status-issues.js";
14
+ import type { ChannelDirectoryEntry, ChannelPlugin } from "./channel-api.js";
15
+ import { DEFAULT_ACCOUNT_ID } from "./channel-api.js";
50
16
  import {
51
- listZaloFriendsMatching,
52
- listZaloGroupMembers,
53
- listZaloGroupsMatching,
54
- logoutZaloProfile,
55
- startZaloQrLogin,
56
- waitForZaloQrLogin,
57
- getZaloUserInfo,
58
- } from "./zalo-js.js";
59
-
60
- const meta = {
61
- id: "zalouser",
62
- label: "Zalo Personal",
63
- selectionLabel: "Zalo (Personal Account)",
64
- docsPath: "/channels/zalouser",
65
- docsLabel: "zalouser",
66
- blurb: "Zalo personal account via QR code login.",
67
- aliases: ["zlu"],
68
- order: 85,
69
- quickstartAllowFrom: true,
70
- };
71
-
72
- function stripZalouserTargetPrefix(raw: string): string {
73
- return raw
74
- .trim()
75
- .replace(/^(zalouser|zlu):/i, "")
76
- .trim();
77
- }
78
-
79
- function normalizePrefixedTarget(raw: string): string | undefined {
80
- const trimmed = stripZalouserTargetPrefix(raw);
81
- if (!trimmed) {
82
- return undefined;
83
- }
84
-
85
- const lower = trimmed.toLowerCase();
86
- if (lower.startsWith("group:")) {
87
- const id = trimmed.slice("group:".length).trim();
88
- return id ? `group:${id}` : undefined;
89
- }
90
- if (lower.startsWith("g:")) {
91
- const id = trimmed.slice("g:".length).trim();
92
- return id ? `group:${id}` : undefined;
93
- }
94
- if (lower.startsWith("user:")) {
95
- const id = trimmed.slice("user:".length).trim();
96
- return id ? `user:${id}` : undefined;
97
- }
98
- if (lower.startsWith("dm:")) {
99
- const id = trimmed.slice("dm:".length).trim();
100
- return id ? `user:${id}` : undefined;
101
- }
102
- if (lower.startsWith("u:")) {
103
- const id = trimmed.slice("u:".length).trim();
104
- return id ? `user:${id}` : undefined;
105
- }
106
- if (/^g-\S+$/i.test(trimmed)) {
107
- return `group:${trimmed}`;
108
- }
109
- if (/^u-\S+$/i.test(trimmed)) {
110
- return `user:${trimmed}`;
111
- }
112
-
113
- return trimmed;
114
- }
115
-
116
- function parseZalouserOutboundTarget(raw: string): {
117
- threadId: string;
118
- isGroup: boolean;
119
- } {
120
- const normalized = normalizePrefixedTarget(raw);
121
- if (!normalized) {
122
- throw new Error("Zalouser target is required");
123
- }
124
- const lowered = normalized.toLowerCase();
125
- if (lowered.startsWith("group:")) {
126
- const threadId = normalized.slice("group:".length).trim();
127
- if (!threadId) {
128
- throw new Error("Zalouser group target is missing group id");
129
- }
130
- return { threadId, isGroup: true };
131
- }
132
- if (lowered.startsWith("user:")) {
133
- const threadId = normalized.slice("user:".length).trim();
134
- if (!threadId) {
135
- throw new Error("Zalouser user target is missing user id");
136
- }
137
- return { threadId, isGroup: false };
138
- }
139
- // Backward-compatible fallback for bare IDs.
140
- // Group sends should use explicit `group:<id>` targets.
141
- return { threadId: normalized, isGroup: false };
142
- }
143
-
144
- function parseZalouserDirectoryGroupId(raw: string): string {
145
- const normalized = normalizePrefixedTarget(raw);
146
- if (!normalized) {
147
- throw new Error("Zalouser group target is required");
148
- }
149
- const lowered = normalized.toLowerCase();
150
- if (lowered.startsWith("group:")) {
151
- const groupId = normalized.slice("group:".length).trim();
152
- if (!groupId) {
153
- throw new Error("Zalouser group target is missing group id");
154
- }
155
- return groupId;
156
- }
157
- if (lowered.startsWith("user:")) {
158
- throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
159
- }
160
- return normalized;
161
- }
162
-
163
- function resolveZalouserQrProfile(accountId?: string | null): string {
164
- const normalized = normalizeAccountId(accountId);
165
- if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
166
- return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
167
- }
168
- return normalized;
169
- }
170
-
171
- function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
172
- return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
173
- }
17
+ zalouserAuthAdapter,
18
+ zalouserGroupsAdapter,
19
+ zalouserMessageActions,
20
+ zalouserMessagingAdapter,
21
+ zalouserOutboundAdapter,
22
+ zalouserPairingTextAdapter,
23
+ resolveZalouserQrProfile,
24
+ zalouserResolverAdapter,
25
+ zalouserSecurityAdapter,
26
+ zalouserThreadingAdapter,
27
+ } from "./channel.adapters.js";
28
+ import { listZalouserDirectoryGroupMembers } from "./directory.js";
29
+ import type { ZalouserProbeResult } from "./probe.js";
30
+ import { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./setup-core.js";
31
+ import { createZalouserPluginBase } from "./shared.js";
32
+ import { collectZalouserStatusIssues } from "./status-issues.js";
174
33
 
175
- function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
176
- return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
177
- fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000,
178
- });
179
- }
34
+ const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
35
+ const zalouserSetupWizardProxy = createZalouserSetupWizardProxy(
36
+ async () => (await import("./setup-surface.js")).zalouserSetupWizard,
37
+ );
180
38
 
181
39
  function mapUser(params: {
182
40
  id: string;
@@ -206,523 +64,156 @@ function mapGroup(params: {
206
64
  };
207
65
  }
208
66
 
209
- function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
210
- const account = resolveZalouserAccountSync({
211
- cfg: params.cfg,
212
- accountId: params.accountId ?? undefined,
213
- });
214
- const groups = account.config.groups ?? {};
215
- return findZalouserGroupEntry(
216
- groups,
217
- buildZalouserGroupCandidates({
218
- groupId: params.groupId,
219
- groupChannel: params.groupChannel,
220
- includeWildcard: true,
221
- allowNameMatching: isDangerousNameMatchingEnabled(account.config),
222
- }),
223
- );
224
- }
225
-
226
- function resolveZalouserGroupToolPolicy(
227
- params: ChannelGroupContext,
228
- ): GroupToolPolicyConfig | undefined {
229
- return resolveZalouserGroupPolicyEntry(params)?.tools;
230
- }
231
-
232
- function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
233
- const entry = resolveZalouserGroupPolicyEntry(params);
234
- if (typeof entry?.requireMention === "boolean") {
235
- return entry.requireMention;
236
- }
237
- return true;
238
- }
239
-
240
- const zalouserMessageActions: ChannelMessageActionAdapter = {
241
- listActions: ({ cfg }) => {
242
- const accounts = listZalouserAccountIds(cfg)
243
- .map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
244
- .filter((account) => account.enabled);
245
- if (accounts.length === 0) {
246
- return [];
247
- }
248
- return ["react"];
249
- },
250
- supportsAction: ({ action }) => action === "react",
251
- handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
252
- if (action !== "react") {
253
- throw new Error(`Zalouser action ${action} not supported`);
254
- }
255
- const account = resolveZalouserAccountSync({ cfg, accountId });
256
- const threadId =
257
- (typeof params.threadId === "string" ? params.threadId.trim() : "") ||
258
- (typeof params.to === "string" ? params.to.trim() : "") ||
259
- (typeof params.chatId === "string" ? params.chatId.trim() : "") ||
260
- (toolContext?.currentChannelId?.trim() ?? "");
261
- if (!threadId) {
262
- throw new Error("Zalouser react requires threadId (or to/chatId).");
263
- }
264
- const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
265
- if (!emoji) {
266
- throw new Error("Zalouser react requires emoji.");
267
- }
268
- const ids = resolveZalouserReactionMessageIds({
269
- messageId: typeof params.messageId === "string" ? params.messageId : undefined,
270
- cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
271
- currentMessageId: toolContext?.currentMessageId,
272
- });
273
- if (!ids) {
274
- throw new Error(
275
- "Zalouser react requires messageId + cliMsgId (or a current message context id).",
276
- );
277
- }
278
- const result = await sendReactionZalouser({
279
- profile: account.profile,
280
- threadId,
281
- isGroup: params.isGroup === true,
282
- msgId: ids.msgId,
283
- cliMsgId: ids.cliMsgId,
284
- emoji,
285
- remove: params.remove === true,
286
- });
287
- if (!result.ok) {
288
- throw new Error(result.error || "Failed to react on Zalo message");
289
- }
290
- return {
291
- content: [
292
- {
293
- type: "text" as const,
294
- text:
295
- params.remove === true
296
- ? `Removed reaction ${emoji} from ${ids.msgId}`
297
- : `Reacted ${emoji} on ${ids.msgId}`,
298
- },
299
- ],
300
- details: {
301
- messageId: ids.msgId,
302
- cliMsgId: ids.cliMsgId,
303
- threadId,
304
- },
305
- };
306
- },
307
- };
308
-
309
- export const zalouserDock: ChannelDock = {
310
- id: "zalouser",
311
- capabilities: {
312
- chatTypes: ["direct", "group"],
313
- media: true,
314
- blockStreaming: true,
315
- },
316
- outbound: { textChunkLimit: 2000 },
317
- config: {
318
- resolveAllowFrom: ({ cfg, accountId }) =>
319
- mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom),
320
- formatAllowFrom: ({ allowFrom }) =>
321
- formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
322
- },
323
- groups: {
324
- resolveRequireMention: resolveZalouserRequireMention,
325
- resolveToolPolicy: resolveZalouserGroupToolPolicy,
326
- },
327
- threading: {
328
- resolveReplyToMode: () => "off",
329
- },
330
- };
331
-
332
- export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
333
- id: "zalouser",
334
- meta,
335
- onboarding: zalouserOnboardingAdapter,
336
- capabilities: {
337
- chatTypes: ["direct", "group"],
338
- media: true,
339
- reactions: true,
340
- threads: false,
341
- polls: false,
342
- nativeCommands: false,
343
- blockStreaming: true,
344
- },
345
- reload: { configPrefixes: ["channels.zalouser"] },
346
- configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
347
- config: {
348
- listAccountIds: (cfg) => listZalouserAccountIds(cfg),
349
- resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg: cfg, accountId }),
350
- defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg),
351
- setAccountEnabled: ({ cfg, accountId, enabled }) =>
352
- setAccountEnabledInConfigSection({
353
- cfg: cfg,
354
- sectionKey: "zalouser",
355
- accountId,
356
- enabled,
357
- allowTopLevel: true,
67
+ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount, ZalouserProbeResult> =
68
+ createChatChannelPlugin({
69
+ base: {
70
+ ...createZalouserPluginBase({
71
+ setupWizard: zalouserSetupWizardProxy,
72
+ setup: zalouserSetupAdapter,
358
73
  }),
359
- deleteAccount: ({ cfg, accountId }) =>
360
- deleteAccountFromConfigSection({
361
- cfg: cfg,
362
- sectionKey: "zalouser",
363
- accountId,
364
- clearBaseFields: [
365
- "profile",
366
- "name",
367
- "dmPolicy",
368
- "allowFrom",
369
- "historyLimit",
370
- "groupAllowFrom",
371
- "groupPolicy",
372
- "groups",
373
- "messagePrefix",
374
- ],
375
- }),
376
- isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
377
- describeAccount: (account): ChannelAccountSnapshot => ({
378
- accountId: account.accountId,
379
- name: account.name,
380
- enabled: account.enabled,
381
- configured: undefined,
382
- }),
383
- resolveAllowFrom: ({ cfg, accountId }) =>
384
- mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom),
385
- formatAllowFrom: ({ allowFrom }) =>
386
- formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
387
- },
388
- security: {
389
- resolveDmPolicy: ({ cfg, accountId, account }) => {
390
- return buildAccountScopedDmSecurityPolicy({
391
- cfg,
392
- channelKey: "zalouser",
393
- accountId,
394
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
395
- policy: account.config.dmPolicy,
396
- allowFrom: account.config.allowFrom ?? [],
397
- policyPathSuffix: "dmPolicy",
398
- normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
399
- });
400
- },
401
- },
402
- groups: {
403
- resolveRequireMention: resolveZalouserRequireMention,
404
- resolveToolPolicy: resolveZalouserGroupToolPolicy,
405
- },
406
- threading: {
407
- resolveReplyToMode: () => "off",
408
- },
409
- actions: zalouserMessageActions,
410
- setup: {
411
- resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
412
- applyAccountName: ({ cfg, accountId, name }) =>
413
- applyAccountNameToChannelSection({
414
- cfg: cfg,
415
- channelKey: "zalouser",
416
- accountId,
417
- name,
418
- }),
419
- validateInput: () => null,
420
- applyAccountConfig: ({ cfg, accountId, input }) => {
421
- const namedConfig = applyAccountNameToChannelSection({
422
- cfg: cfg,
423
- channelKey: "zalouser",
424
- accountId,
425
- name: input.name,
426
- });
427
- const next =
428
- accountId !== DEFAULT_ACCOUNT_ID
429
- ? migrateBaseNameToDefaultAccount({
430
- cfg: namedConfig,
431
- channelKey: "zalouser",
432
- })
433
- : namedConfig;
434
- return applySetupAccountConfigPatch({
435
- cfg: next,
436
- channelKey: "zalouser",
437
- accountId,
438
- patch: {},
439
- });
440
- },
441
- },
442
- messaging: {
443
- normalizeTarget: (raw) => normalizePrefixedTarget(raw),
444
- targetResolver: {
445
- looksLikeId: (raw) => {
446
- const normalized = normalizePrefixedTarget(raw);
447
- if (!normalized) {
448
- return false;
449
- }
450
- if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
451
- return true;
452
- }
453
- return isNumericTargetId(normalized);
454
- },
455
- hint: "<user:id|group:id>",
456
- },
457
- },
458
- directory: {
459
- self: async ({ cfg, accountId }) => {
460
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
461
- const parsed = await getZaloUserInfo(account.profile);
462
- if (!parsed?.userId) {
463
- return null;
464
- }
465
- return mapUser({
466
- id: String(parsed.userId),
467
- name: parsed.displayName ?? null,
468
- avatarUrl: parsed.avatar ?? null,
469
- raw: parsed,
470
- });
471
- },
472
- listPeers: async ({ cfg, accountId, query, limit }) => {
473
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
474
- const friends = await listZaloFriendsMatching(account.profile, query);
475
- const rows = friends.map((friend) =>
476
- mapUser({
477
- id: String(friend.userId),
478
- name: friend.displayName ?? null,
479
- avatarUrl: friend.avatar ?? null,
480
- raw: friend,
481
- }),
482
- );
483
- return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
484
- },
485
- listGroups: async ({ cfg, accountId, query, limit }) => {
486
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
487
- const groups = await listZaloGroupsMatching(account.profile, query);
488
- const rows = groups.map((group) =>
489
- mapGroup({
490
- id: `group:${String(group.groupId)}`,
491
- name: group.name ?? null,
492
- raw: group,
493
- }),
494
- );
495
- return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
496
- },
497
- listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
498
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
499
- const normalizedGroupId = parseZalouserDirectoryGroupId(groupId);
500
- const members = await listZaloGroupMembers(account.profile, normalizedGroupId);
501
- const rows = members.map((member) =>
502
- mapUser({
503
- id: member.userId,
504
- name: member.displayName,
505
- avatarUrl: member.avatar ?? null,
506
- raw: member,
507
- }),
508
- );
509
- return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
510
- },
511
- },
512
- resolver: {
513
- resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
514
- const results = [];
515
- for (const input of inputs) {
516
- const trimmed = input.trim();
517
- if (!trimmed) {
518
- results.push({ input, resolved: false, note: "empty input" });
519
- continue;
520
- }
521
- if (/^\d+$/.test(trimmed)) {
522
- results.push({ input, resolved: true, id: trimmed });
523
- continue;
524
- }
525
- try {
526
- const account = resolveZalouserAccountSync({
527
- cfg: cfg,
528
- accountId: accountId ?? DEFAULT_ACCOUNT_ID,
74
+ groups: zalouserGroupsAdapter,
75
+ actions: zalouserMessageActions,
76
+ messaging: zalouserMessagingAdapter,
77
+ directory: {
78
+ self: async ({ cfg, accountId }) => {
79
+ const { getZaloUserInfo } = await loadZalouserChannelRuntime();
80
+ const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
81
+ const parsed = await getZaloUserInfo(account.profile);
82
+ if (!parsed?.userId) {
83
+ return null;
84
+ }
85
+ return mapUser({
86
+ id: parsed.userId,
87
+ name: parsed.displayName ?? null,
88
+ avatarUrl: parsed.avatar ?? null,
89
+ raw: parsed,
529
90
  });
530
- if (kind === "user") {
531
- const friends = await listZaloFriendsMatching(account.profile, trimmed);
532
- const best = friends[0];
533
- results.push({
534
- input,
535
- resolved: Boolean(best?.userId),
536
- id: best?.userId,
537
- name: best?.displayName,
538
- note: friends.length > 1 ? "multiple matches; chose first" : undefined,
539
- });
540
- } else {
541
- const groups = await listZaloGroupsMatching(account.profile, trimmed);
542
- const best =
543
- groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ??
544
- groups[0];
545
- results.push({
546
- input,
547
- resolved: Boolean(best?.groupId),
548
- id: best?.groupId,
549
- name: best?.name,
550
- note: groups.length > 1 ? "multiple matches; chose first" : undefined,
91
+ },
92
+ listPeers: async ({ cfg, accountId, query, limit }) => {
93
+ const { listZaloFriendsMatching } = await loadZalouserChannelRuntime();
94
+ const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
95
+ const friends = await listZaloFriendsMatching(account.profile, query);
96
+ const rows = friends.map((friend) =>
97
+ mapUser({
98
+ id: friend.userId,
99
+ name: friend.displayName ?? null,
100
+ avatarUrl: friend.avatar ?? null,
101
+ raw: friend,
102
+ }),
103
+ );
104
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
105
+ },
106
+ listGroups: async ({ cfg, accountId, query, limit }) => {
107
+ const { listZaloGroupsMatching } = await loadZalouserChannelRuntime();
108
+ const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
109
+ const groups = await listZaloGroupsMatching(account.profile, query);
110
+ const rows = groups.map((group) =>
111
+ mapGroup({
112
+ id: `group:${group.groupId}`,
113
+ name: group.name ?? null,
114
+ raw: group,
115
+ }),
116
+ );
117
+ return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
118
+ },
119
+ listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
120
+ const { listZaloGroupMembers } = await loadZalouserChannelRuntime();
121
+ return await listZalouserDirectoryGroupMembers(
122
+ {
123
+ cfg,
124
+ accountId: accountId ?? undefined,
125
+ groupId,
126
+ limit: limit ?? undefined,
127
+ },
128
+ { listZaloGroupMembers },
129
+ );
130
+ },
131
+ },
132
+ resolver: zalouserResolverAdapter,
133
+ auth: zalouserAuthAdapter,
134
+ status: createAsyncComputedAccountStatusAdapter<ResolvedZalouserAccount, ZalouserProbeResult>(
135
+ {
136
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
137
+ collectStatusIssues: collectZalouserStatusIssues,
138
+ buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
139
+ probeAccount: async ({ account, timeoutMs }) =>
140
+ (await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs),
141
+ resolveAccountSnapshot: async ({ account, runtime }) => {
142
+ const configured = await checkZcaAuthenticated(account.profile);
143
+ const configError = "not authenticated";
144
+ return {
145
+ accountId: account.accountId,
146
+ name: account.name,
147
+ enabled: account.enabled,
148
+ configured,
149
+ extra: {
150
+ dmPolicy: account.config.dmPolicy ?? "pairing",
151
+ lastError: configured
152
+ ? (runtime?.lastError ?? null)
153
+ : (runtime?.lastError ?? configError),
154
+ },
155
+ };
156
+ },
157
+ },
158
+ ),
159
+ gateway: {
160
+ startAccount: async (ctx) => {
161
+ const { getZaloUserInfo } = await loadZalouserChannelRuntime();
162
+ const account = ctx.account;
163
+ let userLabel = "";
164
+ try {
165
+ const userInfo = await getZaloUserInfo(account.profile);
166
+ if (userInfo?.displayName) {
167
+ userLabel = ` (${userInfo.displayName})`;
168
+ }
169
+ ctx.setStatus({
170
+ accountId: account.accountId,
171
+ profile: userInfo,
551
172
  });
173
+ } catch {
174
+ // ignore probe errors
552
175
  }
553
- } catch (err) {
554
- runtime.error?.(`zalouser resolve failed: ${String(err)}`);
555
- results.push({ input, resolved: false, note: "lookup failed" });
556
- }
557
- }
558
- return results;
559
- },
560
- },
561
- pairing: {
562
- idLabel: "zalouserUserId",
563
- normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
564
- notifyApproval: async ({ cfg, id }) => {
565
- const account = resolveZalouserAccountSync({ cfg: cfg });
566
- const authenticated = await checkZcaAuthenticated(account.profile);
567
- if (!authenticated) {
568
- throw new Error("Zalouser not authenticated");
569
- }
570
- await sendMessageZalouser(id, "Your pairing request has been approved.", {
571
- profile: account.profile,
572
- });
573
- },
574
- },
575
- auth: {
576
- login: async ({ cfg, accountId, runtime }) => {
577
- const account = resolveZalouserAccountSync({
578
- cfg: cfg,
579
- accountId: accountId ?? DEFAULT_ACCOUNT_ID,
580
- });
581
-
582
- runtime.log(
583
- `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
584
- );
585
-
586
- const started = await startZaloQrLogin({
587
- profile: account.profile,
588
- timeoutMs: 35_000,
589
- });
590
- if (!started.qrDataUrl) {
591
- throw new Error(started.message || "Failed to start QR login");
592
- }
593
-
594
- const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
595
- if (qrPath) {
596
- runtime.log(`Scan QR image: ${qrPath}`);
597
- } else {
598
- runtime.log("QR generated but could not be written to a temp file.");
599
- }
600
-
601
- const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
602
- if (!waited.connected) {
603
- throw new Error(waited.message || "Zalouser login failed");
604
- }
605
-
606
- runtime.log(waited.message);
607
- },
608
- },
609
- outbound: {
610
- deliveryMode: "direct",
611
- chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit),
612
- chunkerMode: "markdown",
613
- sendPayload: async (ctx) =>
614
- await sendPayloadWithChunkedTextAndMedia({
615
- ctx,
616
- sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
617
- sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
618
- emptyResult: { channel: "zalouser", messageId: "" },
619
- }),
620
- sendText: async ({ to, text, accountId, cfg }) => {
621
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
622
- const target = parseZalouserOutboundTarget(to);
623
- const result = await sendMessageZalouser(target.threadId, text, {
624
- profile: account.profile,
625
- isGroup: target.isGroup,
626
- textMode: "markdown",
627
- textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
628
- textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
629
- });
630
- return buildChannelSendResult("zalouser", result);
631
- },
632
- sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
633
- const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
634
- const target = parseZalouserOutboundTarget(to);
635
- const result = await sendMessageZalouser(target.threadId, text, {
636
- profile: account.profile,
637
- isGroup: target.isGroup,
638
- mediaUrl,
639
- mediaLocalRoots,
640
- textMode: "markdown",
641
- textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
642
- textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
643
- });
644
- return buildChannelSendResult("zalouser", result);
645
- },
646
- },
647
- status: {
648
- defaultRuntime: {
649
- accountId: DEFAULT_ACCOUNT_ID,
650
- running: false,
651
- lastStartAt: null,
652
- lastStopAt: null,
653
- lastError: null,
654
- },
655
- collectStatusIssues: collectZalouserStatusIssues,
656
- buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
657
- probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
658
- buildAccountSnapshot: async ({ account, runtime }) => {
659
- const configured = await checkZcaAuthenticated(account.profile);
660
- const configError = "not authenticated";
661
- const base = buildBaseAccountStatusSnapshot({
662
- account: {
663
- accountId: account.accountId,
664
- name: account.name,
665
- enabled: account.enabled,
666
- configured,
176
+ const statusSink = createAccountStatusSink({
177
+ accountId: ctx.accountId,
178
+ setStatus: ctx.setStatus,
179
+ });
180
+ ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
181
+ const { monitorZalouserProvider } = await import("./monitor.js");
182
+ return monitorZalouserProvider({
183
+ account,
184
+ config: ctx.cfg,
185
+ runtime: ctx.runtime,
186
+ abortSignal: ctx.abortSignal,
187
+ statusSink,
188
+ });
667
189
  },
668
- runtime: configured
669
- ? runtime
670
- : { ...runtime, lastError: runtime?.lastError ?? configError },
671
- });
672
- return {
673
- ...base,
674
- dmPolicy: account.config.dmPolicy ?? "pairing",
675
- };
676
- },
677
- },
678
- gateway: {
679
- startAccount: async (ctx) => {
680
- const account = ctx.account;
681
- let userLabel = "";
682
- try {
683
- const userInfo = await getZcaUserInfo(account.profile);
684
- if (userInfo?.displayName) {
685
- userLabel = ` (${userInfo.displayName})`;
686
- }
687
- ctx.setStatus({
688
- accountId: account.accountId,
689
- profile: userInfo,
690
- });
691
- } catch {
692
- // ignore probe errors
693
- }
694
- const statusSink = createAccountStatusSink({
695
- accountId: ctx.accountId,
696
- setStatus: ctx.setStatus,
697
- });
698
- ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
699
- const { monitorZalouserProvider } = await import("./monitor.js");
700
- return monitorZalouserProvider({
701
- account,
702
- config: ctx.cfg,
703
- runtime: ctx.runtime,
704
- abortSignal: ctx.abortSignal,
705
- statusSink,
706
- });
707
- },
708
- loginWithQrStart: async (params) => {
709
- const profile = resolveZalouserQrProfile(params.accountId);
710
- return await startZaloQrLogin({
711
- profile,
712
- force: params.force,
713
- timeoutMs: params.timeoutMs,
714
- });
190
+ loginWithQrStart: async (params) => {
191
+ const { startZaloQrLogin } = await loadZalouserChannelRuntime();
192
+ const profile = resolveZalouserQrProfile(params.accountId);
193
+ return await startZaloQrLogin({
194
+ profile,
195
+ force: params.force,
196
+ timeoutMs: params.timeoutMs,
197
+ });
198
+ },
199
+ loginWithQrWait: async (params) => {
200
+ const { waitForZaloQrLogin } = await loadZalouserChannelRuntime();
201
+ const profile = resolveZalouserQrProfile(params.accountId);
202
+ return await waitForZaloQrLogin({
203
+ profile,
204
+ timeoutMs: params.timeoutMs,
205
+ });
206
+ },
207
+ logoutAccount: async (ctx) =>
208
+ await (
209
+ await loadZalouserChannelRuntime()
210
+ ).logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
211
+ },
715
212
  },
716
- loginWithQrWait: async (params) => {
717
- const profile = resolveZalouserQrProfile(params.accountId);
718
- return await waitForZaloQrLogin({
719
- profile,
720
- timeoutMs: params.timeoutMs,
721
- });
213
+ security: zalouserSecurityAdapter,
214
+ threading: zalouserThreadingAdapter,
215
+ pairing: {
216
+ text: zalouserPairingTextAdapter,
722
217
  },
723
- logoutAccount: async (ctx) =>
724
- await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
725
- },
726
- };
727
-
728
- export type { ResolvedZalouserAccount };
218
+ outbound: zalouserOutboundAdapter,
219
+ });