@openclaw/zalo 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 +1 -1
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.test.ts +15 -0
  6. package/index.ts +16 -13
  7. package/openclaw.plugin.json +514 -1
  8. package/package.json +31 -5
  9. package/runtime-api.test.ts +17 -0
  10. package/runtime-api.ts +75 -0
  11. package/secret-contract-api.ts +5 -0
  12. package/setup-api.ts +34 -0
  13. package/setup-entry.ts +13 -0
  14. package/src/accounts.test.ts +70 -0
  15. package/src/accounts.ts +19 -19
  16. package/src/actions.runtime.ts +5 -0
  17. package/src/actions.test.ts +32 -0
  18. package/src/actions.ts +20 -14
  19. package/src/api.test.ts +93 -2
  20. package/src/api.ts +29 -2
  21. package/src/approval-auth.test.ts +17 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/channel.directory.test.ts +19 -6
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +26 -19
  26. package/src/channel.ts +228 -336
  27. package/src/config-schema.ts +3 -3
  28. package/src/group-access.ts +4 -3
  29. package/src/monitor.group-policy.test.ts +0 -12
  30. package/src/monitor.image.polling.test.ts +110 -0
  31. package/src/monitor.lifecycle.test.ts +41 -22
  32. package/src/monitor.pairing.lifecycle.test.ts +141 -0
  33. package/src/monitor.polling.media-reply.test.ts +425 -0
  34. package/src/monitor.reply-once.lifecycle.test.ts +171 -0
  35. package/src/monitor.ts +460 -206
  36. package/src/monitor.types.ts +4 -0
  37. package/src/monitor.webhook.test.ts +392 -62
  38. package/src/monitor.webhook.ts +73 -36
  39. package/src/outbound-media.test.ts +182 -0
  40. package/src/outbound-media.ts +241 -0
  41. package/src/outbound-payload.contract.test.ts +45 -0
  42. package/src/probe.ts +1 -1
  43. package/src/proxy.ts +1 -1
  44. package/src/runtime-api.ts +75 -0
  45. package/src/runtime-support.ts +91 -0
  46. package/src/runtime.ts +6 -3
  47. package/src/secret-contract.ts +109 -0
  48. package/src/secret-input.ts +1 -9
  49. package/src/send.test.ts +120 -0
  50. package/src/send.ts +15 -13
  51. package/src/session-route.ts +32 -0
  52. package/src/setup-allow-from.ts +94 -0
  53. package/src/setup-core.ts +149 -0
  54. package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
  55. package/src/setup-surface.test.ts +175 -0
  56. package/src/{onboarding.ts → setup-surface.ts} +59 -177
  57. package/src/status-issues.test.ts +2 -14
  58. package/src/status-issues.ts +8 -2
  59. package/src/test-support/lifecycle-test-support.ts +413 -0
  60. package/src/test-support/monitor-mocks-test-support.ts +209 -0
  61. package/src/token.test.ts +15 -0
  62. package/src/token.ts +8 -17
  63. package/src/types.ts +2 -2
  64. package/test-api.ts +1 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -101
  67. package/src/channel.sendpayload.test.ts +0 -44
