@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.
- package/README.md +1 -1
- package/api.ts +9 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.test.ts +15 -0
- package/index.ts +16 -13
- package/openclaw.plugin.json +514 -1
- package/package.json +31 -5
- package/runtime-api.test.ts +17 -0
- package/runtime-api.ts +75 -0
- package/secret-contract-api.ts +5 -0
- package/setup-api.ts +34 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +70 -0
- package/src/accounts.ts +19 -19
- package/src/actions.runtime.ts +5 -0
- package/src/actions.test.ts +32 -0
- package/src/actions.ts +20 -14
- package/src/api.test.ts +93 -2
- package/src/api.ts +29 -2
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +25 -0
- package/src/channel.directory.test.ts +19 -6
- package/src/channel.runtime.ts +93 -0
- package/src/channel.startup.test.ts +26 -19
- package/src/channel.ts +228 -336
- package/src/config-schema.ts +3 -3
- package/src/group-access.ts +4 -3
- package/src/monitor.group-policy.test.ts +0 -12
- package/src/monitor.image.polling.test.ts +110 -0
- package/src/monitor.lifecycle.test.ts +41 -22
- package/src/monitor.pairing.lifecycle.test.ts +141 -0
- package/src/monitor.polling.media-reply.test.ts +425 -0
- package/src/monitor.reply-once.lifecycle.test.ts +171 -0
- package/src/monitor.ts +460 -206
- package/src/monitor.types.ts +4 -0
- package/src/monitor.webhook.test.ts +392 -62
- package/src/monitor.webhook.ts +73 -36
- package/src/outbound-media.test.ts +182 -0
- package/src/outbound-media.ts +241 -0
- package/src/outbound-payload.contract.test.ts +45 -0
- package/src/probe.ts +1 -1
- package/src/proxy.ts +1 -1
- package/src/runtime-api.ts +75 -0
- package/src/runtime-support.ts +91 -0
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +109 -0
- package/src/secret-input.ts +1 -9
- package/src/send.test.ts +120 -0
- package/src/send.ts +15 -13
- package/src/session-route.ts +32 -0
- package/src/setup-allow-from.ts +94 -0
- package/src/setup-core.ts +149 -0
- package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
- package/src/setup-surface.test.ts +175 -0
- package/src/{onboarding.ts → setup-surface.ts} +59 -177
- package/src/status-issues.test.ts +2 -14
- package/src/status-issues.ts +8 -2
- package/src/test-support/lifecycle-test-support.ts +413 -0
- package/src/test-support/monitor-mocks-test-support.ts +209 -0
- package/src/token.test.ts +15 -0
- package/src/token.ts +8 -17
- package/src/types.ts +2 -2
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -101
- 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";
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
|
48
|
+
name: normalizeOptionalString(merged.name),
|
|
49
49
|
enabled,
|
|
50
50
|
token: tokenResolution.token,
|
|
51
51
|
tokenSource: tokenResolution.source,
|
|
@@ -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
|
-
|
|
5
|
-
} from "openclaw/plugin-sdk/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
14
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
import {
|
|
2
|
+
createDirectoryTestRuntime,
|
|
3
|
+
expectDirectorySurface,
|
|
4
|
+
} from "openclaw/plugin-sdk/channel-test-helpers";
|
|
2
5
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
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
|
-
|
|
13
|
+
async function expectPeersFromAllowFrom(allowFrom: string[]) {
|
|
10
14
|
const cfg = {
|
|
11
15
|
channels: {
|
|
12
16
|
zalo: {
|
|
13
|
-
allowFrom
|
|
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
|
});
|