@openclaw/zalo 2026.3.8-beta.1 → 2026.3.10
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/package.json +1 -1
- package/src/channel.sendpayload.test.ts +15 -73
- package/src/channel.ts +7 -2
- package/src/config-schema.ts +7 -5
- package/src/onboarding.ts +50 -63
- package/src/token.test.ts +19 -0
- package/src/token.ts +2 -11
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
installSendPayloadContractSuite,
|
|
5
|
+
primeSendMock,
|
|
6
|
+
} from "../../../src/test-utils/send-payload-contract.js";
|
|
3
7
|
import { zaloPlugin } from "./channel.js";
|
|
4
8
|
|
|
5
9
|
vi.mock("./send.js", () => ({
|
|
@@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => {
|
|
|
25
29
|
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
|
26
30
|
});
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const result = await zaloPlugin.outbound!.sendPayload!(
|
|
41
|
-
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
expect(mockedSend).toHaveBeenCalledWith(
|
|
45
|
-
"123456789",
|
|
46
|
-
"cap",
|
|
47
|
-
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
|
|
48
|
-
);
|
|
49
|
-
expect(result).toMatchObject({ channel: "zalo" });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("multi-media iterates URLs with caption on first", async () => {
|
|
53
|
-
mockedSend
|
|
54
|
-
.mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
|
|
55
|
-
.mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
|
|
56
|
-
|
|
57
|
-
const result = await zaloPlugin.outbound!.sendPayload!(
|
|
58
|
-
baseCtx({
|
|
59
|
-
text: "caption",
|
|
60
|
-
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
|
61
|
-
}),
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
expect(mockedSend).toHaveBeenCalledTimes(2);
|
|
65
|
-
expect(mockedSend).toHaveBeenNthCalledWith(
|
|
66
|
-
1,
|
|
67
|
-
"123456789",
|
|
68
|
-
"caption",
|
|
69
|
-
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
|
|
70
|
-
);
|
|
71
|
-
expect(mockedSend).toHaveBeenNthCalledWith(
|
|
72
|
-
2,
|
|
73
|
-
"123456789",
|
|
74
|
-
"",
|
|
75
|
-
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
|
|
76
|
-
);
|
|
77
|
-
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("empty payload returns no-op", async () => {
|
|
81
|
-
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
|
|
82
|
-
|
|
83
|
-
expect(mockedSend).not.toHaveBeenCalled();
|
|
84
|
-
expect(result).toEqual({ channel: "zalo", messageId: "" });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("chunking splits long text", async () => {
|
|
88
|
-
mockedSend
|
|
89
|
-
.mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
|
|
90
|
-
.mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
|
|
91
|
-
|
|
92
|
-
const longText = "a".repeat(3000);
|
|
93
|
-
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
|
|
94
|
-
|
|
95
|
-
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
|
|
96
|
-
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
97
|
-
for (const call of mockedSend.mock.calls) {
|
|
98
|
-
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
|
|
99
|
-
}
|
|
100
|
-
expect(result).toMatchObject({ channel: "zalo" });
|
|
32
|
+
installSendPayloadContractSuite({
|
|
33
|
+
channel: "zalo",
|
|
34
|
+
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
|
35
|
+
createHarness: ({ payload, sendResults }) => {
|
|
36
|
+
primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
|
|
37
|
+
return {
|
|
38
|
+
run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
|
39
|
+
sendMock: mockedSend,
|
|
40
|
+
to: "123456789",
|
|
41
|
+
};
|
|
42
|
+
},
|
|
101
43
|
});
|
|
102
44
|
});
|
package/src/channel.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildAccountScopedDmSecurityPolicy,
|
|
3
|
-
collectOpenProviderGroupPolicyWarnings,
|
|
4
3
|
buildOpenGroupPolicyRestrictSendersWarning,
|
|
5
4
|
buildOpenGroupPolicyWarning,
|
|
5
|
+
collectOpenProviderGroupPolicyWarnings,
|
|
6
|
+
createAccountStatusSink,
|
|
6
7
|
mapAllowFromEntries,
|
|
7
8
|
} from "openclaw/plugin-sdk/compat";
|
|
8
9
|
import type {
|
|
@@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
357
358
|
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
|
358
359
|
);
|
|
359
360
|
}
|
|
361
|
+
const statusSink = createAccountStatusSink({
|
|
362
|
+
accountId: ctx.accountId,
|
|
363
|
+
setStatus: ctx.setStatus,
|
|
364
|
+
});
|
|
360
365
|
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
|
361
366
|
const { monitorZaloProvider } = await import("./monitor.js");
|
|
362
367
|
return monitorZaloProvider({
|
|
@@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
370
375
|
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
|
|
371
376
|
webhookPath: account.config.webhookPath,
|
|
372
377
|
fetcher,
|
|
373
|
-
statusSink
|
|
378
|
+
statusSink,
|
|
374
379
|
});
|
|
375
380
|
},
|
|
376
381
|
},
|
package/src/config-schema.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
AllowFromListSchema,
|
|
3
3
|
buildCatchallMultiAccountChannelSchema,
|
|
4
|
+
DmPolicySchema,
|
|
5
|
+
GroupPolicySchema,
|
|
4
6
|
} from "openclaw/plugin-sdk/compat";
|
|
5
7
|
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
|
6
8
|
import { z } from "zod";
|
|
@@ -15,10 +17,10 @@ const zaloAccountSchema = z.object({
|
|
|
15
17
|
webhookUrl: z.string().optional(),
|
|
16
18
|
webhookSecret: buildSecretInputSchema().optional(),
|
|
17
19
|
webhookPath: z.string().optional(),
|
|
18
|
-
dmPolicy:
|
|
19
|
-
allowFrom:
|
|
20
|
-
groupPolicy:
|
|
21
|
-
groupAllowFrom:
|
|
20
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
21
|
+
allowFrom: AllowFromListSchema,
|
|
22
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
23
|
+
groupAllowFrom: AllowFromListSchema,
|
|
22
24
|
mediaMaxMb: z.number().optional(),
|
|
23
25
|
proxy: z.string().optional(),
|
|
24
26
|
responsePrefix: z.string().optional(),
|
package/src/onboarding.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
mergeAllowFromEntries,
|
|
13
13
|
normalizeAccountId,
|
|
14
14
|
promptSingleChannelSecretInput,
|
|
15
|
+
runSingleChannelSecretStep,
|
|
15
16
|
resolveAccountIdForConfigure,
|
|
16
17
|
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
17
18
|
} from "openclaw/plugin-sdk/zalo";
|
|
@@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
255
256
|
const hasConfigToken = Boolean(
|
|
256
257
|
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
|
257
258
|
);
|
|
258
|
-
const
|
|
259
|
-
accountConfigured,
|
|
260
|
-
hasConfigToken,
|
|
261
|
-
allowEnv,
|
|
262
|
-
envValue: process.env.ZALO_BOT_TOKEN,
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
let token: SecretInput | null = null;
|
|
266
|
-
if (!accountConfigured) {
|
|
267
|
-
await noteZaloTokenHelp(prompter);
|
|
268
|
-
}
|
|
269
|
-
const tokenResult = await promptSingleChannelSecretInput({
|
|
259
|
+
const tokenStep = await runSingleChannelSecretStep({
|
|
270
260
|
cfg: next,
|
|
271
261
|
prompter,
|
|
272
262
|
providerHint: "zalo",
|
|
273
263
|
credentialLabel: "bot token",
|
|
274
|
-
accountConfigured
|
|
275
|
-
|
|
276
|
-
|
|
264
|
+
accountConfigured,
|
|
265
|
+
hasConfigToken,
|
|
266
|
+
allowEnv,
|
|
267
|
+
envValue: process.env.ZALO_BOT_TOKEN,
|
|
277
268
|
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
|
|
278
269
|
keepPrompt: "Zalo token already configured. Keep it?",
|
|
279
270
|
inputPrompt: "Enter Zalo bot token",
|
|
280
271
|
preferredEnvVar: "ZALO_BOT_TOKEN",
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
zalo: {
|
|
291
|
-
...next.channels?.zalo,
|
|
292
|
-
enabled: true,
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
} as OpenClawConfig;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (token) {
|
|
299
|
-
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
|
300
|
-
next = {
|
|
301
|
-
...next,
|
|
302
|
-
channels: {
|
|
303
|
-
...next.channels,
|
|
304
|
-
zalo: {
|
|
305
|
-
...next.channels?.zalo,
|
|
306
|
-
enabled: true,
|
|
307
|
-
botToken: token,
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
} as OpenClawConfig;
|
|
311
|
-
} else {
|
|
312
|
-
next = {
|
|
313
|
-
...next,
|
|
314
|
-
channels: {
|
|
315
|
-
...next.channels,
|
|
316
|
-
zalo: {
|
|
317
|
-
...next.channels?.zalo,
|
|
318
|
-
enabled: true,
|
|
319
|
-
accounts: {
|
|
320
|
-
...next.channels?.zalo?.accounts,
|
|
321
|
-
[zaloAccountId]: {
|
|
322
|
-
...next.channels?.zalo?.accounts?.[zaloAccountId],
|
|
272
|
+
onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
|
|
273
|
+
applyUseEnv: async (cfg) =>
|
|
274
|
+
zaloAccountId === DEFAULT_ACCOUNT_ID
|
|
275
|
+
? ({
|
|
276
|
+
...cfg,
|
|
277
|
+
channels: {
|
|
278
|
+
...cfg.channels,
|
|
279
|
+
zalo: {
|
|
280
|
+
...cfg.channels?.zalo,
|
|
323
281
|
enabled: true,
|
|
324
|
-
botToken: token,
|
|
325
282
|
},
|
|
326
283
|
},
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
284
|
+
} as OpenClawConfig)
|
|
285
|
+
: cfg,
|
|
286
|
+
applySet: async (cfg, value) =>
|
|
287
|
+
zaloAccountId === DEFAULT_ACCOUNT_ID
|
|
288
|
+
? ({
|
|
289
|
+
...cfg,
|
|
290
|
+
channels: {
|
|
291
|
+
...cfg.channels,
|
|
292
|
+
zalo: {
|
|
293
|
+
...cfg.channels?.zalo,
|
|
294
|
+
enabled: true,
|
|
295
|
+
botToken: value,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
} as OpenClawConfig)
|
|
299
|
+
: ({
|
|
300
|
+
...cfg,
|
|
301
|
+
channels: {
|
|
302
|
+
...cfg.channels,
|
|
303
|
+
zalo: {
|
|
304
|
+
...cfg.channels?.zalo,
|
|
305
|
+
enabled: true,
|
|
306
|
+
accounts: {
|
|
307
|
+
...cfg.channels?.zalo?.accounts,
|
|
308
|
+
[zaloAccountId]: {
|
|
309
|
+
...cfg.channels?.zalo?.accounts?.[zaloAccountId],
|
|
310
|
+
enabled: true,
|
|
311
|
+
botToken: value,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
} as OpenClawConfig),
|
|
317
|
+
});
|
|
318
|
+
next = tokenStep.cfg;
|
|
332
319
|
|
|
333
320
|
const wantsWebhook = await prompter.confirm({
|
|
334
321
|
message: "Use webhook mode for Zalo?",
|
package/src/token.test.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { describe, expect, it } from "vitest";
|
|
2
5
|
import { resolveZaloToken } from "./token.js";
|
|
3
6
|
import type { ZaloConfig } from "./types.js";
|
|
@@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
|
|
|
55
58
|
expect(res.token).toBe("work-token");
|
|
56
59
|
expect(res.source).toBe("config");
|
|
57
60
|
});
|
|
61
|
+
|
|
62
|
+
it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
|
|
63
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
|
|
64
|
+
const tokenFile = path.join(dir, "token.txt");
|
|
65
|
+
const tokenLink = path.join(dir, "token-link.txt");
|
|
66
|
+
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
|
|
67
|
+
fs.symlinkSync(tokenFile, tokenLink);
|
|
68
|
+
|
|
69
|
+
const cfg = {
|
|
70
|
+
tokenFile: tokenLink,
|
|
71
|
+
} as ZaloConfig;
|
|
72
|
+
const res = resolveZaloToken(cfg);
|
|
73
|
+
expect(res.token).toBe("");
|
|
74
|
+
expect(res.source).toBe("none");
|
|
75
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
58
77
|
});
|
package/src/token.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
2
|
+
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
|
3
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";
|
|
@@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
function readTokenFromFile(tokenFile: string | undefined): string {
|
|
12
|
-
|
|
13
|
-
if (!trimmedPath) {
|
|
14
|
-
return "";
|
|
15
|
-
}
|
|
16
|
-
try {
|
|
17
|
-
return readFileSync(trimmedPath, "utf8").trim();
|
|
18
|
-
} catch {
|
|
19
|
-
// ignore read failures
|
|
20
|
-
return "";
|
|
21
|
-
}
|
|
12
|
+
return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
|
|
22
13
|
}
|
|
23
14
|
|
|
24
15
|
export function resolveZaloToken(
|