package/src/channel.ts CHANGED
@@ -1,37 +1,42 @@
1
+ import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
3
+ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
1
4
  import {
2
- buildAccountScopedDmSecurityPolicy,
3
- buildOpenGroupPolicyRestrictSendersWarning,
4
- buildOpenGroupPolicyWarning,
5
- collectOpenProviderGroupPolicyWarnings,
6
- createAccountStatusSink,
5
+ adaptScopedAccountAccessor,
6
+ createScopedChannelConfigAdapter,
7
+ createScopedDmSecurityResolver,
7
8
  mapAllowFromEntries,
8
- } from "openclaw/plugin-sdk/compat";
9
- import type {
10
- ChannelAccountSnapshot,
11
- ChannelDock,
12
- ChannelPlugin,
13
- OpenClawConfig,
14
- } from "openclaw/plugin-sdk/zalo";
9
+ } from "openclaw/plugin-sdk/channel-config-helpers";
10
+ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
15
11
  import {
16
- applyAccountNameToChannelSection,
17
- applySetupAccountConfigPatch,
18
- buildBaseAccountStatusSnapshot,
19
12
  buildChannelConfigSchema,
20
- buildTokenChannelStatusSummary,
21
- buildChannelSendResult,
22
- DEFAULT_ACCOUNT_ID,
23
- deleteAccountFromConfigSection,
24
- chunkTextForOutbound,
25
- formatAllowFromLowercase,
26
- migrateBaseNameToDefaultAccount,
27
- listDirectoryUserEntriesFromAllowFrom,
28
- normalizeAccountId,
13
+ createChatChannelPlugin,
14
+ type ChannelPlugin,
15
+ } from "openclaw/plugin-sdk/channel-core";
16
+ import {
17
+ buildOpenGroupPolicyRestrictSendersWarning,
18
+ buildOpenGroupPolicyWarning,
19
+ createOpenProviderGroupPolicyWarningCollector,
20
+ } from "openclaw/plugin-sdk/channel-policy";
21
+ import {
22
+ createEmptyChannelResult,
23
+ createRawChannelSendResultAdapter,
24
+ } from "openclaw/plugin-sdk/channel-send-result";
25
+ import { buildTokenChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
26
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
27
+ import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
28
+ import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
29
+ import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
30
+ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
31
+ import {
29
32
  isNumericTargetId,
30
- PAIRING_APPROVED_MESSAGE,
31
- resolveOutboundMediaUrls,
32
33
  sendPayloadWithChunkedTextAndMedia,
33
- setAccountEnabledInConfigSection,
34
- } from "openclaw/plugin-sdk/zalo";
34
+ } from "openclaw/plugin-sdk/reply-payload";
35
+ import {
36
+ createComputedAccountStatusAdapter,
37
+ createDefaultChannelRuntimeState,
38
+ } from "openclaw/plugin-sdk/status-helpers";
39
+ import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
35
40
  import {
36
41
  listZaloAccountIds,
37
42
  resolveDefaultZaloAccountId,
@@ -39,12 +44,12 @@ import {
39
44
  type ResolvedZaloAccount,
40
45
  } from "./accounts.js";
41
46
  import { zaloMessageActions } from "./actions.js";
47
+ import { zaloApprovalAuth } from "./approval-auth.js";
42
48
  import { ZaloConfigSchema } from "./config-schema.js";
43
- import { zaloOnboardingAdapter } from "./onboarding.js";
44
- import { probeZalo } from "./probe.js";
45
- import { resolveZaloProxyFetch } from "./proxy.js";
46
- import { normalizeSecretInputString } from "./secret-input.js";
47
- import { sendMessageZalo } from "./send.js";
49
+ import type { ZaloProbeResult } from "./probe.js";
50
+ import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
51
+ import { resolveZaloOutboundSessionRoute } from "./session-route.js";
52
+ import { createZaloSetupWizardProxy, zaloSetupAdapter } from "./setup-core.js";
48
53
  import { collectZaloStatusIssues } from "./status-issues.js";
49
54
 
50
55
  const meta = {
@@ -64,319 +69,206 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
64
69
  if (!trimmed) {
65
70
  return undefined;
66
71
  }
67
- return trimmed.replace(/^(zalo|zl):/i, "");
72
+ return trimmed.replace(/^(zalo|zl):/i, "").trim();
68
73
  }
69
74
 
70
- export const zaloDock: ChannelDock = {
71
- id: "zalo",
72
- capabilities: {
73
- chatTypes: ["direct", "group"],
74
- media: true,
75
- blockStreaming: true,
76
- },
77
- outbound: { textChunkLimit: 2000 },
78
- config: {
79
- resolveAllowFrom: ({ cfg, accountId }) =>
80
- mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
81
- formatAllowFrom: ({ allowFrom }) =>
82
- formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
83
- },
84
- groups: {
85
- resolveRequireMention: () => true,
86
- },
87
- threading: {
88
- resolveReplyToMode: () => "off",
89
- },
90
- };
75
+ const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
76
+ const zaloSetupWizard = createZaloSetupWizardProxy(
77
+ async () => (await import("./setup-surface.js")).zaloSetupWizard,
78
+ );
79
+ const zaloTextChunkLimit = 2000;
91
80
 
92
- export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
93
- id: "zalo",
94
- meta,
95
- onboarding: zaloOnboardingAdapter,
96
- capabilities: {
97
- chatTypes: ["direct", "group"],
98
- media: true,
99
- reactions: false,
100
- threads: false,
101
- polls: false,
102
- nativeCommands: false,
103
- blockStreaming: true,
104
- },
105
- reload: { configPrefixes: ["channels.zalo"] },
106
- configSchema: buildChannelConfigSchema(ZaloConfigSchema),
107
- config: {
108
- listAccountIds: (cfg) => listZaloAccountIds(cfg),
109
- resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }),
110
- defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg),
111
- setAccountEnabled: ({ cfg, accountId, enabled }) =>
112
- setAccountEnabledInConfigSection({
113
- cfg: cfg,
114
- sectionKey: "zalo",
115
- accountId,
116
- enabled,
117
- allowTopLevel: true,
118
- }),
119
- deleteAccount: ({ cfg, accountId }) =>
120
- deleteAccountFromConfigSection({
121
- cfg: cfg,
122
- sectionKey: "zalo",
123
- accountId,
124
- clearBaseFields: ["botToken", "tokenFile", "name"],
125
- }),
126
- isConfigured: (account) => Boolean(account.token?.trim()),
127
- describeAccount: (account): ChannelAccountSnapshot => ({
128
- accountId: account.accountId,
129
- name: account.name,
130
- enabled: account.enabled,
131
- configured: Boolean(account.token?.trim()),
132
- tokenSource: account.tokenSource,
81
+ const zaloRawSendResultAdapter = createRawChannelSendResultAdapter({
82
+ channel: "zalo",
83
+ sendText: async ({ to, text, accountId, cfg }) =>
84
+ await (
85
+ await loadZaloChannelRuntime()
86
+ ).sendZaloText({
87
+ to,
88
+ text,
89
+ accountId: accountId ?? undefined,
90
+ cfg,
91
+ }),
92
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) =>
93
+ await (
94
+ await loadZaloChannelRuntime()
95
+ ).sendZaloText({
96
+ to,
97
+ text,
98
+ accountId: accountId ?? undefined,
99
+ mediaUrl,
100
+ cfg,
133
101
  }),
134
- resolveAllowFrom: ({ cfg, accountId }) =>
135
- mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
136
- formatAllowFrom: ({ allowFrom }) =>
137
- formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
102
+ });
103
+
104
+ const zaloConfigAdapter = createScopedChannelConfigAdapter<ResolvedZaloAccount>({
105
+ sectionKey: "zalo",
106
+ listAccountIds: listZaloAccountIds,
107
+ resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
108
+ defaultAccountId: resolveDefaultZaloAccountId,
109
+ clearBaseFields: ["botToken", "tokenFile", "name"],
110
+ resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom,
111
+ formatAllowFrom: (allowFrom) =>
112
+ formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
113
+ });
114
+
115
+ const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>({
116
+ channelKey: "zalo",
117
+ resolvePolicy: (account) => account.config.dmPolicy,
118
+ resolveAllowFrom: (account) => account.config.allowFrom,
119
+ policyPathSuffix: "dmPolicy",
120
+ normalizeEntry: (raw) => raw.trim().replace(/^(zalo|zl):/i, ""),
121
+ });
122
+
123
+ const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{
124
+ cfg: OpenClawConfig;
125
+ account: ResolvedZaloAccount;
126
+ }>({
127
+ providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined,
128
+ resolveGroupPolicy: ({ account }) => account.config.groupPolicy,
129
+ collect: ({ account, groupPolicy }) => {
130
+ if (groupPolicy !== "open") {
131
+ return [];
132
+ }
133
+ const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
134
+ const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
135
+ const effectiveAllowFrom =
136
+ explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
137
+ if (effectiveAllowFrom.length > 0) {
138
+ return [
139
+ buildOpenGroupPolicyRestrictSendersWarning({
140
+ surface: "Zalo groups",
141
+ openScope: "any member",
142
+ groupPolicyPath: "channels.zalo.groupPolicy",
143
+ groupAllowFromPath: "channels.zalo.groupAllowFrom",
144
+ }),
145
+ ];
146
+ }
147
+ return [
148
+ buildOpenGroupPolicyWarning({
149
+ surface: "Zalo groups",
150
+ openBehavior:
151
+ "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
152
+ remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
153
+ }),
154
+ ];
138
155
  },
139
- security: {
140
- resolveDmPolicy: ({ cfg, accountId, account }) => {
141
- return buildAccountScopedDmSecurityPolicy({
142
- cfg,
143
- channelKey: "zalo",
144
- accountId,
145
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
146
- policy: account.config.dmPolicy,
147
- allowFrom: account.config.allowFrom ?? [],
148
- policyPathSuffix: "dmPolicy",
149
- normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
150
- });
151
- },
152
- collectWarnings: ({ account, cfg }) => {
153
- return collectOpenProviderGroupPolicyWarnings({
154
- cfg,
155
- providerConfigPresent: cfg.channels?.zalo !== undefined,
156
- configuredGroupPolicy: account.config.groupPolicy,
157
- collect: (groupPolicy) => {
158
- if (groupPolicy !== "open") {
159
- return [];
160
- }
161
- const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
162
- const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
163
- const effectiveAllowFrom =
164
- explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
165
- if (effectiveAllowFrom.length > 0) {
166
- return [
167
- buildOpenGroupPolicyRestrictSendersWarning({
168
- surface: "Zalo groups",
169
- openScope: "any member",
170
- groupPolicyPath: "channels.zalo.groupPolicy",
171
- groupAllowFromPath: "channels.zalo.groupAllowFrom",
172
- }),
173
- ];
174
- }
175
- return [
176
- buildOpenGroupPolicyWarning({
177
- surface: "Zalo groups",
178
- openBehavior:
179
- "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
180
- remediation:
181
- 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
182
- }),
183
- ];
156
+ });
157
+
158
+ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
159
+ createChatChannelPlugin({
160
+ base: {
161
+ id: "zalo",
162
+ meta,
163
+ setup: zaloSetupAdapter,
164
+ setupWizard: zaloSetupWizard,
165
+ capabilities: {
166
+ chatTypes: ["direct", "group"],
167
+ media: true,
168
+ reactions: false,
169
+ threads: false,
170
+ polls: false,
171
+ nativeCommands: false,
172
+ blockStreaming: true,
173
+ },
174
+ reload: { configPrefixes: ["channels.zalo"] },
175
+ configSchema: buildChannelConfigSchema(ZaloConfigSchema),
176
+ config: {
177
+ ...zaloConfigAdapter,
178
+ isConfigured: (account) => Boolean(account.token?.trim()),
179
+ describeAccount: (account): ChannelAccountSnapshot =>
180
+ describeWebhookAccountSnapshot({
181
+ account,
182
+ configured: Boolean(account.token?.trim()),
183
+ mode: account.config.webhookUrl ? "webhook" : "polling",
184
+ extra: {
185
+ tokenSource: account.tokenSource,
186
+ },
187
+ }),
188
+ },
189
+ approvalCapability: zaloApprovalAuth,
190
+ secrets: {
191
+ secretTargetRegistryEntries,
192
+ collectRuntimeConfigAssignments,
193
+ },
194
+ groups: {
195
+ resolveRequireMention: () => true,
196
+ },
197
+ actions: zaloMessageActions,
198
+ messaging: {
199
+ normalizeTarget: normalizeZaloMessagingTarget,
200
+ resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
201
+ targetResolver: {
202
+ looksLikeId: isNumericTargetId,
203
+ hint: "<chatId>",
184
204
  },
185
- });
186
- },
187
- },
188
- groups: {
189
- resolveRequireMention: () => true,
190
- },
191
- threading: {
192
- resolveReplyToMode: () => "off",
193
- },
194
- actions: zaloMessageActions,
195
- messaging: {
196
- normalizeTarget: normalizeZaloMessagingTarget,
197
- targetResolver: {
198
- looksLikeId: isNumericTargetId,
199
- hint: "<chatId>",
200
- },
201
- },
202
- directory: {
203
- self: async () => null,
204
- listPeers: async ({ cfg, accountId, query, limit }) => {
205
- const account = resolveZaloAccount({ cfg: cfg, accountId });
206
- return listDirectoryUserEntriesFromAllowFrom({
207
- allowFrom: account.config.allowFrom,
208
- query,
209
- limit,
210
- normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
211
- });
212
- },
213
- listGroups: async () => [],
214
- },
215
- setup: {
216
- resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
217
- applyAccountName: ({ cfg, accountId, name }) =>
218
- applyAccountNameToChannelSection({
219
- cfg: cfg,
220
- channelKey: "zalo",
221
- accountId,
222
- name,
205
+ },
206
+ directory: createChannelDirectoryAdapter({
207
+ listPeers: async (params) =>
208
+ listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedZaloAccount>({
209
+ ...params,
210
+ resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
211
+ resolveAllowFrom: (account) => account.config.allowFrom,
212
+ normalizeId: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
213
+ }),
214
+ listGroups: async () => [],
223
215
  }),
224
- validateInput: ({ accountId, input }) => {
225
- if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
226
- return "ZALO_BOT_TOKEN can only be used for the default account.";
227
- }
228
- if (!input.useEnv && !input.token && !input.tokenFile) {
229
- return "Zalo requires token or --token-file (or --use-env).";
230
- }
231
- return null;
232
- },
233
- applyAccountConfig: ({ cfg, accountId, input }) => {
234
- const namedConfig = applyAccountNameToChannelSection({
235
- cfg: cfg,
236
- channelKey: "zalo",
237
- accountId,
238
- name: input.name,
239
- });
240
- const next =
241
- accountId !== DEFAULT_ACCOUNT_ID
242
- ? migrateBaseNameToDefaultAccount({
243
- cfg: namedConfig,
244
- channelKey: "zalo",
245
- })
246
- : namedConfig;
247
- const patch = input.useEnv
248
- ? {}
249
- : input.tokenFile
250
- ? { tokenFile: input.tokenFile }
251
- : input.token
252
- ? { botToken: input.token }
253
- : {};
254
- return applySetupAccountConfigPatch({
255
- cfg: next,
256
- channelKey: "zalo",
257
- accountId,
258
- patch,
259
- });
260
- },
261
- },
262
- pairing: {
263
- idLabel: "zaloUserId",
264
- normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
265
- notifyApproval: async ({ cfg, id }) => {
266
- const account = resolveZaloAccount({ cfg: cfg });
267
- if (!account.token) {
268
- throw new Error("Zalo token not configured");
269
- }
270
- await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
271
- },
272
- },
273
- outbound: {
274
- deliveryMode: "direct",
275
- chunker: chunkTextForOutbound,
276
- chunkerMode: "text",
277
- textChunkLimit: 2000,
278
- sendPayload: async (ctx) =>
279
- await sendPayloadWithChunkedTextAndMedia({
280
- ctx,
281
- textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
282
- chunker: zaloPlugin.outbound!.chunker,
283
- sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
284
- sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
285
- emptyResult: { channel: "zalo", messageId: "" },
216
+ status: createComputedAccountStatusAdapter<ResolvedZaloAccount, ZaloProbeResult>({
217
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
218
+ collectStatusIssues: collectZaloStatusIssues,
219
+ buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
220
+ probeAccount: async ({ account, timeoutMs }) =>
221
+ await (await loadZaloChannelRuntime()).probeZaloAccount({ account, timeoutMs }),
222
+ resolveAccountSnapshot: ({ account }) => {
223
+ const configured = Boolean(account.token?.trim());
224
+ return {
225
+ accountId: account.accountId,
226
+ name: account.name,
227
+ enabled: account.enabled,
228
+ configured,
229
+ extra: {
230
+ tokenSource: account.tokenSource,
231
+ mode: account.config.webhookUrl ? "webhook" : "polling",
232
+ dmPolicy: account.config.dmPolicy ?? "pairing",
233
+ },
234
+ };
235
+ },
286
236
  }),
287
- sendText: async ({ to, text, accountId, cfg }) => {
288
- const result = await sendMessageZalo(to, text, {
289
- accountId: accountId ?? undefined,
290
- cfg: cfg,
291
- });
292
- return buildChannelSendResult("zalo", result);
237
+ gateway: {
238
+ startAccount: async (ctx) =>
239
+ await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx),
240
+ },
293
241
  },
294
- sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
295
- const result = await sendMessageZalo(to, text, {
296
- accountId: accountId ?? undefined,
297
- mediaUrl,
298
- cfg: cfg,
299
- });
300
- return buildChannelSendResult("zalo", result);
242
+ security: {
243
+ resolveDmPolicy: resolveZaloDmPolicy,
244
+ collectWarnings: collectZaloSecurityWarnings,
301
245
  },
302
- },
303
- status: {
304
- defaultRuntime: {
305
- accountId: DEFAULT_ACCOUNT_ID,
306
- running: false,
307
- lastStartAt: null,
308
- lastStopAt: null,
309
- lastError: null,
246
+ pairing: {
247
+ text: {
248
+ idLabel: "zaloUserId",
249
+ message: "Your pairing request has been approved.",
250
+ normalizeAllowEntry: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
251
+ notify: async (params) =>
252
+ await (await loadZaloChannelRuntime()).notifyZaloPairingApproval(params),
253
+ },
310
254
  },
311
- collectStatusIssues: collectZaloStatusIssues,
312
- buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
313
- probeAccount: async ({ account, timeoutMs }) =>
314
- probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
315
- buildAccountSnapshot: ({ account, runtime }) => {
316
- const configured = Boolean(account.token?.trim());
317
- const base = buildBaseAccountStatusSnapshot({
318
- account: {
319
- accountId: account.accountId,
320
- name: account.name,
321
- enabled: account.enabled,
322
- configured,
323
- },
324
- runtime,
325
- });
326
- return {
327
- ...base,
328
- tokenSource: account.tokenSource,
329
- mode: account.config.webhookUrl ? "webhook" : "polling",
330
- dmPolicy: account.config.dmPolicy ?? "pairing",
331
- };
255
+ threading: {
256
+ resolveReplyToMode: createStaticReplyToModeResolver("off"),
332
257
  },
333
- },
334
- gateway: {
335
- startAccount: async (ctx) => {
336
- const account = ctx.account;
337
- const token = account.token.trim();
338
- const mode = account.config.webhookUrl ? "webhook" : "polling";
339
- let zaloBotLabel = "";
340
- const fetcher = resolveZaloProxyFetch(account.config.proxy);
341
- try {
342
- const probe = await probeZalo(token, 2500, fetcher);
343
- const name = probe.ok ? probe.bot?.name?.trim() : null;
344
- if (name) {
345
- zaloBotLabel = ` (${name})`;
346
- }
347
- if (!probe.ok) {
348
- ctx.log?.warn?.(
349
- `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
350
- );
351
- }
352
- ctx.setStatus({
353
- accountId: account.accountId,
354
- bot: probe.bot,
355
- });
356
- } catch (err) {
357
- ctx.log?.warn?.(
358
- `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
359
- );
360
- }
361
- const statusSink = createAccountStatusSink({
362
- accountId: ctx.accountId,
363
- setStatus: ctx.setStatus,
364
- });
365
- ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
366
- const { monitorZaloProvider } = await import("./monitor.js");
367
- return monitorZaloProvider({
368
- token,
369
- account,
370
- config: ctx.cfg,
371
- runtime: ctx.runtime,
372
- abortSignal: ctx.abortSignal,
373
- useWebhook: Boolean(account.config.webhookUrl),
374
- webhookUrl: account.config.webhookUrl,
375
- webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
376
- webhookPath: account.config.webhookPath,
377
- fetcher,
378
- statusSink,
379
- });
258
+ outbound: {
259
+ deliveryMode: "direct",
260
+ chunker: chunkTextForOutbound,
261
+ chunkerMode: "text",
262
+ textChunkLimit: zaloTextChunkLimit,
263
+ sendPayload: async (ctx) =>
264
+ await sendPayloadWithChunkedTextAndMedia({
265
+ ctx,
266
+ textChunkLimit: zaloTextChunkLimit,
267
+ chunker: chunkTextForOutbound,
268
+ sendText: (nextCtx) => zaloRawSendResultAdapter.sendText!(nextCtx),
269
+ sendMedia: (nextCtx) => zaloRawSendResultAdapter.sendMedia!(nextCtx),
270
+ emptyResult: createEmptyChannelResult("zalo"),
271
+ }),
272
+ ...zaloRawSendResultAdapter,
380
273
  },
381
- },
382
- };
274
+ });
@@ -3,9 +3,9 @@ import {
3
3
  buildCatchallMultiAccountChannelSchema,
4
4
  DmPolicySchema,
5
5
  GroupPolicySchema,
6
- } from "openclaw/plugin-sdk/compat";
7
- import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
8
- import { z } from "zod";
6
+ MarkdownConfigSchema,
7
+ } from "openclaw/plugin-sdk/channel-config-schema";
8
+ import { z } from "openclaw/plugin-sdk/zod";
9
9
  import { buildSecretInputSchema } from "./secret-input.js";
10
10
 
11
11
  const zaloAccountSchema = z.object({
@@ -1,9 +1,10 @@
1
- import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk/zalo";
1
+ import { isNormalizedSenderAllowed } from "openclaw/plugin-sdk/allow-from";
2
2
  import {
3
3
  evaluateSenderGroupAccess,
4
- isNormalizedSenderAllowed,
5
4
  resolveOpenProviderRuntimeGroupPolicy,
6
- } from "openclaw/plugin-sdk/zalo";
5
+ type GroupPolicy,
6
+ type SenderGroupAccessDecision,
7
+ } from "openclaw/plugin-sdk/group-access";
7
8
 
8
9
  const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
9
10
 
@@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import { __testing } from "./monitor.js";
3
3
 
4
4
  describe("zalo group policy access", () => {
5
- it("defaults missing provider config to allowlist", () => {
6
- const resolved = __testing.resolveZaloRuntimeGroupPolicy({
7
- providerConfigPresent: false,
8
- groupPolicy: undefined,
9
- defaultGroupPolicy: "open",
10
- });
11
- expect(resolved).toEqual({
12
- groupPolicy: "allowlist",
13
- providerMissingFallbackApplied: true,
14
- });
15
- });
16
-
17
5
  it("blocks all group messages when policy is disabled", () => {
18
6
  const decision = __testing.evaluateZaloGroupAccess({
19
7
  providerConfigPresent: true,