@openclaw/zalo 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 +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 +108 -22
  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 +22 -16
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +36 -35
  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 +77 -92
  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 +527 -304
  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 +64 -40
  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 +17 -0
  58. package/src/status-issues.ts +11 -27
  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 -95
  67. package/src/channel.sendpayload.test.ts +0 -44
package/src/monitor.ts CHANGED
@@ -1,25 +1,24 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type {
3
- MarkdownTableMode,
4
- OpenClawConfig,
5
- OutboundReplyPayload,
6
- } from "openclaw/plugin-sdk/zalo";
2
+ import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
3
+ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
4
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
7
5
  import {
8
- createTypingCallbacks,
9
- createScopedPairingAccess,
10
- createReplyPrefixOptions,
11
- issuePairingChallenge,
12
- logTypingFailure,
13
6
  resolveDirectDmAuthorizationOutcome,
14
7
  resolveSenderCommandAuthorizationWithRuntime,
15
- resolveOutboundMediaUrls,
8
+ } from "openclaw/plugin-sdk/command-auth";
9
+ import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
10
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
11
+ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
12
+ import {
13
+ deliverTextOrMediaReply,
14
+ type OutboundReplyPayload,
15
+ } from "openclaw/plugin-sdk/reply-payload";
16
+ import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
17
+ import {
16
18
  resolveDefaultGroupPolicy,
17
- resolveInboundRouteEnvelopeBuilderWithRuntime,
18
- sendMediaWithLeadingCaption,
19
- resolveWebhookPath,
20
- waitForAbortSignal,
21
19
  warnMissingProviderGroupPolicyFallbackOnce,
22
- } from "openclaw/plugin-sdk/zalo";
20
+ } from "openclaw/plugin-sdk/runtime-group-policy";
21
+ import { registerPluginHttpRoute, resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
23
22
  import type { ResolvedZaloAccount } from "./accounts.js";
24
23
  import {
25
24
  ZaloApiError,
@@ -39,21 +38,15 @@ import {
39
38
  isZaloSenderAllowed,
40
39
  resolveZaloRuntimeGroupPolicy,
41
40
  } from "./group-access.js";
42
- import {
43
- clearZaloWebhookSecurityStateForTest,
44
- getZaloWebhookRateLimitStateSizeForTest,
45
- getZaloWebhookStatusCounterSizeForTest,
46
- handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
47
- registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
48
- type ZaloWebhookTarget,
49
- } from "./monitor.webhook.js";
50
41
  import { resolveZaloProxyFetch } from "./proxy.js";
51
42
  import { getZaloRuntime } from "./runtime.js";
52
-
53
- export type ZaloRuntimeEnv = {
54
- log?: (message: string) => void;
55
- error?: (message: string) => void;
56
- };
43
+ export type { ZaloRuntimeEnv } from "./monitor.types.js";
44
+ import type { ZaloRuntimeEnv } from "./monitor.types.js";
45
+ import {
46
+ prepareHostedZaloMediaUrl,
47
+ resolveHostedZaloMediaRoutePrefix,
48
+ tryHandleHostedZaloMediaRequest,
49
+ } from "./outbound-media.js";
57
50
 
58
51
  export type ZaloMonitorOptions = {
59
52
  token: string;
@@ -75,6 +68,115 @@ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
75
68
  const ZALO_TYPING_TIMEOUT_MS = 5_000;
76
69
 
77
70
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
71
+ type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
72
+ type ZaloWebhookModule = typeof import("./monitor.webhook.js");
73
+ type ZaloProcessingContext = {
74
+ token: string;
75
+ account: ResolvedZaloAccount;
76
+ config: OpenClawConfig;
77
+ runtime: ZaloRuntimeEnv;
78
+ core: ZaloCoreRuntime;
79
+ mediaMaxMb: number;
80
+ canHostMedia: boolean;
81
+ webhookUrl?: string;
82
+ webhookPath?: string;
83
+ statusSink?: ZaloStatusSink;
84
+ fetcher?: ZaloFetch;
85
+ };
86
+ type ZaloPollingLoopParams = ZaloProcessingContext & {
87
+ abortSignal: AbortSignal;
88
+ isStopped: () => boolean;
89
+ };
90
+ type ZaloUpdateProcessingParams = ZaloProcessingContext & {
91
+ update: ZaloUpdate;
92
+ };
93
+
94
+ let zaloWebhookModulePromise: Promise<ZaloWebhookModule> | undefined;
95
+ const hostedMediaRouteRefs = new Map<string, { count: number; unregisters: Array<() => void> }>();
96
+
97
+ function loadZaloWebhookModule(): Promise<ZaloWebhookModule> {
98
+ zaloWebhookModulePromise ??= import("./monitor.webhook.js");
99
+ return zaloWebhookModulePromise;
100
+ }
101
+
102
+ function registerSharedHostedMediaRoute(params: {
103
+ path: string;
104
+ accountId: string;
105
+ log?: (message: string) => void;
106
+ }): () => void {
107
+ const unregister = registerPluginHttpRoute({
108
+ auth: "plugin",
109
+ match: "prefix",
110
+ path: params.path,
111
+ pluginId: "zalo",
112
+ source: "zalo-hosted-media",
113
+ accountId: params.accountId,
114
+ log: params.log,
115
+ handler: async (req, res) => {
116
+ const handled = await tryHandleHostedZaloMediaRequest(req, res);
117
+ if (!handled && !res.headersSent) {
118
+ res.statusCode = 404;
119
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
120
+ res.end("Not Found");
121
+ }
122
+ },
123
+ });
124
+
125
+ const existing = hostedMediaRouteRefs.get(params.path);
126
+ if (existing) {
127
+ existing.count += 1;
128
+ existing.unregisters.push(unregister);
129
+ return () => {
130
+ const current = hostedMediaRouteRefs.get(params.path);
131
+ if (!current) {
132
+ return;
133
+ }
134
+ if (current.count > 1) {
135
+ current.count -= 1;
136
+ return;
137
+ }
138
+ hostedMediaRouteRefs.delete(params.path);
139
+ for (const unregisterHandle of current.unregisters) {
140
+ unregisterHandle();
141
+ }
142
+ };
143
+ }
144
+
145
+ hostedMediaRouteRefs.set(params.path, { count: 1, unregisters: [unregister] });
146
+ return () => {
147
+ const current = hostedMediaRouteRefs.get(params.path);
148
+ if (!current) {
149
+ return;
150
+ }
151
+ if (current.count > 1) {
152
+ current.count -= 1;
153
+ return;
154
+ }
155
+ hostedMediaRouteRefs.delete(params.path);
156
+ for (const unregisterHandle of current.unregisters) {
157
+ unregisterHandle();
158
+ }
159
+ };
160
+ }
161
+
162
+ type ZaloMessagePipelineParams = ZaloProcessingContext & {
163
+ message: ZaloMessage;
164
+ text?: string;
165
+ mediaPath?: string;
166
+ mediaType?: string;
167
+ authorization?: ZaloMessageAuthorizationResult;
168
+ };
169
+ type ZaloImageMessageParams = ZaloProcessingContext & {
170
+ message: ZaloMessage;
171
+ };
172
+ type ZaloMessageAuthorizationResult = {
173
+ chatId: string;
174
+ commandAuthorized: boolean | undefined;
175
+ isGroup: boolean;
176
+ rawBody: string;
177
+ senderId: string;
178
+ senderName: string | undefined;
179
+ };
78
180
 
79
181
  function formatZaloError(error: unknown): string {
80
182
  if (error instanceof Error) {
@@ -103,100 +205,79 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
103
205
  }
104
206
  }
105
207
 
106
- export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
107
- return registerZaloWebhookTargetInternal(target, {
108
- route: {
109
- auth: "plugin",
110
- match: "exact",
111
- pluginId: "zalo",
112
- source: "zalo-webhook",
113
- accountId: target.account.accountId,
114
- log: target.runtime.log,
115
- handler: async (req, res) => {
116
- const handled = await handleZaloWebhookRequest(req, res);
117
- if (!handled && !res.headersSent) {
118
- res.statusCode = 404;
119
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
120
- res.end("Not Found");
121
- }
122
- },
123
- },
124
- });
125
- }
126
-
127
- export {
128
- clearZaloWebhookSecurityStateForTest,
129
- getZaloWebhookRateLimitStateSizeForTest,
130
- getZaloWebhookStatusCounterSizeForTest,
131
- };
132
-
133
208
  export async function handleZaloWebhookRequest(
134
209
  req: IncomingMessage,
135
210
  res: ServerResponse,
136
211
  ): Promise<boolean> {
137
- return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
138
- await processUpdate(
212
+ const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } =
213
+ await loadZaloWebhookModule();
214
+ return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
215
+ await processUpdate({
139
216
  update,
140
- target.token,
141
- target.account,
142
- target.config,
143
- target.runtime,
144
- target.core as ZaloCoreRuntime,
145
- target.mediaMaxMb,
146
- target.statusSink,
147
- target.fetcher,
148
- );
217
+ token: target.token,
218
+ account: target.account,
219
+ config: target.config,
220
+ runtime: target.runtime,
221
+ core: target.core as ZaloCoreRuntime,
222
+ mediaMaxMb: target.mediaMaxMb,
223
+ canHostMedia: target.canHostMedia,
224
+ webhookUrl: target.webhookUrl,
225
+ webhookPath: target.webhookPath,
226
+ statusSink: target.statusSink,
227
+ fetcher: target.fetcher,
228
+ });
149
229
  });
