zalo_bot 1.0.0
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.
- package/.github/workflows/publish.yml +22 -0
- package/README.md +57 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +38 -0
- package/src/accounts.ts +82 -0
- package/src/actions.ts +56 -0
- package/src/api.ts +208 -0
- package/src/channel.ts +404 -0
- package/src/config-schema.ts +27 -0
- package/src/group-access.ts +48 -0
- package/src/monitor.ts +671 -0
- package/src/monitor.webhook.ts +221 -0
- package/src/onboarding.ts +398 -0
- package/src/probe.ts +45 -0
- package/src/proxy.ts +24 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +124 -0
- package/src/status-issues.ts +53 -0
- package/src/token.ts +62 -0
- package/src/types.ts +48 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
|
|
3
|
+
import {
|
|
4
|
+
createReplyPrefixOptions,
|
|
5
|
+
resolveSenderCommandAuthorization,
|
|
6
|
+
resolveOutboundMediaUrls,
|
|
7
|
+
resolveDefaultGroupPolicy,
|
|
8
|
+
sendMediaWithLeadingCaption,
|
|
9
|
+
resolveWebhookPath,
|
|
10
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
11
|
+
} from "openclaw/plugin-sdk";
|
|
12
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
13
|
+
import {
|
|
14
|
+
ZaloApiError,
|
|
15
|
+
deleteWebhook,
|
|
16
|
+
getUpdates,
|
|
17
|
+
sendMessage,
|
|
18
|
+
sendPhoto,
|
|
19
|
+
setWebhook,
|
|
20
|
+
type ZaloFetch,
|
|
21
|
+
type ZaloMessage,
|
|
22
|
+
type ZaloUpdate,
|
|
23
|
+
} from "./api.js";
|
|
24
|
+
import {
|
|
25
|
+
evaluateZaloGroupAccess,
|
|
26
|
+
isZaloSenderAllowed,
|
|
27
|
+
resolveZaloRuntimeGroupPolicy,
|
|
28
|
+
} from "./group-access.js";
|
|
29
|
+
import {
|
|
30
|
+
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
|
|
31
|
+
registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
|
|
32
|
+
type ZaloWebhookTarget,
|
|
33
|
+
} from "./monitor.webhook.js";
|
|
34
|
+
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
35
|
+
import { getZaloRuntime } from "./runtime.js";
|
|
36
|
+
|
|
37
|
+
export type ZaloRuntimeEnv = {
|
|
38
|
+
log?: (message: string) => void;
|
|
39
|
+
error?: (message: string) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ZaloMonitorOptions = {
|
|
43
|
+
token: string;
|
|
44
|
+
account: ResolvedZaloAccount;
|
|
45
|
+
config: OpenClawConfig;
|
|
46
|
+
runtime: ZaloRuntimeEnv;
|
|
47
|
+
abortSignal: AbortSignal;
|
|
48
|
+
useWebhook?: boolean;
|
|
49
|
+
webhookUrl?: string;
|
|
50
|
+
webhookSecret?: string;
|
|
51
|
+
webhookPath?: string;
|
|
52
|
+
fetcher?: ZaloFetch;
|
|
53
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ZaloMonitorResult = {
|
|
57
|
+
stop: () => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ZALO_TEXT_LIMIT = 2000;
|
|
61
|
+
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
62
|
+
|
|
63
|
+
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
64
|
+
|
|
65
|
+
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
66
|
+
if (core.logging.shouldLogVerbose()) {
|
|
67
|
+
runtime.log?.(`[zalo_bot] ${message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
|
72
|
+
return registerZaloWebhookTargetInternal(target);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function handleZaloWebhookRequest(
|
|
76
|
+
req: IncomingMessage,
|
|
77
|
+
res: ServerResponse,
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
|
|
80
|
+
await processUpdate(
|
|
81
|
+
update,
|
|
82
|
+
target.token,
|
|
83
|
+
target.account,
|
|
84
|
+
target.config,
|
|
85
|
+
target.runtime,
|
|
86
|
+
target.core as ZaloCoreRuntime,
|
|
87
|
+
target.mediaMaxMb,
|
|
88
|
+
target.statusSink,
|
|
89
|
+
target.fetcher,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startPollingLoop(params: {
|
|
95
|
+
token: string;
|
|
96
|
+
account: ResolvedZaloAccount;
|
|
97
|
+
config: OpenClawConfig;
|
|
98
|
+
runtime: ZaloRuntimeEnv;
|
|
99
|
+
core: ZaloCoreRuntime;
|
|
100
|
+
abortSignal: AbortSignal;
|
|
101
|
+
isStopped: () => boolean;
|
|
102
|
+
mediaMaxMb: number;
|
|
103
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
104
|
+
fetcher?: ZaloFetch;
|
|
105
|
+
}) {
|
|
106
|
+
const {
|
|
107
|
+
token,
|
|
108
|
+
account,
|
|
109
|
+
config,
|
|
110
|
+
runtime,
|
|
111
|
+
core,
|
|
112
|
+
abortSignal,
|
|
113
|
+
isStopped,
|
|
114
|
+
mediaMaxMb,
|
|
115
|
+
statusSink,
|
|
116
|
+
fetcher,
|
|
117
|
+
} = params;
|
|
118
|
+
const pollTimeout = 30;
|
|
119
|
+
|
|
120
|
+
const poll = async () => {
|
|
121
|
+
if (isStopped() || abortSignal.aborted) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
|
|
127
|
+
if (response.ok && response.result) {
|
|
128
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
129
|
+
await processUpdate(
|
|
130
|
+
response.result,
|
|
131
|
+
token,
|
|
132
|
+
account,
|
|
133
|
+
config,
|
|
134
|
+
runtime,
|
|
135
|
+
core,
|
|
136
|
+
mediaMaxMb,
|
|
137
|
+
statusSink,
|
|
138
|
+
fetcher,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
143
|
+
// no updates
|
|
144
|
+
} else if (!isStopped() && !abortSignal.aborted) {
|
|
145
|
+
console.error(`[${account.accountId}] Zalo polling error:`, err);
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isStopped() && !abortSignal.aborted) {
|
|
151
|
+
setImmediate(poll);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
void poll();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function processUpdate(
|
|
159
|
+
update: ZaloUpdate,
|
|
160
|
+
token: string,
|
|
161
|
+
account: ResolvedZaloAccount,
|
|
162
|
+
config: OpenClawConfig,
|
|
163
|
+
runtime: ZaloRuntimeEnv,
|
|
164
|
+
core: ZaloCoreRuntime,
|
|
165
|
+
mediaMaxMb: number,
|
|
166
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
167
|
+
fetcher?: ZaloFetch,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const { event_name, message } = update;
|
|
170
|
+
if (!message) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
switch (event_name) {
|
|
175
|
+
case "message.text.received":
|
|
176
|
+
await handleTextMessage(message, token, account, config, runtime, core, statusSink, fetcher);
|
|
177
|
+
break;
|
|
178
|
+
case "message.image.received":
|
|
179
|
+
await handleImageMessage(
|
|
180
|
+
message,
|
|
181
|
+
token,
|
|
182
|
+
account,
|
|
183
|
+
config,
|
|
184
|
+
runtime,
|
|
185
|
+
core,
|
|
186
|
+
mediaMaxMb,
|
|
187
|
+
statusSink,
|
|
188
|
+
fetcher,
|
|
189
|
+
);
|
|
190
|
+
break;
|
|
191
|
+
case "message.sticker.received":
|
|
192
|
+
console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
|
|
193
|
+
break;
|
|
194
|
+
case "message.unsupported.received":
|
|
195
|
+
console.log(
|
|
196
|
+
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
|
|
197
|
+
);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handleTextMessage(
|
|
203
|
+
message: ZaloMessage,
|
|
204
|
+
token: string,
|
|
205
|
+
account: ResolvedZaloAccount,
|
|
206
|
+
config: OpenClawConfig,
|
|
207
|
+
runtime: ZaloRuntimeEnv,
|
|
208
|
+
core: ZaloCoreRuntime,
|
|
209
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
210
|
+
fetcher?: ZaloFetch,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const { text } = message;
|
|
213
|
+
if (!text?.trim()) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await processMessageWithPipeline({
|
|
218
|
+
message,
|
|
219
|
+
token,
|
|
220
|
+
account,
|
|
221
|
+
config,
|
|
222
|
+
runtime,
|
|
223
|
+
core,
|
|
224
|
+
text,
|
|
225
|
+
mediaPath: undefined,
|
|
226
|
+
mediaType: undefined,
|
|
227
|
+
statusSink,
|
|
228
|
+
fetcher,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleImageMessage(
|
|
233
|
+
message: ZaloMessage,
|
|
234
|
+
token: string,
|
|
235
|
+
account: ResolvedZaloAccount,
|
|
236
|
+
config: OpenClawConfig,
|
|
237
|
+
runtime: ZaloRuntimeEnv,
|
|
238
|
+
core: ZaloCoreRuntime,
|
|
239
|
+
mediaMaxMb: number,
|
|
240
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
241
|
+
fetcher?: ZaloFetch,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const { photo_url, caption } = message;
|
|
244
|
+
|
|
245
|
+
let mediaPath: string | undefined;
|
|
246
|
+
let mediaType: string | undefined;
|
|
247
|
+
|
|
248
|
+
if (photo_url) {
|
|
249
|
+
try {
|
|
250
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
251
|
+
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
|
|
252
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
253
|
+
fetched.buffer,
|
|
254
|
+
fetched.contentType,
|
|
255
|
+
"inbound",
|
|
256
|
+
maxBytes,
|
|
257
|
+
);
|
|
258
|
+
mediaPath = saved.path;
|
|
259
|
+
mediaType = saved.contentType;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await processMessageWithPipeline({
|
|
266
|
+
message,
|
|
267
|
+
token,
|
|
268
|
+
account,
|
|
269
|
+
config,
|
|
270
|
+
runtime,
|
|
271
|
+
core,
|
|
272
|
+
text: caption,
|
|
273
|
+
mediaPath,
|
|
274
|
+
mediaType,
|
|
275
|
+
statusSink,
|
|
276
|
+
fetcher,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function processMessageWithPipeline(params: {
|
|
281
|
+
message: ZaloMessage;
|
|
282
|
+
token: string;
|
|
283
|
+
account: ResolvedZaloAccount;
|
|
284
|
+
config: OpenClawConfig;
|
|
285
|
+
runtime: ZaloRuntimeEnv;
|
|
286
|
+
core: ZaloCoreRuntime;
|
|
287
|
+
text?: string;
|
|
288
|
+
mediaPath?: string;
|
|
289
|
+
mediaType?: string;
|
|
290
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
291
|
+
fetcher?: ZaloFetch;
|
|
292
|
+
}): Promise<void> {
|
|
293
|
+
const {
|
|
294
|
+
message,
|
|
295
|
+
token,
|
|
296
|
+
account,
|
|
297
|
+
config,
|
|
298
|
+
runtime,
|
|
299
|
+
core,
|
|
300
|
+
text,
|
|
301
|
+
mediaPath,
|
|
302
|
+
mediaType,
|
|
303
|
+
statusSink,
|
|
304
|
+
fetcher,
|
|
305
|
+
} = params;
|
|
306
|
+
const { from, chat, message_id, date } = message;
|
|
307
|
+
|
|
308
|
+
const isGroup = chat.chat_type === "GROUP";
|
|
309
|
+
const chatId = chat.id;
|
|
310
|
+
const senderId = from.id;
|
|
311
|
+
const senderName = from.name;
|
|
312
|
+
|
|
313
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
314
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
315
|
+
const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
|
|
316
|
+
const groupAllowFrom =
|
|
317
|
+
configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
|
|
318
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
|
319
|
+
const groupAccess = isGroup
|
|
320
|
+
? evaluateZaloGroupAccess({
|
|
321
|
+
providerConfigPresent: config.channels?.zalo_bot !== undefined,
|
|
322
|
+
configuredGroupPolicy: account.config.groupPolicy,
|
|
323
|
+
defaultGroupPolicy,
|
|
324
|
+
groupAllowFrom,
|
|
325
|
+
senderId,
|
|
326
|
+
})
|
|
327
|
+
: undefined;
|
|
328
|
+
if (groupAccess) {
|
|
329
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
330
|
+
providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied,
|
|
331
|
+
providerKey: "zalo_bot",
|
|
332
|
+
accountId: account.accountId,
|
|
333
|
+
log: (message) => logVerbose(core, runtime, message),
|
|
334
|
+
});
|
|
335
|
+
if (!groupAccess.allowed) {
|
|
336
|
+
if (groupAccess.reason === "disabled") {
|
|
337
|
+
logVerbose(core, runtime, `zalo_bot: drop group ${chatId} (groupPolicy=disabled)`);
|
|
338
|
+
} else if (groupAccess.reason === "empty_allowlist") {
|
|
339
|
+
logVerbose(
|
|
340
|
+
core,
|
|
341
|
+
runtime,
|
|
342
|
+
`zalo_bot: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`,
|
|
343
|
+
);
|
|
344
|
+
} else if (groupAccess.reason === "sender_not_allowlisted") {
|
|
345
|
+
logVerbose(core, runtime, `zalo_bot: drop group sender ${senderId} (groupPolicy=allowlist)`);
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const rawBody = (mediaPath ? "<media:image>\n" : "") + (text?.trim() || "");
|
|
352
|
+
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
|
|
353
|
+
cfg: config,
|
|
354
|
+
rawBody,
|
|
355
|
+
isGroup,
|
|
356
|
+
dmPolicy,
|
|
357
|
+
configuredAllowFrom: configAllowFrom,
|
|
358
|
+
senderId,
|
|
359
|
+
isSenderAllowed: isZaloSenderAllowed,
|
|
360
|
+
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo_bot"),
|
|
361
|
+
shouldComputeCommandAuthorized: (body, cfg) =>
|
|
362
|
+
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
|
363
|
+
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
|
364
|
+
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
if (!isGroup) {
|
|
368
|
+
if (dmPolicy === "disabled") {
|
|
369
|
+
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (dmPolicy !== "open") {
|
|
374
|
+
const allowed = senderAllowedForCommands;
|
|
375
|
+
|
|
376
|
+
if (!allowed) {
|
|
377
|
+
if (dmPolicy === "pairing") {
|
|
378
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
379
|
+
channel: "zalo_bot",
|
|
380
|
+
id: senderId,
|
|
381
|
+
meta: { name: senderName ?? undefined },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (created) {
|
|
385
|
+
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
|
386
|
+
try {
|
|
387
|
+
await sendMessage(
|
|
388
|
+
token,
|
|
389
|
+
{
|
|
390
|
+
chat_id: chatId,
|
|
391
|
+
text: core.channel.pairing.buildPairingReply({
|
|
392
|
+
channel: "zalo_bot",
|
|
393
|
+
idLine: `Your Zalo user id: ${senderId}`,
|
|
394
|
+
code,
|
|
395
|
+
}),
|
|
396
|
+
},
|
|
397
|
+
fetcher,
|
|
398
|
+
);
|
|
399
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
400
|
+
} catch (err) {
|
|
401
|
+
logVerbose(
|
|
402
|
+
core,
|
|
403
|
+
runtime,
|
|
404
|
+
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
logVerbose(
|
|
410
|
+
core,
|
|
411
|
+
runtime,
|
|
412
|
+
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
421
|
+
cfg: config,
|
|
422
|
+
channel: "zalo_bot",
|
|
423
|
+
accountId: account.accountId,
|
|
424
|
+
peer: {
|
|
425
|
+
kind: isGroup ? "group" : "direct",
|
|
426
|
+
id: chatId,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
isGroup &&
|
|
432
|
+
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
433
|
+
commandAuthorized !== true
|
|
434
|
+
) {
|
|
435
|
+
logVerbose(core, runtime, `zalo_bot: drop control command from unauthorized sender ${senderId}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
440
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
441
|
+
agentId: route.agentId,
|
|
442
|
+
});
|
|
443
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
444
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
445
|
+
storePath,
|
|
446
|
+
sessionKey: route.sessionKey,
|
|
447
|
+
});
|
|
448
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
449
|
+
channel: "zalo_bot",
|
|
450
|
+
from: fromLabel,
|
|
451
|
+
timestamp: date ? date * 1000 : undefined,
|
|
452
|
+
previousTimestamp,
|
|
453
|
+
envelope: envelopeOptions,
|
|
454
|
+
body: rawBody,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
458
|
+
Body: body,
|
|
459
|
+
BodyForAgent: rawBody,
|
|
460
|
+
RawBody: rawBody,
|
|
461
|
+
CommandBody: rawBody,
|
|
462
|
+
From: isGroup ? `zalo_bot:group:${chatId}` : `zalo_bot:${senderId}`,
|
|
463
|
+
To: `zalo_bot:${chatId}`,
|
|
464
|
+
SessionKey: route.sessionKey,
|
|
465
|
+
AccountId: route.accountId,
|
|
466
|
+
ChatType: isGroup ? "group" : "direct",
|
|
467
|
+
ConversationLabel: fromLabel,
|
|
468
|
+
SenderName: senderName || undefined,
|
|
469
|
+
SenderId: senderId,
|
|
470
|
+
CommandAuthorized: commandAuthorized,
|
|
471
|
+
Provider: "zalo_bot",
|
|
472
|
+
Surface: "zalo_bot",
|
|
473
|
+
MessageSid: message_id,
|
|
474
|
+
MediaPath: mediaPath,
|
|
475
|
+
MediaType: mediaType,
|
|
476
|
+
MediaUrl: mediaPath,
|
|
477
|
+
OriginatingChannel: "zalo_bot",
|
|
478
|
+
OriginatingTo: `zalo_bot:${chatId}`,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await core.channel.session.recordInboundSession({
|
|
482
|
+
storePath,
|
|
483
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
484
|
+
ctx: ctxPayload,
|
|
485
|
+
onRecordError: (err) => {
|
|
486
|
+
runtime.error?.(`zalo_bot: failed updating session meta: ${String(err)}`);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
491
|
+
cfg: config,
|
|
492
|
+
channel: "zalo_bot",
|
|
493
|
+
accountId: account.accountId,
|
|
494
|
+
});
|
|
495
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
496
|
+
cfg: config,
|
|
497
|
+
agentId: route.agentId,
|
|
498
|
+
channel: "zalo_bot",
|
|
499
|
+
accountId: account.accountId,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
503
|
+
ctx: ctxPayload,
|
|
504
|
+
cfg: config,
|
|
505
|
+
dispatcherOptions: {
|
|
506
|
+
...prefixOptions,
|
|
507
|
+
deliver: async (payload) => {
|
|
508
|
+
await deliverZaloReply({
|
|
509
|
+
payload,
|
|
510
|
+
token,
|
|
511
|
+
chatId,
|
|
512
|
+
runtime,
|
|
513
|
+
core,
|
|
514
|
+
config,
|
|
515
|
+
accountId: account.accountId,
|
|
516
|
+
statusSink,
|
|
517
|
+
fetcher,
|
|
518
|
+
tableMode,
|
|
519
|
+
});
|
|
520
|
+
},
|
|
521
|
+
onError: (err, info) => {
|
|
522
|
+
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
replyOptions: {
|
|
526
|
+
onModelSelected,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function deliverZaloReply(params: {
|
|
532
|
+
payload: OutboundReplyPayload;
|
|
533
|
+
token: string;
|
|
534
|
+
chatId: string;
|
|
535
|
+
runtime: ZaloRuntimeEnv;
|
|
536
|
+
core: ZaloCoreRuntime;
|
|
537
|
+
config: OpenClawConfig;
|
|
538
|
+
accountId?: string;
|
|
539
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
540
|
+
fetcher?: ZaloFetch;
|
|
541
|
+
tableMode?: MarkdownTableMode;
|
|
542
|
+
}): Promise<void> {
|
|
543
|
+
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
|
544
|
+
const tableMode = params.tableMode ?? "code";
|
|
545
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
546
|
+
|
|
547
|
+
const sentMedia = await sendMediaWithLeadingCaption({
|
|
548
|
+
mediaUrls: resolveOutboundMediaUrls(payload),
|
|
549
|
+
caption: text,
|
|
550
|
+
send: async ({ mediaUrl, caption }) => {
|
|
551
|
+
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
|
552
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
553
|
+
},
|
|
554
|
+
onError: (error) => {
|
|
555
|
+
runtime.error?.(`Zalo photo send failed: ${String(error)}`);
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
if (sentMedia) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (text) {
|
|
563
|
+
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo_bot", accountId);
|
|
564
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode);
|
|
565
|
+
for (const chunk of chunks) {
|
|
566
|
+
try {
|
|
567
|
+
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
|
568
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
569
|
+
} catch (err) {
|
|
570
|
+
runtime.error?.(`Zalo message send failed: ${String(err)}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
|
|
577
|
+
const {
|
|
578
|
+
token,
|
|
579
|
+
account,
|
|
580
|
+
config,
|
|
581
|
+
runtime,
|
|
582
|
+
abortSignal,
|
|
583
|
+
useWebhook,
|
|
584
|
+
webhookUrl,
|
|
585
|
+
webhookSecret,
|
|
586
|
+
webhookPath,
|
|
587
|
+
statusSink,
|
|
588
|
+
fetcher: fetcherOverride,
|
|
589
|
+
} = options;
|
|
590
|
+
|
|
591
|
+
const core = getZaloRuntime();
|
|
592
|
+
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
593
|
+
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
|
594
|
+
|
|
595
|
+
let stopped = false;
|
|
596
|
+
const stopHandlers: Array<() => void> = [];
|
|
597
|
+
|
|
598
|
+
const stop = () => {
|
|
599
|
+
stopped = true;
|
|
600
|
+
for (const handler of stopHandlers) {
|
|
601
|
+
handler();
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
if (useWebhook) {
|
|
606
|
+
if (!webhookUrl || !webhookSecret) {
|
|
607
|
+
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
|
608
|
+
}
|
|
609
|
+
if (!webhookUrl.startsWith("https://")) {
|
|
610
|
+
throw new Error("Zalo webhook URL must use HTTPS");
|
|
611
|
+
}
|
|
612
|
+
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
|
613
|
+
throw new Error("Zalo webhook secret must be 8-256 characters");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
|
617
|
+
if (!path) {
|
|
618
|
+
throw new Error("Zalo webhookPath could not be derived");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
|
622
|
+
|
|
623
|
+
const unregister = registerZaloWebhookTarget({
|
|
624
|
+
token,
|
|
625
|
+
account,
|
|
626
|
+
config,
|
|
627
|
+
runtime,
|
|
628
|
+
core,
|
|
629
|
+
path,
|
|
630
|
+
secret: webhookSecret,
|
|
631
|
+
statusSink: (patch) => statusSink?.(patch),
|
|
632
|
+
mediaMaxMb: effectiveMediaMaxMb,
|
|
633
|
+
fetcher,
|
|
634
|
+
});
|
|
635
|
+
stopHandlers.push(unregister);
|
|
636
|
+
abortSignal.addEventListener(
|
|
637
|
+
"abort",
|
|
638
|
+
() => {
|
|
639
|
+
void deleteWebhook(token, fetcher).catch(() => {});
|
|
640
|
+
},
|
|
641
|
+
{ once: true },
|
|
642
|
+
);
|
|
643
|
+
return { stop };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
await deleteWebhook(token, fetcher);
|
|
648
|
+
} catch {
|
|
649
|
+
// ignore
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
startPollingLoop({
|
|
653
|
+
token,
|
|
654
|
+
account,
|
|
655
|
+
config,
|
|
656
|
+
runtime,
|
|
657
|
+
core,
|
|
658
|
+
abortSignal,
|
|
659
|
+
isStopped: () => stopped,
|
|
660
|
+
mediaMaxMb: effectiveMediaMaxMb,
|
|
661
|
+
statusSink,
|
|
662
|
+
fetcher,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
return { stop };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export const __testing = {
|
|
669
|
+
evaluateZaloGroupAccess,
|
|
670
|
+
resolveZaloRuntimeGroupPolicy,
|
|
671
|
+
};
|