@openclaw/zalo 2026.3.13 → 2026.5.2-beta.1

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 +229 -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/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;
@@ -76,33 +69,113 @@ const ZALO_TYPING_TIMEOUT_MS = 5_000;
76
69
 
77
70
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
78
71
  type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
72
+ type ZaloWebhookModule = typeof import("./monitor.webhook.js");
79
73
  type ZaloProcessingContext = {
80
74
  token: string;
81
75
  account: ResolvedZaloAccount;
82
76
  config: OpenClawConfig;
83
77
  runtime: ZaloRuntimeEnv;
84
78
  core: ZaloCoreRuntime;
79
+ mediaMaxMb: number;
80
+ canHostMedia: boolean;
81
+ webhookUrl?: string;
82
+ webhookPath?: string;
85
83
  statusSink?: ZaloStatusSink;
86
84
  fetcher?: ZaloFetch;
87
85
  };
88
86
  type ZaloPollingLoopParams = ZaloProcessingContext & {
89
87
  abortSignal: AbortSignal;
90
88
  isStopped: () => boolean;
91
- mediaMaxMb: number;
92
89
  };
93
90
  type ZaloUpdateProcessingParams = ZaloProcessingContext & {
94
91
  update: ZaloUpdate;
95
- mediaMaxMb: number;
96
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
+
97
162
  type ZaloMessagePipelineParams = ZaloProcessingContext & {
98
163
  message: ZaloMessage;
99
164
  text?: string;
100
165
  mediaPath?: string;
101
166
  mediaType?: string;
167
+ authorization?: ZaloMessageAuthorizationResult;
102
168
  };
103
169
  type ZaloImageMessageParams = ZaloProcessingContext & {
104
170
  message: ZaloMessage;
105
- mediaMaxMb: number;
171
+ };
172
+ type ZaloMessageAuthorizationResult = {
173
+ chatId: string;
174
+ commandAuthorized: boolean | undefined;
175
+ isGroup: boolean;
176
+ rawBody: string;
177
+ senderId: string;
178
+ senderName: string | undefined;
106
179
  };
107
180
 
108
181
  function formatZaloError(error: unknown): string {
@@ -132,38 +205,13 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
132
205
  }
133
206
  }
134
207
 
135
- export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
136
- return registerZaloWebhookTargetInternal(target, {
137
- route: {
138
- auth: "plugin",
139
- match: "exact",
140
- pluginId: "zalo",
141
- source: "zalo-webhook",
142
- accountId: target.account.accountId,
143
- log: target.runtime.log,
144
- handler: async (req, res) => {
145
- const handled = await handleZaloWebhookRequest(req, res);
146
- if (!handled && !res.headersSent) {
147
- res.statusCode = 404;
148
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
149
- res.end("Not Found");
150
- }
151
- },
152
- },
153
- });
154
- }
155
-
156
- export {
157
- clearZaloWebhookSecurityStateForTest,
158
- getZaloWebhookRateLimitStateSizeForTest,
159
- getZaloWebhookStatusCounterSizeForTest,
160
- };
161
-
162
208
  export async function handleZaloWebhookRequest(
163
209
  req: IncomingMessage,
164
210
  res: ServerResponse,
165
211
  ): Promise<boolean> {
166
- return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
212
+ const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } =
213
+ await loadZaloWebhookModule();
214
+ return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
167
215
  await processUpdate({
168
216
  update,
169
217
  token: target.token,
@@ -172,6 +220,9 @@ export async function handleZaloWebhookRequest(
172
220
  runtime: target.runtime,
173
221
  core: target.core as ZaloCoreRuntime,
174
222
  mediaMaxMb: target.mediaMaxMb,
223
+ canHostMedia: target.canHostMedia,
224
+ webhookUrl: target.webhookUrl,
225
+ webhookPath: target.webhookPath,
175
226
  statusSink: target.statusSink,
176
227
  fetcher: target.fetcher,
177
228
  });
@@ -185,9 +236,12 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
185
236
  config,
186
237
  runtime,
187
238
  core,
239
+ mediaMaxMb,
240
+ canHostMedia,
241
+ webhookUrl,
242
+ webhookPath,
188
243
  abortSignal,
189
244
  isStopped,
190
- mediaMaxMb,
191
245
  statusSink,
192
246
  fetcher,
193
247
  } = params;
@@ -199,19 +253,25 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
199
253
  runtime,
200
254
  core,
201
255
  mediaMaxMb,