150
230
  }
151
231
 
152
- function startPollingLoop(params: {
153
- token: string;
154
- account: ResolvedZaloAccount;
155
- config: OpenClawConfig;
156
- runtime: ZaloRuntimeEnv;
157
- core: ZaloCoreRuntime;
158
- abortSignal: AbortSignal;
159
- isStopped: () => boolean;
160
- mediaMaxMb: number;
161
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
162
- fetcher?: ZaloFetch;
163
- }) {
232
+ function startPollingLoop(params: ZaloPollingLoopParams) {
164
233
  const {
165
234
  token,
166
235
  account,
167
236
  config,
168
237
  runtime,
169
238
  core,
239
+ mediaMaxMb,
240
+ canHostMedia,
241
+ webhookUrl,
242
+ webhookPath,
170
243
  abortSignal,
171
244
  isStopped,
172
- mediaMaxMb,
173
245
  statusSink,
174
246
  fetcher,
175
247
  } = params;
176
248
  const pollTimeout = 30;
249
+ const processingContext = {
250
+ token,
251
+ account,
252
+ config,
253
+ runtime,
254
+ core,
255
+ mediaMaxMb,
256
+ canHostMedia,
257
+ webhookUrl,
258
+ webhookPath,
259
+ statusSink,
260
+ fetcher,
261
+ };
177
262
 
178
263
  runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
179
264
 
180
- const poll = async () => {
265
+ const poll = async (): Promise<void> => {
181
266
  if (isStopped() || abortSignal.aborted) {
182
- return;
267
+ return undefined;
183
268
  }
184
269
 
185
270
  try {
186
271
  const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
272
+ if (isStopped() || abortSignal.aborted) {
273
+ return undefined;
274
+ }
187
275
  if (response.ok && response.result) {
188
276
  statusSink?.({ lastInboundAt: Date.now() });
189
- await processUpdate(
190
- response.result,
191
- token,
192
- account,
193
- config,
194
- runtime,
195
- core,
196
- mediaMaxMb,
197
- statusSink,
198
- fetcher,
199
- );
277
+ await processUpdate({
278
+ update: response.result,
279
+ ...processingContext,
280
+ });
200
281
  }
201
282
  } catch (err) {
202
283
  if (err instanceof ZaloApiError && err.isPollingTimeout) {
@@ -215,38 +296,39 @@ function startPollingLoop(params: {
215
296
  void poll();
216
297
  }
217
298
 
218
- async function processUpdate(
219
- update: ZaloUpdate,
220
- token: string,
221
- account: ResolvedZaloAccount,
222
- config: OpenClawConfig,
223
- runtime: ZaloRuntimeEnv,
224
- core: ZaloCoreRuntime,
225
- mediaMaxMb: number,
226
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
227
- fetcher?: ZaloFetch,
228
- ): Promise<void> {
299
+ async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
300
+ const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
229
301
  const { event_name, message } = update;
302
+ const sharedContext = {
303
+ token,
304
+ account,
305
+ config,
306
+ runtime,
307
+ core,
308
+ mediaMaxMb,
309
+ canHostMedia: params.canHostMedia,
310
+ webhookUrl: params.webhookUrl,
311
+ webhookPath: params.webhookPath,
312
+ statusSink,
313
+ fetcher,
314
+ };
230
315
  if (!message) {
231
- return;
316
+ return undefined;
232
317
  }
233
318
 
234
319
  switch (event_name) {
235
320
  case "message.text.received":
236
- await handleTextMessage(message, token, account, config, runtime, core, statusSink, fetcher);
321
+ await handleTextMessage({
322
+ message,
323
+ ...sharedContext,
324
+ });
237
325
  break;
238
326
  case "message.image.received":
239
- await handleImageMessage(
327
+ await handleImageMessage({
240
328
  message,
241
- token,
242
- account,
243
- config,
244
- runtime,
245
- core,
329
+ ...sharedContext,
246
330
  mediaMaxMb,
247
- statusSink,
248
- fetcher,
249
- );
331
+ });
250
332
  break;
251
333
  case "message.sticker.received":
252
334
  logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
@@ -262,55 +344,43 @@ async function processUpdate(
262
344
  }
263
345
 
264
346
  async function handleTextMessage(
265
- message: ZaloMessage,
266
- token: string,
267
- account: ResolvedZaloAccount,
268
- config: OpenClawConfig,
269
- runtime: ZaloRuntimeEnv,
270
- core: ZaloCoreRuntime,
271
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
272
- fetcher?: ZaloFetch,
347
+ params: ZaloProcessingContext & { message: ZaloMessage },
273
348
  ): Promise<void> {
349
+ const { message } = params;
274
350
  const { text } = message;
275
351
  if (!text?.trim()) {
276
- return;
352
+ return undefined;
277
353
  }
278
354
 
279
355
  await processMessageWithPipeline({
280
- message,
281
- token,
282
- account,
283
- config,
284
- runtime,
285
- core,
356
+ ...params,
286
357
  text,
287
358
  mediaPath: undefined,
288
359
  mediaType: undefined,
289
- statusSink,
290
- fetcher,
291
360
  });
292
361
  }
293
362
 
294
- async function handleImageMessage(
295
- message: ZaloMessage,
296
- token: string,
297
- account: ResolvedZaloAccount,
298
- config: OpenClawConfig,
299
- runtime: ZaloRuntimeEnv,
300
- core: ZaloCoreRuntime,
301
- mediaMaxMb: number,
302
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
303
- fetcher?: ZaloFetch,
304
- ): Promise<void> {
305
- const { photo, caption } = message;
363
+ async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
364
+ const { message, mediaMaxMb, account, core, runtime } = params;
365
+ const { photo_url, caption } = message;
366
+ const authorization = await authorizeZaloMessage({
367
+ ...params,
368
+ text: caption,
369
+ // Use a sentinel so auth sees this as an inbound image before the download happens.
370
+ mediaPath: photo_url ? "__pending_media__" : undefined,
371
+ mediaType: undefined,
372
+ });
373
+ if (!authorization) {
374
+ return;
375
+ }
306
376
 
307
377
  let mediaPath: string | undefined;
308
378
  let mediaType: string | undefined;
309
379
 
310
- if (photo) {
380
+ if (photo_url) {
311
381
  try {
312
382
  const maxBytes = mediaMaxMb * 1024 * 1024;
313
- const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
383
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
314
384
  const saved = await core.channel.media.saveMediaBuffer(
315
385
  fetched.buffer,
316
386
  fetched.contentType,
@@ -325,57 +395,30 @@ async function handleImageMessage(
325
395
  }
326
396
 
327
397
  await processMessageWithPipeline({
328
- message,
329
- token,
330
- account,
331
- config,
332
- runtime,
333
- core,
398
+ ...params,
399
+ authorization,
334
400
  text: caption,
335
401
  mediaPath,
336
402
  mediaType,
337
- statusSink,
338
- fetcher,
339
403
  });
340
404
  }
341
405
 
342
- async function processMessageWithPipeline(params: {
343
- message: ZaloMessage;
344
- token: string;
345
- account: ResolvedZaloAccount;
346
- config: OpenClawConfig;
347
- runtime: ZaloRuntimeEnv;
348
- core: ZaloCoreRuntime;
349
- text?: string;
350
- mediaPath?: string;
351
- mediaType?: string;
352
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
353
- fetcher?: ZaloFetch;
354
- }): Promise<void> {
355
- const {
356
- message,
357
- token,
358
- account,
359
- config,
360
- runtime,
361
- core,
362
- text,
363
- mediaPath,
364
- mediaType,
365
- statusSink,
366
- fetcher,
367
- } = params;
368
- const pairing = createScopedPairingAccess({
406
+ async function authorizeZaloMessage(
407
+ params: ZaloMessagePipelineParams,
408
+ ): Promise<ZaloMessageAuthorizationResult | undefined> {
409
+ const { message, account, config, runtime, core, text, mediaPath, token, statusSink, fetcher } =
410
+ params;
411
+ const pairing = createChannelPairingController({
369
412
  core,
370
413
  channel: "zalo",
371
414
  accountId: account.accountId,
372
415
  });
373
- const { from, chat, message_id, date } = message;
416
+ const { from, chat } = message;
374
417
 
375
418
  const isGroup = chat.chat_type === "GROUP";
376
419
  const chatId = chat.id;
377
420
  const senderId = from.id;
378
- const senderName = from.name;
421
+ const senderName = from.display_name ?? from.name;
379
422
 
380
423
  const dmPolicy = account.config.dmPolicy ?? "pairing";
381
424
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
@@ -411,7 +454,7 @@ async function processMessageWithPipeline(params: {
411
454
  } else if (groupAccess.reason === "sender_not_allowlisted") {
412
455
  logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
413
456
  }
414
- return;
457
+ return undefined;
415
458
  }
416
459
  }
417
460
 
@@ -426,6 +469,8 @@ async function processMessageWithPipeline(params: {
426
469
  configuredGroupAllowFrom: groupAllowFrom,
427
470
  senderId,
428
471
  isSenderAllowed: isZaloSenderAllowed,
472
+ channel: "zalo",
473
+ accountId: account.accountId,
429
474
  readAllowFromStore: pairing.readAllowFromStore,
430
475
  runtime: core.channel.commands,
431
476
  });
@@ -437,16 +482,14 @@ async function processMessageWithPipeline(params: {
437
482
  });
438
483
  if (directDmOutcome === "disabled") {
439
484
  logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
440
- return;
485
+ return undefined;
441
486
  }
442
487
  if (directDmOutcome === "unauthorized") {
443
488
  if (dmPolicy === "pairing") {
444
- await issuePairingChallenge({
445
- channel: "zalo",
489
+ await pairing.issueChallenge({
446
490
  senderId,
447
491
  senderIdLine: `Your Zalo user id: ${senderId}`,
448
492
  meta: { name: senderName ?? undefined },
449
- upsertPairingRequest: pairing.upsertPairingRequest,
450
493
  onCreated: () => {
451
494
  logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
452
495
  },
@@ -472,8 +515,45 @@ async function processMessageWithPipeline(params: {
472
515
  `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
473
516
  );
474
517
  }
518
+ return undefined;
519
+ }
520
+
521
+ return {
522
+ chatId,
523
+ commandAuthorized,
524
+ isGroup,
525
+ rawBody,
526
+ senderId,
527
+ senderName,
528
+ };
529
+ }
530
+
531
+ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
532
+ const {
533
+ message,
534
+ token,
535
+ account,
536
+ config,
537
+ runtime,
538
+ core,
539
+ mediaPath,
540
+ mediaType,
541
+ statusSink,
542
+ fetcher,
543
+ authorization: authorizationOverride,
544
+ } = params;
545
+ const { message_id, date } = message;
546
+ const authorization =
547
+ authorizationOverride ??
548
+ (await authorizeZaloMessage({
549
+ ...params,
550
+ mediaPath,
551
+ mediaType,
552
+ }));
553
+ if (!authorization) {
475
554
  return;
476
555
  }
556
+ const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
477
557
 
478
558
  const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
479
559
  cfg: config,
@@ -504,36 +584,54 @@ async function processMessageWithPipeline(params: {
504
584
  body: rawBody,
505
585
  });
506
586
 
507
- const ctxPayload = core.channel.reply.finalizeInboundContext({
508
- Body: body,
509
- BodyForAgent: rawBody,
510
- RawBody: rawBody,
511
- CommandBody: rawBody,
512
- From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
513
- To: `zalo:${chatId}`,
514
- SessionKey: route.sessionKey,
515
- AccountId: route.accountId,
516
- ChatType: isGroup ? "group" : "direct",
517
- ConversationLabel: fromLabel,
518
- SenderName: senderName || undefined,
519
- SenderId: senderId,
520
- CommandAuthorized: commandAuthorized,
521
- Provider: "zalo",
522
- Surface: "zalo",
523
- MessageSid: message_id,
524
- MediaPath: mediaPath,
525
- MediaType: mediaType,
526
- MediaUrl: mediaPath,
527
- OriginatingChannel: "zalo",
528
- OriginatingTo: `zalo:${chatId}`,
529
- });
530
-
531
- await core.channel.session.recordInboundSession({
532
- storePath,
533
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
534
- ctx: ctxPayload,
535
- onRecordError: (err) => {
536
- runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
587
+ const ctxPayload = core.channel.turn.buildContext({
588
+ channel: "zalo",
589
+ accountId: route.accountId,
590
+ messageId: message_id,
591
+ timestamp: date ? date * 1000 : undefined,
592
+ from: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
593
+ sender: {
594
+ id: senderId,
595
+ name: senderName || undefined,
596
+ },
597
+ conversation: {
598
+ kind: isGroup ? "group" : "direct",
599
+ id: chatId,
600
+ label: fromLabel,
601
+ routePeer: {
602
+ kind: isGroup ? "group" : "direct",
603
+ id: chatId,
604
+ },
605
+ },
606
+ route: {
607
+ agentId: route.agentId,
608
+ accountId: route.accountId,
609
+ routeSessionKey: route.sessionKey,
610
+ },
611
+ reply: {
612
+ to: `zalo:${chatId}`,
613
+ originatingTo: `zalo:${chatId}`,
614
+ },
615
+ message: {
616
+ body,
617
+ bodyForAgent: rawBody,
618
+ rawBody,
619
+ commandBody: rawBody,
620
+ envelopeFrom: fromLabel,
621
+ },
622
+ media:
623
+ mediaPath || mediaType
624
+ ? [
625
+ {
626
+ path: mediaPath,
627
+ url: mediaPath,
628
+ contentType: mediaType,
629
+ },
630
+ ]
631
+ : undefined,
632
+ extra: {
633
+ CommandAuthorized: commandAuthorized,
634
+ GroupSubject: undefined,
537
635
  },
538
636
  });
539
637
 
@@ -542,61 +640,95 @@ async function processMessageWithPipeline(params: {
542
640
  channel: "zalo",
543
641
  accountId: account.accountId,
544
642
  });
545
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
643
+ const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
546
644
  cfg: config,
547
645
  agentId: route.agentId,
548
646
  channel: "zalo",
549
647
  accountId: account.accountId,
550
- });
551
- const typingCallbacks = createTypingCallbacks({
552
- start: async () => {
553
- await sendChatAction(
554
- token,
555
- {
556
- chat_id: chatId,
557
- action: "typing",
558
- },
559
- fetcher,
560
- ZALO_TYPING_TIMEOUT_MS,
561
- );
562
- },
563
- onStartError: (err) => {
564
- logTypingFailure({
565
- log: (message) => logVerbose(core, runtime, message),
566
- channel: "zalo",
567
- action: "start",
568
- target: chatId,
569
- error: err,
570
- });
571
- },
572
- });
573
-
574
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
575
- ctx: ctxPayload,
576
- cfg: config,
577
- dispatcherOptions: {
578
- ...prefixOptions,
579
- typingCallbacks,
580
- deliver: async (payload) => {
581
- await deliverZaloReply({
582
- payload,
648
+ typing: {
649
+ start: async () => {
650
+ await sendChatAction(
583
651
  token,
584
- chatId,
585
- runtime,
586
- core,
587
- config,
588
- accountId: account.accountId,
589
- statusSink,
652
+ {
653
+ chat_id: chatId,
654
+ action: "typing",
655
+ },
590
656
  fetcher,
591
- tableMode,
592
- });
657
+ ZALO_TYPING_TIMEOUT_MS,
658
+ );
593
659
  },
594
- onError: (err, info) => {
595
- runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
660
+ onStartError: (err) => {
661
+ logTypingFailure({
662
+ log: (message) => logVerbose(core, runtime, message),
663
+ channel: "zalo",
664
+ action: "start",
665
+ target: chatId,
666
+ error: err,
667
+ });
596
668
  },
597
669
  },
598
- replyOptions: {
599
- onModelSelected,
670
+ });
671
+
672
+ await core.channel.turn.run({
673
+ channel: "zalo",
674
+ accountId: account.accountId,
675
+ raw: message,
676
+ adapter: {
677
+ ingest: () => ({
678
+ id: message_id,
679
+ timestamp: date ? date * 1000 : undefined,
680
+ rawText: rawBody,
681
+ textForAgent: rawBody,
682
+ textForCommands: rawBody,
683
+ raw: message,
684
+ }),
685
+ resolveTurn: () => ({
686
+ cfg: config,
687
+ channel: "zalo",
688
+ accountId: account.accountId,
689
+ agentId: route.agentId,
690
+ routeSessionKey: route.sessionKey,
691
+ storePath,
692
+ ctxPayload,
693
+ recordInboundSession: core.channel.session.recordInboundSession,
694
+ dispatchReplyWithBufferedBlockDispatcher:
695
+ core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
696
+ delivery: {
697
+ deliver: async (payload) => {
698
+ await deliverZaloReply({
699
+ payload,
700
+ token,
701
+ chatId,
702
+ runtime,
703
+ core,
704
+ config,
705
+ webhookUrl: params.webhookUrl,
706
+ webhookPath: params.webhookPath,
707
+ proxyUrl: account.config.proxy,
708
+ mediaMaxBytes: params.mediaMaxMb * 1024 * 1024,
709
+ canHostMedia: params.canHostMedia,
710
+ accountId: account.accountId,
711
+ statusSink,
712
+ fetcher,
713
+ tableMode,
714
+ });
715
+ },
716
+ onError: (err, info) => {
717
+ runtime.error?.(
718
+ `[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`,
719
+ );
720
+ },
721
+ },
722
+ dispatcherOptions: replyPipeline,
723
+ replyOptions: {
724
+ onModelSelected,
725
+ },
726
+ record: {
727
+ onRecordError: (err) => {
728
+ runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
729
+ },
730
+ },
731
+ }),
600
732
  },
601
733
  });
602
734
  }
@@ -608,41 +740,70 @@ async function deliverZaloReply(params: {
608
740
  runtime: ZaloRuntimeEnv;
609
741
  core: ZaloCoreRuntime;
610
742
  config: OpenClawConfig;
743
+ webhookUrl?: string;
744
+ webhookPath?: string;
745
+ proxyUrl?: string;
746
+ mediaMaxBytes: number;
747
+ canHostMedia: boolean;
611
748
  accountId?: string;
612
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
749
+ statusSink?: ZaloStatusSink;
613
750
  fetcher?: ZaloFetch;
614
751
  tableMode?: MarkdownTableMode;
615
752
  }): Promise<void> {
616
- const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
753
+ const {
754
+ payload,
755
+ token,
756
+ chatId,
757
+ runtime,
758
+ core,
759
+ config,
760
+ webhookUrl,
761
+ webhookPath,
762
+ proxyUrl,
763
+ mediaMaxBytes,
764
+ canHostMedia,
765
+ accountId,
766
+ statusSink,
767
+ fetcher,
768
+ } = params;
617
769
  const tableMode = params.tableMode ?? "code";
618
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
619
- const sentMedia = await sendMediaWithLeadingCaption({
620
- mediaUrls: resolveOutboundMediaUrls(payload),
621
- caption: text,
622
- send: async ({ mediaUrl, caption }) => {
623
- await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
624
- statusSink?.({ lastOutboundAt: Date.now() });
625
- },
626
- onError: (error) => {
627
- runtime.error?.(`Zalo photo send failed: ${String(error)}`);
628
- },
770
+ const reply = resolveSendableOutboundReplyParts(payload, {
771
+ text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
629
772
  });
630
- if (sentMedia) {
631
- return;
632
- }
633
-
634
- if (text) {
635
- const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
636
- const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode);
637
- for (const chunk of chunks) {
773
+ const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
774
+ await deliverTextOrMediaReply({
775
+ payload,
776
+ text: reply.text,
777
+ chunkText: (value) =>
778
+ core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
779
+ sendText: async (chunk) => {
638
780
  try {
639
781
  await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
640
782
  statusSink?.({ lastOutboundAt: Date.now() });
641
783
  } catch (err) {
642
784
  runtime.error?.(`Zalo message send failed: ${String(err)}`);
643
785
  }
644
- }
645
- }
786
+ },
787
+ sendMedia: async ({ mediaUrl, caption }) => {
788
+ const sendableMediaUrl =
789
+ canHostMedia && webhookUrl && webhookPath
790
+ ? await prepareHostedZaloMediaUrl({
791
+ mediaUrl,
792
+ webhookUrl,
793
+ webhookPath,
794
+ maxBytes: mediaMaxBytes,
795
+ proxyUrl,
796
+ })
797
+ : mediaUrl;
798
+ await sendPhoto(token, { chat_id: chatId, photo: sendableMediaUrl, caption }, fetcher);
799
+ statusSink?.({ lastOutboundAt: Date.now() });
800
+ },
801
+ onMediaError: (error) => {
802
+ runtime.error?.(
803
+ `Zalo photo send failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
804
+ );
805
+ },
806
+ });
646
807
  }
647
808
 
648
809
  export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
@@ -664,6 +825,23 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
664
825
  const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
665
826
  const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
666
827
  const mode = useWebhook ? "webhook" : "polling";
828
+ const effectiveWebhookUrl = normalizeWebhookUrl(webhookUrl ?? account.config.webhookUrl);
829
+ const effectiveWebhookPath =
830
+ effectiveWebhookUrl || webhookPath?.trim() || account.config.webhookPath?.trim()
831
+ ? (resolveWebhookPath({
832
+ webhookPath: webhookPath ?? account.config.webhookPath,
833
+ webhookUrl: effectiveWebhookUrl,
834
+ defaultPath: null,
835
+ }) ?? undefined)
836
+ : undefined;
837
+ const canHostMedia = Boolean(effectiveWebhookUrl && effectiveWebhookPath);
838
+ const hostedMediaRoutePath =
839
+ canHostMedia && effectiveWebhookUrl
840
+ ? resolveHostedZaloMediaRoutePrefix({
841
+ webhookUrl: effectiveWebhookUrl,
842
+ webhookPath: effectiveWebhookPath,
843
+ })
844
+ : undefined;
667
845
 
668
846
  let stopped = false;
669
847
  const stopHandlers: Array<() => void> = [];
@@ -678,32 +856,49 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
678
856
  handler();
679
857
  }
680
858
  };
859
+ const stopOnAbort = () => {
860
+ if (!useWebhook) {
861
+ stop();
862
+ }
863
+ };
864
+
865
+ abortSignal.addEventListener("abort", stopOnAbort, { once: true });
681
866
 
682
867
  runtime.log?.(
683
868
  `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
684
869
  );
685
870
 
686
871
  try {
872
+ if (hostedMediaRoutePath) {
873
+ const unregisterHostedMediaRoute = registerSharedHostedMediaRoute({
874
+ path: hostedMediaRoutePath,
875
+ accountId: account.accountId,
876
+ log: runtime.log,
877
+ });
878
+ stopHandlers.push(unregisterHostedMediaRoute);
879
+ }
880
+
687
881
  if (useWebhook) {
688
- if (!webhookUrl || !webhookSecret) {
882
+ const { registerZaloWebhookTarget } = await loadZaloWebhookModule();
883
+ if (!effectiveWebhookUrl || !webhookSecret) {
689
884
  throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
690
885
  }
691
- if (!webhookUrl.startsWith("https://")) {
886
+ if (!effectiveWebhookUrl.startsWith("https://")) {
692
887
  throw new Error("Zalo webhook URL must use HTTPS");
693
888
  }
694
889
  if (webhookSecret.length < 8 || webhookSecret.length > 256) {
695
890
  throw new Error("Zalo webhook secret must be 8-256 characters");
696
891
  }
697
892
 
698
- const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
893
+ const path = effectiveWebhookPath;
699
894
  if (!path) {
700
895
  throw new Error("Zalo webhookPath could not be derived");
701
896
  }
702
897
 
703
898
  runtime.log?.(
704
- `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
899
+ `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(effectiveWebhookUrl)}`,
705
900
  );
706
- await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
901
+ await setWebhook(token, { url: effectiveWebhookUrl, secret_token: webhookSecret }, fetcher);
707
902
  let webhookCleanupPromise: Promise<void> | undefined;
708
903
  cleanupWebhook = async () => {
709
904
  if (!webhookCleanupPromise) {
@@ -725,18 +920,41 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
725
920
  };
726
921
  runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
727
922
 
728
- const unregister = registerZaloWebhookTarget({
729
- token,
730
- account,
731
- config,
732
- runtime,
733
- core,
734
- path,
735
- secret: webhookSecret,
736
- statusSink: (patch) => statusSink?.(patch),
737
- mediaMaxMb: effectiveMediaMaxMb,
738
- fetcher,
739
- });
923
+ const unregister = registerZaloWebhookTarget(
924
+ {
925
+ token,
926
+ account,
927
+ config,
928
+ runtime,
929
+ core,
930
+ path,
931
+ webhookUrl: effectiveWebhookUrl,
932
+ webhookPath: path,
933
+ secret: webhookSecret,
934
+ statusSink: (patch) => statusSink?.(patch),
935
+ mediaMaxMb: effectiveMediaMaxMb,
936
+ canHostMedia,
937
+ fetcher,
938
+ },
939
+ {
940
+ route: {
941
+ auth: "plugin",
942
+ match: "exact",
943
+ pluginId: "zalo",
944
+ source: "zalo-webhook",
945
+ accountId: account.accountId,
946
+ log: runtime.log,
947
+ handler: async (req, res) => {
948
+ const handled = await handleZaloWebhookRequest(req, res);
949
+ if (!handled && !res.headersSent) {
950
+ res.statusCode = 404;
951
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
952
+ res.end("Not Found");
953
+ }
954
+ },
955
+ },
956
+ },
957
+ );
740
958
  stopHandlers.push(unregister);
741
959
  await waitForAbortSignal(abortSignal);
742
960
  return;
@@ -779,6 +997,9 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
779
997
  config,
780
998
  runtime,
781
999
  core,
1000
+ canHostMedia,
1001
+ webhookUrl: effectiveWebhookUrl,
1002
+ webhookPath: effectiveWebhookPath,
782
1003
  abortSignal,
783
1004
  isStopped: () => stopped,
784
1005
  mediaMaxMb: effectiveMediaMaxMb,
@@ -793,6 +1014,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
793
1014
  );
794
1015
  throw err;
795
1016
  } finally {
1017
+ abortSignal.removeEventListener("abort", stopOnAbort);
796
1018
  await cleanupWebhook?.();
797
1019
  stop();
798
1020
  runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
@@ -802,4 +1024,5 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
802
1024
  export const __testing = {
803
1025
  evaluateZaloGroupAccess,
804
1026
  resolveZaloRuntimeGroupPolicy,
1027
+ clearHostedMediaRouteRefsForTest: () => hostedMediaRouteRefs.clear(),
805
1028
  };