@openclaw/zalo 2026.3.12 → 2026.5.1-beta.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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.test.ts +15 -0
  6. package/index.ts +16 -13
  7. package/openclaw.plugin.json +514 -1
  8. package/package.json +31 -5
  9. package/runtime-api.test.ts +17 -0
  10. package/runtime-api.ts +75 -0
  11. package/secret-contract-api.ts +5 -0
  12. package/setup-api.ts +34 -0
  13. package/setup-entry.ts +13 -0
  14. package/src/accounts.test.ts +70 -0
  15. package/src/accounts.ts +19 -19
  16. package/src/actions.runtime.ts +5 -0
  17. package/src/actions.test.ts +32 -0
  18. package/src/actions.ts +20 -14
  19. package/src/api.test.ts +108 -22
  20. package/src/api.ts +29 -2
  21. package/src/approval-auth.test.ts +17 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/channel.directory.test.ts +22 -16
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +36 -35
  26. package/src/channel.ts +228 -336
  27. package/src/config-schema.ts +3 -3
  28. package/src/group-access.ts +4 -3
  29. package/src/monitor.group-policy.test.ts +0 -12
  30. package/src/monitor.image.polling.test.ts +110 -0
  31. package/src/monitor.lifecycle.test.ts +77 -92
  32. package/src/monitor.pairing.lifecycle.test.ts +141 -0
  33. package/src/monitor.polling.media-reply.test.ts +425 -0
  34. package/src/monitor.reply-once.lifecycle.test.ts +171 -0
  35. package/src/monitor.ts +527 -304
  36. package/src/monitor.types.ts +4 -0
  37. package/src/monitor.webhook.test.ts +392 -62
  38. package/src/monitor.webhook.ts +73 -36
  39. package/src/outbound-media.test.ts +182 -0
  40. package/src/outbound-media.ts +241 -0
  41. package/src/outbound-payload.contract.test.ts +45 -0
  42. package/src/probe.ts +1 -1
  43. package/src/proxy.ts +1 -1
  44. package/src/runtime-api.ts +75 -0
  45. package/src/runtime-support.ts +91 -0
  46. package/src/runtime.ts +6 -3
  47. package/src/secret-contract.ts +109 -0
  48. package/src/secret-input.ts +1 -9
  49. package/src/send.test.ts +120 -0
  50. package/src/send.ts +64 -40
  51. package/src/session-route.ts +32 -0
  52. package/src/setup-allow-from.ts +94 -0
  53. package/src/setup-core.ts +149 -0
  54. package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
  55. package/src/setup-surface.test.ts +175 -0
  56. package/src/{onboarding.ts → setup-surface.ts} +59 -177
  57. package/src/status-issues.test.ts +17 -0
  58. package/src/status-issues.ts +11 -27
  59. package/src/test-support/lifecycle-test-support.ts +413 -0
  60. package/src/test-support/monitor-mocks-test-support.ts +209 -0
  61. package/src/token.test.ts +15 -0
  62. package/src/token.ts +8 -17
  63. package/src/types.ts +2 -2
  64. package/test-api.ts +1 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -95
  67. package/src/channel.sendpayload.test.ts +0 -44
