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