@openclaw/zalo 2026.3.2 → 2026.3.8-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.
- package/CHANGELOG.md +24 -0
- package/index.ts +2 -2
- package/package.json +3 -2
- package/src/accounts.ts +5 -37
- package/src/actions.ts +2 -2
- package/src/api.test.ts +63 -0
- package/src/api.ts +36 -6
- package/src/channel.directory.test.ts +1 -1
- package/src/channel.sendpayload.test.ts +1 -1
- package/src/channel.startup.test.ts +100 -0
- package/src/channel.ts +107 -163
- package/src/config-schema.ts +8 -9
- package/src/group-access.ts +2 -2
- package/src/monitor.lifecycle.test.ts +213 -0
- package/src/monitor.ts +185 -71
- package/src/monitor.webhook.test.ts +40 -32
- package/src/monitor.webhook.ts +77 -92
- package/src/onboarding.status.test.ts +1 -1
- package/src/onboarding.ts +38 -39
- package/src/probe.ts +1 -1
- package/src/runtime.ts +5 -13
- package/src/secret-input.ts +8 -14
- package/src/send.ts +29 -24
- package/src/status-issues.ts +1 -1
- package/src/token.ts +24 -30
- package/src/types.ts +1 -1
package/src/channel.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildAccountScopedDmSecurityPolicy,
|
|
3
|
+
collectOpenProviderGroupPolicyWarnings,
|
|
4
|
+
buildOpenGroupPolicyRestrictSendersWarning,
|
|
5
|
+
buildOpenGroupPolicyWarning,
|
|
6
|
+
mapAllowFromEntries,
|
|
7
|
+
} from "openclaw/plugin-sdk/compat";
|
|
1
8
|
import type {
|
|
2
9
|
ChannelAccountSnapshot,
|
|
3
10
|
ChannelDock,
|
|
4
11
|
ChannelPlugin,
|
|
5
12
|
OpenClawConfig,
|
|
6
|
-
} from "openclaw/plugin-sdk";
|
|
13
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
7
14
|
import {
|
|
8
15
|
applyAccountNameToChannelSection,
|
|
16
|
+
applySetupAccountConfigPatch,
|
|
17
|
+
buildBaseAccountStatusSnapshot,
|
|
9
18
|
buildChannelConfigSchema,
|
|
10
19
|
buildTokenChannelStatusSummary,
|
|
20
|
+
buildChannelSendResult,
|
|
11
21
|
DEFAULT_ACCOUNT_ID,
|
|
12
22
|
deleteAccountFromConfigSection,
|
|
13
23
|
chunkTextForOutbound,
|
|
14
24
|
formatAllowFromLowercase,
|
|
15
|
-
formatPairingApproveHint,
|
|
16
25
|
migrateBaseNameToDefaultAccount,
|
|
26
|
+
listDirectoryUserEntriesFromAllowFrom,
|
|
17
27
|
normalizeAccountId,
|
|
28
|
+
isNumericTargetId,
|
|
18
29
|
PAIRING_APPROVED_MESSAGE,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
resolveChannelAccountConfigBasePath,
|
|
30
|
+
resolveOutboundMediaUrls,
|
|
31
|
+
sendPayloadWithChunkedTextAndMedia,
|
|
22
32
|
setAccountEnabledInConfigSection,
|
|
23
|
-
} from "openclaw/plugin-sdk";
|
|
33
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
24
34
|
import {
|
|
25
35
|
listZaloAccountIds,
|
|
26
36
|
resolveDefaultZaloAccountId,
|
|
@@ -66,9 +76,7 @@ export const zaloDock: ChannelDock = {
|
|
|
66
76
|
outbound: { textChunkLimit: 2000 },
|
|
67
77
|
config: {
|
|
68
78
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
69
|
-
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom
|
|
70
|
-
String(entry),
|
|
71
|
-
),
|
|
79
|
+
mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
|
|
72
80
|
formatAllowFrom: ({ allowFrom }) =>
|
|
73
81
|
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
|
|
74
82
|
},
|
|
@@ -123,53 +131,57 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
123
131
|
tokenSource: account.tokenSource,
|
|
124
132
|
}),
|
|
125
133
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
126
|
-
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom
|
|
127
|
-
String(entry),
|
|
128
|
-
),
|
|
134
|
+
mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
|
|
129
135
|
formatAllowFrom: ({ allowFrom }) =>
|
|
130
136
|
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
|
|
131
137
|
},
|
|
132
138
|
security: {
|
|
133
139
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
134
|
-
|
|
135
|
-
const basePath = resolveChannelAccountConfigBasePath({
|
|
140
|
+
return buildAccountScopedDmSecurityPolicy({
|
|
136
141
|
cfg,
|
|
137
142
|
channelKey: "zalo",
|
|
138
|
-
accountId
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
policy: account.config.dmPolicy ?? "pairing",
|
|
143
|
+
accountId,
|
|
144
|
+
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
145
|
+
policy: account.config.dmPolicy,
|
|
142
146
|
allowFrom: account.config.allowFrom ?? [],
|
|
143
|
-
|
|
144
|
-
allowFromPath: basePath,
|
|
145
|
-
approveHint: formatPairingApproveHint("zalo"),
|
|
147
|
+
policyPathSuffix: "dmPolicy",
|
|
146
148
|
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
|
|
147
|
-
};
|
|
149
|
+
});
|
|
148
150
|
},
|
|
149
151
|
collectWarnings: ({ account, cfg }) => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
return collectOpenProviderGroupPolicyWarnings({
|
|
153
|
+
cfg,
|
|
152
154
|
providerConfigPresent: cfg.channels?.zalo !== undefined,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
configuredGroupPolicy: account.config.groupPolicy,
|
|
156
|
+
collect: (groupPolicy) => {
|
|
157
|
+
if (groupPolicy !== "open") {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
|
|
161
|
+
const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
|
|
162
|
+
const effectiveAllowFrom =
|
|
163
|
+
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
|
|
164
|
+
if (effectiveAllowFrom.length > 0) {
|
|
165
|
+
return [
|
|
166
|
+
buildOpenGroupPolicyRestrictSendersWarning({
|
|
167
|
+
surface: "Zalo groups",
|
|
168
|
+
openScope: "any member",
|
|
169
|
+
groupPolicyPath: "channels.zalo.groupPolicy",
|
|
170
|
+
groupAllowFromPath: "channels.zalo.groupAllowFrom",
|
|
171
|
+
}),
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
return [
|
|
175
|
+
buildOpenGroupPolicyWarning({
|
|
176
|
+
surface: "Zalo groups",
|
|
177
|
+
openBehavior:
|
|
178
|
+
"with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
|
|
179
|
+
remediation:
|
|
180
|
+
'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
|
|
181
|
+
}),
|
|
182
|
+
];
|
|
183
|
+
},
|
|
155
184
|
});
|
|
156
|
-
if (groupPolicy !== "open") {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
|
|
160
|
-
String(entry),
|
|
161
|
-
);
|
|
162
|
-
const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
163
|
-
const effectiveAllowFrom =
|
|
164
|
-
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
|
|
165
|
-
if (effectiveAllowFrom.length > 0) {
|
|
166
|
-
return [
|
|
167
|
-
`- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
|
|
168
|
-
];
|
|
169
|
-
}
|
|
170
|
-
return [
|
|
171
|
-
`- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
|
|
172
|
-
];
|
|
173
185
|
},
|
|
174
186
|
},
|
|
175
187
|
groups: {
|
|
@@ -182,13 +194,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
182
194
|
messaging: {
|
|
183
195
|
normalizeTarget: normalizeZaloMessagingTarget,
|
|
184
196
|
targetResolver: {
|
|
185
|
-
looksLikeId:
|
|
186
|
-
const trimmed = raw.trim();
|
|
187
|
-
if (!trimmed) {
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
return /^\d{3,}$/.test(trimmed);
|
|
191
|
-
},
|
|
197
|
+
looksLikeId: isNumericTargetId,
|
|
192
198
|
hint: "<chatId>",
|
|
193
199
|
},
|
|
194
200
|
},
|
|
@@ -196,19 +202,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
196
202
|
self: async () => null,
|
|
197
203
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
198
204
|
const account = resolveZaloAccount({ cfg: cfg, accountId });
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
|
|
206
|
-
),
|
|
207
|
-
)
|
|
208
|
-
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
209
|
-
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
210
|
-
.map((id) => ({ kind: "user", id }) as const);
|
|
211
|
-
return peers;
|
|
205
|
+
return listDirectoryUserEntriesFromAllowFrom({
|
|
206
|
+
allowFrom: account.config.allowFrom,
|
|
207
|
+
query,
|
|
208
|
+
limit,
|
|
209
|
+
normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
|
210
|
+
});
|
|
212
211
|
},
|
|
213
212
|
listGroups: async () => [],
|
|
214
213
|
},
|
|
@@ -244,47 +243,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
244
243
|
channelKey: "zalo",
|
|
245
244
|
})
|
|
246
245
|
: namedConfig;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
? { botToken: input.token }
|
|
261
|
-
: {}),
|
|
262
|
-
},
|
|
263
|
-
},
|
|
264
|
-
} as OpenClawConfig;
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
...next,
|
|
268
|
-
channels: {
|
|
269
|
-
...next.channels,
|
|
270
|
-
zalo: {
|
|
271
|
-
...next.channels?.zalo,
|
|
272
|
-
enabled: true,
|
|
273
|
-
accounts: {
|
|
274
|
-
...next.channels?.zalo?.accounts,
|
|
275
|
-
[accountId]: {
|
|
276
|
-
...next.channels?.zalo?.accounts?.[accountId],
|
|
277
|
-
enabled: true,
|
|
278
|
-
...(input.tokenFile
|
|
279
|
-
? { tokenFile: input.tokenFile }
|
|
280
|
-
: input.token
|
|
281
|
-
? { botToken: input.token }
|
|
282
|
-
: {}),
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
},
|
|
287
|
-
} as OpenClawConfig;
|
|
246
|
+
const patch = input.useEnv
|
|
247
|
+
? {}
|
|
248
|
+
: input.tokenFile
|
|
249
|
+
? { tokenFile: input.tokenFile }
|
|
250
|
+
: input.token
|
|
251
|
+
? { botToken: input.token }
|
|
252
|
+
: {};
|
|
253
|
+
return applySetupAccountConfigPatch({
|
|
254
|
+
cfg: next,
|
|
255
|
+
channelKey: "zalo",
|
|
256
|
+
accountId,
|
|
257
|
+
patch,
|
|
258
|
+
});
|
|
288
259
|
},
|
|
289
260
|
},
|
|
290
261
|
pairing: {
|
|
@@ -303,51 +274,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
303
274
|
chunker: chunkTextForOutbound,
|
|
304
275
|
chunkerMode: "text",
|
|
305
276
|
textChunkLimit: 2000,
|
|
306
|
-
sendPayload: async (ctx) =>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
:
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
if (urls.length > 0) {
|
|
317
|
-
let lastResult = await zaloPlugin.outbound!.sendMedia!({
|
|
318
|
-
...ctx,
|
|
319
|
-
text,
|
|
320
|
-
mediaUrl: urls[0],
|
|
321
|
-
});
|
|
322
|
-
for (let i = 1; i < urls.length; i++) {
|
|
323
|
-
lastResult = await zaloPlugin.outbound!.sendMedia!({
|
|
324
|
-
...ctx,
|
|
325
|
-
text: "",
|
|
326
|
-
mediaUrl: urls[i],
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
return lastResult;
|
|
330
|
-
}
|
|
331
|
-
const outbound = zaloPlugin.outbound!;
|
|
332
|
-
const limit = outbound.textChunkLimit;
|
|
333
|
-
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
|
|
334
|
-
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
|
|
335
|
-
for (const chunk of chunks) {
|
|
336
|
-
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
|
|
337
|
-
}
|
|
338
|
-
return lastResult!;
|
|
339
|
-
},
|
|
277
|
+
sendPayload: async (ctx) =>
|
|
278
|
+
await sendPayloadWithChunkedTextAndMedia({
|
|
279
|
+
ctx,
|
|
280
|
+
textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
|
|
281
|
+
chunker: zaloPlugin.outbound!.chunker,
|
|
282
|
+
sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
|
|
283
|
+
sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
|
|
284
|
+
emptyResult: { channel: "zalo", messageId: "" },
|
|
285
|
+
}),
|
|
340
286
|
sendText: async ({ to, text, accountId, cfg }) => {
|
|
341
287
|
const result = await sendMessageZalo(to, text, {
|
|
342
288
|
accountId: accountId ?? undefined,
|
|
343
289
|
cfg: cfg,
|
|
344
290
|
});
|
|
345
|
-
return
|
|
346
|
-
channel: "zalo",
|
|
347
|
-
ok: result.ok,
|
|
348
|
-
messageId: result.messageId ?? "",
|
|
349
|
-
error: result.error ? new Error(result.error) : undefined,
|
|
350
|
-
};
|
|
291
|
+
return buildChannelSendResult("zalo", result);
|
|
351
292
|
},
|
|
352
293
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
|
353
294
|
const result = await sendMessageZalo(to, text, {
|
|
@@ -355,12 +296,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
355
296
|
mediaUrl,
|
|
356
297
|
cfg: cfg,
|
|
357
298
|
});
|
|
358
|
-
return
|
|
359
|
-
channel: "zalo",
|
|
360
|
-
ok: result.ok,
|
|
361
|
-
messageId: result.messageId ?? "",
|
|
362
|
-
error: result.error ? new Error(result.error) : undefined,
|
|
363
|
-
};
|
|
299
|
+
return buildChannelSendResult("zalo", result);
|
|
364
300
|
},
|
|
365
301
|
},
|
|
366
302
|
status: {
|
|
@@ -377,19 +313,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
377
313
|
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
|
378
314
|
buildAccountSnapshot: ({ account, runtime }) => {
|
|
379
315
|
const configured = Boolean(account.token?.trim());
|
|
316
|
+
const base = buildBaseAccountStatusSnapshot({
|
|
317
|
+
account: {
|
|
318
|
+
accountId: account.accountId,
|
|
319
|
+
name: account.name,
|
|
320
|
+
enabled: account.enabled,
|
|
321
|
+
configured,
|
|
322
|
+
},
|
|
323
|
+
runtime,
|
|
324
|
+
});
|
|
380
325
|
return {
|
|
381
|
-
|
|
382
|
-
name: account.name,
|
|
383
|
-
enabled: account.enabled,
|
|
384
|
-
configured,
|
|
326
|
+
...base,
|
|
385
327
|
tokenSource: account.tokenSource,
|
|
386
|
-
running: runtime?.running ?? false,
|
|
387
|
-
lastStartAt: runtime?.lastStartAt ?? null,
|
|
388
|
-
lastStopAt: runtime?.lastStopAt ?? null,
|
|
389
|
-
lastError: runtime?.lastError ?? null,
|
|
390
328
|
mode: account.config.webhookUrl ? "webhook" : "polling",
|
|
391
|
-
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
392
|
-
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
393
329
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
394
330
|
};
|
|
395
331
|
},
|
|
@@ -398,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
398
334
|
startAccount: async (ctx) => {
|
|
399
335
|
const account = ctx.account;
|
|
400
336
|
const token = account.token.trim();
|
|
337
|
+
const mode = account.config.webhookUrl ? "webhook" : "polling";
|
|
401
338
|
let zaloBotLabel = "";
|
|
402
339
|
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
|
403
340
|
try {
|
|
@@ -406,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
406
343
|
if (name) {
|
|
407
344
|
zaloBotLabel = ` (${name})`;
|
|
408
345
|
}
|
|
346
|
+
if (!probe.ok) {
|
|
347
|
+
ctx.log?.warn?.(
|
|
348
|
+
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
409
351
|
ctx.setStatus({
|
|
410
352
|
accountId: account.accountId,
|
|
411
353
|
bot: probe.bot,
|
|
412
354
|
});
|
|
413
|
-
} catch {
|
|
414
|
-
|
|
355
|
+
} catch (err) {
|
|
356
|
+
ctx.log?.warn?.(
|
|
357
|
+
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
|
358
|
+
);
|
|
415
359
|
}
|
|
416
|
-
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
|
360
|
+
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
|
417
361
|
const { monitorZaloProvider } = await import("./monitor.js");
|
|
418
362
|
return monitorZaloProvider({
|
|
419
363
|
token,
|
package/src/config-schema.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AllowFromEntrySchema,
|
|
3
|
+
buildCatchallMultiAccountChannelSchema,
|
|
4
|
+
} from "openclaw/plugin-sdk/compat";
|
|
5
|
+
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
|
2
6
|
import { z } from "zod";
|
|
3
7
|
import { buildSecretInputSchema } from "./secret-input.js";
|
|
4
8
|
|
|
5
|
-
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
6
|
-
|
|
7
9
|
const zaloAccountSchema = z.object({
|
|
8
10
|
name: z.string().optional(),
|
|
9
11
|
enabled: z.boolean().optional(),
|
|
@@ -14,15 +16,12 @@ const zaloAccountSchema = z.object({
|
|
|
14
16
|
webhookSecret: buildSecretInputSchema().optional(),
|
|
15
17
|
webhookPath: z.string().optional(),
|
|
16
18
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
17
|
-
allowFrom: z.array(
|
|
19
|
+
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
|
18
20
|
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
|
19
|
-
groupAllowFrom: z.array(
|
|
21
|
+
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
|
20
22
|
mediaMaxMb: z.number().optional(),
|
|
21
23
|
proxy: z.string().optional(),
|
|
22
24
|
responsePrefix: z.string().optional(),
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
export const ZaloConfigSchema = zaloAccountSchema
|
|
26
|
-
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
|
27
|
-
defaultAccount: z.string().optional(),
|
|
28
|
-
});
|
|
27
|
+
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
|
package/src/group-access.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import {
|
|
3
3
|
evaluateSenderGroupAccess,
|
|
4
4
|
isNormalizedSenderAllowed,
|
|
5
5
|
resolveOpenProviderRuntimeGroupPolicy,
|
|
6
|
-
} from "openclaw/plugin-sdk";
|
|
6
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
7
7
|
|
|
8
8
|
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
|
|
9
9
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
4
|
+
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
5
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
6
|
+
|
|
7
|
+
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
8
|
+
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
9
|
+
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
|
|
10
|
+
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
|
11
|
+
|
|
12
|
+
vi.mock("./api.js", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("./api.js")>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
deleteWebhook: deleteWebhookMock,
|
|
17
|
+
getWebhookInfo: getWebhookInfoMock,
|
|
18
|
+
getUpdates: getUpdatesMock,
|
|
19
|
+
setWebhook: setWebhookMock,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock("./runtime.js", () => ({
|
|
24
|
+
getZaloRuntime: () => ({
|
|
25
|
+
logging: {
|
|
26
|
+
shouldLogVerbose: () => false,
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
async function waitForPollingLoopStart(): Promise<void> {
|
|
32
|
+
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("monitorZaloProvider lifecycle", () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stays alive in polling mode until abort", async () => {
|
|
42
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
43
|
+
const abort = new AbortController();
|
|
44
|
+
const runtime = {
|
|
45
|
+
log: vi.fn<(message: string) => void>(),
|
|
46
|
+
error: vi.fn<(message: string) => void>(),
|
|
47
|
+
};
|
|
48
|
+
const account = {
|
|
49
|
+
accountId: "default",
|
|
50
|
+
config: {},
|
|
51
|
+
} as unknown as ResolvedZaloAccount;
|
|
52
|
+
const config = {} as OpenClawConfig;
|
|
53
|
+
|
|
54
|
+
let settled = false;
|
|
55
|
+
const run = monitorZaloProvider({
|
|
56
|
+
token: "test-token",
|
|
57
|
+
account,
|
|
58
|
+
config,
|
|
59
|
+
runtime,
|
|
60
|
+
abortSignal: abort.signal,
|
|
61
|
+
}).then(() => {
|
|
62
|
+
settled = true;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await waitForPollingLoopStart();
|
|
66
|
+
|
|
67
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
|
69
|
+
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(settled).toBe(false);
|
|
71
|
+
|
|
72
|
+
abort.abort();
|
|
73
|
+
await run;
|
|
74
|
+
|
|
75
|
+
expect(settled).toBe(true);
|
|
76
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
77
|
+
expect.stringContaining("Zalo provider stopped mode=polling"),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deletes an existing webhook before polling", async () => {
|
|
82
|
+
getWebhookInfoMock.mockResolvedValueOnce({
|
|
83
|
+
ok: true,
|
|
84
|
+
result: { url: "https://example.com/hooks/zalo" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
88
|
+
const abort = new AbortController();
|
|
89
|
+
const runtime = {
|
|
90
|
+
log: vi.fn<(message: string) => void>(),
|
|
91
|
+
error: vi.fn<(message: string) => void>(),
|
|
92
|
+
};
|
|
93
|
+
const account = {
|
|
94
|
+
accountId: "default",
|
|
95
|
+
config: {},
|
|
96
|
+
} as unknown as ResolvedZaloAccount;
|
|
97
|
+
const config = {} as OpenClawConfig;
|
|
98
|
+
|
|
99
|
+
const run = monitorZaloProvider({
|
|
100
|
+
token: "test-token",
|
|
101
|
+
account,
|
|
102
|
+
config,
|
|
103
|
+
runtime,
|
|
104
|
+
abortSignal: abort.signal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await waitForPollingLoopStart();
|
|
108
|
+
|
|
109
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
112
|
+
expect.stringContaining("Zalo polling mode ready (webhook disabled)"),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
abort.abort();
|
|
116
|
+
await run;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("continues polling when webhook inspection returns 404", async () => {
|
|
120
|
+
const { ZaloApiError } = await import("./api.js");
|
|
121
|
+
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
|
|
122
|
+
|
|
123
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
124
|
+
const abort = new AbortController();
|
|
125
|
+
const runtime = {
|
|
126
|
+
log: vi.fn<(message: string) => void>(),
|
|
127
|
+
error: vi.fn<(message: string) => void>(),
|
|
128
|
+
};
|
|
129
|
+
const account = {
|
|
130
|
+
accountId: "default",
|
|
131
|
+
config: {},
|
|
132
|
+
} as unknown as ResolvedZaloAccount;
|
|
133
|
+
const config = {} as OpenClawConfig;
|
|
134
|
+
|
|
135
|
+
const run = monitorZaloProvider({
|
|
136
|
+
token: "test-token",
|
|
137
|
+
account,
|
|
138
|
+
config,
|
|
139
|
+
runtime,
|
|
140
|
+
abortSignal: abort.signal,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await waitForPollingLoopStart();
|
|
144
|
+
|
|
145
|
+
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
|
147
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
148
|
+
expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"),
|
|
149
|
+
);
|
|
150
|
+
expect(runtime.error).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
abort.abort();
|
|
153
|
+
await run;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("waits for webhook deletion before finishing webhook shutdown", async () => {
|
|
157
|
+
const registry = createEmptyPluginRegistry();
|
|
158
|
+
setActivePluginRegistry(registry);
|
|
159
|
+
|
|
160
|
+
let resolveDeleteWebhook: (() => void) | undefined;
|
|
161
|
+
deleteWebhookMock.mockImplementationOnce(
|
|
162
|
+
() =>
|
|
163
|
+
new Promise((resolve) => {
|
|
164
|
+
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
169
|
+
const abort = new AbortController();
|
|
170
|
+
const runtime = {
|
|
171
|
+
log: vi.fn<(message: string) => void>(),
|
|
172
|
+
error: vi.fn<(message: string) => void>(),
|
|
173
|
+
};
|
|
174
|
+
const account = {
|
|
175
|
+
accountId: "default",
|
|
176
|
+
config: {},
|
|
177
|
+
} as unknown as ResolvedZaloAccount;
|
|
178
|
+
const config = {} as OpenClawConfig;
|
|
179
|
+
|
|
180
|
+
let settled = false;
|
|
181
|
+
const run = monitorZaloProvider({
|
|
182
|
+
token: "test-token",
|
|
183
|
+
account,
|
|
184
|
+
config,
|
|
185
|
+
runtime,
|
|
186
|
+
abortSignal: abort.signal,
|
|
187
|
+
useWebhook: true,
|
|
188
|
+
webhookUrl: "https://example.com/hooks/zalo",
|
|
189
|
+
webhookSecret: "supersecret", // pragma: allowlist secret
|
|
190
|
+
}).then(() => {
|
|
191
|
+
settled = true;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
|
|
195
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
196
|
+
|
|
197
|
+
abort.abort();
|
|
198
|
+
|
|
199
|
+
await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
|
|
200
|
+
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
|
|
201
|
+
expect(settled).toBe(false);
|
|
202
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
203
|
+
|
|
204
|
+
resolveDeleteWebhook?.();
|
|
205
|
+
await run;
|
|
206
|
+
|
|
207
|
+
expect(settled).toBe(true);
|
|
208
|
+
expect(registry.httpRoutes).toHaveLength(0);
|
|
209
|
+
expect(runtime.log).toHaveBeenCalledWith(
|
|
210
|
+
expect.stringContaining("Zalo provider stopped mode=webhook"),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|