@openclaw/zalo 2026.3.1 → 2026.3.2
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 +6 -0
- package/index.ts +0 -2
- package/package.json +1 -1
- package/src/accounts.ts +2 -0
- package/src/channel.sendpayload.test.ts +102 -0
- package/src/channel.ts +36 -1
- package/src/config-schema.test.ts +30 -0
- package/src/config-schema.ts +3 -2
- package/src/monitor.ts +77 -74
- package/src/monitor.webhook.test.ts +119 -4
- package/src/monitor.webhook.ts +21 -2
- package/src/onboarding.status.test.ts +24 -0
- package/src/onboarding.ts +89 -64
- package/src/secret-input.ts +19 -0
- package/src/token.test.ts +58 -0
- package/src/token.ts +50 -9
- package/src/types.ts +4 -2
package/CHANGELOG.md
CHANGED
package/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
3
|
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
|
4
|
-
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
|
5
4
|
import { setZaloRuntime } from "./src/runtime.js";
|
|
6
5
|
|
|
7
6
|
const plugin = {
|
|
@@ -12,7 +11,6 @@ const plugin = {
|
|
|
12
11
|
register(api: OpenClawPluginApi) {
|
|
13
12
|
setZaloRuntime(api.runtime);
|
|
14
13
|
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
|
15
|
-
api.registerHttpHandler(handleZaloWebhookRequest);
|
|
16
14
|
},
|
|
17
15
|
};
|
|
18
16
|
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -62,6 +62,7 @@ function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAcc
|
|
|
62
62
|
export function resolveZaloAccount(params: {
|
|
63
63
|
cfg: OpenClawConfig;
|
|
64
64
|
accountId?: string | null;
|
|
65
|
+
allowUnresolvedSecretRef?: boolean;
|
|
65
66
|
}): ResolvedZaloAccount {
|
|
66
67
|
const accountId = normalizeAccountId(params.accountId);
|
|
67
68
|
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
|
|
@@ -71,6 +72,7 @@ export function resolveZaloAccount(params: {
|
|
|
71
72
|
const tokenResolution = resolveZaloToken(
|
|
72
73
|
params.cfg.channels?.zalo as ZaloConfig | undefined,
|
|
73
74
|
accountId,
|
|
75
|
+
{ allowUnresolvedSecretRef: params.allowUnresolvedSecretRef },
|
|
74
76
|
);
|
|
75
77
|
|
|
76
78
|
return {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { zaloPlugin } from "./channel.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("./send.js", () => ({
|
|
6
|
+
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
function baseCtx(payload: ReplyPayload) {
|
|
10
|
+
return {
|
|
11
|
+
cfg: {},
|
|
12
|
+
to: "123456789",
|
|
13
|
+
text: "",
|
|
14
|
+
payload,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("zaloPlugin outbound sendPayload", () => {
|
|
19
|
+
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
const mod = await import("./send.js");
|
|
23
|
+
mockedSend = vi.mocked(mod.sendMessageZalo);
|
|
24
|
+
mockedSend.mockClear();
|
|
25
|
+
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("text-only delegates to sendText", async () => {
|
|
29
|
+
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
|
|
30
|
+
|
|
31
|
+
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
|
|
32
|
+
|
|
33
|
+
expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
|
|
34
|
+
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("single media delegates to sendMedia", async () => {
|
|
38
|
+
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
|
|
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" });
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { ZaloConfigSchema } from "./config-schema.js";
|
|
|
32
32
|
import { zaloOnboardingAdapter } from "./onboarding.js";
|
|
33
33
|
import { probeZalo } from "./probe.js";
|
|
34
34
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
35
|
+
import { normalizeSecretInputString } from "./secret-input.js";
|
|
35
36
|
import { sendMessageZalo } from "./send.js";
|
|
36
37
|
import { collectZaloStatusIssues } from "./status-issues.js";
|
|
37
38
|
|
|
@@ -302,6 +303,40 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
302
303
|
chunker: chunkTextForOutbound,
|
|
303
304
|
chunkerMode: "text",
|
|
304
305
|
textChunkLimit: 2000,
|
|
306
|
+
sendPayload: async (ctx) => {
|
|
307
|
+
const text = ctx.payload.text ?? "";
|
|
308
|
+
const urls = ctx.payload.mediaUrls?.length
|
|
309
|
+
? ctx.payload.mediaUrls
|
|
310
|
+
: ctx.payload.mediaUrl
|
|
311
|
+
? [ctx.payload.mediaUrl]
|
|
312
|
+
: [];
|
|
313
|
+
if (!text && urls.length === 0) {
|
|
314
|
+
return { channel: "zalo", messageId: "" };
|
|
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
|
+
},
|
|
305
340
|
sendText: async ({ to, text, accountId, cfg }) => {
|
|
306
341
|
const result = await sendMessageZalo(to, text, {
|
|
307
342
|
accountId: accountId ?? undefined,
|
|
@@ -388,7 +423,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
388
423
|
abortSignal: ctx.abortSignal,
|
|
389
424
|
useWebhook: Boolean(account.config.webhookUrl),
|
|
390
425
|
webhookUrl: account.config.webhookUrl,
|
|
391
|
-
webhookSecret: account.config.webhookSecret,
|
|
426
|
+
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
|
|
392
427
|
webhookPath: account.config.webhookPath,
|
|
393
428
|
fetcher,
|
|
394
429
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ZaloConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("ZaloConfigSchema SecretInput", () => {
|
|
5
|
+
it("accepts SecretRef botToken and webhookSecret at top-level", () => {
|
|
6
|
+
const result = ZaloConfigSchema.safeParse({
|
|
7
|
+
botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" },
|
|
8
|
+
webhookUrl: "https://example.com/zalo",
|
|
9
|
+
webhookSecret: { source: "env", provider: "default", id: "ZALO_WEBHOOK_SECRET" },
|
|
10
|
+
});
|
|
11
|
+
expect(result.success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("accepts SecretRef botToken and webhookSecret on account", () => {
|
|
15
|
+
const result = ZaloConfigSchema.safeParse({
|
|
16
|
+
accounts: {
|
|
17
|
+
work: {
|
|
18
|
+
botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" },
|
|
19
|
+
webhookUrl: "https://example.com/zalo/work",
|
|
20
|
+
webhookSecret: {
|
|
21
|
+
source: "env",
|
|
22
|
+
provider: "default",
|
|
23
|
+
id: "ZALO_WORK_WEBHOOK_SECRET",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/config-schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { buildSecretInputSchema } from "./secret-input.js";
|
|
3
4
|
|
|
4
5
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
6
|
|
|
@@ -7,10 +8,10 @@ const zaloAccountSchema = z.object({
|
|
|
7
8
|
name: z.string().optional(),
|
|
8
9
|
enabled: z.boolean().optional(),
|
|
9
10
|
markdown: MarkdownConfigSchema,
|
|
10
|
-
botToken:
|
|
11
|
+
botToken: buildSecretInputSchema().optional(),
|
|
11
12
|
tokenFile: z.string().optional(),
|
|
12
13
|
webhookUrl: z.string().optional(),
|
|
13
|
-
webhookSecret:
|
|
14
|
+
webhookSecret: buildSecretInputSchema().optional(),
|
|
14
15
|
webhookPath: z.string().optional(),
|
|
15
16
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
16
17
|
allowFrom: z.array(allowFromEntry).optional(),
|
package/src/monitor.ts
CHANGED
|
@@ -3,9 +3,11 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "op
|
|
|
3
3
|
import {
|
|
4
4
|
createScopedPairingAccess,
|
|
5
5
|
createReplyPrefixOptions,
|
|
6
|
-
|
|
6
|
+
resolveDirectDmAuthorizationOutcome,
|
|
7
|
+
resolveSenderCommandAuthorizationWithRuntime,
|
|
7
8
|
resolveOutboundMediaUrls,
|
|
8
9
|
resolveDefaultGroupPolicy,
|
|
10
|
+
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
9
11
|
sendMediaWithLeadingCaption,
|
|
10
12
|
resolveWebhookPath,
|
|
11
13
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
@@ -73,7 +75,24 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
|
76
|
-
return registerZaloWebhookTargetInternal(target
|
|
78
|
+
return registerZaloWebhookTargetInternal(target, {
|
|
79
|
+
route: {
|
|
80
|
+
auth: "plugin",
|
|
81
|
+
match: "exact",
|
|
82
|
+
pluginId: "zalo",
|
|
83
|
+
source: "zalo-webhook",
|
|
84
|
+
accountId: target.account.accountId,
|
|
85
|
+
log: target.runtime.log,
|
|
86
|
+
handler: async (req, res) => {
|
|
87
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
88
|
+
if (!handled && !res.headersSent) {
|
|
89
|
+
res.statusCode = 404;
|
|
90
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
91
|
+
res.end("Not Found");
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
export {
|
|
@@ -366,82 +385,76 @@ async function processMessageWithPipeline(params: {
|
|
|
366
385
|
}
|
|
367
386
|
|
|
368
387
|
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
|
369
|
-
const { senderAllowedForCommands, commandAuthorized } =
|
|
370
|
-
|
|
371
|
-
|
|
388
|
+
const { senderAllowedForCommands, commandAuthorized } =
|
|
389
|
+
await resolveSenderCommandAuthorizationWithRuntime({
|
|
390
|
+
cfg: config,
|
|
391
|
+
rawBody,
|
|
392
|
+
isGroup,
|
|
393
|
+
dmPolicy,
|
|
394
|
+
configuredAllowFrom: configAllowFrom,
|
|
395
|
+
configuredGroupAllowFrom: groupAllowFrom,
|
|
396
|
+
senderId,
|
|
397
|
+
isSenderAllowed: isZaloSenderAllowed,
|
|
398
|
+
readAllowFromStore: pairing.readAllowFromStore,
|
|
399
|
+
runtime: core.channel.commands,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
|
|
372
403
|
isGroup,
|
|
373
404
|
dmPolicy,
|
|
374
|
-
|
|
375
|
-
configuredGroupAllowFrom: groupAllowFrom,
|
|
376
|
-
senderId,
|
|
377
|
-
isSenderAllowed: isZaloSenderAllowed,
|
|
378
|
-
readAllowFromStore: pairing.readAllowFromStore,
|
|
379
|
-
shouldComputeCommandAuthorized: (body, cfg) =>
|
|
380
|
-
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
|
381
|
-
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
|
382
|
-
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
|
|
405
|
+
senderAllowedForCommands,
|
|
383
406
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
channel: "zalo",
|
|
410
|
-
idLine: `Your Zalo user id: ${senderId}`,
|
|
411
|
-
code,
|
|
412
|
-
}),
|
|
413
|
-
},
|
|
414
|
-
fetcher,
|
|
415
|
-
);
|
|
416
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
417
|
-
} catch (err) {
|
|
418
|
-
logVerbose(
|
|
419
|
-
core,
|
|
420
|
-
runtime,
|
|
421
|
-
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
} else {
|
|
426
|
-
logVerbose(
|
|
427
|
-
core,
|
|
428
|
-
runtime,
|
|
429
|
-
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
|
|
407
|
+
if (directDmOutcome === "disabled") {
|
|
408
|
+
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (directDmOutcome === "unauthorized") {
|
|
412
|
+
if (dmPolicy === "pairing") {
|
|
413
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
414
|
+
id: senderId,
|
|
415
|
+
meta: { name: senderName ?? undefined },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (created) {
|
|
419
|
+
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
|
420
|
+
try {
|
|
421
|
+
await sendMessage(
|
|
422
|
+
token,
|
|
423
|
+
{
|
|
424
|
+
chat_id: chatId,
|
|
425
|
+
text: core.channel.pairing.buildPairingReply({
|
|
426
|
+
channel: "zalo",
|
|
427
|
+
idLine: `Your Zalo user id: ${senderId}`,
|
|
428
|
+
code,
|
|
429
|
+
}),
|
|
430
|
+
},
|
|
431
|
+
fetcher,
|
|
430
432
|
);
|
|
433
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
434
|
+
} catch (err) {
|
|
435
|
+
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
|
431
436
|
}
|
|
432
|
-
return;
|
|
433
437
|
}
|
|
438
|
+
} else {
|
|
439
|
+
logVerbose(
|
|
440
|
+
core,
|
|
441
|
+
runtime,
|
|
442
|
+
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
|
|
443
|
+
);
|
|
434
444
|
}
|
|
445
|
+
return;
|
|
435
446
|
}
|
|
436
447
|
|
|
437
|
-
const route =
|
|
448
|
+
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
438
449
|
cfg: config,
|
|
439
450
|
channel: "zalo",
|
|
440
451
|
accountId: account.accountId,
|
|
441
452
|
peer: {
|
|
442
|
-
kind: isGroup ? "group" : "direct",
|
|
453
|
+
kind: isGroup ? ("group" as const) : ("direct" as const),
|
|
443
454
|
id: chatId,
|
|
444
455
|
},
|
|
456
|
+
runtime: core.channel,
|
|
457
|
+
sessionStore: config.session?.store,
|
|
445
458
|
});
|
|
446
459
|
|
|
447
460
|
if (
|
|
@@ -454,20 +467,10 @@ async function processMessageWithPipeline(params: {
|
|
|
454
467
|
}
|
|
455
468
|
|
|
456
469
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
457
|
-
const storePath =
|
|
458
|
-
agentId: route.agentId,
|
|
459
|
-
});
|
|
460
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
461
|
-
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
462
|
-
storePath,
|
|
463
|
-
sessionKey: route.sessionKey,
|
|
464
|
-
});
|
|
465
|
-
const body = core.channel.reply.formatAgentEnvelope({
|
|
470
|
+
const { storePath, body } = buildEnvelope({
|
|
466
471
|
channel: "Zalo",
|
|
467
472
|
from: fromLabel,
|
|
468
473
|
timestamp: date ? date * 1000 : undefined,
|
|
469
|
-
previousTimestamp,
|
|
470
|
-
envelope: envelopeOptions,
|
|
471
474
|
body: rawBody,
|
|
472
475
|
});
|
|
473
476
|
|
|
@@ -2,6 +2,8 @@ import { createServer, type RequestListener } from "node:http";
|
|
|
2
2
|
import type { AddressInfo } from "node:net";
|
|
3
3
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
6
|
+
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
5
7
|
import {
|
|
6
8
|
clearZaloWebhookSecurityStateForTest,
|
|
7
9
|
getZaloWebhookRateLimitStateSizeForTest,
|
|
@@ -47,13 +49,16 @@ function registerTarget(params: {
|
|
|
47
49
|
path: string;
|
|
48
50
|
secret?: string;
|
|
49
51
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
52
|
+
account?: ResolvedZaloAccount;
|
|
53
|
+
config?: OpenClawConfig;
|
|
54
|
+
core?: PluginRuntime;
|
|
50
55
|
}): () => void {
|
|
51
56
|
return registerZaloWebhookTarget({
|
|
52
57
|
token: "tok",
|
|
53
|
-
account: DEFAULT_ACCOUNT,
|
|
54
|
-
config: {} as OpenClawConfig,
|
|
58
|
+
account: params.account ?? DEFAULT_ACCOUNT,
|
|
59
|
+
config: params.config ?? ({} as OpenClawConfig),
|
|
55
60
|
runtime: {},
|
|
56
|
-
core: {} as PluginRuntime,
|
|
61
|
+
core: params.core ?? ({} as PluginRuntime),
|
|
57
62
|
secret: params.secret ?? "secret",
|
|
58
63
|
path: params.path,
|
|
59
64
|
mediaMaxMb: 5,
|
|
@@ -61,9 +66,59 @@ function registerTarget(params: {
|
|
|
61
66
|
});
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
|
|
70
|
+
core: PluginRuntime;
|
|
71
|
+
readAllowFromStore: ReturnType<typeof vi.fn>;
|
|
72
|
+
upsertPairingRequest: ReturnType<typeof vi.fn>;
|
|
73
|
+
} {
|
|
74
|
+
const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
|
|
75
|
+
const upsertPairingRequest = vi
|
|
76
|
+
.fn()
|
|
77
|
+
.mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
|
|
78
|
+
const core = {
|
|
79
|
+
logging: {
|
|
80
|
+
shouldLogVerbose: () => false,
|
|
81
|
+
},
|
|
82
|
+
channel: {
|
|
83
|
+
pairing: {
|
|
84
|
+
readAllowFromStore,
|
|
85
|
+
upsertPairingRequest,
|
|
86
|
+
buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
|
|
87
|
+
},
|
|
88
|
+
commands: {
|
|
89
|
+
shouldComputeCommandAuthorized: vi.fn(() => false),
|
|
90
|
+
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
} as unknown as PluginRuntime;
|
|
94
|
+
return { core, readAllowFromStore, upsertPairingRequest };
|
|
95
|
+
}
|
|
96
|
+
|
|
64
97
|
describe("handleZaloWebhookRequest", () => {
|
|
65
98
|
afterEach(() => {
|
|
66
99
|
clearZaloWebhookSecurityStateForTest();
|
|
100
|
+
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
|
104
|
+
const registry = createEmptyPluginRegistry();
|
|
105
|
+
setActivePluginRegistry(registry);
|
|
106
|
+
const unregisterA = registerTarget({ path: "/hook" });
|
|
107
|
+
const unregisterB = registerTarget({ path: "/hook" });
|
|
108
|
+
|
|
109
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
110
|
+
expect(registry.httpRoutes[0]).toEqual(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
pluginId: "zalo",
|
|
113
|
+
path: "/hook",
|
|
114
|
+
source: "zalo-webhook",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
unregisterA();
|
|
119
|
+
expect(registry.httpRoutes).toHaveLength(1);
|
|
120
|
+
unregisterB();
|
|
121
|
+
expect(registry.httpRoutes).toHaveLength(0);
|
|
67
122
|
});
|
|
68
123
|
|
|
69
124
|
it("returns 400 for non-object payloads", async () => {
|
|
@@ -206,7 +261,6 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
206
261
|
unregister();
|
|
207
262
|
}
|
|
208
263
|
});
|
|
209
|
-
|
|
210
264
|
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
|
|
211
265
|
const unregister = registerTarget({ path: "/hook-query-status" });
|
|
212
266
|
|
|
@@ -259,4 +313,65 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
259
313
|
unregister();
|
|
260
314
|
}
|
|
261
315
|
});
|
|
316
|
+
|
|
317
|
+
it("scopes DM pairing store reads and writes to accountId", async () => {
|
|
318
|
+
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
|
|
319
|
+
pairingCreated: false,
|
|
320
|
+
});
|
|
321
|
+
const account: ResolvedZaloAccount = {
|
|
322
|
+
...DEFAULT_ACCOUNT,
|
|
323
|
+
accountId: "work",
|
|
324
|
+
config: {
|
|
325
|
+
dmPolicy: "pairing",
|
|
326
|
+
allowFrom: [],
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const unregister = registerTarget({
|
|
330
|
+
path: "/hook-account-scope",
|
|
331
|
+
account,
|
|
332
|
+
core,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const payload = {
|
|
336
|
+
event_name: "message.text.received",
|
|
337
|
+
message: {
|
|
338
|
+
from: { id: "123", name: "Attacker" },
|
|
339
|
+
chat: { id: "dm-work", chat_type: "PRIVATE" },
|
|
340
|
+
message_id: "msg-work-1",
|
|
341
|
+
date: Math.floor(Date.now() / 1000),
|
|
342
|
+
text: "hello",
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
348
|
+
const response = await fetch(`${baseUrl}/hook-account-scope`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: {
|
|
351
|
+
"x-bot-api-secret-token": "secret",
|
|
352
|
+
"content-type": "application/json",
|
|
353
|
+
},
|
|
354
|
+
body: JSON.stringify(payload),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(response.status).toBe(200);
|
|
358
|
+
});
|
|
359
|
+
} finally {
|
|
360
|
+
unregister();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
expect(readAllowFromStore).toHaveBeenCalledWith(
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
channel: "zalo",
|
|
366
|
+
accountId: "work",
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
expect(upsertPairingRequest).toHaveBeenCalledWith(
|
|
370
|
+
expect.objectContaining({
|
|
371
|
+
channel: "zalo",
|
|
372
|
+
id: "123",
|
|
373
|
+
accountId: "work",
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
});
|
|
262
377
|
});
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
createWebhookAnomalyTracker,
|
|
8
8
|
readJsonWebhookBodyOrReject,
|
|
9
9
|
applyBasicWebhookRequestGuards,
|
|
10
|
+
registerWebhookTargetWithPluginRoute,
|
|
11
|
+
type RegisterWebhookTargetOptions,
|
|
12
|
+
type RegisterWebhookPluginRouteOptions,
|
|
10
13
|
registerWebhookTarget,
|
|
11
14
|
resolveSingleWebhookTarget,
|
|
12
15
|
resolveWebhookTargets,
|
|
@@ -106,8 +109,24 @@ function recordWebhookStatus(
|
|
|
106
109
|
});
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
export function registerZaloWebhookTarget(
|
|
110
|
-
|
|
112
|
+
export function registerZaloWebhookTarget(
|
|
113
|
+
target: ZaloWebhookTarget,
|
|
114
|
+
opts?: {
|
|
115
|
+
route?: RegisterWebhookPluginRouteOptions;
|
|
116
|
+
} & Pick<
|
|
117
|
+
RegisterWebhookTargetOptions<ZaloWebhookTarget>,
|
|
118
|
+
"onFirstPathTarget" | "onLastPathTargetRemoved"
|
|
119
|
+
>,
|
|
120
|
+
): () => void {
|
|
121
|
+
if (opts?.route) {
|
|
122
|
+
return registerWebhookTargetWithPluginRoute({
|
|
123
|
+
targetsByPath: webhookTargets,
|
|
124
|
+
target,
|
|
125
|
+
route: opts.route,
|
|
126
|
+
onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
|
|
127
|
+
}).unregister;
|
|
128
|
+
}
|
|
129
|
+
return registerWebhookTarget(webhookTargets, target, opts).unregister;
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
export async function handleZaloWebhookRequest(
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
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,14 +2,17 @@ import type {
|
|
|
2
2
|
ChannelOnboardingAdapter,
|
|
3
3
|
ChannelOnboardingDmPolicy,
|
|
4
4
|
OpenClawConfig,
|
|
5
|
+
SecretInput,
|
|
5
6
|
WizardPrompter,
|
|
6
7
|
} from "openclaw/plugin-sdk";
|
|
7
8
|
import {
|
|
8
9
|
addWildcardAllowFrom,
|
|
9
10
|
DEFAULT_ACCOUNT_ID,
|
|
11
|
+
hasConfiguredSecretInput,
|
|
10
12
|
mergeAllowFromEntries,
|
|
11
13
|
normalizeAccountId,
|
|
12
14
|
promptAccountId,
|
|
15
|
+
promptSingleChannelSecretInput,
|
|
13
16
|
} from "openclaw/plugin-sdk";
|
|
14
17
|
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|
15
18
|
|
|
@@ -41,7 +44,7 @@ function setZaloUpdateMode(
|
|
|
41
44
|
accountId: string,
|
|
42
45
|
mode: UpdateMode,
|
|
43
46
|
webhookUrl?: string,
|
|
44
|
-
webhookSecret?:
|
|
47
|
+
webhookSecret?: SecretInput,
|
|
45
48
|
webhookPath?: string,
|
|
46
49
|
): OpenClawConfig {
|
|
47
50
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
@@ -210,9 +213,18 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
210
213
|
channel,
|
|
211
214
|
dmPolicy,
|
|
212
215
|
getStatus: async ({ cfg }) => {
|
|
213
|
-
const configured = listZaloAccountIds(cfg).some((accountId) =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
const configured = listZaloAccountIds(cfg).some((accountId) => {
|
|
217
|
+
const account = resolveZaloAccount({
|
|
218
|
+
cfg: cfg,
|
|
219
|
+
accountId,
|
|
220
|
+
allowUnresolvedSecretRef: true,
|
|
221
|
+
});
|
|
222
|
+
return (
|
|
223
|
+
Boolean(account.token) ||
|
|
224
|
+
hasConfiguredSecretInput(account.config.botToken) ||
|
|
225
|
+
Boolean(account.config.tokenFile?.trim())
|
|
226
|
+
);
|
|
227
|
+
});
|
|
216
228
|
return {
|
|
217
229
|
channel,
|
|
218
230
|
configured,
|
|
@@ -243,62 +255,49 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
let next = cfg;
|
|
246
|
-
const resolvedAccount = resolveZaloAccount({
|
|
258
|
+
const resolvedAccount = resolveZaloAccount({
|
|
259
|
+
cfg: next,
|
|
260
|
+
accountId: zaloAccountId,
|
|
261
|
+
allowUnresolvedSecretRef: true,
|
|
262
|
+
});
|
|
247
263
|
const accountConfigured = Boolean(resolvedAccount.token);
|
|
248
264
|
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
|
249
265
|
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
|
250
266
|
const hasConfigToken = Boolean(
|
|
251
|
-
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
|
267
|
+
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
|
252
268
|
);
|
|
253
269
|
|
|
254
|
-
let token:
|
|
270
|
+
let token: SecretInput | null = null;
|
|
255
271
|
if (!accountConfigured) {
|
|
256
272
|
await noteZaloTokenHelp(prompter);
|
|
257
273
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
274
|
+
const tokenResult = await promptSingleChannelSecretInput({
|
|
275
|
+
cfg: next,
|
|
276
|
+
prompter,
|
|
277
|
+
providerHint: "zalo",
|
|
278
|
+
credentialLabel: "bot token",
|
|
279
|
+
accountConfigured,
|
|
280
|
+
canUseEnv: canUseEnv && !hasConfigToken,
|
|
281
|
+
hasConfigToken,
|
|
282
|
+
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
|
|
283
|
+
keepPrompt: "Zalo token already configured. Keep it?",
|
|
284
|
+
inputPrompt: "Enter Zalo bot token",
|
|
285
|
+
preferredEnvVar: "ZALO_BOT_TOKEN",
|
|
286
|
+
});
|
|
287
|
+
if (tokenResult.action === "set") {
|
|
288
|
+
token = tokenResult.value;
|
|
289
|
+
}
|
|
290
|
+
if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
|
291
|
+
next = {
|
|
292
|
+
...next,
|
|
293
|
+
channels: {
|
|
294
|
+
...next.channels,
|
|
295
|
+
zalo: {
|
|
296
|
+
...next.channels?.zalo,
|
|
297
|
+
enabled: true,
|
|
272
298
|
},
|
|
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();
|
|
299
|
+
},
|
|
300
|
+
} as OpenClawConfig;
|
|
302
301
|
}
|
|
303
302
|
|
|
304
303
|
if (token) {
|
|
@@ -338,12 +337,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
338
337
|
|
|
339
338
|
const wantsWebhook = await prompter.confirm({
|
|
340
339
|
message: "Use webhook mode for Zalo?",
|
|
341
|
-
initialValue:
|
|
340
|
+
initialValue: Boolean(resolvedAccount.config.webhookUrl),
|
|
342
341
|
});
|
|
343
342
|
if (wantsWebhook) {
|
|
344
343
|
const webhookUrl = String(
|
|
345
344
|
await prompter.text({
|
|
346
345
|
message: "Webhook URL (https://...) ",
|
|
346
|
+
initialValue: resolvedAccount.config.webhookUrl,
|
|
347
347
|
validate: (value) =>
|
|
348
348
|
value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
|
|
349
349
|
}),
|
|
@@ -355,22 +355,47 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
355
355
|
return "/zalo-webhook";
|
|
356
356
|
}
|
|
357
357
|
})();
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
358
|
+
let webhookSecretResult = await promptSingleChannelSecretInput({
|
|
359
|
+
cfg: next,
|
|
360
|
+
prompter,
|
|
361
|
+
providerHint: "zalo-webhook",
|
|
362
|
+
credentialLabel: "webhook secret",
|
|
363
|
+
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
364
|
+
canUseEnv: false,
|
|
365
|
+
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
|
366
|
+
envPrompt: "",
|
|
367
|
+
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
368
|
+
inputPrompt: "Webhook secret (8-256 chars)",
|
|
369
|
+
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
|
370
|
+
});
|
|
371
|
+
while (
|
|
372
|
+
webhookSecretResult.action === "set" &&
|
|
373
|
+
typeof webhookSecretResult.value === "string" &&
|
|
374
|
+
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
|
|
375
|
+
) {
|
|
376
|
+
await prompter.note("Webhook secret must be between 8 and 256 characters.", "Zalo webhook");
|
|
377
|
+
webhookSecretResult = await promptSingleChannelSecretInput({
|
|
378
|
+
cfg: next,
|
|
379
|
+
prompter,
|
|
380
|
+
providerHint: "zalo-webhook",
|
|
381
|
+
credentialLabel: "webhook secret",
|
|
382
|
+
accountConfigured: false,
|
|
383
|
+
canUseEnv: false,
|
|
384
|
+
hasConfigToken: false,
|
|
385
|
+
envPrompt: "",
|
|
386
|
+
keepPrompt: "Zalo webhook secret already configured. Keep it?",
|
|
387
|
+
inputPrompt: "Webhook secret (8-256 chars)",
|
|
388
|
+
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const webhookSecret =
|
|
392
|
+
webhookSecretResult.action === "set"
|
|
393
|
+
? webhookSecretResult.value
|
|
394
|
+
: resolvedAccount.config.webhookSecret;
|
|
370
395
|
const webhookPath = String(
|
|
371
396
|
await prompter.text({
|
|
372
397
|
message: "Webhook path (optional)",
|
|
373
|
-
initialValue: defaultPath,
|
|
398
|
+
initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
|
|
374
399
|
}),
|
|
375
400
|
).trim();
|
|
376
401
|
next = setZaloUpdateMode(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasConfiguredSecretInput,
|
|
3
|
+
normalizeResolvedSecretInputString,
|
|
4
|
+
normalizeSecretInputString,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
|
|
9
|
+
|
|
10
|
+
export function buildSecretInputSchema() {
|
|
11
|
+
return z.union([
|
|
12
|
+
z.string(),
|
|
13
|
+
z.object({
|
|
14
|
+
source: z.enum(["env", "file", "exec"]),
|
|
15
|
+
provider: z.string().min(1),
|
|
16
|
+
id: z.string().min(1),
|
|
17
|
+
}),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import type { BaseTokenResolution } from "openclaw/plugin-sdk";
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
4
|
+
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
|
|
3
5
|
import type { ZaloConfig } from "./types.js";
|
|
4
6
|
|
|
5
7
|
export type ZaloTokenResolution = BaseTokenResolution & {
|
|
@@ -9,17 +11,36 @@ export type ZaloTokenResolution = BaseTokenResolution & {
|
|
|
9
11
|
export function resolveZaloToken(
|
|
10
12
|
config: ZaloConfig | undefined,
|
|
11
13
|
accountId?: string | null,
|
|
14
|
+
options?: { allowUnresolvedSecretRef?: boolean },
|
|
12
15
|
): ZaloTokenResolution {
|
|
13
16
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
14
17
|
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
|
|
15
18
|
const baseConfig = config;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const resolveAccountConfig = (id: string): ZaloConfig | undefined => {
|
|
20
|
+
const accounts = baseConfig?.accounts;
|
|
21
|
+
if (!accounts || typeof accounts !== "object") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const direct = accounts[id] as ZaloConfig | undefined;
|
|
25
|
+
if (direct) {
|
|
26
|
+
return direct;
|
|
27
|
+
}
|
|
28
|
+
const normalized = normalizeAccountId(id);
|
|
29
|
+
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
|
30
|
+
return matchKey ? ((accounts as Record<string, ZaloConfig>)[matchKey] ?? undefined) : undefined;
|
|
31
|
+
};
|
|
32
|
+
const accountConfig = resolveAccountConfig(resolvedAccountId);
|
|
33
|
+
const accountHasBotToken = Boolean(
|
|
34
|
+
accountConfig && Object.prototype.hasOwnProperty.call(accountConfig, "botToken"),
|
|
35
|
+
);
|
|
20
36
|
|
|
21
|
-
if (accountConfig) {
|
|
22
|
-
const token =
|
|
37
|
+
if (accountConfig && accountHasBotToken) {
|
|
38
|
+
const token = options?.allowUnresolvedSecretRef
|
|
39
|
+
? normalizeSecretInputString(accountConfig.botToken)
|
|
40
|
+
: normalizeResolvedSecretInputString({
|
|
41
|
+
value: accountConfig.botToken,
|
|
42
|
+
path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
|
|
43
|
+
});
|
|
23
44
|
if (token) {
|
|
24
45
|
return { token, source: "config" };
|
|
25
46
|
}
|
|
@@ -36,8 +57,25 @@ export function resolveZaloToken(
|
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
const accountTokenFile = accountConfig?.tokenFile?.trim();
|
|
61
|
+
if (!accountHasBotToken && accountTokenFile) {
|
|
62
|
+
try {
|
|
63
|
+
const fileToken = readFileSync(accountTokenFile, "utf8").trim();
|
|
64
|
+
if (fileToken) {
|
|
65
|
+
return { token: fileToken, source: "configFile" };
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore read failures
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!accountHasBotToken) {
|
|
73
|
+
const token = options?.allowUnresolvedSecretRef
|
|
74
|
+
? normalizeSecretInputString(baseConfig?.botToken)
|
|
75
|
+
: normalizeResolvedSecretInputString({
|
|
76
|
+
value: baseConfig?.botToken,
|
|
77
|
+
path: "channels.zalo.botToken",
|
|
78
|
+
});
|
|
41
79
|
if (token) {
|
|
42
80
|
return { token, source: "config" };
|
|
43
81
|
}
|
|
@@ -52,6 +90,9 @@ export function resolveZaloToken(
|
|
|
52
90
|
// ignore read failures
|
|
53
91
|
}
|
|
54
92
|
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (isDefaultAccount) {
|
|
55
96
|
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
|
|
56
97
|
if (envToken) {
|
|
57
98
|
return { token: envToken, source: "env" };
|
package/src/types.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import type { SecretInput } from "openclaw/plugin-sdk";
|
|
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). */
|