@openclaw/zalo 2026.3.2 → 2026.3.7
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 +12 -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/channel.directory.test.ts +1 -1
- package/src/channel.sendpayload.test.ts +1 -1
- package/src/channel.ts +96 -160
- package/src/config-schema.ts +1 -1
- package/src/group-access.ts +2 -2
- package/src/monitor.ts +21 -17
- 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 +1 -1
- 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/CHANGELOG.md
CHANGED
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/zalo";
|
|
3
3
|
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
|
4
4
|
import { setZaloRuntime } from "./src/runtime.js";
|
|
5
5
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/zalo",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.7",
|
|
4
4
|
"description": "OpenClaw Zalo channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"undici": "7.22.0"
|
|
7
|
+
"undici": "7.22.0",
|
|
8
|
+
"zod": "^4.3.6"
|
|
8
9
|
},
|
|
9
10
|
"openclaw": {
|
|
10
11
|
"extensions": [
|
package/src/accounts.ts
CHANGED
|
@@ -1,45 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
DEFAULT_ACCOUNT_ID,
|
|
4
|
-
normalizeAccountId,
|
|
5
|
-
normalizeOptionalAccountId,
|
|
6
|
-
} from "openclaw/plugin-sdk/account-id";
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
2
|
+
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
7
3
|
import { resolveZaloToken } from "./token.js";
|
|
8
4
|
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
|
|
9
5
|
|
|
10
6
|
export type { ResolvedZaloAccount };
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return [];
|
|
16
|
-
}
|
|
17
|
-
return Object.keys(accounts).filter(Boolean);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
|
|
21
|
-
const ids = listConfiguredAccountIds(cfg);
|
|
22
|
-
if (ids.length === 0) {
|
|
23
|
-
return [DEFAULT_ACCOUNT_ID];
|
|
24
|
-
}
|
|
25
|
-
return ids.toSorted((a, b) => a.localeCompare(b));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
|
|
29
|
-
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
|
30
|
-
const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
|
|
31
|
-
if (
|
|
32
|
-
preferred &&
|
|
33
|
-
listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
|
|
34
|
-
) {
|
|
35
|
-
return preferred;
|
|
36
|
-
}
|
|
37
|
-
const ids = listZaloAccountIds(cfg);
|
|
38
|
-
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
39
|
-
return DEFAULT_ACCOUNT_ID;
|
|
40
|
-
}
|
|
41
|
-
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
42
|
-
}
|
|
8
|
+
const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
|
|
9
|
+
createAccountListHelpers("zalo");
|
|
10
|
+
export { listZaloAccountIds, resolveDefaultZaloAccountId };
|
|
43
11
|
|
|
44
12
|
function resolveAccountConfig(
|
|
45
13
|
cfg: OpenClawConfig,
|
package/src/actions.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type {
|
|
|
2
2
|
ChannelMessageActionAdapter,
|
|
3
3
|
ChannelMessageActionName,
|
|
4
4
|
OpenClawConfig,
|
|
5
|
-
} from "openclaw/plugin-sdk";
|
|
6
|
-
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
|
|
5
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
6
|
+
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo";
|
|
7
7
|
import { listEnabledZaloAccounts } from "./accounts.js";
|
|
8
8
|
import { sendMessageZalo } from "./send.js";
|
|
9
9
|
|
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
|
},
|
package/src/config-schema.ts
CHANGED
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
|
|
package/src/monitor.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
MarkdownTableMode,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
OutboundReplyPayload,
|
|
6
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
3
7
|
import {
|
|
4
8
|
createScopedPairingAccess,
|
|
5
9
|
createReplyPrefixOptions,
|
|
10
|
+
issuePairingChallenge,
|
|
6
11
|
resolveDirectDmAuthorizationOutcome,
|
|
7
12
|
resolveSenderCommandAuthorizationWithRuntime,
|
|
8
13
|
resolveOutboundMediaUrls,
|
|
@@ -11,7 +16,7 @@ import {
|
|
|
11
16
|
sendMediaWithLeadingCaption,
|
|
12
17
|
resolveWebhookPath,
|
|
13
18
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
14
|
-
} from "openclaw/plugin-sdk";
|
|
19
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
15
20
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
16
21
|
import {
|
|
17
22
|
ZaloApiError,
|
|
@@ -410,31 +415,30 @@ async function processMessageWithPipeline(params: {
|
|
|
410
415
|
}
|
|
411
416
|
if (directDmOutcome === "unauthorized") {
|
|
412
417
|
if (dmPolicy === "pairing") {
|
|
413
|
-
|
|
414
|
-
|
|
418
|
+
await issuePairingChallenge({
|
|
419
|
+
channel: "zalo",
|
|
420
|
+
senderId,
|
|
421
|
+
senderIdLine: `Your Zalo user id: ${senderId}`,
|
|
415
422
|
meta: { name: senderName ?? undefined },
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
423
|
+
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
424
|
+
onCreated: () => {
|
|
425
|
+
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
|
426
|
+
},
|
|
427
|
+
sendPairingReply: async (text) => {
|
|
421
428
|
await sendMessage(
|
|
422
429
|
token,
|
|
423
430
|
{
|
|
424
431
|
chat_id: chatId,
|
|
425
|
-
text
|
|
426
|
-
channel: "zalo",
|
|
427
|
-
idLine: `Your Zalo user id: ${senderId}`,
|
|
428
|
-
code,
|
|
429
|
-
}),
|
|
432
|
+
text,
|
|
430
433
|
},
|
|
431
434
|
fetcher,
|
|
432
435
|
);
|
|
433
436
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
434
|
-
}
|
|
437
|
+
},
|
|
438
|
+
onReplyError: (err) => {
|
|
435
439
|
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
440
|
+
},
|
|
441
|
+
});
|
|
438
442
|
} else {
|
|
439
443
|
logVerbose(
|
|
440
444
|
core,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer, type RequestListener } from "node:http";
|
|
2
2
|
import type { AddressInfo } from "node:net";
|
|
3
|
-
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
6
6
|
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
|
|
|
94
94
|
return { core, readAllowFromStore, upsertPairingRequest };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
async function postUntilRateLimited(params: {
|
|
98
|
+
baseUrl: string;
|
|
99
|
+
path: string;
|
|
100
|
+
secret: string;
|
|
101
|
+
withNonceQuery?: boolean;
|
|
102
|
+
attempts?: number;
|
|
103
|
+
}): Promise<boolean> {
|
|
104
|
+
const attempts = params.attempts ?? 130;
|
|
105
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
106
|
+
const url = params.withNonceQuery
|
|
107
|
+
? `${params.baseUrl}${params.path}?nonce=${i}`
|
|
108
|
+
: `${params.baseUrl}${params.path}`;
|
|
109
|
+
const response = await fetch(url, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"x-bot-api-secret-token": params.secret,
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
body: "{}",
|
|
116
|
+
});
|
|
117
|
+
if (response.status === 429) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
describe("handleZaloWebhookRequest", () => {
|
|
98
125
|
afterEach(() => {
|
|
99
126
|
clearZaloWebhookSecurityStateForTest();
|
|
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
239
266
|
|
|
240
267
|
try {
|
|
241
268
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"x-bot-api-secret-token": "secret",
|
|
248
|
-
"content-type": "application/json",
|
|
249
|
-
},
|
|
250
|
-
body: "{}",
|
|
251
|
-
});
|
|
252
|
-
if (response.status === 429) {
|
|
253
|
-
saw429 = true;
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
269
|
+
const saw429 = await postUntilRateLimited({
|
|
270
|
+
baseUrl,
|
|
271
|
+
path: "/hook-rate",
|
|
272
|
+
secret: "secret", // pragma: allowlist secret
|
|
273
|
+
});
|
|
257
274
|
|
|
258
275
|
expect(saw429).toBe(true);
|
|
259
276
|
});
|
|
@@ -270,7 +287,7 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
270
287
|
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
271
288
|
method: "POST",
|
|
272
289
|
headers: {
|
|
273
|
-
"x-bot-api-secret-token": "invalid-token",
|
|
290
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
274
291
|
"content-type": "application/json",
|
|
275
292
|
},
|
|
276
293
|
body: "{}",
|
|
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
290
307
|
|
|
291
308
|
try {
|
|
292
309
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"content-type": "application/json",
|
|
300
|
-
},
|
|
301
|
-
body: "{}",
|
|
302
|
-
});
|
|
303
|
-
if (response.status === 429) {
|
|
304
|
-
saw429 = true;
|
|
305
|
-
break;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
310
|
+
const saw429 = await postUntilRateLimited({
|
|
311
|
+
baseUrl,
|
|
312
|
+
path: "/hook-query-rate",
|
|
313
|
+
secret: "secret", // pragma: allowlist secret
|
|
314
|
+
withNonceQuery: true,
|
|
315
|
+
});
|
|
308
316
|
|
|
309
317
|
expect(saw429).toBe(true);
|
|
310
318
|
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
4
4
|
import {
|
|
5
5
|
createDedupeCache,
|
|
6
6
|
createFixedWindowRateLimiter,
|
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
type RegisterWebhookTargetOptions,
|
|
12
12
|
type RegisterWebhookPluginRouteOptions,
|
|
13
13
|
registerWebhookTarget,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
resolveWebhookTargetWithAuthOrRejectSync,
|
|
15
|
+
withResolvedWebhookRequestPipeline,
|
|
16
16
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
17
17
|
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
18
|
-
} from "openclaw/plugin-sdk";
|
|
18
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
19
19
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
20
20
|
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
21
21
|
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
@@ -134,95 +134,80 @@ export async function handleZaloWebhookRequest(
|
|
|
134
134
|
res: ServerResponse,
|
|
135
135
|
processUpdate: ZaloWebhookProcessUpdate,
|
|
136
136
|
): Promise<boolean> {
|
|
137
|
-
|
|
138
|
-
if (!resolved) {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
const { targets, path } = resolved;
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
!applyBasicWebhookRequestGuards({
|
|
145
|
-
req,
|
|
146
|
-
res,
|
|
147
|
-
allowMethods: ["POST"],
|
|
148
|
-
})
|
|
149
|
-
) {
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
154
|
-
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
|
|
155
|
-
timingSafeEquals(entry.secret, headerToken),
|
|
156
|
-
);
|
|
157
|
-
if (matchedTarget.kind === "none") {
|
|
158
|
-
res.statusCode = 401;
|
|
159
|
-
res.end("unauthorized");
|
|
160
|
-
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
if (matchedTarget.kind === "ambiguous") {
|
|
164
|
-
res.statusCode = 401;
|
|
165
|
-
res.end("ambiguous webhook target");
|
|
166
|
-
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
const target = matchedTarget.target;
|
|
170
|
-
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
171
|
-
const nowMs = Date.now();
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
!applyBasicWebhookRequestGuards({
|
|
175
|
-
req,
|
|
176
|
-
res,
|
|
177
|
-
rateLimiter: webhookRateLimiter,
|
|
178
|
-
rateLimitKey,
|
|
179
|
-
nowMs,
|
|
180
|
-
requireJsonContentType: true,
|
|
181
|
-
})
|
|
182
|
-
) {
|
|
183
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
const body = await readJsonWebhookBodyOrReject({
|
|
137
|
+
return await withResolvedWebhookRequestPipeline({
|
|
187
138
|
req,
|
|
188
139
|
res,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
140
|
+
targetsByPath: webhookTargets,
|
|
141
|
+
allowMethods: ["POST"],
|
|
142
|
+
handle: async ({ targets, path }) => {
|
|
143
|
+
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
144
|
+
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
|
145
|
+
targets,
|
|
146
|
+
res,
|
|
147
|
+
isMatch: (entry) => timingSafeEquals(entry.secret, headerToken),
|
|
148
|
+
});
|
|
149
|
+
if (!target) {
|
|
150
|
+
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
154
|
+
const nowMs = Date.now();
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
!applyBasicWebhookRequestGuards({
|
|
158
|
+
req,
|
|
159
|
+
res,
|
|
160
|
+
rateLimiter: webhookRateLimiter,
|
|
161
|
+
rateLimitKey,
|
|
162
|
+
nowMs,
|
|
163
|
+
requireJsonContentType: true,
|
|
164
|
+
})
|
|
165
|
+
) {
|
|
166
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const body = await readJsonWebhookBodyOrReject({
|
|
170
|
+
req,
|
|
171
|
+
res,
|
|
172
|
+
maxBytes: 1024 * 1024,
|
|
173
|
+
timeoutMs: 30_000,
|
|
174
|
+
emptyObjectOnEmpty: false,
|
|
175
|
+
invalidJsonMessage: "Bad Request",
|
|
176
|
+
});
|
|
177
|
+
if (!body.ok) {
|
|
178
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const raw = body.value;
|
|
182
|
+
|
|
183
|
+
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
|
184
|
+
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
185
|
+
const update: ZaloUpdate | undefined =
|
|
186
|
+
record && record.ok === true && record.result
|
|
187
|
+
? (record.result as ZaloUpdate)
|
|
188
|
+
: ((record as ZaloUpdate | null) ?? undefined);
|
|
189
|
+
|
|
190
|
+
if (!update?.event_name) {
|
|
191
|
+
res.statusCode = 400;
|
|
192
|
+
res.end("Bad Request");
|
|
193
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isReplayEvent(update, nowMs)) {
|
|
198
|
+
res.statusCode = 200;
|
|
199
|
+
res.end("ok");
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
204
|
+
processUpdate({ update, target }).catch((err) => {
|
|
205
|
+
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
res.statusCode = 200;
|
|
209
|
+
res.end("ok");
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
193
212
|
});
|
|
194
|
-
if (!body.ok) {
|
|
195
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
const raw = body.value;
|
|
199
|
-
|
|
200
|
-
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
|
201
|
-
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
202
|
-
const update: ZaloUpdate | undefined =
|
|
203
|
-
record && record.ok === true && record.result
|
|
204
|
-
? (record.result as ZaloUpdate)
|
|
205
|
-
: ((record as ZaloUpdate | null) ?? undefined);
|
|
206
|
-
|
|
207
|
-
if (!update?.event_name) {
|
|
208
|
-
res.statusCode = 400;
|
|
209
|
-
res.end("Bad Request");
|
|
210
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (isReplayEvent(update, nowMs)) {
|
|
215
|
-
res.statusCode = 200;
|
|
216
|
-
res.end("ok");
|
|
217
|
-
return true;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
221
|
-
processUpdate({ update, target }).catch((err) => {
|
|
222
|
-
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
res.statusCode = 200;
|
|
226
|
-
res.end("ok");
|
|
227
|
-
return true;
|
|
228
213
|
}
|
package/src/onboarding.ts
CHANGED
|
@@ -4,16 +4,17 @@ import type {
|
|
|
4
4
|
OpenClawConfig,
|
|
5
5
|
SecretInput,
|
|
6
6
|
WizardPrompter,
|
|
7
|
-
} from "openclaw/plugin-sdk";
|
|
7
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
buildSingleChannelSecretPromptState,
|
|
10
10
|
DEFAULT_ACCOUNT_ID,
|
|
11
11
|
hasConfiguredSecretInput,
|
|
12
12
|
mergeAllowFromEntries,
|
|
13
13
|
normalizeAccountId,
|
|
14
|
-
promptAccountId,
|
|
15
14
|
promptSingleChannelSecretInput,
|
|
16
|
-
|
|
15
|
+
resolveAccountIdForConfigure,
|
|
16
|
+
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
17
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
17
18
|
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|
18
19
|
|
|
19
20
|
const channel = "zalo" as const;
|
|
@@ -24,19 +25,11 @@ function setZaloDmPolicy(
|
|
|
24
25
|
cfg: OpenClawConfig,
|
|
25
26
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
|
26
27
|
) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
...cfg.channels,
|
|
33
|
-
zalo: {
|
|
34
|
-
...cfg.channels?.zalo,
|
|
35
|
-
dmPolicy,
|
|
36
|
-
...(allowFrom ? { allowFrom } : {}),
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
} as OpenClawConfig;
|
|
28
|
+
return setTopLevelChannelDmPolicyWithAllowFrom({
|
|
29
|
+
cfg,
|
|
30
|
+
channel: "zalo",
|
|
31
|
+
dmPolicy,
|
|
32
|
+
}) as OpenClawConfig;
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
function setZaloUpdateMode(
|
|
@@ -240,19 +233,16 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
240
233
|
shouldPromptAccountIds,
|
|
241
234
|
forceAllowFrom,
|
|
242
235
|
}) => {
|
|
243
|
-
const zaloOverride = accountOverrides.zalo?.trim();
|
|
244
236
|
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
});
|
|
255
|
-
}
|
|
237
|
+
const zaloAccountId = await resolveAccountIdForConfigure({
|
|
238
|
+
cfg,
|
|
239
|
+
prompter,
|
|
240
|
+
label: "Zalo",
|
|
241
|
+
accountOverride: accountOverrides.zalo,
|
|
242
|
+
shouldPromptAccountIds,
|
|
243
|
+
listAccountIds: listZaloAccountIds,
|
|
244
|
+
defaultAccountId: defaultZaloAccountId,
|
|
245
|
+
});
|
|
256
246
|
|
|
257
247
|
let next = cfg;
|
|
258
248
|
const resolvedAccount = resolveZaloAccount({
|
|
@@ -262,10 +252,15 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
262
252
|
});
|
|
263
253
|
const accountConfigured = Boolean(resolvedAccount.token);
|
|
264
254
|
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
|
265
|
-
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
|
266
255
|
const hasConfigToken = Boolean(
|
|
267
256
|
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
|
268
257
|
);
|
|
258
|
+
const tokenPromptState = buildSingleChannelSecretPromptState({
|
|
259
|
+
accountConfigured,
|
|
260
|
+
hasConfigToken,
|
|
261
|
+
allowEnv,
|
|
262
|
+
envValue: process.env.ZALO_BOT_TOKEN,
|
|
263
|
+
});
|
|
269
264
|
|
|
270
265
|
let token: SecretInput | null = null;
|
|
271
266
|
if (!accountConfigured) {
|
|
@@ -276,9 +271,9 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
276
271
|
prompter,
|
|
277
272
|
providerHint: "zalo",
|
|
278
273
|
credentialLabel: "bot token",
|
|
279
|
-
accountConfigured,
|
|
280
|
-
canUseEnv: canUseEnv
|
|
281
|
-
hasConfigToken,
|
|
274
|
+
accountConfigured: tokenPromptState.accountConfigured,
|
|
275
|
+
canUseEnv: tokenPromptState.canUseEnv,
|
|
276
|
+
hasConfigToken: tokenPromptState.hasConfigToken,
|
|
282
277
|
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
|
|
283
278
|
keepPrompt: "Zalo token already configured. Keep it?",
|
|
284
279
|
inputPrompt: "Enter Zalo bot token",
|
|
@@ -360,9 +355,11 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
360
355
|
prompter,
|
|
361
356
|
providerHint: "zalo-webhook",
|
|
362
357
|
credentialLabel: "webhook secret",
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
358
|
+
...buildSingleChannelSecretPromptState({
|
|
359
|
+
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
360
|
+
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
361
|
+
allowEnv: false,
|
|
362
|
+
}),
|
|
366
363
|
envPrompt: "",
|
|
367
364
|
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
368
365
|
inputPrompt: "Webhook secret (8-256 chars)",
|
|
@@ -379,9 +376,11 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
379
376
|
prompter,
|
|
380
377
|
providerHint: "zalo-webhook",
|
|
381
378
|
credentialLabel: "webhook secret",
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
379
|
+
...buildSingleChannelSecretPromptState({
|
|
380
|
+
accountConfigured: false,
|
|
381
|
+
hasConfigToken: false,
|
|
382
|
+
allowEnv: false,
|
|
383
|
+
}),
|
|
385
384
|
envPrompt: "",
|
|
386
385
|
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
387
386
|
inputPrompt: "Webhook secret (8-256 chars)",
|
package/src/probe.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { BaseProbeResult } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
|
3
3
|
|
|
4
4
|
export type ZaloProbeResult = BaseProbeResult<string> & {
|
package/src/runtime.ts
CHANGED
package/src/secret-input.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
buildSecretInputSchema,
|
|
2
3
|
hasConfiguredSecretInput,
|
|
3
4
|
normalizeResolvedSecretInputString,
|
|
4
5
|
normalizeSecretInputString,
|
|
5
|
-
} from "openclaw/plugin-sdk";
|
|
6
|
-
import { z } from "zod";
|
|
6
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
7
7
|
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
source: z.enum(["env", "file", "exec"]),
|
|
15
|
-
provider: z.string().min(1),
|
|
16
|
-
id: z.string().min(1),
|
|
17
|
-
}),
|
|
18
|
-
]);
|
|
19
|
-
}
|
|
8
|
+
export {
|
|
9
|
+
buildSecretInputSchema,
|
|
10
|
+
hasConfiguredSecretInput,
|
|
11
|
+
normalizeResolvedSecretInputString,
|
|
12
|
+
normalizeSecretInputString,
|
|
13
|
+
};
|
package/src/send.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import { resolveZaloAccount } from "./accounts.js";
|
|
3
3
|
import type { ZaloFetch } from "./api.js";
|
|
4
4
|
import { sendMessage, sendPhoto } from "./api.js";
|
|
@@ -40,37 +40,47 @@ function resolveSendContext(options: ZaloSendOptions): {
|
|
|
40
40
|
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
function resolveValidatedSendContext(
|
|
44
44
|
chatId: string,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
): Promise<ZaloSendResult> {
|
|
45
|
+
options: ZaloSendOptions,
|
|
46
|
+
): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
|
|
48
47
|
const { token, fetcher } = resolveSendContext(options);
|
|
49
|
-
|
|
50
48
|
if (!token) {
|
|
51
49
|
return { ok: false, error: "No Zalo bot token configured" };
|
|
52
50
|
}
|
|
53
|
-
|
|
54
|
-
if (!
|
|
51
|
+
const trimmedChatId = chatId?.trim();
|
|
52
|
+
if (!trimmedChatId) {
|
|
55
53
|
return { ok: false, error: "No chat_id provided" };
|
|
56
54
|
}
|
|
55
|
+
return { ok: true, chatId: trimmedChatId, token, fetcher };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function sendMessageZalo(
|
|
59
|
+
chatId: string,
|
|
60
|
+
text: string,
|
|
61
|
+
options: ZaloSendOptions = {},
|
|
62
|
+
): Promise<ZaloSendResult> {
|
|
63
|
+
const context = resolveValidatedSendContext(chatId, options);
|
|
64
|
+
if (!context.ok) {
|
|
65
|
+
return { ok: false, error: context.error };
|
|
66
|
+
}
|
|
57
67
|
|
|
58
68
|
if (options.mediaUrl) {
|
|
59
|
-
return sendPhotoZalo(chatId, options.mediaUrl, {
|
|
69
|
+
return sendPhotoZalo(context.chatId, options.mediaUrl, {
|
|
60
70
|
...options,
|
|
61
|
-
token,
|
|
71
|
+
token: context.token,
|
|
62
72
|
caption: text || options.caption,
|
|
63
73
|
});
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
try {
|
|
67
77
|
const response = await sendMessage(
|
|
68
|
-
token,
|
|
78
|
+
context.token,
|
|
69
79
|
{
|
|
70
|
-
chat_id: chatId
|
|
80
|
+
chat_id: context.chatId,
|
|
71
81
|
text: text.slice(0, 2000),
|
|
72
82
|
},
|
|
73
|
-
fetcher,
|
|
83
|
+
context.fetcher,
|
|
74
84
|
);
|
|
75
85
|
|
|
76
86
|
if (response.ok && response.result) {
|
|
@@ -88,14 +98,9 @@ export async function sendPhotoZalo(
|
|
|
88
98
|
photoUrl: string,
|
|
89
99
|
options: ZaloSendOptions = {},
|
|
90
100
|
): Promise<ZaloSendResult> {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return { ok: false, error: "No Zalo bot token configured" };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!chatId?.trim()) {
|
|
98
|
-
return { ok: false, error: "No chat_id provided" };
|
|
101
|
+
const context = resolveValidatedSendContext(chatId, options);
|
|
102
|
+
if (!context.ok) {
|
|
103
|
+
return { ok: false, error: context.error };
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
if (!photoUrl?.trim()) {
|
|
@@ -104,13 +109,13 @@ export async function sendPhotoZalo(
|
|
|
104
109
|
|
|
105
110
|
try {
|
|
106
111
|
const response = await sendPhoto(
|
|
107
|
-
token,
|
|
112
|
+
context.token,
|
|
108
113
|
{
|
|
109
|
-
chat_id: chatId
|
|
114
|
+
chat_id: context.chatId,
|
|
110
115
|
photo: photoUrl.trim(),
|
|
111
116
|
caption: options.caption?.slice(0, 2000),
|
|
112
117
|
},
|
|
113
|
-
fetcher,
|
|
118
|
+
context.fetcher,
|
|
114
119
|
);
|
|
115
120
|
|
|
116
121
|
if (response.ok && response.result) {
|
package/src/status-issues.ts
CHANGED
package/src/token.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import type { BaseTokenResolution } from "openclaw/plugin-sdk";
|
|
3
2
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
3
|
+
import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
|
|
4
4
|
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
|
|
5
5
|
import type { ZaloConfig } from "./types.js";
|
|
6
6
|
|
|
@@ -8,6 +8,19 @@ export type ZaloTokenResolution = BaseTokenResolution & {
|
|
|
8
8
|
source: "env" | "config" | "configFile" | "none";
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
function readTokenFromFile(tokenFile: string | undefined): string {
|
|
12
|
+
const trimmedPath = tokenFile?.trim();
|
|
13
|
+
if (!trimmedPath) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(trimmedPath, "utf8").trim();
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore read failures
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
export function resolveZaloToken(
|
|
12
25
|
config: ZaloConfig | undefined,
|
|
13
26
|
accountId?: string | null,
|
|
@@ -44,28 +57,16 @@ export function resolveZaloToken(
|
|
|
44
57
|
if (token) {
|
|
45
58
|
return { token, source: "config" };
|
|
46
59
|
}
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
51
|
-
if (fileToken) {
|
|
52
|
-
return { token: fileToken, source: "configFile" };
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
// ignore read failures
|
|
56
|
-
}
|
|
60
|
+
const fileToken = readTokenFromFile(accountConfig.tokenFile);
|
|
61
|
+
if (fileToken) {
|
|
62
|
+
return { token: fileToken, source: "configFile" };
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (fileToken) {
|
|
65
|
-
return { token: fileToken, source: "configFile" };
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
// ignore read failures
|
|
66
|
+
if (!accountHasBotToken) {
|
|
67
|
+
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
|
|
68
|
+
if (fileToken) {
|
|
69
|
+
return { token: fileToken, source: "configFile" };
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -79,16 +80,9 @@ export function resolveZaloToken(
|
|
|
79
80
|
if (token) {
|
|
80
81
|
return { token, source: "config" };
|
|
81
82
|
}
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
86
|
-
if (fileToken) {
|
|
87
|
-
return { token: fileToken, source: "configFile" };
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
// ignore read failures
|
|
91
|
-
}
|
|
83
|
+
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
|
|
84
|
+
if (fileToken) {
|
|
85
|
+
return { token: fileToken, source: "configFile" };
|
|
92
86
|
}
|
|
93
87
|
}
|
|
94
88
|
|
package/src/types.ts
CHANGED