@openclaw/zalo 2026.3.13 → 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 +93 -2
  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 +19 -6
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +26 -19
  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 +41 -22
  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 +460 -206
  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 +15 -13
  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 +2 -14
  58. package/src/status-issues.ts +8 -2
  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 -101
  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,5 +1,13 @@
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";
2
+
3
+ const resolvePinnedHostnameWithPolicyMock = vi.fn();
4
+
5
+ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
6
+ resolvePinnedHostnameWithPolicy: (...args: unknown[]) =>
7
+ resolvePinnedHostnameWithPolicyMock(...args),
8
+ }));
9
+
10
+ import { deleteWebhook, getWebhookInfo, sendChatAction, sendPhoto, type ZaloFetch } from "./api.js";
3
11
 
4
12
  function createOkFetcher() {
5
13
  return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
@@ -15,6 +23,15 @@ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) =>
15
23
  }
16
24
 
17
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
+
18
35
  it("uses POST for getWebhookInfo", async () => {
19
36
  await expectPostJsonRequest(getWebhookInfo);
20
37
  });
@@ -55,4 +72,78 @@ describe("Zalo API request methods", () => {
55
72
  vi.useRealTimers();
56
73
  }
57
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
+ });
58
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,22 +1,24 @@
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";
3
- import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js";
6
+ import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
4
7
  import { zaloPlugin } from "./channel.js";
5
8
 
6
9
  describe("zalo directory", () => {
7
10
  const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
11
+ const directory = expectDirectorySurface(zaloPlugin.directory);
8
12
 
9
- it("lists peers from allowFrom", async () => {
13
+ async function expectPeersFromAllowFrom(allowFrom: string[]) {
10
14
  const cfg = {
11
15
  channels: {
12
16
  zalo: {
13
- allowFrom: ["zalo:123", "zl:234", "345"],
17
+ allowFrom,
14
18
  },
15
19
  },
16
20
  } as unknown as OpenClawConfig;
17
21
 
18
- const directory = expectDirectorySurface(zaloPlugin.directory);
19
-
20
22
  await expect(
21
23
  directory.listPeers({
22
24
  cfg,
@@ -42,5 +44,16 @@ describe("zalo directory", () => {
42
44
  runtime: runtimeEnv,
43
45
  }),
44
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");
45
58
  });
46
59
  });