256
+ canHostMedia,
257
+ webhookUrl,
258
+ webhookPath,
202
259
  statusSink,
203
260
  fetcher,
204
261
  };
205
262
 
206
263
  runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
207
264
 
208
- const poll = async () => {
265
+ const poll = async (): Promise<void> => {
209
266
  if (isStopped() || abortSignal.aborted) {
210
- return;
267
+ return undefined;
211
268
  }
212
269
 
213
270
  try {
214
271
  const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
272
+ if (isStopped() || abortSignal.aborted) {
273
+ return undefined;
274
+ }
215
275
  if (response.ok && response.result) {
216
276
  statusSink?.({ lastInboundAt: Date.now() });
217
277
  await processUpdate({
@@ -239,9 +299,21 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
239
299
  async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
240
300
  const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
241
301
  const { event_name, message } = update;
242
- const sharedContext = { token, account, config, runtime, core, statusSink, fetcher };
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
+ };
243
315
  if (!message) {
244
- return;
316
+ return undefined;
245
317
  }
246
318
 
247
319
  switch (event_name) {
@@ -277,7 +349,7 @@ async function handleTextMessage(
277
349
  const { message } = params;
278
350
  const { text } = message;
279
351
  if (!text?.trim()) {
280
- return;
352
+ return undefined;
281
353
  }
282
354
 
283
355
  await processMessageWithPipeline({
@@ -290,15 +362,25 @@ async function handleTextMessage(
290
362
 
291
363
  async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
292
364
  const { message, mediaMaxMb, account, core, runtime } = params;
293
- const { photo, caption } = message;
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
+ }
294
376
 
295
377
  let mediaPath: string | undefined;
296
378
  let mediaType: string | undefined;
297
379
 
298
- if (photo) {
380
+ if (photo_url) {
299
381
  try {
300
382
  const maxBytes = mediaMaxMb * 1024 * 1024;
301
- const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
383
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
302
384
  const saved = await core.channel.media.saveMediaBuffer(
303
385
  fetched.buffer,
304
386
  fetched.contentType,
@@ -314,37 +396,29 @@ async function handleImageMessage(params: ZaloImageMessageParams): Promise<void>
314
396
 
315
397
  await processMessageWithPipeline({
316
398
  ...params,
399
+ authorization,
317
400
  text: caption,
318
401
  mediaPath,
319
402
  mediaType,
320
403
  });
321
404
  }
322
405
 
323
- async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
324
- const {
325
- message,
326
- token,
327
- account,
328
- config,
329
- runtime,
330
- core,
331
- text,
332
- mediaPath,
333
- mediaType,
334
- statusSink,
335
- fetcher,
336
- } = params;
337
- 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({
338
412
  core,
339
413
  channel: "zalo",
340
414
  accountId: account.accountId,
341
415
  });
342
- const { from, chat, message_id, date } = message;
416
+ const { from, chat } = message;
343
417
 
344
418
  const isGroup = chat.chat_type === "GROUP";
345
419
  const chatId = chat.id;
346
420
  const senderId = from.id;
347
- const senderName = from.name;
421
+ const senderName = from.display_name ?? from.name;
348
422
 
349
423
  const dmPolicy = account.config.dmPolicy ?? "pairing";
350
424
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
@@ -380,7 +454,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
380
454
  } else if (groupAccess.reason === "sender_not_allowlisted") {
381
455
  logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
382
456
  }
383
- return;
457
+ return undefined;
384
458
  }
385
459
  }
386
460
 
@@ -395,6 +469,8 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
395
469
  configuredGroupAllowFrom: groupAllowFrom,
396
470
  senderId,
397
471
  isSenderAllowed: isZaloSenderAllowed,
472
+ channel: "zalo",
473
+ accountId: account.accountId,
398
474
  readAllowFromStore: pairing.readAllowFromStore,
399
475
  runtime: core.channel.commands,
400
476
  });
@@ -406,16 +482,14 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
406
482
  });
407
483
  if (directDmOutcome === "disabled") {
408
484
  logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
409
- return;
485
+ return undefined;
410
486
  }
411
487
  if (directDmOutcome === "unauthorized") {
412
488
  if (dmPolicy === "pairing") {
413
- await issuePairingChallenge({
414
- channel: "zalo",
489
+ await pairing.issueChallenge({
415
490
  senderId,
416
491
  senderIdLine: `Your Zalo user id: ${senderId}`,
417
492
  meta: { name: senderName ?? undefined },
418
- upsertPairingRequest: pairing.upsertPairingRequest,
419
493
  onCreated: () => {
420
494
  logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
421
495
  },
@@ -441,8 +515,45 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
441
515
  `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
442
516
  );
443
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) {
444
554
  return;
445
555
  }
556
+ const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
446
557
 
447
558
  const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
448
559
  cfg: config,
@@ -473,36 +584,54 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
473
584
  body: rawBody,
474
585
  });
475
586
 
476
- const ctxPayload = core.channel.reply.finalizeInboundContext({
477
- Body: body,
478
- BodyForAgent: rawBody,
479
- RawBody: rawBody,
480
- CommandBody: rawBody,
481
- From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
482
- To: `zalo:${chatId}`,
483
- SessionKey: route.sessionKey,
484
- AccountId: route.accountId,
485
- ChatType: isGroup ? "group" : "direct",
486
- ConversationLabel: fromLabel,
487
- SenderName: senderName || undefined,
488
- SenderId: senderId,
489
- CommandAuthorized: commandAuthorized,
490
- Provider: "zalo",
491
- Surface: "zalo",
492
- MessageSid: message_id,
493
- MediaPath: mediaPath,
494
- MediaType: mediaType,
495
- MediaUrl: mediaPath,
496
- OriginatingChannel: "zalo",
497
- OriginatingTo: `zalo:${chatId}`,
498
- });
499
-
500
- await core.channel.session.recordInboundSession({
501
- storePath,
502
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
503
- ctx: ctxPayload,
504
- onRecordError: (err) => {
505
- 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,
506
635
  },
507
636
  });
508
637
 
@@ -511,61 +640,95 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
511
640
  channel: "zalo",
512
641
  accountId: account.accountId,
513
642
  });
514
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
643
+ const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
515
644
  cfg: config,
516
645
  agentId: route.agentId,
517
646
  channel: "zalo",
518
647
  accountId: account.accountId,
519
- });
520
- const typingCallbacks = createTypingCallbacks({
521
- start: async () => {
522
- await sendChatAction(
523
- token,
524
- {
525
- chat_id: chatId,
526
- action: "typing",
527
- },
528
- fetcher,
529
- ZALO_TYPING_TIMEOUT_MS,
530
- );
531
- },
532
- onStartError: (err) => {
533
- logTypingFailure({
534
- log: (message) => logVerbose(core, runtime, message),
535
- channel: "zalo",
536
- action: "start",
537
- target: chatId,
538
- error: err,
539
- });
540
- },
541
- });
542
-
543
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
544
- ctx: ctxPayload,
545
- cfg: config,
546
- dispatcherOptions: {
547
- ...prefixOptions,
548
- typingCallbacks,
549
- deliver: async (payload) => {
550
- await deliverZaloReply({
551
- payload,
648
+ typing: {
649
+ start: async () => {
650
+ await sendChatAction(
552
651
  token,
553
- chatId,
554
- runtime,
555
- core,
556
- config,
557
- accountId: account.accountId,
558
- statusSink,
652
+ {
653
+ chat_id: chatId,
654
+ action: "typing",
655
+ },
559
656
  fetcher,
560
- tableMode,
561
- });
657
+ ZALO_TYPING_TIMEOUT_MS,
658
+ );
562
659
  },
563
- onError: (err, info) => {
564
- 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
+ });
565
668
  },
566
669
  },
567
- replyOptions: {
568
- 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
+ }),
569
732
  },
570
733
  });
571
734
  }
@@ -577,41 +740,70 @@ async function deliverZaloReply(params: {
577
740
  runtime: ZaloRuntimeEnv;
578
741
  core: ZaloCoreRuntime;
579
742
  config: OpenClawConfig;
743
+ webhookUrl?: string;
744
+ webhookPath?: string;
745
+ proxyUrl?: string;
746
+ mediaMaxBytes: number;
747
+ canHostMedia: boolean;
580
748
  accountId?: string;
581
749
  statusSink?: ZaloStatusSink;
582
750
  fetcher?: ZaloFetch;
583
751
  tableMode?: MarkdownTableMode;
584
752
  }): Promise<void> {
585
- 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;
586
769
  const tableMode = params.tableMode ?? "code";
587
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
588
- const sentMedia = await sendMediaWithLeadingCaption({
589
- mediaUrls: resolveOutboundMediaUrls(payload),
590
- caption: text,
591
- send: async ({ mediaUrl, caption }) => {
592
- await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
593
- statusSink?.({ lastOutboundAt: Date.now() });
594
- },
595
- onError: (error) => {
596
- runtime.error?.(`Zalo photo send failed: ${String(error)}`);
597
- },
770
+ const reply = resolveSendableOutboundReplyParts(payload, {
771
+ text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
598
772
  });
599
- if (sentMedia) {
600
- return;
601
- }
602
-
603
- if (text) {
604
- const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
605
- const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode);
606
- 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) => {
607
780
  try {
608
781
  await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
609
782
  statusSink?.({ lastOutboundAt: Date.now() });
610
783
  } catch (err) {
611
784
  runtime.error?.(`Zalo message send failed: ${String(err)}`);
612
785
  }
613
- }
614
- }
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
+ });
615
807
  }
616
808
 
617
809
  export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
@@ -633,6 +825,23 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
633
825
  const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
634
826
  const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
635
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;
636
845
 
637
846
  let stopped = false;
638
847
  const stopHandlers: Array<() => void> = [];
@@ -647,32 +856,49 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
647
856
  handler();
648
857
  }
649
858
  };
859
+ const stopOnAbort = () => {
860
+ if (!useWebhook) {
861
+ stop();
862
+ }
863
+ };
864
+
865
+ abortSignal.addEventListener("abort", stopOnAbort, { once: true });
650
866
 
651
867
  runtime.log?.(
652
868
  `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
653
869
  );
654
870
 
655
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
+
656
881
  if (useWebhook) {
657
- if (!webhookUrl || !webhookSecret) {
882
+ const { registerZaloWebhookTarget } = await loadZaloWebhookModule();
883
+ if (!effectiveWebhookUrl || !webhookSecret) {
658
884
  throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
659
885
  }
660
- if (!webhookUrl.startsWith("https://")) {
886
+ if (!effectiveWebhookUrl.startsWith("https://")) {
661
887
  throw new Error("Zalo webhook URL must use HTTPS");
662
888
  }
663
889
  if (webhookSecret.length < 8 || webhookSecret.length > 256) {
664
890
  throw new Error("Zalo webhook secret must be 8-256 characters");
665
891
  }
666
892
 
667
- const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
893
+ const path = effectiveWebhookPath;
668
894
  if (!path) {
669
895
  throw new Error("Zalo webhookPath could not be derived");
670
896
  }
671
897
 
672
898
  runtime.log?.(
673
- `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
899
+ `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(effectiveWebhookUrl)}`,
674
900
  );
675
- await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
901
+ await setWebhook(token, { url: effectiveWebhookUrl, secret_token: webhookSecret }, fetcher);
676
902
  let webhookCleanupPromise: Promise<void> | undefined;
677
903
  cleanupWebhook = async () => {
678
904
  if (!webhookCleanupPromise) {
@@ -694,18 +920,41 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
694
920
  };
695
921
  runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
696
922
 
697
- const unregister = registerZaloWebhookTarget({
698
- token,
699
- account,
700
- config,
701
- runtime,
702
- core,
703
- path,
704
- secret: webhookSecret,
705
- statusSink: (patch) => statusSink?.(patch),
706
- mediaMaxMb: effectiveMediaMaxMb,
707
- fetcher,
708
- });
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
+ );
709
958
  stopHandlers.push(unregister);
710
959
  await waitForAbortSignal(abortSignal);
711
960
  return;
@@ -748,6 +997,9 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
748
997
  config,
749
998
  runtime,
750
999
  core,
1000
+ canHostMedia,
1001
+ webhookUrl: effectiveWebhookUrl,
1002
+ webhookPath: effectiveWebhookPath,
751
1003
  abortSignal,
752
1004
  isStopped: () => stopped,
753
1005
  mediaMaxMb: effectiveMediaMaxMb,
@@ -762,6 +1014,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
762
1014
  );
763
1015
  throw err;
764
1016
  } finally {
1017
+ abortSignal.removeEventListener("abort", stopOnAbort);
765
1018
  await cleanupWebhook?.();
766
1019
  stop();
767
1020
  runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
@@ -771,4 +1024,5 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
771
1024
  export const __testing = {
772
1025
  evaluateZaloGroupAccess,
773
1026
  resolveZaloRuntimeGroupPolicy,
1027
+ clearHostedMediaRouteRefsForTest: () => hostedMediaRouteRefs.clear(),
774
1028
  };