@openclaw/zalo 2026.3.1 → 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 +18 -0
- package/index.ts +2 -4
- package/package.json +3 -2
- package/src/accounts.ts +7 -37
- package/src/actions.ts +2 -2
- package/src/channel.directory.test.ts +1 -1
- package/src/channel.sendpayload.test.ts +102 -0
- package/src/channel.ts +98 -127
- package/src/config-schema.test.ts +30 -0
- package/src/config-schema.ts +4 -3
- package/src/group-access.ts +2 -2
- package/src/monitor.ts +85 -78
- package/src/monitor.webhook.test.ts +159 -36
- package/src/monitor.webhook.ts +98 -94
- package/src/onboarding.status.test.ts +24 -0
- package/src/onboarding.ts +117 -93
- package/src/probe.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send.ts +29 -24
- package/src/status-issues.ts +1 -1
- package/src/token.test.ts +58 -0
- package/src/token.ts +64 -29
- package/src/types.ts +4 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { zaloOnboardingAdapter } from "./onboarding.js";
|
|
4
|
+
|
|
5
|
+
describe("zalo onboarding status", () => {
|
|
6
|
+
it("treats SecretRef botToken as configured", async () => {
|
|
7
|
+
const status = await zaloOnboardingAdapter.getStatus({
|
|
8
|
+
cfg: {
|
|
9
|
+
channels: {
|
|
10
|
+
zalo: {
|
|
11
|
+
botToken: {
|
|
12
|
+
source: "env",
|
|
13
|
+
provider: "default",
|
|
14
|
+
id: "ZALO_BOT_TOKEN",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
} as OpenClawConfig,
|
|
19
|
+
accountOverrides: {},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(status.configured).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/onboarding.ts
CHANGED
|
@@ -2,15 +2,19 @@ import type {
|
|
|
2
2
|
ChannelOnboardingAdapter,
|
|
3
3
|
ChannelOnboardingDmPolicy,
|
|
4
4
|
OpenClawConfig,
|
|
5
|
+
SecretInput,
|
|
5
6
|
WizardPrompter,
|
|
6
|
-
} from "openclaw/plugin-sdk";
|
|
7
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
+
buildSingleChannelSecretPromptState,
|
|
9
10
|
DEFAULT_ACCOUNT_ID,
|
|
11
|
+
hasConfiguredSecretInput,
|
|
10
12
|
mergeAllowFromEntries,
|
|
11
13
|
normalizeAccountId,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
promptSingleChannelSecretInput,
|
|
15
|
+
resolveAccountIdForConfigure,
|
|
16
|
+
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
17
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
14
18
|
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|
15
19
|
|
|
16
20
|
const channel = "zalo" as const;
|
|
@@ -21,19 +25,11 @@ function setZaloDmPolicy(
|
|
|
21
25
|
cfg: OpenClawConfig,
|
|
22
26
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
|
23
27
|
) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
...cfg.channels,
|
|
30
|
-
zalo: {
|
|
31
|
-
...cfg.channels?.zalo,
|
|
32
|
-
dmPolicy,
|
|
33
|
-
...(allowFrom ? { allowFrom } : {}),
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
} as OpenClawConfig;
|
|
28
|
+
return setTopLevelChannelDmPolicyWithAllowFrom({
|
|
29
|
+
cfg,
|
|
30
|
+
channel: "zalo",
|
|
31
|
+
dmPolicy,
|
|
32
|
+
}) as OpenClawConfig;
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
function setZaloUpdateMode(
|
|
@@ -41,7 +37,7 @@ function setZaloUpdateMode(
|
|
|
41
37
|
accountId: string,
|
|
42
38
|
mode: UpdateMode,
|
|
43
39
|
webhookUrl?: string,
|
|
44
|
-
webhookSecret?:
|
|
40
|
+
webhookSecret?: SecretInput,
|
|
45
41
|
webhookPath?: string,
|
|
46
42
|
): OpenClawConfig {
|
|
47
43
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
@@ -210,9 +206,18 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
210
206
|
channel,
|
|
211
207
|
dmPolicy,
|
|
212
208
|
getStatus: async ({ cfg }) => {
|
|
213
|
-
const configured = listZaloAccountIds(cfg).some((accountId) =>
|
|
214
|
-
|
|
215
|
-
|
|
209
|
+
const configured = listZaloAccountIds(cfg).some((accountId) => {
|
|
210
|
+
const account = resolveZaloAccount({
|
|
211
|
+
cfg: cfg,
|
|
212
|
+
accountId,
|
|
213
|
+
allowUnresolvedSecretRef: true,
|
|
214
|
+
});
|
|
215
|
+
return (
|
|
216
|
+
Boolean(account.token) ||
|
|
217
|
+
hasConfiguredSecretInput(account.config.botToken) ||
|
|
218
|
+
Boolean(account.config.tokenFile?.trim())
|
|
219
|
+
);
|
|
220
|
+
});
|
|
216
221
|
return {
|
|
217
222
|
channel,
|
|
218
223
|
configured,
|
|
@@ -228,77 +233,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
228
233
|
shouldPromptAccountIds,
|
|
229
234
|
forceAllowFrom,
|
|
230
235
|
}) => {
|
|
231
|
-
const zaloOverride = accountOverrides.zalo?.trim();
|
|
232
236
|
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
});
|
|
243
|
-
}
|
|
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
|
+
});
|
|
244
246
|
|
|
245
247
|
let next = cfg;
|
|
246
|
-
const resolvedAccount = resolveZaloAccount({
|
|
248
|
+
const resolvedAccount = resolveZaloAccount({
|
|
249
|
+
cfg: next,
|
|
250
|
+
accountId: zaloAccountId,
|
|
251
|
+
allowUnresolvedSecretRef: true,
|
|
252
|
+
});
|
|
247
253
|
const accountConfigured = Boolean(resolvedAccount.token);
|
|
248
254
|
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
|
249
|
-
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
|
250
255
|
const hasConfigToken = Boolean(
|
|
251
|
-
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
|
256
|
+
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
|
252
257
|
);
|
|
258
|
+
const tokenPromptState = buildSingleChannelSecretPromptState({
|
|
259
|
+
accountConfigured,
|
|
260
|
+
hasConfigToken,
|
|
261
|
+
allowEnv,
|
|
262
|
+
envValue: process.env.ZALO_BOT_TOKEN,
|
|
263
|
+
});
|
|
253
264
|
|
|
254
|
-
let token:
|
|
265
|
+
let token: SecretInput | null = null;
|
|
255
266
|
if (!accountConfigured) {
|
|
256
267
|
await noteZaloTokenHelp(prompter);
|
|
257
268
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
const tokenResult = await promptSingleChannelSecretInput({
|
|
270
|
+
cfg: next,
|
|
271
|
+
prompter,
|
|
272
|
+
providerHint: "zalo",
|
|
273
|
+
credentialLabel: "bot token",
|
|
274
|
+
accountConfigured: tokenPromptState.accountConfigured,
|
|
275
|
+
canUseEnv: tokenPromptState.canUseEnv,
|
|
276
|
+
hasConfigToken: tokenPromptState.hasConfigToken,
|
|
277
|
+
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
|
|
278
|
+
keepPrompt: "Zalo token already configured. Keep it?",
|
|
279
|
+
inputPrompt: "Enter Zalo bot token",
|
|
280
|
+
preferredEnvVar: "ZALO_BOT_TOKEN",
|
|
281
|
+
});
|
|
282
|
+
if (tokenResult.action === "set") {
|
|
283
|
+
token = tokenResult.value;
|
|
284
|
+
}
|
|
285
|
+
if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
|
286
|
+
next = {
|
|
287
|
+
...next,
|
|
288
|
+
channels: {
|
|
289
|
+
...next.channels,
|
|
290
|
+
zalo: {
|
|
291
|
+
...next.channels?.zalo,
|
|
292
|
+
enabled: true,
|
|
272
293
|
},
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
token = String(
|
|
276
|
-
await prompter.text({
|
|
277
|
-
message: "Enter Zalo bot token",
|
|
278
|
-
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
279
|
-
}),
|
|
280
|
-
).trim();
|
|
281
|
-
}
|
|
282
|
-
} else if (hasConfigToken) {
|
|
283
|
-
const keep = await prompter.confirm({
|
|
284
|
-
message: "Zalo token already configured. Keep it?",
|
|
285
|
-
initialValue: true,
|
|
286
|
-
});
|
|
287
|
-
if (!keep) {
|
|
288
|
-
token = String(
|
|
289
|
-
await prompter.text({
|
|
290
|
-
message: "Enter Zalo bot token",
|
|
291
|
-
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
292
|
-
}),
|
|
293
|
-
).trim();
|
|
294
|
-
}
|
|
295
|
-
} else {
|
|
296
|
-
token = String(
|
|
297
|
-
await prompter.text({
|
|
298
|
-
message: "Enter Zalo bot token",
|
|
299
|
-
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
300
|
-
}),
|
|
301
|
-
).trim();
|
|
294
|
+
},
|
|
295
|
+
} as OpenClawConfig;
|
|
302
296
|
}
|
|
303
297
|
|
|
304
298
|
if (token) {
|
|
@@ -338,12 +332,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
338
332
|
|
|
339
333
|
const wantsWebhook = await prompter.confirm({
|
|
340
334
|
message: "Use webhook mode for Zalo?",
|
|
341
|
-
initialValue:
|
|
335
|
+
initialValue: Boolean(resolvedAccount.config.webhookUrl),
|
|
342
336
|
});
|
|
343
337
|
if (wantsWebhook) {
|
|
344
338
|
const webhookUrl = String(
|
|
345
339
|
await prompter.text({
|
|
346
340
|
message: "Webhook URL (https://...) ",
|
|
341
|
+
initialValue: resolvedAccount.config.webhookUrl,
|
|
347
342
|
validate: (value) =>
|
|
348
343
|
value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
|
|
349
344
|
}),
|
|
@@ -355,22 +350,51 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
355
350
|
return "/zalo-webhook";
|
|
356
351
|
}
|
|
357
352
|
})();
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
},
|
|
353
|
+
let webhookSecretResult = await promptSingleChannelSecretInput({
|
|
354
|
+
cfg: next,
|
|
355
|
+
prompter,
|
|
356
|
+
providerHint: "zalo-webhook",
|
|
357
|
+
credentialLabel: "webhook secret",
|
|
358
|
+
...buildSingleChannelSecretPromptState({
|
|
359
|
+
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
360
|
+
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
361
|
+
allowEnv: false,
|
|
368
362
|
}),
|
|
369
|
-
|
|
363
|
+
envPrompt: "",
|
|
364
|
+
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
365
|
+
inputPrompt: "Webhook secret (8-256 chars)",
|
|
366
|
+
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
|
367
|
+
});
|
|
368
|
+
while (
|
|
369
|
+
webhookSecretResult.action === "set" &&
|
|
370
|
+
typeof webhookSecretResult.value === "string" &&
|
|
371
|
+
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
|
|
372
|
+
) {
|
|
373
|
+
await prompter.note("Webhook secret must be between 8 and 256 characters.", "Zalo webhook");
|
|
374
|
+
webhookSecretResult = await promptSingleChannelSecretInput({
|
|
375
|
+
cfg: next,
|
|
376
|
+
prompter,
|
|
377
|
+
providerHint: "zalo-webhook",
|
|
378
|
+
credentialLabel: "webhook secret",
|
|
379
|
+
...buildSingleChannelSecretPromptState({
|
|
380
|
+
accountConfigured: false,
|
|
381
|
+
hasConfigToken: false,
|
|
382
|
+
allowEnv: false,
|
|
383
|
+
}),
|
|
384
|
+
envPrompt: "",
|
|
385
|
+
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
386
|
+
inputPrompt: "Webhook secret (8-256 chars)",
|
|
387
|
+
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const webhookSecret =
|
|
391
|
+
webhookSecretResult.action === "set"
|
|
392
|
+
? webhookSecretResult.value
|
|
393
|
+
: resolvedAccount.config.webhookSecret;
|
|
370
394
|
const webhookPath = String(
|
|
371
395
|
await prompter.text({
|
|
372
396
|
message: "Webhook path (optional)",
|
|
373
|
-
initialValue: defaultPath,
|
|
397
|
+
initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
|
|
374
398
|
}),
|
|
375
399
|
).trim();
|
|
376
400
|
next = setZaloUpdateMode(
|
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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSecretInputSchema,
|
|
3
|
+
hasConfiguredSecretInput,
|
|
4
|
+
normalizeResolvedSecretInputString,
|
|
5
|
+
normalizeSecretInputString,
|
|
6
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
7
|
+
|
|
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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveZaloToken } from "./token.js";
|
|
3
|
+
import type { ZaloConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
describe("resolveZaloToken", () => {
|
|
6
|
+
it("falls back to top-level token for non-default accounts without overrides", () => {
|
|
7
|
+
const cfg = {
|
|
8
|
+
botToken: "top-level-token",
|
|
9
|
+
accounts: {
|
|
10
|
+
work: {},
|
|
11
|
+
},
|
|
12
|
+
} as ZaloConfig;
|
|
13
|
+
const res = resolveZaloToken(cfg, "work");
|
|
14
|
+
expect(res.token).toBe("top-level-token");
|
|
15
|
+
expect(res.source).toBe("config");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses accounts.default botToken for default account when configured", () => {
|
|
19
|
+
const cfg = {
|
|
20
|
+
botToken: "top-level-token",
|
|
21
|
+
accounts: {
|
|
22
|
+
default: {
|
|
23
|
+
botToken: "default-account-token",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
} as ZaloConfig;
|
|
27
|
+
const res = resolveZaloToken(cfg, "default");
|
|
28
|
+
expect(res.token).toBe("default-account-token");
|
|
29
|
+
expect(res.source).toBe("config");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not inherit top-level token when account token is explicitly blank", () => {
|
|
33
|
+
const cfg = {
|
|
34
|
+
botToken: "top-level-token",
|
|
35
|
+
accounts: {
|
|
36
|
+
work: {
|
|
37
|
+
botToken: "",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
} as ZaloConfig;
|
|
41
|
+
const res = resolveZaloToken(cfg, "work");
|
|
42
|
+
expect(res.token).toBe("");
|
|
43
|
+
expect(res.source).toBe("none");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("resolves account token when account key casing differs from normalized id", () => {
|
|
47
|
+
const cfg = {
|
|
48
|
+
accounts: {
|
|
49
|
+
Work: {
|
|
50
|
+
botToken: "work-token",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
} as ZaloConfig;
|
|
54
|
+
const res = resolveZaloToken(cfg, "work");
|
|
55
|
+
expect(res.token).toBe("work-token");
|
|
56
|
+
expect(res.source).toBe("config");
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/token.ts
CHANGED
|
@@ -1,57 +1,92 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
3
|
+
import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
|
|
4
|
+
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
|
|
3
5
|
import type { ZaloConfig } from "./types.js";
|
|
4
6
|
|
|
5
7
|
export type ZaloTokenResolution = BaseTokenResolution & {
|
|
6
8
|
source: "env" | "config" | "configFile" | "none";
|
|
7
9
|
};
|
|
8
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
|
+
|
|
9
24
|
export function resolveZaloToken(
|
|
10
25
|
config: ZaloConfig | undefined,
|
|
11
26
|
accountId?: string | null,
|
|
27
|
+
options?: { allowUnresolvedSecretRef?: boolean },
|
|
12
28
|
): ZaloTokenResolution {
|
|
13
29
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
14
30
|
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
|
|
15
31
|
const baseConfig = config;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
const resolveAccountConfig = (id: string): ZaloConfig | undefined => {
|
|
33
|
+
const accounts = baseConfig?.accounts;
|
|
34
|
+
if (!accounts || typeof accounts !== "object") {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const direct = accounts[id] as ZaloConfig | undefined;
|
|
38
|
+
if (direct) {
|
|
39
|
+
return direct;
|
|
40
|
+
}
|
|
41
|
+
const normalized = normalizeAccountId(id);
|
|
42
|
+
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
|
43
|
+
return matchKey ? ((accounts as Record<string, ZaloConfig>)[matchKey] ?? undefined) : undefined;
|
|
44
|
+
};
|
|
45
|
+
const accountConfig = resolveAccountConfig(resolvedAccountId);
|
|
46
|
+
const accountHasBotToken = Boolean(
|
|
47
|
+
accountConfig && Object.prototype.hasOwnProperty.call(accountConfig, "botToken"),
|
|
48
|
+
);
|
|
20
49
|
|
|
21
|
-
if (accountConfig) {
|
|
22
|
-
const token =
|
|
50
|
+
if (accountConfig && accountHasBotToken) {
|
|
51
|
+
const token = options?.allowUnresolvedSecretRef
|
|
52
|
+
? normalizeSecretInputString(accountConfig.botToken)
|
|
53
|
+
: normalizeResolvedSecretInputString({
|
|
54
|
+
value: accountConfig.botToken,
|
|
55
|
+
path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
|
|
56
|
+
});
|
|
23
57
|
if (token) {
|
|
24
58
|
return { token, source: "config" };
|
|
25
59
|
}
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
30
|
-
if (fileToken) {
|
|
31
|
-
return { token: fileToken, source: "configFile" };
|
|
32
|
-
}
|
|
33
|
-
} catch {
|
|
34
|
-
// ignore read failures
|
|
35
|
-
}
|
|
60
|
+
const fileToken = readTokenFromFile(accountConfig.tokenFile);
|
|
61
|
+
if (fileToken) {
|
|
62
|
+
return { token: fileToken, source: "configFile" };
|
|
36
63
|
}
|
|
37
64
|
}
|
|
38
65
|
|
|
39
|
-
if (
|
|
40
|
-
const
|
|
66
|
+
if (!accountHasBotToken) {
|
|
67
|
+
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
|
|
68
|
+
if (fileToken) {
|
|
69
|
+
return { token: fileToken, source: "configFile" };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!accountHasBotToken) {
|
|
74
|
+
const token = options?.allowUnresolvedSecretRef
|
|
75
|
+
? normalizeSecretInputString(baseConfig?.botToken)
|
|
76
|
+
: normalizeResolvedSecretInputString({
|
|
77
|
+
value: baseConfig?.botToken,
|
|
78
|
+
path: "channels.zalo.botToken",
|
|
79
|
+
});
|
|
41
80
|
if (token) {
|
|
42
81
|
return { token, source: "config" };
|
|
43
82
|
}
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
48
|
-
if (fileToken) {
|
|
49
|
-
return { token: fileToken, source: "configFile" };
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
// ignore read failures
|
|
53
|
-
}
|
|
83
|
+
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
|
|
84
|
+
if (fileToken) {
|
|
85
|
+
return { token: fileToken, source: "configFile" };
|
|
54
86
|
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isDefaultAccount) {
|
|
55
90
|
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
|
|
56
91
|
if (envToken) {
|
|
57
92
|
return { token: envToken, source: "env" };
|
package/src/types.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import type { SecretInput } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
|
|
1
3
|
export type ZaloAccountConfig = {
|
|
2
4
|
/** Optional display name for this account (used in CLI/UI lists). */
|
|
3
5
|
name?: string;
|
|
4
6
|
/** If false, do not start this Zalo account. Default: true. */
|
|
5
7
|
enabled?: boolean;
|
|
6
8
|
/** Bot token from Zalo Bot Creator. */
|
|
7
|
-
botToken?:
|
|
9
|
+
botToken?: SecretInput;
|
|
8
10
|
/** Path to file containing the bot token. */
|
|
9
11
|
tokenFile?: string;
|
|
10
12
|
/** Webhook URL for receiving updates (HTTPS required). */
|
|
11
13
|
webhookUrl?: string;
|
|
12
14
|
/** Webhook secret token (8-256 chars) for request verification. */
|
|
13
|
-
webhookSecret?:
|
|
15
|
+
webhookSecret?: SecretInput;
|
|
14
16
|
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
|
|
15
17
|
webhookPath?: string;
|
|
16
18
|
/** Direct message access policy (default: pairing). */
|