package/runtime-api.ts ADDED
@@ -0,0 +1,75 @@
1
+ export {
2
+ addWildcardAllowFrom,
3
+ applyAccountNameToChannelSection,
4
+ applyBasicWebhookRequestGuards,
5
+ applySetupAccountConfigPatch,
6
+ type BaseProbeResult,
7
+ type BaseTokenResolution,
8
+ buildBaseAccountStatusSnapshot,
9
+ buildChannelConfigSchema,
10
+ buildSecretInputSchema,
11
+ buildSingleChannelSecretPromptState,
12
+ buildTokenChannelStatusSummary,
13
+ type ChannelAccountSnapshot,
14
+ type ChannelMessageActionAdapter,
15
+ type ChannelMessageActionName,
16
+ type ChannelPlugin,
17
+ type ChannelStatusIssue,
18
+ chunkTextForOutbound,
19
+ createChannelPairingController,
20
+ createChannelReplyPipeline,
21
+ createDedupeCache,
22
+ createFixedWindowRateLimiter,
23
+ createWebhookAnomalyTracker,
24
+ DEFAULT_ACCOUNT_ID,
25
+ deliverTextOrMediaReply,
26
+ evaluateSenderGroupAccess,
27
+ formatAllowFromLowercase,
28
+ formatPairingApproveHint,
29
+ type GroupPolicy,
30
+ hasConfiguredSecretInput,
31
+ isNormalizedSenderAllowed,
32
+ isNumericTargetId,
33
+ jsonResult,
34
+ logTypingFailure,
35
+ type MarkdownTableMode,
36
+ mergeAllowFromEntries,
37
+ migrateBaseNameToDefaultAccount,
38
+ normalizeAccountId,
39
+ normalizeResolvedSecretInputString,
40
+ normalizeSecretInputString,
41
+ type OpenClawConfig,
42
+ type OutboundReplyPayload,
43
+ PAIRING_APPROVED_MESSAGE,
44
+ type PluginRuntime,
45
+ promptSingleChannelSecretInput,
46
+ readJsonWebhookBodyOrReject,
47
+ readStringParam,
48
+ registerPluginHttpRoute,
49
+ type RegisterWebhookPluginRouteOptions,
50
+ registerWebhookTarget,
51
+ type RegisterWebhookTargetOptions,
52
+ registerWebhookTargetWithPluginRoute,
53
+ type ReplyPayload,
54
+ resolveClientIp,
55
+ resolveDefaultGroupPolicy,
56
+ resolveDirectDmAuthorizationOutcome,
57
+ resolveInboundRouteEnvelopeBuilderWithRuntime,
58
+ resolveOpenProviderRuntimeGroupPolicy,
59
+ resolveSenderCommandAuthorizationWithRuntime,
60
+ resolveWebhookPath,
61
+ resolveWebhookTargetWithAuthOrRejectSync,
62
+ runSingleChannelSecretStep,
63
+ type RuntimeEnv,
64
+ type SecretInput,
65
+ type SenderGroupAccessDecision,
66
+ sendPayloadWithChunkedTextAndMedia,
67
+ setTopLevelChannelDmPolicyWithAllowFrom,
68
+ setZaloRuntime,
69
+ waitForAbortSignal,
70
+ warnMissingProviderGroupPolicyFallbackOnce,
71
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
72
+ WEBHOOK_RATE_LIMIT_DEFAULTS,
73
+ withResolvedWebhookRequestPipeline,
74
+ type WizardPrompter,
75
+ } from "./src/runtime-api.js";
@@ -0,0 +1,5 @@
1
+ export {
2
+ channelSecrets,
3
+ collectRuntimeConfigAssignments,
4
+ secretTargetRegistryEntries,
5
+ } from "./src/secret-contract.js";
package/setup-api.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract";
2
+
3
+ type SetupSurfaceModule = typeof import("./src/setup-surface.js");
4
+
5
+ function createLazyObjectValue<T extends object>(load: () => T): T {
6
+ return new Proxy({} as T, {
7
+ get(_target, property, receiver) {
8
+ return Reflect.get(load(), property, receiver);
9
+ },
10
+ has(_target, property) {
11
+ return property in load();
12
+ },
13
+ ownKeys() {
14
+ return Reflect.ownKeys(load());
15
+ },
16
+ getOwnPropertyDescriptor(_target, property) {
17
+ const descriptor = Object.getOwnPropertyDescriptor(load(), property);
18
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
19
+ },
20
+ });
21
+ }
22
+
23
+ function loadSetupSurfaceModule(): SetupSurfaceModule {
24
+ return loadBundledEntryExportSync<SetupSurfaceModule>(import.meta.url, {
25
+ specifier: "./src/setup-surface.js",
26
+ });
27
+ }
28
+
29
+ export { zaloDmPolicy, zaloSetupAdapter, createZaloSetupWizardProxy } from "./src/setup-core.js";
30
+ export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
31
+
32
+ export const zaloSetupWizard: SetupSurfaceModule["zaloSetupWizard"] = createLazyObjectValue(
33
+ () => loadSetupSurfaceModule().zaloSetupWizard as object,
34
+ ) as SetupSurfaceModule["zaloSetupWizard"];
package/setup-entry.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./api.js",
7
+ exportName: "zaloPlugin",
8
+ },
9
+ secrets: {
10
+ specifier: "./secret-contract-api.js",
11
+ exportName: "channelSecrets",
12
+ },
13
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveZaloAccount } from "./accounts.js";
3
+
4
+ describe("resolveZaloAccount", () => {
5
+ it("resolves account config when account key casing differs from normalized id", () => {
6
+ const resolved = resolveZaloAccount({
7
+ cfg: {
8
+ channels: {
9
+ zalo: {
10
+ webhookUrl: "https://top.example.com",
11
+ accounts: {
12
+ Work: {
13
+ name: "Work",
14
+ webhookUrl: "https://work.example.com",
15
+ },
16
+ },
17
+ },
18
+ },
19
+ },
20
+ accountId: "work",
21
+ });
22
+
23
+ expect(resolved.accountId).toBe("work");
24
+ expect(resolved.name).toBe("Work");
25
+ expect(resolved.config.webhookUrl).toBe("https://work.example.com");
26
+ });
27
+
28
+ it("falls back to top-level config for named accounts without overrides", () => {
29
+ const resolved = resolveZaloAccount({
30
+ cfg: {
31
+ channels: {
32
+ zalo: {
33
+ enabled: true,
34
+ webhookUrl: "https://top.example.com",
35
+ accounts: {
36
+ work: {},
37
+ },
38
+ },
39
+ },
40
+ },
41
+ accountId: "work",
42
+ });
43
+
44
+ expect(resolved.accountId).toBe("work");
45
+ expect(resolved.enabled).toBe(true);
46
+ expect(resolved.config.webhookUrl).toBe("https://top.example.com");
47
+ });
48
+
49
+ it("uses configured defaultAccount when accountId is omitted", () => {
50
+ const resolved = resolveZaloAccount({
51
+ cfg: {
52
+ channels: {
53
+ zalo: {
54
+ defaultAccount: "work",
55
+ accounts: {
56
+ work: {
57
+ name: "Work",
58
+ botToken: "work-token",
59
+ },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ });
65
+
66
+ expect(resolved.accountId).toBe("work");
67
+ expect(resolved.name).toBe("Work");
68
+ expect(resolved.token).toBe("work-token");
69
+ });
70
+ });
package/src/accounts.ts CHANGED
@@ -1,5 +1,10 @@
1
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
- import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalo";
1
+ import {
2
+ createAccountListHelpers,
3
+ resolveMergedAccountConfig,
4
+ } from "openclaw/plugin-sdk/account-helpers";
5
+ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
7
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
3
8
  import { resolveZaloToken } from "./token.js";
4
9
  import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
5
10
 
@@ -9,22 +14,15 @@ const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefa
9
14
  createAccountListHelpers("zalo");
10
15
  export { listZaloAccountIds, resolveDefaultZaloAccountId };
11
16
 
12
- function resolveAccountConfig(
13
- cfg: OpenClawConfig,
14
- accountId: string,
15
- ): ZaloAccountConfig | undefined {
16
- const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
17
- if (!accounts || typeof accounts !== "object") {
18
- return undefined;
19
- }
20
- return accounts[accountId] as ZaloAccountConfig | undefined;
21
- }
22
-
23
17
  function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
24
- const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
25
- const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
26
- const account = resolveAccountConfig(cfg, accountId) ?? {};
27
- return { ...base, ...account };
18
+ return resolveMergedAccountConfig<ZaloAccountConfig>({
19
+ channelConfig: cfg.channels?.zalo as ZaloAccountConfig | undefined,
20
+ accounts: (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts as
21
+ | Record<string, Partial<ZaloAccountConfig>>
22
+ | undefined,
23
+ accountId,
24
+ omitKeys: ["defaultAccount"],
25
+ });
28
26
  }
29
27
 
30
28
  export function resolveZaloAccount(params: {
@@ -32,7 +30,9 @@ export function resolveZaloAccount(params: {
32
30
  accountId?: string | null;
33
31
  allowUnresolvedSecretRef?: boolean;
34
32
  }): ResolvedZaloAccount {
35
- const accountId = normalizeAccountId(params.accountId);
33
+ const accountId = normalizeAccountId(
34
+ params.accountId ?? (params.cfg.channels?.zalo as ZaloConfig | undefined)?.defaultAccount,
35
+ );
36
36
  const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
37
37
  const merged = mergeZaloAccountConfig(params.cfg, accountId);
38
38
  const accountEnabled = merged.enabled !== false;
@@ -45,7 +45,7 @@ export function resolveZaloAccount(params: {
45
45
 
46
46
  return {
47
47
  accountId,
48
- name: merged.name?.trim() || undefined,
48
+ name: normalizeOptionalString(merged.name),
49
49
  enabled,
50
50
  token: tokenResolution.token,
51
51
  tokenSource: tokenResolution.source,
@@ -0,0 +1,5 @@
1
+ import { sendMessageZalo as sendMessageZaloImpl } from "./send.js";
2
+
3
+ export const zaloActionsRuntime = {
4
+ sendMessageZalo: sendMessageZaloImpl,
5
+ };
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { zaloMessageActions } from "./actions.js";
3
+ import type { OpenClawConfig } from "./runtime-api.js";
4
+
5
+ describe("zaloMessageActions.describeMessageTool", () => {
6
+ it("honors the selected Zalo account during discovery", () => {
7
+ const cfg: OpenClawConfig = {
8
+ channels: {
9
+ zalo: {
10
+ enabled: true,
11
+ botToken: "root-token",
12
+ accounts: {
13
+ default: {
14
+ enabled: false,
15
+ botToken: "default-token",
16
+ },
17
+ work: {
18
+ enabled: true,
19
+ botToken: "work-token",
20
+ },
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
27
+ expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "work" })).toEqual({
28
+ actions: ["send"],
29
+ capabilities: [],
30
+ });
31
+ });
32
+ });
package/src/actions.ts CHANGED
@@ -1,30 +1,35 @@
1
+ import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
1
2
  import type {
2
3
  ChannelMessageActionAdapter,
3
4
  ChannelMessageActionName,
4
- OpenClawConfig,
5
- } from "openclaw/plugin-sdk/zalo";
6
- import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo";
7
- import { listEnabledZaloAccounts } from "./accounts.js";
8
- import { sendMessageZalo } from "./send.js";
5
+ } from "openclaw/plugin-sdk/channel-contract";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
7
+ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
8
+ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
9
+ import { listEnabledZaloAccounts, resolveZaloAccount } from "./accounts.js";
10
+
11
+ const loadZaloActionsRuntime = createLazyRuntimeNamedExport(
12
+ () => import("./actions.runtime.js"),
13
+ "zaloActionsRuntime",
14
+ );
9
15
 
10
16
  const providerId = "zalo";
11
17
 
12
- function listEnabledAccounts(cfg: OpenClawConfig) {
13
- return listEnabledZaloAccounts(cfg).filter(
14
- (account) => account.enabled && account.tokenSource !== "none",
15
- );
18
+ function listEnabledAccounts(cfg: OpenClawConfig, accountId?: string | null) {
19
+ return (
20
+ accountId ? [resolveZaloAccount({ cfg, accountId })] : listEnabledZaloAccounts(cfg)
21
+ ).filter((account) => account.enabled && account.tokenSource !== "none");
16
22
  }
17
23
 
18
24
  export const zaloMessageActions: ChannelMessageActionAdapter = {
19
- listActions: ({ cfg }) => {
20
- const accounts = listEnabledAccounts(cfg);
25
+ describeMessageTool: ({ cfg, accountId }) => {
26
+ const accounts = listEnabledAccounts(cfg, accountId);
21
27
  if (accounts.length === 0) {
22
- return [];
28
+ return null;
23
29
  }
24
30
  const actions = new Set<ChannelMessageActionName>(["send"]);
25
- return Array.from(actions);
31
+ return { actions: Array.from(actions), capabilities: [] };
26
32
  },
27
- supportsButtons: () => false,
28
33
  extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
29
34
  handleAction: async ({ action, params, cfg, accountId }) => {
30
35
  if (action === "send") {
@@ -35,6 +40,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
35
40
  });
36
41
  const mediaUrl = readStringParam(params, "media", { trim: false });
37
42
 
43
+ const { sendMessageZalo } = await loadZaloActionsRuntime();
38
44
  const result = await sendMessageZalo(to ?? "", content ?? "", {
39
45
  accountId: accountId ?? undefined,
40
46
  mediaUrl: mediaUrl ?? undefined,
package/src/api.test.ts CHANGED
@@ -1,31 +1,43 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
2
 
4
- describe("Zalo API request methods", () => {
5
- it("uses POST for getWebhookInfo", async () => {
6
- const fetcher = vi.fn<ZaloFetch>(
7
- async () => new Response(JSON.stringify({ ok: true, result: {} })),
8
- );
3
+ const resolvePinnedHostnameWithPolicyMock = vi.fn();
9
4
 
10
- await getWebhookInfo("test-token", fetcher);
5
+ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
6
+ resolvePinnedHostnameWithPolicy: (...args: unknown[]) =>
7
+ resolvePinnedHostnameWithPolicyMock(...args),
8
+ }));
11
9
 
12
- expect(fetcher).toHaveBeenCalledTimes(1);
13
- const [, init] = fetcher.mock.calls[0] ?? [];
14
- expect(init?.method).toBe("POST");
15
- expect(init?.headers).toEqual({ "Content-Type": "application/json" });
16
- });
10
+ import { deleteWebhook, getWebhookInfo, sendChatAction, sendPhoto, type ZaloFetch } from "./api.js";
17
11
 
18
- it("keeps POST for deleteWebhook", async () => {
19
- const fetcher = vi.fn<ZaloFetch>(
20
- async () => new Response(JSON.stringify({ ok: true, result: {} })),
21
- );
12
+ function createOkFetcher() {
13
+ return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
14
+ }
22
15
 
23
- await deleteWebhook("test-token", fetcher);
16
+ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
17
+ const fetcher = createOkFetcher();
18
+ await run("test-token", fetcher);
19
+ expect(fetcher).toHaveBeenCalledTimes(1);
20
+ const [, init] = fetcher.mock.calls[0] ?? [];
21
+ expect(init?.method).toBe("POST");
22
+ expect(init?.headers).toEqual({ "Content-Type": "application/json" });
23
+ }
24
24
 
25
- expect(fetcher).toHaveBeenCalledTimes(1);
26
- const [, init] = fetcher.mock.calls[0] ?? [];
27
- expect(init?.method).toBe("POST");
28
- expect(init?.headers).toEqual({ "Content-Type": "application/json" });
25
+ describe("Zalo API request methods", () => {
26
+ beforeEach(() => {
27
+ resolvePinnedHostnameWithPolicyMock.mockReset();
28
+ resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
29
+ hostname: "example.com",
30
+ addresses: ["93.184.216.34"],
31
+ lookup: vi.fn(),
32
+ });
33
+ });
34
+
35
+ it("uses POST for getWebhookInfo", async () => {
36
+ await expectPostJsonRequest(getWebhookInfo);
37
+ });
38
+
39
+ it("keeps POST for deleteWebhook", async () => {
40
+ await expectPostJsonRequest(deleteWebhook);
29
41
  });
30
42
 
31
43
  it("aborts sendChatAction when the typing timeout elapses", async () => {
@@ -60,4 +72,78 @@ describe("Zalo API request methods", () => {
60
72
  vi.useRealTimers();
61
73
  }
62
74
  });
75
+
76
+ it("validates outbound photo URLs against the SSRF guard before posting", async () => {
77
+ const fetcher = createOkFetcher();
78
+
79
+ await sendPhoto(
80
+ "test-token",
81
+ {
82
+ chat_id: "chat-123",
83
+ photo: "https://example.com/image.png",
84
+ },
85
+ fetcher,
86
+ );
87
+
88
+ expect(resolvePinnedHostnameWithPolicyMock).toHaveBeenCalledWith("example.com", {
89
+ policy: {},
90
+ });
91
+ expect(fetcher).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ it("blocks private-network photo URLs before they reach the Zalo API", async () => {
95
+ const fetcher = createOkFetcher();
96
+ resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
97
+ new Error("Blocked hostname or private/internal/special-use IP address"),
98
+ );
99
+
100
+ await expect(
101
+ sendPhoto(
102
+ "test-token",
103
+ {
104
+ chat_id: "chat-123",
105
+ photo: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
106
+ },
107
+ fetcher,
108
+ ),
109
+ ).rejects.toThrow("Blocked hostname or private/internal/special-use IP address");
110
+
111
+ expect(fetcher).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("rejects non-http photo URLs", async () => {
115
+ const fetcher = createOkFetcher();
116
+
117
+ await expect(
118
+ sendPhoto(
119
+ "test-token",
120
+ {
121
+ chat_id: "chat-123",
122
+ photo: "file:///etc/passwd",
123
+ },
124
+ fetcher,
125
+ ),
126
+ ).rejects.toThrow("Zalo photo URL must use HTTP or HTTPS");
127
+
128
+ expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
129
+ expect(fetcher).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("rejects non-URL strings", async () => {
133
+ const fetcher = createOkFetcher();
134
+
135
+ await expect(
136
+ sendPhoto(
137
+ "test-token",
138
+ {
139
+ chat_id: "chat-123",
140
+ photo: "not a url",
141
+ },
142
+ fetcher,
143
+ ),
144
+ ).rejects.toThrow("Zalo photo URL must be an absolute HTTP or HTTPS URL");
145
+
146
+ expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
147
+ expect(fetcher).not.toHaveBeenCalled();
148
+ });
63
149
  });
package/src/api.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  * @see https://bot.zaloplatforms.com/docs
4
4
  */
5
5
 
6
+ import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
7
+
6
8
  const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
9
+ const ZALO_MEDIA_SSRF_POLICY: SsrFPolicy = {};
7
10
 
8
11
  export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
9
12
 
@@ -25,7 +28,9 @@ export type ZaloMessage = {
25
28
  from: {
26
29
  id: string;
27
30
  name?: string;
31
+ display_name?: string;
28
32
  avatar?: string;
33
+ is_bot?: boolean;
29
34
  };
30
35
  chat: {
31
36
  id: string;
@@ -33,9 +38,10 @@ export type ZaloMessage = {
33
38
  };
34
39
  date: number;
35
40
  text?: string;
36
- photo?: string;
41
+ photo_url?: string;
37
42
  caption?: string;
38
43
  sticker?: string;
44
+ message_type?: string;
39
45
  };
40
46
 
41
47
  export type ZaloUpdate = {
@@ -169,7 +175,28 @@ export async function sendPhoto(
169
175
  params: ZaloSendPhotoParams,
170
176
  fetcher?: ZaloFetch,
171
177
  ): Promise<ZaloApiResponse<ZaloMessage>> {
172
- return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
178
+ const photoUrl = params.photo.trim();
179
+ let parsedPhotoUrl: URL;
180
+ try {
181
+ parsedPhotoUrl = new URL(photoUrl);
182
+ } catch {
183
+ throw new Error("Zalo photo URL must be an absolute HTTP or HTTPS URL");
184
+ }
185
+
186
+ if (parsedPhotoUrl.protocol !== "http:" && parsedPhotoUrl.protocol !== "https:") {
187
+ throw new Error("Zalo photo URL must use HTTP or HTTPS");
188
+ }
189
+
190
+ await resolvePinnedHostnameWithPolicy(parsedPhotoUrl.hostname, {
191
+ policy: ZALO_MEDIA_SSRF_POLICY,
192
+ });
193
+
194
+ return callZaloApi<ZaloMessage>(
195
+ "sendPhoto",
196
+ token,
197
+ { ...params, photo: parsedPhotoUrl.href },
198
+ { fetch: fetcher },
199
+ );
173
200
  }
174
201
 
175
202
  /**
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { zaloApprovalAuth } from "./approval-auth.js";
3
+
4
+ describe("zaloApprovalAuth", () => {
5
+ it("authorizes numeric Zalo user ids", () => {
6
+ const cfg = { channels: { zalo: { allowFrom: ["zl:123"] } } };
7
+
8
+ expect(
9
+ zaloApprovalAuth.authorizeActorAction({
10
+ cfg,
11
+ senderId: "123",
12
+ action: "approve",
13
+ approvalKind: "exec",
14
+ }),
15
+ ).toEqual({ authorized: true });
16
+ });
17
+ });
@@ -0,0 +1,25 @@
1
+ import {
2
+ createResolvedApproverActionAuthAdapter,
3
+ resolveApprovalApprovers,
4
+ } from "openclaw/plugin-sdk/approval-auth-runtime";
5
+ import { resolveZaloAccount } from "./accounts.js";
6
+
7
+ function normalizeZaloApproverId(value: string | number): string | undefined {
8
+ const normalized = String(value)
9
+ .trim()
10
+ .replace(/^(zalo|zl):/i, "")
11
+ .trim();
12
+ return /^\d+$/.test(normalized) ? normalized : undefined;
13
+ }
14
+
15
+ export const zaloApprovalAuth = createResolvedApproverActionAuthAdapter({
16
+ channelLabel: "Zalo",
17
+ resolveApprovers: ({ cfg, accountId }) => {
18
+ const account = resolveZaloAccount({ cfg, accountId }).config;
19
+ return resolveApprovalApprovers({
20
+ allowFrom: account.allowFrom,
21
+ normalizeApprover: normalizeZaloApproverId,
22
+ });
23
+ },
24
+ normalizeSenderId: (value) => normalizeZaloApproverId(value),
25
+ });
@@ -1,31 +1,26 @@
1
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo";
1
+ import {
2
+ createDirectoryTestRuntime,
3
+ expectDirectorySurface,
4
+ } from "openclaw/plugin-sdk/channel-test-helpers";
2
5
  import { describe, expect, it } from "vitest";
6
+ import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
3
7
  import { zaloPlugin } from "./channel.js";
4
8
 
5
9
  describe("zalo directory", () => {
6
- const runtimeEnv: RuntimeEnv = {
7
- log: () => {},
8
- error: () => {},
9
- exit: (code: number): never => {
10
- throw new Error(`exit ${code}`);
11
- },
12
- };
10
+ const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
11
+ const directory = expectDirectorySurface(zaloPlugin.directory);
13
12
 
14
- it("lists peers from allowFrom", async () => {
13
+ async function expectPeersFromAllowFrom(allowFrom: string[]) {
15
14
  const cfg = {
16
15
  channels: {
17
16
  zalo: {
18
- allowFrom: ["zalo:123", "zl:234", "345"],
17
+ allowFrom,
19
18
  },
20
19
  },
21
20
  } as unknown as OpenClawConfig;
22
21
 
23
- expect(zaloPlugin.directory).toBeTruthy();
24
- expect(zaloPlugin.directory?.listPeers).toBeTruthy();
25
- expect(zaloPlugin.directory?.listGroups).toBeTruthy();
26
-
27
22
  await expect(
28
- zaloPlugin.directory!.listPeers!({
23
+ directory.listPeers({
29
24
  cfg,
30
25
  accountId: undefined,
31
26
  query: undefined,
@@ -41,7 +36,7 @@ describe("zalo directory", () => {
41
36
  );
42
37
 
43
38
  await expect(
44
- zaloPlugin.directory!.listGroups!({
39
+ directory.listGroups({
45
40
  cfg,
46
41
  accountId: undefined,
47
42
  query: undefined,
@@ -49,5 +44,16 @@ describe("zalo directory", () => {
49
44
  runtime: runtimeEnv,
50
45
  }),
51
46
  ).resolves.toEqual([]);
47
+ }
48
+
49
+ it("lists peers from allowFrom", async () => {
50
+ await expectPeersFromAllowFrom(["zalo:123", "zl:234", "345"]);
51
+ });
52
+
53
+ it("normalizes spaced zalo prefixes in allowFrom and pairing entries", async () => {
54
+ await expectPeersFromAllowFrom([" zalo:123 ", " zl:234 ", " 345 "]);
55
+
56
+ expect(zaloPlugin.pairing?.normalizeAllowEntry?.(" zalo:123 ")).toBe("123");
57
+ expect(zaloPlugin.messaging?.normalizeTarget?.(" zl:234 ")).toBe("234");
52
58
  });
53
59
  });