Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

50
extensions/zalo/README.md Normal file
View File

@@ -0,0 +1,50 @@
# @openclaw/zalo
Zalo channel plugin for OpenClaw (Bot API).
## Install (local checkout)
```bash
openclaw plugins install ./path/to/local/zalo-plugin
```
## Install (npm)
```bash
openclaw plugins install @openclaw/zalo
```
Onboarding: select Zalo and confirm the install prompt to fetch the plugin automatically.
## Config
```json5
{
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
proxy: "http://proxy.local:8080",
},
},
}
```
## Webhook mode
```json5
{
channels: {
zalo: {
webhookUrl: "https://example.com/zalo-webhook",
webhookSecret: "your-secret-8-plus-chars",
webhookPath: "/zalo-webhook",
},
},
}
```
If `webhookPath` is omitted, the plugin uses the webhook URL path.
Restart the gateway after config changes.

9
extensions/zalo/api.ts Normal file
View File

@@ -0,0 +1,9 @@
// Zalo API module exposes the plugin public contract.
export { zaloPlugin } from "./src/channel.js";
export {
createZaloSetupWizardProxy,
resolveZaloRuntimeGroupPolicy,
zaloDmPolicy,
zaloSetupAdapter,
zaloSetupWizard,
} from "./setup-api.js";

View File

@@ -0,0 +1,2 @@
// Zalo API module exposes the plugin public contract.
export { zaloPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,2 @@
// Zalo API module exposes the plugin public contract.
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";

View File

@@ -0,0 +1,16 @@
// Zalo tests cover index plugin behavior.
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe } from "vitest";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("zalo bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "zalo",
expectedName: "Zalo",
setupEntry,
channelMessage: "declares the channel plugin without a runtime-barrel cycle",
setupMessage: "declares the setup plugin without a runtime-barrel cycle",
});
});

21
extensions/zalo/index.ts Normal file
View File

@@ -0,0 +1,21 @@
// Zalo plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "zalo",
name: "Zalo",
description: "Zalo channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "zaloPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setZaloRuntime",
},
});

32
extensions/zalo/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@openclaw/zalo",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/zalo",
"version": "2026.6.11",
"dependencies": {
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,18 @@
{
"id": "zalo",
"name": "Zalo",
"description": "OpenClaw Zalo channel plugin for bot and webhook chats.",
"icon": "https://cdn.simpleicons.org/zalo",
"activation": {
"onStartup": false
},
"channels": ["zalo"],
"channelEnvVars": {
"zalo": ["ZALO_BOT_TOKEN", "ZALO_WEBHOOK_SECRET"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,59 @@
{
"name": "@openclaw/zalo",
"version": "2026.6.11",
"description": "OpenClaw Zalo channel plugin for bot and webhook chats.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "zalo",
"label": "Zalo",
"selectionLabel": "Zalo (Bot API)",
"docsPath": "/channels/zalo",
"docsLabel": "zalo",
"blurb": "Vietnam-focused messaging platform with Bot API.",
"aliases": [
"zl"
],
"order": 80,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/zalo",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"dependencies": {
"zod": "4.4.3"
}
}

View File

@@ -0,0 +1,11 @@
// Zalo tests cover runtime api plugin behavior.
import { describe, expect, it } from "vitest";
import * as runtime from "./runtime-api.js";
describe("zalo runtime api", () => {
it("loads the narrow runtime api without reentering setup surfaces", () => {
expect(Object.hasOwn(runtime, "zaloPlugin")).toBe(false);
expect(Object.hasOwn(runtime, "zaloSetupWizard")).toBe(false);
expect(typeof runtime.setZaloRuntime).toBe("function");
});
});

View File

@@ -0,0 +1,72 @@
// Zalo API module exposes the plugin public contract.
export {
addWildcardAllowFrom,
applyAccountNameToChannelSection,
applyBasicWebhookRequestGuards,
applySetupAccountConfigPatch,
type BaseProbeResult,
type BaseTokenResolution,
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
buildSecretInputSchema,
buildSingleChannelSecretPromptState,
buildTokenChannelStatusSummary,
type ChannelAccountSnapshot,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
type ChannelStatusIssue,
chunkTextForOutbound,
createChannelPairingController,
createChannelMessageReplyPipeline,
createDedupeCache,
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
DEFAULT_ACCOUNT_ID,
deliverTextOrMediaReply,
formatAllowFromLowercase,
formatPairingApproveHint,
type GroupPolicy,
hasConfiguredSecretInput,
isNormalizedSenderAllowed,
isNumericTargetId,
jsonResult,
logTypingFailure,
type MarkdownTableMode,
mergeAllowFromEntries,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
type OpenClawConfig,
type OutboundReplyPayload,
PAIRING_APPROVED_MESSAGE,
type PluginRuntime,
promptSingleChannelSecretInput,
readJsonWebhookBodyOrReject,
readStringParam,
registerPluginHttpRoute,
type RegisterWebhookPluginRouteOptions,
registerWebhookTarget,
type RegisterWebhookTargetOptions,
registerWebhookTargetWithPluginRoute,
type ReplyPayload,
resolveClientIp,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveOpenProviderRuntimeGroupPolicy,
resolveWebhookPath,
resolveWebhookTargetWithAuthOrRejectSync,
runSingleChannelSecretStep,
type RuntimeEnv,
type SecretInput,
sendPayloadWithChunkedTextAndMedia,
setTopLevelChannelDmPolicyWithAllowFrom,
setZaloRuntime,
waitForAbortSignal,
warnMissingProviderGroupPolicyFallbackOnce,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
withResolvedWebhookRequestPipeline,
type WizardPrompter,
} from "./src/runtime-api.js";

View File

@@ -0,0 +1,6 @@
// Zalo API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,35 @@
// Zalo API module exposes the plugin public contract.
import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract";
type SetupSurfaceModule = typeof import("./src/setup-surface.js");
function createLazyObjectValue<T extends object>(load: () => T): T {
return new Proxy({} as T, {
get(_target, property, receiver) {
return Reflect.get(load(), property, receiver);
},
has(_target, property) {
return property in load();
},
ownKeys() {
return Reflect.ownKeys(load());
},
getOwnPropertyDescriptor(_target, property) {
const descriptor = Object.getOwnPropertyDescriptor(load(), property);
return descriptor ? { ...descriptor, configurable: true } : undefined;
},
});
}
function loadSetupSurfaceModule(): SetupSurfaceModule {
return loadBundledEntryExportSync<SetupSurfaceModule>(import.meta.url, {
specifier: "./src/setup-surface.js",
});
}
export { zaloDmPolicy, zaloSetupAdapter, createZaloSetupWizardProxy } from "./src/setup-core.js";
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
export const zaloSetupWizard: SetupSurfaceModule["zaloSetupWizard"] = createLazyObjectValue(
() => loadSetupSurfaceModule().zaloSetupWizard as object,
) as SetupSurfaceModule["zaloSetupWizard"];

View File

@@ -0,0 +1,14 @@
// Zalo plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "zaloPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View File

@@ -0,0 +1,96 @@
// Zalo tests cover accounts plugin behavior.
import { describe, expect, it } from "vitest";
import {
listEnabledZaloAccounts,
listZaloAccountIds,
resolveDefaultZaloAccountId,
resolveZaloAccount,
} from "./accounts.js";
describe("resolveZaloAccount", () => {
it("resolves account config when account key casing differs from normalized id", () => {
const resolved = resolveZaloAccount({
cfg: {
channels: {
zalo: {
webhookUrl: "https://top.example.com",
accounts: {
Work: {
name: "Work",
webhookUrl: "https://work.example.com",
},
},
},
},
},
accountId: "work",
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.config.webhookUrl).toBe("https://work.example.com");
});
it("falls back to top-level config for named accounts without overrides", () => {
const resolved = resolveZaloAccount({
cfg: {
channels: {
zalo: {
enabled: true,
webhookUrl: "https://top.example.com",
accounts: {
work: {},
},
},
},
},
accountId: "work",
});
expect(resolved.accountId).toBe("work");
expect(resolved.enabled).toBe(true);
expect(resolved.config.webhookUrl).toBe("https://top.example.com");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const resolved = resolveZaloAccount({
cfg: {
channels: {
zalo: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
botToken: "work-token",
},
},
},
},
},
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.token).toBe("work-token");
});
it("keeps the implicit default account when named accounts are added to top-level credentials", () => {
const cfg = {
channels: {
zalo: {
botToken: "default-token",
accounts: {
work: {
enabled: false,
botToken: "work-token",
},
},
},
},
};
expect(listZaloAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultZaloAccountId(cfg)).toBe("default");
expect(listEnabledZaloAccounts(cfg).map((account) => account.accountId)).toEqual(["default"]);
});
});

View File

@@ -0,0 +1,66 @@
// Zalo plugin module implements accounts behavior.
import {
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveZaloToken } from "./token.js";
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
export type { ResolvedZaloAccount };
const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
createAccountListHelpers("zalo", {
implicitDefaultAccount: {
channelKeys: ["botToken", "tokenFile"],
envVars: ["ZALO_BOT_TOKEN"],
},
});
export { listZaloAccountIds, resolveDefaultZaloAccountId };
function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
return resolveMergedAccountConfig<ZaloAccountConfig>({
channelConfig: cfg.channels?.zalo as ZaloAccountConfig | undefined,
accounts: (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts as
| Record<string, Partial<ZaloAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
});
}
export function resolveZaloAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
allowUnresolvedSecretRef?: boolean;
}): ResolvedZaloAccount {
const accountId = normalizeAccountId(
params.accountId ?? (params.cfg.channels?.zalo as ZaloConfig | undefined)?.defaultAccount,
);
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
const merged = mergeZaloAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveZaloToken(
params.cfg.channels?.zalo as ZaloConfig | undefined,
accountId,
{ allowUnresolvedSecretRef: params.allowUnresolvedSecretRef },
);
return {
accountId,
name: normalizeOptionalString(merged.name),
enabled,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
config: merged,
};
}
export function listEnabledZaloAccounts(cfg: OpenClawConfig): ResolvedZaloAccount[] {
return listZaloAccountIds(cfg)
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,6 @@
// Zalo plugin module implements actions behavior.
import { sendMessageZalo as sendMessageZaloImpl } from "./send.js";
export const zaloActionsRuntime = {
sendMessageZalo: sendMessageZaloImpl,
};

View File

@@ -0,0 +1,33 @@
// Zalo tests cover actions plugin behavior.
import { describe, expect, it } from "vitest";
import { zaloMessageActions } from "./actions.js";
import type { OpenClawConfig } from "./runtime-api.js";
describe("zaloMessageActions.describeMessageTool", () => {
it("honors the selected Zalo account during discovery", () => {
const cfg: OpenClawConfig = {
channels: {
zalo: {
enabled: true,
botToken: "root-token",
accounts: {
default: {
enabled: false,
botToken: "default-token",
},
work: {
enabled: true,
botToken: "work-token",
},
},
},
},
};
expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "work" })).toEqual({
actions: ["send"],
capabilities: [],
});
});
});

View File

@@ -0,0 +1,63 @@
// Zalo plugin module implements actions behavior.
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledZaloAccounts, resolveZaloAccount } from "./accounts.js";
const loadZaloActionsRuntime = createLazyRuntimeNamedExport(
() => import("./actions.runtime.js"),
"zaloActionsRuntime",
);
const providerId = "zalo";
function listEnabledAccounts(cfg: OpenClawConfig, accountId?: string | null) {
return (
accountId ? [resolveZaloAccount({ cfg, accountId })] : listEnabledZaloAccounts(cfg)
).filter((account) => account.enabled && account.tokenSource !== "none");
}
export const zaloMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId }) => {
const accounts = listEnabledAccounts(cfg, accountId);
if (accounts.length === 0) {
return null;
}
const actions = new Set<ChannelMessageActionName>(["send"]);
return { actions: Array.from(actions), capabilities: [] };
},
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const { sendMessageZalo } = await loadZaloActionsRuntime();
const result = await sendMessageZalo(to ?? "", content ?? "", {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
cfg,
});
if (!result.ok) {
return jsonResult({
ok: false,
error: result.error ?? "Failed to send Zalo message",
});
}
return jsonResult({ ok: true, to, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -0,0 +1,335 @@
// Zalo tests cover api plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { resolvePinnedHostnameWithPolicyMock } = vi.hoisted(() => ({
resolvePinnedHostnameWithPolicyMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
resolvePinnedHostnameWithPolicy: (...args: unknown[]) =>
resolvePinnedHostnameWithPolicyMock(...args),
}));
import {
callZaloApi,
deleteWebhook,
getMe,
getWebhookInfo,
sendChatAction,
sendPhoto,
type ZaloFetch,
} from "./api.js";
const ZALO_JSON_CAP_BYTES = 16 * 1024 * 1024;
function oversizedZaloJsonResponse(onCancel: () => void): Response {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(ZALO_JSON_CAP_BYTES + 1));
},
cancel() {
onCancel();
},
}),
{ headers: { "content-type": "application/json" }, status: 200 },
);
Object.defineProperty(response, "json", {
value: async () => {
throw new Error("unbounded json reader was used");
},
});
return response;
}
function createOkFetcher() {
return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
}
function requireFirstFetchCall(fetcher: ReturnType<typeof createOkFetcher>, label: string) {
const [call] = fetcher.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
return call;
}
async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
const fetcher = createOkFetcher();
await run("test-token", fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
const [, init] = requireFirstFetchCall(fetcher, "Zalo request");
if (!init) {
throw new Error("expected Zalo request init");
}
expect(init.method).toBe("POST");
expect(init.headers).toEqual({ "Content-Type": "application/json" });
}
describe("Zalo API request methods", () => {
beforeEach(() => {
vi.unstubAllEnvs();
resolvePinnedHostnameWithPolicyMock.mockReset();
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
hostname: "example.com",
addresses: ["93.184.216.34"],
lookup: vi.fn(),
});
});
it("accepts the native Zalo getMe identity fields", async () => {
const fetcher: ZaloFetch = vi.fn(async () =>
Response.json({
ok: true,
result: {
account_name: "bot.example",
account_type: "BASIC",
can_join_groups: false,
id: "1459232241454765289",
},
}),
);
await expect(getMe("test-token", undefined, fetcher)).resolves.toMatchObject({
result: {
account_name: "bot.example",
account_type: "BASIC",
can_join_groups: false,
},
});
});
it("uses the production API root by default", async () => {
const fetcher = createOkFetcher();
await callZaloApi("getMe", "test-token", undefined, { fetch: fetcher });
expect(fetcher).toHaveBeenCalledWith(
"https://bot-api.zaloplatforms.com/bottest-token/getMe",
expect.any(Object),
);
});
it("uses ZALO_API_URL for provider-compatible alternate endpoints", async () => {
vi.stubEnv("ZALO_API_URL", " http://127.0.0.1:49152/zalo/ ");
const fetcher = createOkFetcher();
await callZaloApi("getMe", "test-token", undefined, { fetch: fetcher });
expect(fetcher).toHaveBeenCalledWith(
"http://127.0.0.1:49152/zalo/bottest-token/getMe",
expect.any(Object),
);
});
it("prefers an explicit API URL over ZALO_API_URL", async () => {
vi.stubEnv("ZALO_API_URL", "http://127.0.0.1:49152/env");
const fetcher = createOkFetcher();
await callZaloApi("getMe", "test-token", undefined, {
apiUrl: "http://127.0.0.1:49153/explicit/",
fetch: fetcher,
});
expect(fetcher).toHaveBeenCalledWith(
"http://127.0.0.1:49153/explicit/bottest-token/getMe",
expect.any(Object),
);
});
it("rejects an explicitly empty API URL instead of falling back to ZALO_API_URL", async () => {
vi.stubEnv("ZALO_API_URL", "http://127.0.0.1:49152/env");
await expect(
callZaloApi("getMe", "test-token", undefined, {
apiUrl: " ",
fetch: createOkFetcher(),
}),
).rejects.toThrow("ZALO_API_URL must not be empty.");
});
it("rejects invalid alternate API URLs", async () => {
vi.stubEnv("ZALO_API_URL", "file:///tmp/zalo");
await expect(
callZaloApi("getMe", "test-token", undefined, { fetch: createOkFetcher() }),
).rejects.toThrow("ZALO_API_URL must use http:// or https://.");
});
it.each(["https://proxy.example/zalo?tenant=1", "https://proxy.example/zalo#provider"])(
"rejects an API root with URL suffix components: %s",
async (apiUrl) => {
await expect(
callZaloApi("getMe", "test-token", undefined, {
apiUrl,
fetch: createOkFetcher(),
}),
).rejects.toThrow("ZALO_API_URL must not include a query string or fragment.");
},
);
it("uses POST for getWebhookInfo", async () => {
await expectPostJsonRequest(getWebhookInfo);
});
it("keeps POST for deleteWebhook", async () => {
await expectPostJsonRequest(deleteWebhook);
});
it("aborts sendChatAction when the typing timeout elapses", async () => {
vi.useFakeTimers();
try {
const fetcher = vi.fn<ZaloFetch>(
(_, init) =>
new Promise<Response>((_Local, reject) => {
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
}),
);
const promise = sendChatAction(
"test-token",
{
chat_id: "chat-123",
action: "typing",
},
fetcher,
25,
);
const rejected = expect(promise).rejects.toThrow("aborted");
await vi.advanceTimersByTimeAsync(25);
await rejected;
const [, init] = requireFirstFetchCall(fetcher, "Zalo chat action request");
if (!init) {
throw new Error("expected Zalo chat action request init");
}
if (!init.signal) {
throw new Error("expected Zalo chat action abort signal");
}
expect(init.signal.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("caps oversized sendChatAction timeouts before scheduling the timer", async () => {
const setTimeoutMock = vi
.spyOn(globalThis, "setTimeout")
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
const clearTimeoutMock = vi
.spyOn(globalThis, "clearTimeout")
.mockImplementation(() => undefined);
try {
const fetcher = vi.fn<ZaloFetch>(
async () => new Response(JSON.stringify({ ok: true, result: {} })),
);
await sendChatAction(
"test-token",
{
chat_id: "chat-123",
action: "typing",
},
fetcher,
MAX_TIMER_TIMEOUT_MS + 1_000_000,
);
expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
setTimeoutMock.mockRestore();
clearTimeoutMock.mockRestore();
}
});
it("validates outbound photo URLs against the SSRF guard before posting", async () => {
const fetcher = createOkFetcher();
await sendPhoto(
"test-token",
{
chat_id: "chat-123",
photo: "https://example.com/image.png",
},
fetcher,
);
expect(resolvePinnedHostnameWithPolicyMock).toHaveBeenCalledWith("example.com", {
policy: {},
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("blocks private-network photo URLs before they reach the Zalo API", async () => {
const fetcher = createOkFetcher();
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
new Error("Blocked hostname or private/internal/special-use IP address"),
);
await expect(
sendPhoto(
"test-token",
{
chat_id: "chat-123",
photo: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
},
fetcher,
),
).rejects.toThrow("Blocked hostname or private/internal/special-use IP address");
expect(fetcher).not.toHaveBeenCalled();
});
it("rejects non-http photo URLs", async () => {
const fetcher = createOkFetcher();
await expect(
sendPhoto(
"test-token",
{
chat_id: "chat-123",
photo: "file:///etc/passwd",
},
fetcher,
),
).rejects.toThrow("Zalo photo URL must use HTTP or HTTPS");
expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
it("rejects non-URL strings", async () => {
const fetcher = createOkFetcher();
await expect(
sendPhoto(
"test-token",
{
chat_id: "chat-123",
photo: "not a url",
},
fetcher,
),
).rejects.toThrow("Zalo photo URL must be an absolute HTTP or HTTPS URL");
expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
it("bounds oversized getMe JSON responses and cancels the stream", async () => {
let cancelCount = 0;
const fetcher = vi.fn<ZaloFetch>(async () =>
oversizedZaloJsonResponse(() => {
cancelCount += 1;
}),
);
await expect(getMe("test-token", undefined, fetcher)).rejects.toThrow(
"zalo.getMe: JSON response exceeds 16777216 bytes",
);
expect(cancelCount).toBe(1);
});
});

293
extensions/zalo/src/api.ts Normal file
View File

@@ -0,0 +1,293 @@
/**
* Zalo Bot API client
* @see https://bot.zaloplatforms.com/docs
*/
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
const ZALO_API_URL_ENV = "ZALO_API_URL";
const ZALO_MEDIA_SSRF_POLICY: SsrFPolicy = {};
export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
export type ZaloApiResponse<T = unknown> = {
ok: boolean;
result?: T;
error_code?: number;
description?: string;
};
export type ZaloBotInfo = {
id: string;
account_name: string;
account_type: string;
can_join_groups: boolean;
};
export type ZaloMessage = {
message_id: string;
from: {
id: string;
name?: string;
display_name?: string;
avatar?: string;
is_bot?: boolean;
};
chat: {
id: string;
chat_type: "PRIVATE" | "GROUP";
};
date: number;
text?: string;
photo_url?: string;
caption?: string;
sticker?: string;
message_type?: string;
};
export type ZaloUpdate = {
event_name:
| "message.text.received"
| "message.image.received"
| "message.sticker.received"
| "message.unsupported.received";
message?: ZaloMessage;
};
export type ZaloSendMessageParams = {
chat_id: string;
text: string;
};
export type ZaloSendPhotoParams = {
chat_id: string;
photo: string;
caption?: string;
};
export type ZaloSendChatActionParams = {
chat_id: string;
action: "typing" | "upload_photo";
};
export type ZaloSetWebhookParams = {
url: string;
secret_token: string;
};
export type ZaloWebhookInfo = {
url?: string;
updated_at?: number;
has_custom_certificate?: boolean;
};
export type ZaloGetUpdatesParams = {
/** Timeout in seconds (passed as string to API) */
timeout?: number;
};
export class ZaloApiError extends Error {
constructor(
message: string,
public readonly errorCode?: number,
public readonly description?: string,
) {
super(message);
this.name = "ZaloApiError";
}
/** True if this is a long-polling timeout (no updates available) */
get isPollingTimeout(): boolean {
return this.errorCode === 408;
}
}
function resolveZaloApiUrl(apiUrl?: string): string {
const value =
apiUrl === undefined ? (process.env[ZALO_API_URL_ENV]?.trim() ?? ZALO_API_BASE) : apiUrl.trim();
if (!value) {
throw new Error(`${ZALO_API_URL_ENV} must not be empty.`);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${ZALO_API_URL_ENV} must be a valid URL.`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${ZALO_API_URL_ENV} must use http:// or https://.`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${ZALO_API_URL_ENV} must not include a query string or fragment.`);
}
return parsed.href.replace(/\/+$/u, "");
}
/**
* Call the Zalo Bot API
*/
export async function callZaloApi<T = unknown>(
method: string,
token: string,
body?: Record<string, unknown>,
options?: { apiUrl?: string; timeoutMs?: number; fetch?: ZaloFetch },
): Promise<ZaloApiResponse<T>> {
const url = `${resolveZaloApiUrl(options?.apiUrl)}/bot${token}/${method}`;
const controller = new AbortController();
const requestTimeoutMs =
options?.timeoutMs === undefined ? undefined : resolveTimerTimeoutMs(options.timeoutMs, 1);
const timeoutId =
requestTimeoutMs === undefined
? undefined
: setTimeout(() => controller.abort(), requestTimeoutMs);
const fetcher = options?.fetch ?? fetch;
try {
const response = await fetcher(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const data = await readProviderJsonResponse<ZaloApiResponse<T>>(response, `zalo.${method}`);
if (!data.ok) {
throw new ZaloApiError(
data.description ?? `Zalo API error: ${method}`,
data.error_code,
data.description,
);
}
return data;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Validate bot token and get bot info
*/
export async function getMe(
token: string,
timeoutMs?: number,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloBotInfo>> {
return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
}
/**
* Send a text message
*/
export async function sendMessage(
token: string,
params: ZaloSendMessageParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloMessage>> {
return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
}
/**
* Send a photo message
*/
export async function sendPhoto(
token: string,
params: ZaloSendPhotoParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloMessage>> {
const photoUrl = params.photo.trim();
let parsedPhotoUrl: URL;
try {
parsedPhotoUrl = new URL(photoUrl);
} catch {
throw new Error("Zalo photo URL must be an absolute HTTP or HTTPS URL");
}
if (parsedPhotoUrl.protocol !== "http:" && parsedPhotoUrl.protocol !== "https:") {
throw new Error("Zalo photo URL must use HTTP or HTTPS");
}
await resolvePinnedHostnameWithPolicy(parsedPhotoUrl.hostname, {
policy: ZALO_MEDIA_SSRF_POLICY,
});
return callZaloApi<ZaloMessage>(
"sendPhoto",
token,
{ ...params, photo: parsedPhotoUrl.href },
{ fetch: fetcher },
);
}
/**
* Send a temporary chat action such as typing.
*/
export async function sendChatAction(
token: string,
params: ZaloSendChatActionParams,
fetcher?: ZaloFetch,
timeoutMs?: number,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("sendChatAction", token, params, {
timeoutMs,
fetch: fetcher,
});
}
/**
* Get updates using long polling (dev/testing only)
* Note: Zalo returns a single update per call, not an array like Telegram
*/
export async function getUpdates(
token: string,
params?: ZaloGetUpdatesParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloUpdate>> {
const pollTimeoutSec = params?.timeout ?? 30;
const timeoutMs = (pollTimeoutSec + 5) * 1000;
const body = { timeout: String(pollTimeoutSec) };
return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
}
/**
* Set webhook URL for receiving updates
*/
export async function setWebhook(
token: string,
params: ZaloSetWebhookParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
}
/**
* Delete webhook configuration
*/
export async function deleteWebhook(
token: string,
fetcher?: ZaloFetch,
timeoutMs?: number,
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
timeoutMs,
fetch: fetcher,
});
}
/**
* Get current webhook info
*/
export async function getWebhookInfo(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
}

View File

@@ -0,0 +1,18 @@
// Zalo tests cover approval auth plugin behavior.
import { describe, expect, it } from "vitest";
import { zaloApprovalAuth } from "./approval-auth.js";
describe("zaloApprovalAuth", () => {
it("authorizes numeric Zalo user ids", () => {
const cfg = { channels: { zalo: { allowFrom: ["zl:123"] } } };
expect(
zaloApprovalAuth.authorizeActorAction({
cfg,
senderId: "123",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View File

@@ -0,0 +1,26 @@
// Zalo plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { resolveZaloAccount } from "./accounts.js";
function normalizeZaloApproverId(value: string | number): string | undefined {
const normalized = String(value)
.trim()
.replace(/^(zalo|zl):/i, "")
.trim();
return /^\d+$/.test(normalized) ? normalized : undefined;
}
export const zaloApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Zalo",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveZaloAccount({ cfg, accountId }).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
normalizeApprover: normalizeZaloApproverId,
});
},
normalizeSenderId: (value) => normalizeZaloApproverId(value),
});

View File

@@ -0,0 +1,57 @@
// Zalo tests cover channelirectory plugin behavior.
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { zaloPlugin } from "./channel.js";
describe("zalo directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
const directory = expectDirectorySurface(zaloPlugin.directory);
async function expectPeersFromAllowFrom(allowFrom: string[]) {
const cfg = {
channels: {
zalo: {
allowFrom,
},
},
} as unknown as OpenClawConfig;
const peers = await directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(peers).toStrictEqual([
{ kind: "user", id: "123" },
{ kind: "user", id: "234" },
{ kind: "user", id: "345" },
]);
await expect(
directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toStrictEqual([]);
}
it("lists peers from allowFrom", async () => {
await expectPeersFromAllowFrom(["zalo:123", "zl:234", "345"]);
});
it("normalizes spaced zalo prefixes in allowFrom and pairing entries", async () => {
await expectPeersFromAllowFrom([" zalo:123 ", " zl:234 ", " 345 "]);
expect(zaloPlugin.pairing?.normalizeAllowEntry?.(" zalo:123 ")).toBe("123");
expect(zaloPlugin.messaging?.normalizeTarget?.(" zl:234 ")).toBe("234");
});
});

View File

@@ -0,0 +1,94 @@
// Zalo plugin module implements channel behavior.
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
import { probeZalo } from "./probe.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import {
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
type OpenClawConfig,
} from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { sendMessageZalo } from "./send.js";
import type { ResolvedZaloAccount } from "./types.js";
export async function notifyZaloPairingApproval(params: { cfg: OpenClawConfig; id: string }) {
const { resolveZaloAccount } = await import("./accounts.js");
const account = resolveZaloAccount({ cfg: params.cfg });
if (!account.token) {
throw new Error("Zalo token not configured");
}
await sendMessageZalo(params.id, PAIRING_APPROVED_MESSAGE, {
token: account.token,
});
}
export async function sendZaloText(
params: Parameters<typeof sendMessageZalo>[2] & {
to: string;
text: string;
},
) {
return await sendMessageZalo(params.to, params.text, params);
}
export async function probeZaloAccount(params: {
account: import("./accounts.js").ResolvedZaloAccount;
timeoutMs?: number;
}) {
return await probeZalo(
params.account.token,
params.timeoutMs,
resolveZaloProxyFetch(params.account.config.proxy),
);
}
export async function startZaloGatewayAccount(
ctx: Parameters<
NonNullable<NonNullable<ChannelPlugin<ResolvedZaloAccount>["gateway"]>["startAccount"]>
>[0],
) {
const account = ctx.account;
const token = account.token.trim();
const mode = account.config.webhookUrl ? "webhook" : "polling";
let zaloBotLabel = "";
const fetcher = resolveZaloProxyFetch(account.config.proxy);
try {
const probe = await probeZalo(token, 2500, fetcher);
const name = probe.ok ? probe.bot?.account_name?.trim() : null;
if (name) {
zaloBotLabel = ` (${name})`;
}
if (!probe.ok) {
ctx.log?.warn?.(
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
);
}
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
});
} catch (err) {
ctx.log?.warn?.(
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
);
}
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
const { monitorZaloProvider } = await import("./monitor.js");
return monitorZaloProvider({
token,
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
webhookUrl: account.config.webhookUrl,
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
webhookPath: account.config.webhookPath,
fetcher,
statusSink,
});
}

View File

@@ -0,0 +1,122 @@
// Zalo tests cover channel.startup plugin behavior.
import {
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedZaloAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
monitorZaloProvider: vi.fn(),
probeZalo: vi.fn(async () => ({
ok: false as const,
error: "probe failed",
elapsedMs: 1,
})),
}));
vi.mock("./monitor.js", () => {
return {
monitorZaloProvider: hoisted.monitorZaloProvider,
};
});
vi.mock("./probe.js", () => {
return {
probeZalo: hoisted.probeZalo,
};
});
vi.mock("./channel.runtime.js", () => ({
probeZaloAccount: hoisted.probeZalo,
startZaloGatewayAccount: async (ctx: {
account: ResolvedZaloAccount;
abortSignal: AbortSignal;
setStatus: (patch: Partial<ResolvedZaloAccount>) => void;
}) => {
await hoisted.probeZalo();
ctx.setStatus({ accountId: ctx.account.accountId });
return await hoisted.monitorZaloProvider({
token: ctx.account.token,
account: ctx.account,
abortSignal: ctx.abortSignal,
useWebhook: false,
});
},
}));
import { zaloPlugin } from "./channel.js";
type ZaloGateway = NonNullable<typeof zaloPlugin.gateway>;
type ZaloStartAccount = NonNullable<ZaloGateway["startAccount"]>;
function requireStartAccount(): ZaloStartAccount {
const startAccount = zaloPlugin.gateway?.startAccount;
if (!startAccount) {
throw new Error("Expected Zalo gateway startAccount");
}
return startAccount;
}
function buildAccount(): ResolvedZaloAccount {
return {
accountId: "default",
enabled: true,
token: "test-token",
tokenSource: "config",
config: {},
};
}
function requireMonitorArgs() {
const [call] = hoisted.monitorZaloProvider.mock.calls;
if (!call) {
throw new Error("expected Zalo monitor call");
}
const [monitorArgs] = call;
return monitorArgs;
}
describe("zaloPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("keeps startAccount pending until abort", async () => {
hoisted.monitorZaloProvider.mockImplementationOnce(
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
await new Promise<void>((resolve) => {
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: requireStartAccount(),
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.probeZalo, hoisted.monitorZaloProvider),
isSettled,
abort,
task,
});
expectLifecyclePatch(patches, { accountId: "default" });
expect(isSettled()).toBe(true);
expect(hoisted.monitorZaloProvider).toHaveBeenCalledTimes(1);
const monitorArgs = requireMonitorArgs();
expect(monitorArgs).toStrictEqual({
token: "test-token",
account: buildAccount(),
abortSignal: abort.signal,
useWebhook: false,
});
});
});

View File

@@ -0,0 +1,311 @@
// Zalo plugin module implements channel behavior.
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import {
buildChannelConfigSchema,
createChatChannelPlugin,
type ChannelPlugin,
} from "openclaw/plugin-sdk/channel-core";
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
import {
buildOpenGroupPolicyRestrictSendersWarning,
buildOpenGroupPolicyWarning,
createOpenProviderGroupPolicyWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createEmptyChannelResult,
createRawChannelSendResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { buildTokenChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "openclaw/plugin-sdk/reply-payload";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import {
listZaloAccountIds,
resolveDefaultZaloAccountId,
resolveZaloAccount,
type ResolvedZaloAccount,
} from "./accounts.js";
import { zaloMessageActions } from "./actions.js";
import { zaloApprovalAuth } from "./approval-auth.js";
import { ZaloConfigSchema } from "./config-schema.js";
import type { ZaloProbeResult } from "./probe.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveZaloOutboundSessionRoute } from "./session-route.js";
import { createZaloSetupWizardProxy, zaloSetupAdapter } from "./setup-core.js";
import { collectZaloStatusIssues } from "./status-issues.js";
const meta = {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
docsLabel: "zalo",
blurb: "Vietnam-focused messaging platform with Bot API.",
aliases: ["zl"],
order: 80,
quickstartAllowFrom: true,
};
function normalizeZaloMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.replace(/^(zalo|zl):/i, "").trim();
}
const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const zaloSetupWizard = createZaloSetupWizardProxy(
async () => (await import("./setup-surface.js")).zaloSetupWizard,
);
const zaloTextChunkLimit = 2000;
const zaloRawSendResultAdapter = createRawChannelSendResultAdapter({
channel: "zalo",
sendText: async ({ to, text, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
cfg,
}),
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
mediaUrl,
cfg,
}),
});
export const zaloMessageAdapter = defineChannelMessageAdapter({
id: "zalo",
durableFinal: {
capabilities: {
text: true,
media: true,
messageSendingHooks: true,
},
},
send: {
text: async ({ to, text, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
cfg,
}),
media: async ({ to, text, mediaUrl, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
mediaUrl,
cfg,
}),
},
});
const zaloConfigAdapter = createScopedChannelConfigAdapter<ResolvedZaloAccount>({
sectionKey: "zalo",
listAccountIds: listZaloAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
defaultAccountId: resolveDefaultZaloAccountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
});
const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>({
channelKey: "zalo",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.trim().replace(/^(zalo|zl):/i, ""),
});
const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{
cfg: OpenClawConfig;
account: ResolvedZaloAccount;
}>({
providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined,
resolveGroupPolicy: ({ account }) => account.config.groupPolicy,
collect: ({ account, groupPolicy }) => {
if (groupPolicy !== "open") {
return [];
}
const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
const effectiveAllowFrom =
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
if (effectiveAllowFrom.length > 0) {
return [
buildOpenGroupPolicyRestrictSendersWarning({
surface: "Zalo groups",
openScope: "any member",
groupPolicyPath: "channels.zalo.groupPolicy",
groupAllowFromPath: "channels.zalo.groupAllowFrom",
}),
];
}
return [
buildOpenGroupPolicyWarning({
surface: "Zalo groups",
openBehavior:
"with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
}),
];
},
});
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
createChatChannelPlugin({
base: {
id: "zalo",
meta,
setup: zaloSetupAdapter,
setupWizard: zaloSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
...zaloConfigAdapter,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account): ChannelAccountSnapshot =>
describeWebhookAccountSnapshot({
account,
configured: Boolean(account.token?.trim()),
mode: account.config.webhookUrl ? "webhook" : "polling",
extra: {
tokenSource: account.tokenSource,
},
}),
},
approvalCapability: zaloApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
groups: {
resolveRequireMention: () => true,
},
actions: zaloMessageActions,
messaging: {
targetPrefixes: ["zalo", "zl"],
normalizeTarget: normalizeZaloMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
targetResolver: {
looksLikeId: isNumericTargetId,
hint: "<chatId>",
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedZaloAccount>({
...params,
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
resolveAllowFrom: (account) => account.config.allowFrom,
normalizeId: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
}),
listGroups: async () => [],
}),
status: createComputedAccountStatusAdapter<ResolvedZaloAccount, ZaloProbeResult>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: collectZaloStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await (await loadZaloChannelRuntime()).probeZaloAccount({ account, timeoutMs }),
resolveAccountSnapshot: ({ account }) => {
const configured = Boolean(account.token?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
extra: {
tokenSource: account.tokenSource,
mode: account.config.webhookUrl ? "webhook" : "polling",
dmPolicy: account.config.dmPolicy ?? "pairing",
},
};
},
}),
gateway: {
startAccount: async (ctx) =>
await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx),
},
message: zaloMessageAdapter,
},
security: {
resolveDmPolicy: resolveZaloDmPolicy,
collectWarnings: collectZaloSecurityWarnings,
},
pairing: {
text: {
idLabel: "zaloUserId",
message: "Your pairing request has been approved.",
normalizeAllowEntry: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
notify: async (params) =>
await (await loadZaloChannelRuntime()).notifyZaloPairingApproval(params),
},
},
threading: {
resolveReplyToMode: createStaticReplyToModeResolver("off"),
},
outbound: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: zaloTextChunkLimit,
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: zaloTextChunkLimit,
chunker: chunkTextForOutbound,
sendText: (nextCtx) => zaloRawSendResultAdapter.sendText!(nextCtx),
sendMedia: (nextCtx) => zaloRawSendResultAdapter.sendMedia!(nextCtx),
emptyResult: createEmptyChannelResult("zalo"),
onResult: ctx.onDeliveryResult,
}),
...zaloRawSendResultAdapter,
},
});

View File

@@ -0,0 +1,31 @@
// Zalo tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { ZaloConfigSchema } from "./config-schema.js";
describe("ZaloConfigSchema SecretInput", () => {
it("accepts SecretRef botToken and webhookSecret at top-level", () => {
const result = ZaloConfigSchema.safeParse({
botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" },
webhookUrl: "https://example.com/zalo",
webhookSecret: { source: "env", provider: "default", id: "ZALO_WEBHOOK_SECRET" },
});
expect(result.success).toBe(true);
});
it("accepts SecretRef botToken and webhookSecret on account", () => {
const result = ZaloConfigSchema.safeParse({
accounts: {
work: {
botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" },
webhookUrl: "https://example.com/zalo/work",
webhookSecret: {
source: "env",
provider: "default",
id: "ZALO_WORK_WEBHOOK_SECRET",
},
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,30 @@
// Zalo helper module supports config schema behavior.
import {
AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: buildSecretInputSchema().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: AllowFromListSchema,
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);

View File

@@ -0,0 +1,24 @@
// Zalo plugin module implements group access behavior.
import type { GroupPolicy } from "openclaw/plugin-sdk/config-contracts";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
export function normalizeZaloAllowEntry(value: string): string {
return value.trim().replace(ZALO_ALLOW_FROM_PREFIX_RE, "").trim().toLowerCase();
}
export function resolveZaloRuntimeGroupPolicy(params: {
providerConfigPresent: boolean;
groupPolicy?: GroupPolicy;
defaultGroupPolicy?: GroupPolicy;
}): {
groupPolicy: GroupPolicy;
providerMissingFallbackApplied: boolean;
} {
return resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
});
}

View File

@@ -0,0 +1,50 @@
// Zalo tests cover monitor durable plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
prepareZaloDurableReplyPayload,
resolveZaloDurableReplyOptions,
} from "./monitor-durable.js";
describe("Zalo durable reply helpers", () => {
it("normalizes markdown tables before durable or legacy delivery", () => {
const convertMarkdownTables = vi.fn(() => "converted table");
expect(
prepareZaloDurableReplyPayload({
payload: { text: "| a |\n| - |" },
tableMode: "code",
convertMarkdownTables,
}),
).toEqual({ text: "converted table" });
expect(convertMarkdownTables).toHaveBeenCalledWith("| a |\n| - |", "code");
});
it("uses durable final delivery for text-only final replies", () => {
expect(
resolveZaloDurableReplyOptions({
payload: { text: "hello" },
infoKind: "final",
chatId: "123456789",
}),
).toEqual({
to: "123456789",
});
});
it("keeps media and non-final replies on the legacy path", () => {
expect(
resolveZaloDurableReplyOptions({
payload: { text: "photo", mediaUrl: "https://example.com/photo.jpg" },
infoKind: "final",
chatId: "123456789",
}),
).toBe(false);
expect(
resolveZaloDurableReplyOptions({
payload: { text: "hello" },
infoKind: "block",
chatId: "123456789",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
// Zalo plugin module implements monitor durable behavior.
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export type ZaloDurableReplyOptions = {
to: string;
};
export function prepareZaloDurableReplyPayload(params: {
payload: OutboundReplyPayload;
tableMode: MarkdownTableMode;
convertMarkdownTables: (text: string, tableMode: MarkdownTableMode) => string;
}): OutboundReplyPayload {
if (!params.payload.text) {
return params.payload;
}
return {
...params.payload,
text: params.convertMarkdownTables(params.payload.text, params.tableMode),
};
}
export function resolveZaloDurableReplyOptions(params: {
payload: OutboundReplyPayload;
infoKind: string;
chatId: string;
}): ZaloDurableReplyOptions | false {
if (params.infoKind !== "final") {
return false;
}
const reply = resolveSendableOutboundReplyParts(params.payload);
if (reply.hasMedia || !reply.hasText) {
return false;
}
return {
to: params.chatId,
};
}

View File

@@ -0,0 +1,214 @@
// Zalo tests cover monitor.group policy plugin behavior.
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { normalizeZaloAllowEntry, resolveZaloRuntimeGroupPolicy } from "./group-access.js";
import type { ZaloAccountConfig } from "./types.js";
function stringEntries(entries: Array<string | number> | undefined): string[] {
return (entries ?? []).map((entry) => String(entry));
}
const groupPolicyCases: Array<[string, ZaloAccountConfig, string, boolean, string]> = [
[
"disabled policy",
{ groupPolicy: "disabled", groupAllowFrom: ["zalo:123"] },
"123",
false,
"group_policy_disabled",
],
[
"empty allowlist",
{ groupPolicy: "allowlist", groupAllowFrom: [] },
"attacker",
false,
"group_policy_empty_allowlist",
],
[
"allowlist mismatch",
{ groupPolicy: "allowlist", groupAllowFrom: ["zalo:victim-user-001"] },
"attacker-user-999",
false,
"group_policy_not_allowlisted",
],
[
"Zalo prefix match",
{ groupPolicy: "allowlist", groupAllowFrom: ["zl:12345"] },
"12345",
true,
"group_policy_allowed",
],
[
"allowFrom fallback",
{ groupPolicy: "allowlist", allowFrom: ["zl:12345"], groupAllowFrom: [] },
"12345",
true,
"group_policy_allowed",
],
[
"open policy",
{ groupPolicy: "open", groupAllowFrom: [] },
"attacker-user-999",
true,
"group_policy_open",
],
];
async function resolveAccess(
params: {
cfg?: OpenClawConfig;
accountConfig?: ZaloAccountConfig;
providerConfigPresent?: boolean;
defaultGroupPolicy?: GroupPolicy;
isGroup?: boolean;
senderId?: string;
rawBody?: string;
storeAllowFrom?: string[];
shouldComputeCommandAuthorized?: boolean;
} = {},
) {
const readAllowFromStore = vi.fn(async () => params.storeAllowFrom ?? []);
const accountConfig = {
dmPolicy: "pairing",
groupPolicy: "allowlist",
allowFrom: [],
groupAllowFrom: [],
...params.accountConfig,
} satisfies ZaloAccountConfig;
const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent ?? true,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy ?? "open",
});
const shouldComputeAuth = params.shouldComputeCommandAuthorized ?? false;
const isGroup = params.isGroup ?? true;
const result = await resolveStableChannelMessageIngress({
channelId: "zalo",
accountId: "default",
identity: {
key: "zalo-user-id",
normalize: normalizeZaloAllowEntry,
sensitivity: "pii",
entryIdPrefix: "zalo-entry",
},
accessGroups: params.cfg?.accessGroups,
readStoreAllowFrom: async () => await readAllowFromStore(),
useAccessGroups: params.cfg?.commands?.useAccessGroups !== false,
subject: { stableId: params.senderId ?? "123" },
conversation: {
kind: isGroup ? "group" : "direct",
id: "chat-1",
},
providerMissingFallbackApplied,
dmPolicy: accountConfig.dmPolicy ?? "pairing",
groupPolicy,
policy: { groupAllowFromFallbackToAllowFrom: true },
allowFrom: stringEntries(accountConfig.allowFrom),
groupAllowFrom: stringEntries(accountConfig.groupAllowFrom),
command: shouldComputeAuth ? {} : undefined,
});
return { result, readAllowFromStore };
}
function stableSenderAccess(access: { allowed: boolean; decision: string; reasonCode: string }) {
return {
allowed: access.allowed,
decision: access.decision,
reasonCode: access.reasonCode,
};
}
describe("zalo shared ingress access policy", () => {
it.each(groupPolicyCases)(
"maps %s through shared ingress",
async (_name, accountConfig, senderId, allowed, reasonCode) => {
const { result } = await resolveAccess({ accountConfig, senderId });
expect(stableSenderAccess(result.senderAccess)).toEqual({
allowed,
decision: allowed ? "allow" : "block",
reasonCode,
});
},
);
it("keeps group control-command authorization separate from group sender access", async () => {
const { result } = await resolveAccess({
accountConfig: {
groupPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
},
rawBody: "/reset",
shouldComputeCommandAuthorized: true,
});
expect(result.senderAccess.decision).toBe("allow");
expect(result.commandAccess.authorized).toBe(false);
});
it("authorizes direct commands from the pairing store", async () => {
const { result, readAllowFromStore } = await resolveAccess({
isGroup: false,
accountConfig: {
dmPolicy: "pairing",
allowFrom: [],
},
senderId: "12345",
storeAllowFrom: ["zl:12345"],
rawBody: "/status",
shouldComputeCommandAuthorized: true,
});
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
expect(stableSenderAccess(result.senderAccess)).toEqual({
allowed: true,
decision: "allow",
reasonCode: "dm_policy_allowlisted",
});
expect(result.commandAccess.authorized).toBe(true);
});
it("requires an explicit wildcard or allowlist match for open DMs", async () => {
const { result, readAllowFromStore } = await resolveAccess({
isGroup: false,
accountConfig: {
dmPolicy: "open",
allowFrom: [],
},
senderId: "12345",
});
expect(readAllowFromStore).not.toHaveBeenCalled();
expect(stableSenderAccess(result.senderAccess)).toEqual({
allowed: false,
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
});
});
it("matches static access-group entries through the shared ingress resolver", async () => {
const { result } = await resolveAccess({
cfg: {
accessGroups: {
operators: {
type: "message.senders",
members: {
zalo: ["zl:12345"],
},
},
},
},
accountConfig: {
groupPolicy: "allowlist",
groupAllowFrom: ["accessGroup:operators"],
},
senderId: "12345",
});
expect(stableSenderAccess(result.senderAccess)).toEqual({
allowed: true,
decision: "allow",
reasonCode: "group_policy_allowed",
});
});
});

View File

@@ -0,0 +1,155 @@
// Zalo tests cover monitor.image.polling plugin behavior.
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createImageLifecycleCore,
createImageUpdate,
createLifecycleMonitorSetup,
expectImageLifecycleDelivery,
settleAsyncWork,
} from "./test-support/lifecycle-test-support.js";
import {
getUpdatesMock,
getZaloRuntimeMock,
loadCachedLifecycleMonitorModule,
resetLifecycleTestState,
sendMessageMock,
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo polling image handling", () => {
const {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
beforeEach(async () => {
await resetLifecycleTestState();
getZaloRuntimeMock.mockReturnValue(core);
});
afterAll(async () => {
await resetLifecycleTestState();
});
it("downloads inbound image media from photo_url and preserves display_name", async () => {
getUpdatesMock
.mockResolvedValueOnce({
ok: true,
result: createImageUpdate({ date: 1774084566880 }),
})
.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
const abort = new AbortController();
const runtime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "default",
dmPolicy: "open",
});
const run = monitorZaloProvider({
token: "zalo-token", // pragma: allowlist secret
account,
config,
runtime,
abortSignal: abort.signal,
});
await settleAsyncWork();
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expectImageLifecycleDelivery({
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
finalizeInboundContextMock,
recordInboundSessionMock,
});
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({ Timestamp: 1774084566880 }),
);
abort.abort();
await run;
});
it("rejects unauthorized DM images before downloading media", async () => {
getUpdatesMock
.mockResolvedValueOnce({
ok: true,
result: createImageUpdate({
messageId: "msg-unauthorized-1",
userId: "user-unauthorized-1",
chatId: "chat-unauthorized-1",
}),
})
.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
const abort = new AbortController();
const runtime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "default",
dmPolicy: "pairing",
allowFrom: ["allowed-user"],
});
const run = monitorZaloProvider({
token: "zalo-token", // pragma: allowlist secret
account,
config,
runtime,
abortSignal: abort.signal,
});
await settleAsyncWork();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
expect(recordInboundSessionMock).not.toHaveBeenCalled();
abort.abort();
await run;
});
it("dispatches an unavailable notice when the inbound image download fails", async () => {
saveRemoteMediaMock.mockRejectedValueOnce(new Error("expired image URL"));
getUpdatesMock
.mockResolvedValueOnce({
ok: true,
result: createImageUpdate({ caption: "/reset" }),
})
.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
const abort = new AbortController();
const runtime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "default",
dmPolicy: "open",
});
const run = monitorZaloProvider({
token: "zalo-token", // pragma: allowlist secret
account,
config,
runtime,
abortSignal: abort.signal,
});
await vi.waitFor(() => expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1));
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
RawBody: "/reset",
CommandBody: "/reset",
BodyForAgent: "/reset\n\n[zalo image attachment unavailable]",
MediaPath: undefined,
}),
);
abort.abort();
await run;
});
});

View File

@@ -0,0 +1,197 @@
// Zalo tests cover monitor.lifecycle plugin behavior.
import {
createEmptyPluginRegistry,
createRuntimeEnv,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "./accounts.js";
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
vi.mock("./api.js", async () => {
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
return {
...actual,
deleteWebhook: deleteWebhookMock,
getWebhookInfo: getWebhookInfoMock,
getUpdates: getUpdatesMock,
setWebhook: setWebhookMock,
};
});
vi.mock("./runtime.js", () => ({
getZaloRuntime: () => ({
logging: {
shouldLogVerbose: () => false,
},
}),
}));
const TEST_ACCOUNT = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const TEST_CONFIG = {} as OpenClawConfig;
async function settleLifecycleWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => {
setImmediate(resolve);
});
}
}
async function startLifecycleMonitor(
options: {
useWebhook?: boolean;
webhookSecret?: string;
webhookUrl?: string;
} = {},
) {
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = createRuntimeEnv();
const run = monitorZaloProvider({
token: "test-token",
account: TEST_ACCOUNT,
config: TEST_CONFIG,
runtime,
abortSignal: abort.signal,
...options,
});
return { abort, runtime, run };
}
describe("monitorZaloProvider lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("stays alive in polling mode until abort", async () => {
let settled = false;
const { abort, runtime, run } = await startLifecycleMonitor();
const monitoredRun = run.then(() => {
settled = true;
});
await settleLifecycleWork();
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).not.toHaveBeenCalled();
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
expect(settled).toBe(false);
abort.abort();
await monitoredRun;
expect(settled).toBe(true);
expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=polling");
});
it("deletes an existing webhook before polling", async () => {
getWebhookInfoMock.mockResolvedValueOnce({
ok: true,
result: { url: "https://example.com/hooks/zalo" },
});
const { abort, runtime, run } = await startLifecycleMonitor();
await settleLifecycleWork();
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith(
"[default] Zalo polling mode ready (webhook disabled)",
);
abort.abort();
await run;
});
it("continues polling when webhook inspection returns 404", async () => {
const { ZaloApiError } = await import("./api.js");
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
const { abort, runtime, run } = await startLifecycleMonitor();
await settleLifecycleWork();
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
"[default] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup",
);
expect(runtime.error).not.toHaveBeenCalled();
abort.abort();
await run;
});
it("waits for webhook deletion before finishing webhook shutdown", async () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
let resolveSetWebhookCalled: (() => void) | undefined;
const setWebhookCalled = new Promise<void>((resolve) => {
resolveSetWebhookCalled = resolve;
});
setWebhookMock.mockImplementationOnce(async () => {
resolveSetWebhookCalled?.();
return { ok: true, result: { url: "" } };
});
let resolveDeleteWebhookCalled: (() => void) | undefined;
const deleteWebhookCalled = new Promise<void>((resolve) => {
resolveDeleteWebhookCalled = resolve;
});
let resolveDeleteWebhook: (() => void) | undefined;
deleteWebhookMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDeleteWebhookCalled?.();
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
}),
);
let settled = false;
const { abort, runtime, run } = await startLifecycleMonitor({
useWebhook: true,
webhookUrl: "https://example.com/hooks/zalo",
webhookSecret: "supersecret", // pragma: allowlist secret
});
const monitoredRun = run.then(() => {
settled = true;
});
await setWebhookCalled;
await settleLifecycleWork();
expect(setWebhookMock).toHaveBeenCalledTimes(1);
expect(registry.httpRoutes).toHaveLength(2);
abort.abort();
await deleteWebhookCalled;
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
expect(settled).toBe(false);
expect(registry.httpRoutes).toHaveLength(2);
resolveDeleteWebhook?.();
await monitoredRun;
expect(settled).toBe(true);
expect(registry.httpRoutes).toHaveLength(0);
expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=webhook");
});
});

View File

@@ -0,0 +1,144 @@
// Zalo tests cover monitor.pairing.lifecycle plugin behavior.
import { withServer } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createLifecycleMonitorSetup,
createTextUpdate,
postWebhookReplay,
settleAsyncWork,
} from "./test-support/lifecycle-test-support.js";
import {
resetLifecycleTestState,
sendMessageMock,
setLifecycleRuntimeCore,
startWebhookLifecycleMonitor,
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo pairing lifecycle", () => {
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
beforeEach(async () => {
await resetLifecycleTestState();
setLifecycleRuntimeCore({
pairing: {
readAllowFromStore: readAllowFromStoreMock,
upsertPairingRequest: upsertPairingRequestMock,
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
});
});
afterAll(async () => {
await resetLifecycleTestState();
});
function createPairingMonitorSetup() {
return createLifecycleMonitorSetup({
accountId: "acct-zalo-pairing",
dmPolicy: "pairing",
allowFrom: [],
});
}
it("emits one pairing reply across duplicate webhook replay and scopes reads and writes to accountId", async () => {
const monitor = await startWebhookLifecycleMonitor({
...createPairingMonitorSetup(),
cacheKey: "zalo-pairing-lifecycle",
});
try {
await withServer(
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
path: "/hooks/zalo",
secret: "supersecret",
payload: createTextUpdate({
messageId: `zalo-pairing-${Date.now()}`,
userId: "user-unauthorized",
userName: "Unauthorized User",
chatId: "dm-pairing-1",
}),
});
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
await settleAsyncWork();
},
);
expect(readAllowFromStoreMock).toHaveBeenCalledTimes(1);
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
channel: "zalo",
accountId: "acct-zalo-pairing",
});
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
expect(upsertPairingRequestMock).toHaveBeenCalledWith({
channel: "zalo",
accountId: "acct-zalo-pairing",
id: "user-unauthorized",
meta: { name: "Unauthorized User" },
});
expect(sendMessageMock).toHaveBeenCalledTimes(1);
const [sendToken, sendPayload, sendOptions] = sendMessageMock.mock.calls[0] as [
string,
{ chat_id?: string; text?: string },
unknown,
];
expect(sendToken).toBe("zalo-token");
expect(sendPayload.chat_id).toBe("dm-pairing-1");
expect(sendPayload.text).toContain("PAIRCODE");
expect(sendOptions).toBeUndefined();
} finally {
await monitor.stop();
}
});
it("does not emit a second pairing reply when replay arrives after the first send fails", async () => {
sendMessageMock.mockRejectedValueOnce(new Error("pairing send failed"));
const monitor = await startWebhookLifecycleMonitor({
...createPairingMonitorSetup(),
cacheKey: "zalo-pairing-lifecycle",
});
try {
await withServer(
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
path: "/hooks/zalo",
secret: "supersecret",
payload: createTextUpdate({
messageId: `zalo-pairing-retry-${Date.now()}`,
userId: "user-unauthorized",
userName: "Unauthorized User",
chatId: "dm-pairing-1",
}),
settleBeforeReplay: true,
});
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
await settleAsyncWork();
},
);
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(monitor.runtime.error).not.toHaveBeenCalled();
} finally {
await monitor.stop();
}
});
});

View File

@@ -0,0 +1,453 @@
// Zalo tests cover monitor.polling.media reply plugin behavior.
import type { ServerResponse } from "node:http";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import {
createEmptyPluginRegistry,
createRuntimeEnv,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import { setZaloRuntime } from "./runtime.js";
import {
createLifecycleMonitorSetup,
createTextUpdate,
settleAsyncWork,
} from "./test-support/lifecycle-test-support.js";
import {
getUpdatesMock,
loadCachedLifecycleMonitorModule,
resetLifecycleTestState,
sendPhotoMock,
setLifecycleRuntimeCore,
} from "./test-support/monitor-mocks-test-support.js";
const prepareHostedZaloMediaUrlMock = vi.fn();
vi.mock("./outbound-media.js", async () => {
const actual = await vi.importActual<typeof import("./outbound-media.js")>("./outbound-media.js");
return {
...actual,
prepareHostedZaloMediaUrl: (...args: unknown[]) => prepareHostedZaloMediaUrlMock(...args),
};
});
import { clearHostedZaloMediaForTest } from "./outbound-media.js";
function installZaloRuntimeForTest(): void {
setZaloRuntime({
state: {
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>("zalo", options),
},
} as unknown as PluginRuntime);
}
async function writeHostedZaloMediaFixture(params: {
id: string;
routePath: string;
token: string;
buffer: Buffer;
contentType?: string;
}): Promise<void> {
const metaStore = createPluginStateKeyedStoreForTests("zalo", {
namespace: "hosted-outbound-media",
maxEntries: 80,
});
const chunkStore = createPluginStateKeyedStoreForTests("zalo", {
namespace: "hosted-outbound-media-chunks",
maxEntries: 16_384,
});
await chunkStore.register(`media:${params.id}:chunk:0000`, {
id: params.id,
index: 0,
dataBase64: params.buffer.toString("base64"),
});
await metaStore.register(`media:${params.id}:meta`, {
id: params.id,
routePath: params.routePath,
token: params.token,
...(params.contentType ? { contentType: params.contentType } : {}),
expiresAt: Date.now() + 60_000,
chunkCount: 1,
byteLength: params.buffer.byteLength,
});
}
function createHostedMediaResponse() {
const headers = new Map<string, string>();
const res = {
statusCode: 200,
headersSent: false,
setHeader(name: string, value: string) {
headers.set(name, value);
},
end: vi.fn((body?: unknown) => {
res.headersSent = true;
return body;
}),
};
return { headers, res: res as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> } };
}
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
let count = 0;
for (const item of items) {
if (predicate(item)) {
count += 1;
}
}
return count;
}
describe("Zalo polling media replies", () => {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const recordInboundSessionMock = vi.fn(async () => undefined);
const resolveAgentRouteMock = vi.fn(() => ({
agentId: "main",
channel: "zalo",
accountId: "acct-zalo-polling-media",
sessionKey: "agent:main:zalo:direct:dm-chat-1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeEach(async () => {
await resetLifecycleTestState();
await clearHostedZaloMediaForTest();
resetPluginStateStoreForTests();
installZaloRuntimeForTest();
prepareHostedZaloMediaUrlMock.mockReset();
prepareHostedZaloMediaUrlMock.mockResolvedValue(
"https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
);
dispatchReplyWithBufferedBlockDispatcherMock.mockReset();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
async (params: {
dispatcherOptions: {
deliver: (payload: { text: string; mediaUrl: string }) => Promise<void>;
};
}) => {
await params.dispatcherOptions.deliver({
text: "caption text",
mediaUrl: "https://example.com/reply-image.png",
});
},
);
setLifecycleRuntimeCore({
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
},
session: {
recordInboundSession:
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
},
});
});
afterAll(async () => {
await clearHostedZaloMediaForTest();
await resetLifecycleTestState();
});
it("hosts and sends media replies while polling when a webhook URL is configured", async () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
getUpdatesMock
.mockResolvedValueOnce({
ok: true,
result: createTextUpdate({
messageId: "polling-media-1",
userId: "user-1",
userName: "User One",
chatId: "dm-chat-1",
text: "send media",
}),
})
.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
"zalo-polling-media-reply",
);
const abort = new AbortController();
const runtime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "acct-zalo-polling-media",
dmPolicy: "open",
webhookUrl: "https://example.com/hooks/zalo",
});
const run = monitorZaloProvider({
token: "zalo-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
try {
await settleAsyncWork();
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
expect(registry.httpRoutes).toHaveLength(1);
expect(prepareHostedZaloMediaUrlMock).toHaveBeenCalledWith({
mediaUrl: "https://example.com/reply-image.png",
webhookUrl: "https://example.com/hooks/zalo",
webhookPath: "/hooks/zalo",
maxBytes: 5 * 1024 * 1024,
proxyUrl: undefined,
});
expect(sendPhotoMock).toHaveBeenCalledWith(
"zalo-token",
{
chat_id: "dm-chat-1",
photo: "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
caption: "caption text",
},
undefined,
);
} finally {
abort.abort();
await run;
}
expect(registry.httpRoutes).toHaveLength(0);
});
it("sends media replies directly when webhook hosting is not configured", async () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
getUpdatesMock
.mockResolvedValueOnce({
ok: true,
result: createTextUpdate({
messageId: "polling-media-2",
userId: "user-2",
userName: "User Two",
chatId: "dm-chat-2",
text: "send media directly",
}),
})
.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
"zalo-polling-media-reply",
);
const abort = new AbortController();
const runtime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "acct-zalo-polling-direct-media",
dmPolicy: "open",
webhookUrl: "",
});
const run = monitorZaloProvider({
token: "zalo-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
try {
await settleAsyncWork();
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
expect(prepareHostedZaloMediaUrlMock).not.toHaveBeenCalled();
expect(sendPhotoMock).toHaveBeenCalledWith(
"zalo-token",
{
chat_id: "dm-chat-2",
photo: "https://example.com/reply-image.png",
caption: "caption text",
},
undefined,
);
} finally {
abort.abort();
await run;
}
});
it("shares one hosted media route across accounts on the same path", async () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
"zalo-polling-media-reply",
);
const firstAbort = new AbortController();
const firstRuntime = createRuntimeEnv();
const firstSetup = createLifecycleMonitorSetup({
accountId: "acct-zalo-polling-media-one",
dmPolicy: "open",
webhookUrl: "https://example.com/hooks/zalo",
});
const firstRun = monitorZaloProvider({
token: "zalo-token-one",
account: firstSetup.account,
config: firstSetup.config,
runtime: firstRuntime,
abortSignal: firstAbort.signal,
});
const secondAbort = new AbortController();
let secondRun: Promise<void> | undefined;
try {
await settleAsyncWork();
const firstHostedMediaRoutes = registry.httpRoutes.filter(
(route) => route.source === "zalo-hosted-media",
);
expect(firstHostedMediaRoutes).toHaveLength(1);
const hostedMediaRoute = firstHostedMediaRoutes[0];
expect(hostedMediaRoute?.path).toBe("/hooks/zalo/media");
expect(hostedMediaRoute?.pluginId).toBe("zalo");
expect(hostedMediaRoute?.source).toBe("zalo-hosted-media");
expect(hostedMediaRoute?.handler).toBeTypeOf("function");
const secondRuntime = createRuntimeEnv();
const secondSetup = createLifecycleMonitorSetup({
accountId: "acct-zalo-polling-media-two",
dmPolicy: "open",
webhookUrl: "https://example.com/hooks/zalo",
});
secondRun = monitorZaloProvider({
token: "zalo-token-two",
account: secondSetup.account,
config: secondSetup.config,
runtime: secondRuntime,
abortSignal: secondAbort.signal,
});
await settleAsyncWork();
const hostedMediaRoutes = registry.httpRoutes.filter(
(route) => route.source === "zalo-hosted-media",
);
expect(hostedMediaRoutes).toHaveLength(1);
expect(hostedMediaRoutes[0]).toBe(hostedMediaRoute);
await writeHostedZaloMediaFixture({
id: "abc123abc123abc123abc123",
routePath: "/hooks/zalo/media/",
token: "route-token-one",
buffer: Buffer.from("first-image-bytes"),
contentType: "image/png",
});
const firstFetch = createHostedMediaResponse();
await hostedMediaRoute.handler(
{
method: "GET",
url: "/hooks/zalo/media/abc123abc123abc123abc123?token=route-token-one",
} as never,
firstFetch.res as never,
);
expect(firstFetch.res.statusCode).toBe(200);
expect(firstFetch.headers.get("Content-Type")).toBe("image/png");
expect(firstFetch.headers.get("Cache-Control")).toBe("no-store");
expect(firstFetch.res.end).toHaveBeenCalledWith(Buffer.from("first-image-bytes"));
firstAbort.abort();
await firstRun;
expect(registry.httpRoutes.find((route) => route.source === "zalo-hosted-media")).toEqual(
hostedMediaRoute,
);
expect(
countMatching(registry.httpRoutes, (route) => route.source === "zalo-hosted-media"),
).toBe(1);
await writeHostedZaloMediaFixture({
id: "def456def456def456def456",
routePath: "/hooks/zalo/media/",
token: "route-token-two",
buffer: Buffer.from("second-image-bytes"),
contentType: "image/jpeg",
});
const secondFetch = createHostedMediaResponse();
await hostedMediaRoute.handler(
{
method: "GET",
url: "/hooks/zalo/media/def456def456def456def456?token=route-token-two",
} as never,
secondFetch.res as never,
);
expect(secondFetch.res.statusCode).toBe(200);
expect(secondFetch.headers.get("Content-Type")).toBe("image/jpeg");
expect(secondFetch.res.end).toHaveBeenCalledWith(Buffer.from("second-image-bytes"));
} finally {
firstAbort.abort();
secondAbort.abort();
await firstRun;
await secondRun;
}
expect(
registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media"),
).toHaveLength(0);
});
it("re-registers the hosted media route after the active registry swaps", async () => {
const firstRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(firstRegistry);
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
"zalo-polling-media-reply",
);
const firstAbort = new AbortController();
const firstRuntime = createRuntimeEnv();
const { account, config } = createLifecycleMonitorSetup({
accountId: "acct-zalo-polling-media",
dmPolicy: "open",
webhookUrl: "https://example.com/hooks/zalo",
});
const firstRun = monitorZaloProvider({
token: "zalo-token",
account,
config,
runtime: firstRuntime,
abortSignal: firstAbort.signal,
});
const secondRegistry = createEmptyPluginRegistry();
const secondAbort = new AbortController();
const secondRuntime = createRuntimeEnv();
let secondRun: Promise<void> | undefined;
try {
await settleAsyncWork();
expect(firstRegistry.httpRoutes).toHaveLength(1);
setActivePluginRegistry(secondRegistry);
secondRun = monitorZaloProvider({
token: "zalo-token",
account,
config,
runtime: secondRuntime,
abortSignal: secondAbort.signal,
});
await settleAsyncWork();
expect(secondRegistry.httpRoutes).toHaveLength(1);
} finally {
firstAbort.abort();
secondAbort.abort();
await firstRun;
await secondRun;
}
expect(firstRegistry.httpRoutes).toHaveLength(0);
expect(secondRegistry.httpRoutes).toHaveLength(0);
});
});

View File

@@ -0,0 +1,183 @@
// Zalo tests cover monitor.reply once.lifecycle plugin behavior.
import { withServer } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import {
createLifecycleMonitorSetup,
createTextUpdate,
postWebhookReplay,
settleAsyncWork,
} from "./test-support/lifecycle-test-support.js";
import {
resetLifecycleTestState,
sendMessageMock,
setLifecycleRuntimeCore,
startWebhookLifecycleMonitor,
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo reply-once lifecycle", () => {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const recordInboundSessionMock = vi.fn(
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
);
const resolveAgentRouteMock = vi.fn(() => ({
agentId: "main",
channel: "zalo",
accountId: "acct-zalo-lifecycle",
sessionKey: "agent:main:zalo:direct:dm-chat-1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeEach(async () => {
await resetLifecycleTestState();
setLifecycleRuntimeCore({
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
},
session: {
recordInboundSession:
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
},
});
});
afterAll(async () => {
await resetLifecycleTestState();
});
function createReplyOnceMonitorSetup() {
return createLifecycleMonitorSetup({
accountId: "acct-zalo-lifecycle",
dmPolicy: "open",
});
}
function requireRecordInboundSessionArgs() {
const [call] = recordInboundSessionMock.mock.calls;
if (!call) {
throw new Error("expected inbound session record call");
}
const [recordArgs] = call;
return recordArgs;
}
it("routes one accepted webhook event to one visible reply across duplicate replay", async () => {
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "zalo reply once" });
},
);
const monitor = await startWebhookLifecycleMonitor({
...createReplyOnceMonitorSetup(),
cacheKey: "zalo-reply-once-lifecycle",
});
try {
await withServer(
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
path: "/hooks/zalo",
secret: "supersecret",
payload: createTextUpdate({
messageId: `zalo-replay-${Date.now()}`,
userId: "user-1",
userName: "User One",
chatId: "dm-chat-1",
}),
});
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
await settleAsyncWork();
},
);
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
const recordArgs = requireRecordInboundSessionArgs();
expect(recordArgs?.sessionKey).toBe("agent:main:zalo:direct:dm-chat-1");
expect(recordArgs?.ctx?.AccountId).toBe("acct-zalo-lifecycle");
expect(recordArgs?.ctx?.SessionKey).toBe("agent:main:zalo:direct:dm-chat-1");
expect(recordArgs?.ctx?.From).toBe("zalo:user-1");
expect(recordArgs?.ctx?.To).toBe("zalo:dm-chat-1");
expect(recordArgs?.ctx?.MessageSid).toContain("zalo-replay-");
expect(sendMessageMock).toHaveBeenCalledTimes(1);
const [sendToken, sendPayload, sendOptions] = sendMessageMock.mock.calls[0] as [
string,
{ chat_id?: string; text?: string },
unknown,
];
expect(sendToken).toBe("zalo-token");
expect(sendPayload.chat_id).toBe("dm-chat-1");
expect(sendPayload.text).toBe("zalo reply once");
expect(sendOptions).toBeUndefined();
} finally {
await monitor.stop();
}
});
it("does not emit a second visible reply when replay arrives after a post-send failure", async () => {
let dispatchAttempts = 0;
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
async ({ dispatcherOptions }) => {
dispatchAttempts += 1;
await dispatcherOptions.deliver({ text: "zalo reply after failure" });
if (dispatchAttempts === 1) {
throw new Error("post-send failure");
}
},
);
const monitor = await startWebhookLifecycleMonitor({
...createReplyOnceMonitorSetup(),
cacheKey: "zalo-reply-once-lifecycle",
});
try {
await withServer(
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
path: "/hooks/zalo",
secret: "supersecret",
payload: createTextUpdate({
messageId: `zalo-retry-${Date.now()}`,
userId: "user-1",
userName: "User One",
chatId: "dm-chat-1",
}),
settleBeforeReplay: true,
});
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
await settleAsyncWork();
},
);
expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(monitor.runtime.error).toHaveBeenCalledWith(
"[acct-zalo-lifecycle] Zalo webhook failed: Error: post-send failure",
);
} finally {
await monitor.stop();
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
// Zalo type declarations define plugin contracts.
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};

View File

@@ -0,0 +1,811 @@
// Zalo tests cover monitor.webhook plugin behavior.
import type { RequestListener } from "node:http";
import {
createEmptyPluginRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { withServer } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import { handleZaloWebhookRequest } from "./monitor.js";
import type { ZaloRuntimeEnv } from "./monitor.types.js";
import {
clearZaloWebhookSecurityStateForTest,
getZaloWebhookRateLimitStateSizeForTest,
getZaloWebhookStatusCounterSizeForTest,
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
registerZaloWebhookTarget,
type ZaloWebhookProcessUpdate,
ZaloRetryableWebhookError,
} from "./monitor.webhook.js";
import {
createImageLifecycleCore,
createImageUpdate,
createTextUpdate,
expectImageLifecycleDelivery,
postWebhookReplay,
} from "./test-support/lifecycle-test-support.js";
import type { ResolvedZaloAccount } from "./types.js";
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
return (req, res) => {
void (async () => {
const handled = processUpdate
? await handleZaloWebhookRequestInternal(req, res, processUpdate)
: await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
})();
};
}
const webhookRequestHandler = createWebhookRequestHandler();
function registerTarget(params: {
path: string;
secret?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
account?: ResolvedZaloAccount;
config?: OpenClawConfig;
core?: PluginRuntime;
runtime?: Partial<ZaloRuntimeEnv>;
}): () => void {
return registerZaloWebhookTarget({
token: "tok",
account: params.account ?? DEFAULT_ACCOUNT,
config: params.config ?? ({} as OpenClawConfig),
runtime: (params.runtime ?? {}) as ZaloRuntimeEnv,
core: params.core ?? ({} as PluginRuntime),
secret: params.secret ?? "secret",
path: params.path,
webhookUrl: `https://example.com${params.path}`,
webhookPath: params.path,
mediaMaxMb: 5,
canHostMedia: true,
statusSink: params.statusSink,
});
}
function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
core: PluginRuntime;
readAllowFromStore: ReturnType<typeof vi.fn>;
upsertPairingRequest: ReturnType<typeof vi.fn>;
} {
const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
const upsertPairingRequest = vi
.fn()
.mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
const core = {
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
},
} as unknown as PluginRuntime;
return { core, readAllowFromStore, upsertPairingRequest };
}
async function postUntilRateLimited(params: {
baseUrl: string;
path: string;
secret: string;
withNonceQuery?: boolean;
attempts?: number;
}): Promise<boolean> {
const attempts = params.attempts ?? 130;
for (let i = 0; i < attempts; i += 1) {
const url = params.withNonceQuery
? `${params.baseUrl}${params.path}?nonce=${i}`
: `${params.baseUrl}${params.path}`;
const response = await fetch(url, {
method: "POST",
headers: {
"x-bot-api-secret-token": params.secret,
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
return true;
}
}
return false;
}
async function postWebhookJson(params: {
baseUrl: string;
path: string;
secret: string;
payload: unknown;
}) {
return fetch(`${params.baseUrl}${params.path}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": params.secret,
"content-type": "application/json",
},
body: JSON.stringify(params.payload),
});
}
async function expectTwoWebhookPostsOk(params: {
baseUrl: string;
first: { path: string; secret: string; payload: unknown };
second: { path: string; secret: string; payload: unknown };
}) {
const first = await postWebhookJson({
baseUrl: params.baseUrl,
path: params.first.path,
secret: params.first.secret,
payload: params.first.payload,
});
const second = await postWebhookJson({
baseUrl: params.baseUrl,
path: params.second.path,
secret: params.second.secret,
payload: params.second.payload,
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
}
describe("handleZaloWebhookRequest", () => {
afterEach(() => {
clearZaloWebhookSecurityStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("returns 400 for non-object payloads", async () => {
const unregister = registerTarget({ path: "/hook" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "null",
});
expect(response.status).toBe(400);
expect(await response.text()).toBe("Bad Request");
});
} finally {
unregister();
}
});
it("rejects ambiguous routing when multiple targets match the same secret", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
expect(response.status).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
});
} finally {
unregisterA();
unregisterB();
}
});
it("returns 415 for non-json content-type", async () => {
const unregister = registerTarget({ path: "/hook-content-type" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-content-type`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "text/plain",
},
body: "{}",
});
expect(response.status).toBe(415);
});
} finally {
unregister();
}
});
it("deduplicates webhook replay for the same event origin", async () => {
const sink = vi.fn();
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
const payload = createTextUpdate({
messageId: "msg-replay-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
path: "/hook-replay",
secret: "secret",
payload,
});
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
expect(sink).toHaveBeenCalledTimes(1);
});
} finally {
unregister();
}
});
it("allows a retry after processUpdate throws a retryable replay error", async () => {
const error = vi.fn();
const unregister = registerTarget({
path: "/hook-retry-after-failure",
runtime: { error },
});
const payload = createTextUpdate({
messageId: "msg-retry-after-failure-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
let attempts = 0;
const processUpdate = vi.fn<ZaloWebhookProcessUpdate>(async () => {
attempts += 1;
if (attempts === 1) {
throw new ZaloRetryableWebhookError("boom");
}
});
try {
await withServer(createWebhookRequestHandler(processUpdate), async (baseUrl) => {
const first = await postWebhookJson({
baseUrl,
path: "/hook-retry-after-failure",
secret: "secret",
payload,
});
expect(first.status).toBe(200);
await vi.waitFor(() => expect(error).toHaveBeenCalledTimes(1));
const second = await postWebhookJson({
baseUrl,
path: "/hook-retry-after-failure",
secret: "secret",
payload,
});
expect(second.status).toBe(200);
await vi.waitFor(() => expect(processUpdate).toHaveBeenCalledTimes(2));
});
} finally {
unregister();
}
});
it("keeps replay dedupe isolated per authenticated target", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
const unregisterA = registerTarget({
path: "/hook-replay-scope",
secret: "secret-a",
statusSink: sinkA,
});
const unregisterB = registerTarget({
path: "/hook-replay-scope",
secret: "secret-b",
statusSink: sinkB,
account: {
...DEFAULT_ACCOUNT,
accountId: "work",
},
});
const payload = createTextUpdate({
messageId: "msg-replay-scope-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
await expectTwoWebhookPostsOk({
baseUrl,
first: { path: "/hook-replay-scope", secret: "secret-a", payload },
second: { path: "/hook-replay-scope", secret: "secret-b", payload },
});
});
expect(sinkA).toHaveBeenCalledTimes(1);
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
it("does not collide replay dedupe across different chats", async () => {
const sink = vi.fn();
const unregister = registerTarget({ path: "/hook-replay-chat-scope", statusSink: sink });
const firstPayload = createTextUpdate({
messageId: "msg-replay-chat-1",
userId: "123",
userName: "",
chatId: "chat-a",
text: "hello from a",
});
const secondPayload = createTextUpdate({
messageId: "msg-replay-chat-1",
userId: "123",
userName: "",
chatId: "chat-b",
text: "hello from b",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
await expectTwoWebhookPostsOk({
baseUrl,
first: { path: "/hook-replay-chat-scope", secret: "secret", payload: firstPayload },
second: { path: "/hook-replay-chat-scope", secret: "secret", payload: secondPayload },
});
});
expect(sink).toHaveBeenCalledTimes(2);
} finally {
unregister();
}
});
it("does not collide replay dedupe across different senders in the same chat", async () => {
const sink = vi.fn();
const unregister = registerTarget({ path: "/hook-replay-sender-scope", statusSink: sink });
const firstPayload = createTextUpdate({
messageId: "msg-replay-sender-1",
userId: "user-a",
userName: "",
chatId: "chat-shared",
text: "hello from user a",
});
const secondPayload = createTextUpdate({
messageId: "msg-replay-sender-1",
userId: "user-b",
userName: "",
chatId: "chat-shared",
text: "hello from user b",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
await expectTwoWebhookPostsOk({
baseUrl,
first: { path: "/hook-replay-sender-scope", secret: "secret", payload: firstPayload },
second: { path: "/hook-replay-sender-scope", secret: "secret", payload: secondPayload },
});
});
expect(sink).toHaveBeenCalledTimes(2);
} finally {
unregister();
}
});
it("accepts replay metadata when optional fields are missing", async () => {
const sink = vi.fn();
const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink });
const payload = {
event_name: "message.text.received",
message: {
message_id: "msg-replay-partial-1",
date: Math.floor(Date.now() / 1000),
text: "hello",
},
};
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-replay-partial`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
});
expect(sink).toHaveBeenCalledTimes(1);
} finally {
unregister();
}
});
it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
// Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
const unregisterA = registerTarget({
path: "/hook-replay-collision:a",
secret: "secret-a",
statusSink: sinkA,
account: {
...DEFAULT_ACCOUNT,
accountId: "team",
},
});
const unregisterB = registerTarget({
path: "/hook-replay-collision",
secret: "secret-b",
statusSink: sinkB,
account: {
...DEFAULT_ACCOUNT,
accountId: "a:team",
},
});
const payload = createTextUpdate({
messageId: "msg-replay-collision-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
await expectTwoWebhookPostsOk({
baseUrl,
first: { path: "/hook-replay-collision:a", secret: "secret-a", payload },
second: { path: "/hook-replay-collision", secret: "secret-b", payload },
});
});
expect(sinkA).toHaveBeenCalledTimes(1);
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
it("keeps replay dedupe isolated across different webhook paths", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
const sharedSecret = "secret";
const unregisterA = registerTarget({
path: "/hook-replay-scope-a",
secret: sharedSecret,
statusSink: sinkA,
});
const unregisterB = registerTarget({
path: "/hook-replay-scope-b",
secret: sharedSecret,
statusSink: sinkB,
});
const payload = createTextUpdate({
messageId: "msg-replay-cross-path-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
await expectTwoWebhookPostsOk({
baseUrl,
first: { path: "/hook-replay-scope-a", secret: sharedSecret, payload },
second: { path: "/hook-replay-scope-b", secret: sharedSecret, payload },
});
});
expect(sinkA).toHaveBeenCalledTimes(1);
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
const {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
const unregister = registerTarget({
path: "/hook-image",
core,
account: {
...DEFAULT_ACCOUNT,
config: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
const payload = createImageUpdate();
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-image`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
});
} finally {
unregister();
}
await vi.waitFor(() => expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1));
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expectImageLifecycleDelivery({
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
finalizeInboundContextMock,
recordInboundSessionMock,
});
});
it("returns 429 when per-path request rate exceeds threshold", async () => {
const unregister = registerTarget({ path: "/hook-rate" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const saw429 = await postUntilRateLimited({
baseUrl,
path: "/hook-rate",
secret: "secret", // pragma: allowlist secret
});
expect(saw429).toBe(true);
});
} finally {
unregister();
}
});
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
const unregister = registerTarget({ path: "/hook-query-status" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 200; i += 1) {
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
"content-type": "application/json",
},
body: "{}",
});
expect([401, 429]).toContain(response.status);
if (response.status === 429) {
saw429 = true;
break;
}
}
expect(saw429).toBe(true);
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
});
} finally {
unregister();
}
});
it("rate limits authenticated requests even when query strings churn", async () => {
const unregister = registerTarget({ path: "/hook-query-rate" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const saw429 = await postUntilRateLimited({
baseUrl,
path: "/hook-query-rate",
secret: "secret", // pragma: allowlist secret
withNonceQuery: true,
});
expect(saw429).toBe(true);
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
});
} finally {
unregister();
}
});
it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
const unregister = registerTarget({ path: "/hook-preauth-rate" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const saw429 = await postUntilRateLimited({
baseUrl,
path: "/hook-preauth-rate",
secret: "invalid-token", // pragma: allowlist secret
withNonceQuery: true,
});
expect(saw429).toBe(true);
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
});
} finally {
unregister();
}
});
it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
const unregister = registerTarget({
path: "/hook-preauth-split",
config: {
gateway: {
trustedProxies: ["127.0.0.1"],
},
} as OpenClawConfig,
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
"content-type": "application/json",
"x-forwarded-for": "203.0.113.10",
},
body: "{}",
});
if (response.status === 429) {
break;
}
}
const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
"x-forwarded-for": "198.51.100.20",
},
body: JSON.stringify({ event_name: "message.unsupported.received" }),
});
expect(validResponse.status).toBe(200);
});
} finally {
unregister();
}
});
it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
const unregister = registerTarget({ path: "/hook-auth-before-type" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
"content-type": "text/plain",
},
body: "not-json",
});
expect(response.status).toBe(401);
});
} finally {
unregister();
}
});
it("scopes DM pairing store reads and writes to accountId", async () => {
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
pairingCreated: false,
});
const account: ResolvedZaloAccount = {
...DEFAULT_ACCOUNT,
accountId: "work",
config: {
dmPolicy: "pairing",
allowFrom: [],
},
};
const unregister = registerTarget({
path: "/hook-account-scope",
account,
core,
});
const payload = {
event_name: "message.text.received",
message: {
from: { id: "123", name: "Attacker" },
chat: { id: "dm-work", chat_type: "PRIVATE" },
message_id: "msg-work-1",
date: Math.floor(Date.now() / 1000),
text: "hello",
},
};
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-account-scope`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
});
} finally {
unregister();
}
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "zalo",
accountId: "work",
});
expect(upsertPairingRequest).toHaveBeenCalledTimes(1);
expect(upsertPairingRequest).toHaveBeenCalledWith({
channel: "zalo",
accountId: "work",
id: "123",
meta: { name: "Attacker" },
});
});
});

View File

@@ -0,0 +1,275 @@
// Zalo plugin module implements monitor.webhook behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.types.js";
import {
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
applyBasicWebhookRequestGuards,
registerWebhookTargetWithPluginRoute,
type RegisterWebhookTargetOptions,
type RegisterWebhookPluginRouteOptions,
registerWebhookTarget,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
resolveClientIp,
type OpenClawConfig,
} from "./runtime-api.js";
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
export type ZaloWebhookTarget = {
token: string;
account: ResolvedZaloAccount;
config: OpenClawConfig;
runtime: ZaloRuntimeEnv;
core: unknown;
secret: string;
path: string;
webhookUrl: string;
webhookPath: string;
mediaMaxMb: number;
canHostMedia: boolean;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
};
export type ZaloWebhookProcessUpdate = (params: {
update: ZaloUpdate;
target: ZaloWebhookTarget;
}) => Promise<void>;
const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
const webhookRateLimiter = createFixedWindowRateLimiter({
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
});
const recentWebhookEvents = createClaimableDedupe({
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
memoryMaxSize: 5000,
});
const webhookAnomalyTracker = createWebhookAnomalyTracker({
maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
});
export function clearZaloWebhookSecurityStateForTest(): void {
webhookRateLimiter.clear();
recentWebhookEvents.clearMemory();
webhookAnomalyTracker.clear();
}
export function getZaloWebhookRateLimitStateSizeForTest(): number {
return webhookRateLimiter.size();
}
export function getZaloWebhookStatusCounterSizeForTest(): number {
return webhookAnomalyTracker.size();
}
function buildReplayEventCacheKey(target: ZaloWebhookTarget, update: ZaloUpdate): string | null {
const messageId = update.message?.message_id;
if (!messageId) {
return null;
}
const chatId = update.message?.chat?.id ?? "";
const senderId = update.message?.from?.id ?? "";
return JSON.stringify([
target.path,
target.account.accountId,
update.event_name,
chatId,
senderId,
messageId,
]);
}
export class ZaloRetryableWebhookError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ZaloRetryableWebhookError";
}
}
export async function processZaloReplayGuardedUpdate(params: {
target: ZaloWebhookTarget;
update: ZaloUpdate;
processUpdate: ZaloWebhookProcessUpdate;
nowMs?: number;
}): Promise<"processed" | "duplicate"> {
const replayEventKey = buildReplayEventCacheKey(params.target, params.update);
if (replayEventKey) {
const replayClaim = await recentWebhookEvents.claim(replayEventKey, { now: params.nowMs });
if (replayClaim.kind !== "claimed") {
return "duplicate";
}
}
params.target.statusSink?.({ lastInboundAt: Date.now() });
try {
await params.processUpdate({ update: params.update, target: params.target });
if (replayEventKey) {
await recentWebhookEvents.commit(replayEventKey);
}
return "processed";
} catch (error) {
if (replayEventKey) {
if (error instanceof ZaloRetryableWebhookError) {
recentWebhookEvents.release(replayEventKey, { error });
} else {
await recentWebhookEvents.commit(replayEventKey);
}
}
throw error;
}
}
function recordWebhookStatus(
runtime: ZaloRuntimeEnv | undefined,
path: string,
statusCode: number,
): void {
webhookAnomalyTracker.record({
key: `${path}:${statusCode}`,
statusCode,
log: runtime?.log,
message: (count) =>
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(count)}`,
});
}
function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export function registerZaloWebhookTarget(
target: ZaloWebhookTarget,
opts?: {
route?: RegisterWebhookPluginRouteOptions;
} & Pick<
RegisterWebhookTargetOptions<ZaloWebhookTarget>,
"onFirstPathTarget" | "onLastPathTargetRemoved"
>,
): () => void {
if (opts?.route) {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: opts.route,
onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
}).unregister;
}
return registerWebhookTarget(webhookTargets, target, opts).unregister;
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
processUpdate: ZaloWebhookProcessUpdate,
): Promise<boolean> {
return await withResolvedWebhookRequestPipeline({
req,
res,
targetsByPath: webhookTargets,
allowMethods: ["POST"],
handle: async ({ targets, path }) => {
const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
const clientIp =
resolveClientIp({
remoteAddr: req.socket.remoteAddress,
forwardedFor: headerValue(req.headers["x-forwarded-for"]),
realIp: headerValue(req.headers["x-real-ip"]),
trustedProxies,
allowRealIpFallback,
}) ??
req.socket.remoteAddress ??
"unknown";
const rateLimitKey = `${path}:${clientIp}`;
const nowMs = Date.now();
if (
!applyBasicWebhookRequestGuards({
req,
res,
rateLimiter: webhookRateLimiter,
rateLimitKey,
nowMs,
})
) {
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
return true;
}
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
const target = resolveWebhookTargetWithAuthOrRejectSync({
targets,
res,
isMatch: (entry) => safeEqualSecret(entry.secret, headerToken),
});
if (!target) {
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
return true;
}
// Preserve the historical 401-before-415 ordering for invalid secrets while still
// consuming rate-limit budget on unauthenticated guesses.
if (
!applyBasicWebhookRequestGuards({
req,
res,
requireJsonContentType: true,
})
) {
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
maxBytes: 1024 * 1024,
timeoutMs: 30_000,
emptyObjectOnEmpty: false,
invalidJsonMessage: "Bad Request",
});
if (!body.ok) {
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
const raw = body.value;
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const update: ZaloUpdate | undefined =
record && record.ok === true && record.result
? (record.result as ZaloUpdate)
: ((record as ZaloUpdate | null) ?? undefined);
if (!update?.event_name) {
res.statusCode = 400;
res.end("Bad Request");
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
void processZaloReplayGuardedUpdate({
target,
update,
processUpdate,
nowMs,
}).catch((err: unknown) => {
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
});
res.statusCode = 200;
res.end("ok");
return true;
},
});
}

View File

@@ -0,0 +1,253 @@
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
// Zalo tests cover outbound media plugin behavior.
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
const loadWebMediaMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/web-media", () => {
return {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
};
});
import {
clearHostedZaloMediaForTest,
prepareHostedZaloMediaUrl,
resolveHostedZaloMediaRoutePrefix,
tryHandleHostedZaloMediaRequest,
} from "./outbound-media.js";
import { setZaloRuntime } from "./runtime.js";
function createMockResponse() {
const headers = new Map<string, string>();
return {
headers,
res: {
statusCode: 200,
setHeader(name: string, value: string) {
headers.set(name, value);
},
end: vi.fn(),
},
};
}
function installZaloRuntimeForTest(): void {
setZaloRuntime({
state: {
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>("zalo", options),
},
} as unknown as PluginRuntime);
}
describe("zalo outbound hosted media", () => {
beforeEach(async () => {
resetPluginStateStoreForTests();
installZaloRuntimeForTest();
await clearHostedZaloMediaForTest();
loadWebMediaMock.mockReset();
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
fileName: "photo.png",
});
});
it("loads outbound media under OpenClaw control and returns a hosted URL", async () => {
const hostedUrl = await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"https://example.com/photo.png",
expect.objectContaining({ maxBytes: 1024 }),
);
expect(hostedUrl).toMatch(
/^https:\/\/gateway\.example\.com\/zalo-webhook\/media\/[a-f0-9]+\?token=[a-f0-9]+$/,
);
});
it("passes proxy-aware fetch options into hosted media downloads", async () => {
await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
proxyUrl: "http://proxy.example:8080",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"https://example.com/photo.png",
expect.objectContaining({ maxBytes: 1024, proxyUrl: "http://proxy.example:8080" }),
);
});
it("persists hosted media in SQLite plugin state instead of temp sidecars", async () => {
const hostedUrl = await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
});
const { pathname } = new URL(hostedUrl);
const id = pathname.split("/").pop();
if (!id) {
throw new Error("expected hosted Zalo media id");
}
expect(id).toHaveLength(24);
expect(/^[0-9a-f]+$/.test(id)).toBe(true);
const metaStore = createPluginStateKeyedStoreForTests("zalo", {
namespace: "hosted-outbound-media",
maxEntries: 80,
});
const chunkStore = createPluginStateKeyedStoreForTests("zalo", {
namespace: "hosted-outbound-media-chunks",
maxEntries: 16_384,
});
const metaEntries = await metaStore.entries();
expect(metaEntries).toHaveLength(1);
expect(metaEntries[0]?.value).toMatchObject({
id,
routePath: "/zalo-webhook/media/",
contentType: "image/png",
byteLength: Buffer.byteLength("image-bytes"),
});
expect(await chunkStore.entries()).toHaveLength(1);
});
it("preserves the root webhook path when deriving the hosted media route", () => {
expect(
resolveHostedZaloMediaRoutePrefix({
webhookUrl: "https://gateway.example.com/",
}),
).toBe("/media");
});
it("serves hosted media once when the route token matches", async () => {
const hostedUrl = await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
});
const { pathname, search } = new URL(hostedUrl);
const response = createMockResponse();
const handled = await tryHandleHostedZaloMediaRequest(
{
method: "GET",
url: `${pathname}${search}`,
} as never,
response.res as never,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(response.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes"));
const secondResponse = createMockResponse();
const handledAgain = await tryHandleHostedZaloMediaRequest(
{
method: "GET",
url: `${pathname}${search}`,
} as never,
secondResponse.res as never,
);
expect(handledAgain).toBe(true);
expect(secondResponse.res.statusCode).toBe(404);
});
it("rejects hosted media preparation when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
await expect(
prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
}),
).rejects.toThrow(/expiry/);
expect(loadWebMediaMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("does not serve hosted media when the current clock is invalid", async () => {
const hostedUrl = await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/zalo-webhook",
maxBytes: 1024,
});
const { pathname, search } = new URL(hostedUrl);
const response = createMockResponse();
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
const handled = await tryHandleHostedZaloMediaRequest(
{
method: "GET",
url: `${pathname}${search}`,
} as never,
response.res as never,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(410);
expect(response.res.end).toHaveBeenCalledWith("Expired");
} finally {
dateNow.mockRestore();
}
});
it("rejects hosted media requests with the wrong token", async () => {
const hostedUrl = await prepareHostedZaloMediaUrl({
mediaUrl: "https://example.com/photo.png",
webhookUrl: "https://gateway.example.com/custom/zalo",
webhookPath: "/custom/zalo-hook",
maxBytes: 1024,
});
const pathname = new URL(hostedUrl).pathname;
const response = createMockResponse();
const handled = await tryHandleHostedZaloMediaRequest(
{
method: "GET",
url: `${pathname}?token=wrong`,
} as never,
response.res as never,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(401);
expect(response.res.end).toHaveBeenCalledWith("Unauthorized");
});
it("rejects malformed hosted media ids before touching disk", async () => {
const response = createMockResponse();
const handled = await tryHandleHostedZaloMediaRequest(
{
method: "GET",
url: "/zalo-webhook/media/not-a-valid-hex-id?token=wrong",
} as never,
response.res as never,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(404);
expect(response.res.end).toHaveBeenCalledWith("Not Found");
});
});

View File

@@ -0,0 +1,194 @@
// Zalo plugin module implements outbound media behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
createHostedOutboundMediaStore,
type HostedOutboundMediaChunkRecord,
type HostedOutboundMediaMetaRecord,
type HostedOutboundMediaStore,
} from "openclaw/plugin-sdk/outbound-media";
import { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
import { getZaloRuntime } from "./runtime.js";
const ZALO_OUTBOUND_MEDIA_TTL_MS = 2 * 60_000;
const ZALO_OUTBOUND_MEDIA_SEGMENT = "media";
const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`;
const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
const ZALO_OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media";
const ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks";
const ZALO_OUTBOUND_MEDIA_MAX_ENTRIES = 64;
const ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 256;
const ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS =
ZALO_OUTBOUND_MEDIA_MAX_ENTRIES * ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
let hostedZaloMediaStore: HostedOutboundMediaStore | undefined;
function createHostedZaloMediaStore(): HostedOutboundMediaStore {
const runtime = getZaloRuntime();
return createHostedOutboundMediaStore({
metadataStore: runtime.state.openKeyedStore<HostedOutboundMediaMetaRecord>({
namespace: ZALO_OUTBOUND_MEDIA_NAMESPACE,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES + 16,
}),
chunkStore: runtime.state.openKeyedStore<HostedOutboundMediaChunkRecord>({
namespace: ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
}),
ttlMs: ZALO_OUTBOUND_MEDIA_TTL_MS,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES,
maxChunkRows: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs),
});
}
function getHostedZaloMediaStore(): HostedOutboundMediaStore {
hostedZaloMediaStore ??= createHostedZaloMediaStore();
return hostedZaloMediaStore;
}
export function resolveHostedZaloMediaRoutePrefix(params: {
webhookUrl: string;
webhookPath?: string;
}): string {
const webhookRoutePath = resolveWebhookPath({
webhookPath: params.webhookPath,
webhookUrl: params.webhookUrl,
defaultPath: null,
});
if (!webhookRoutePath) {
throw new Error("Zalo webhookPath could not be derived for outbound media hosting");
}
return webhookRoutePath === "/"
? `/${ZALO_OUTBOUND_MEDIA_SEGMENT}`
: `${webhookRoutePath}/${ZALO_OUTBOUND_MEDIA_SEGMENT}`;
}
function resolveHostedZaloMediaRoutePath(params: {
webhookUrl: string;
webhookPath?: string;
}): string {
return `${resolveHostedZaloMediaRoutePrefix(params)}/`;
}
export async function prepareHostedZaloMediaUrl(params: {
mediaUrl: string;
webhookUrl: string;
webhookPath?: string;
maxBytes: number;
proxyUrl?: string;
}): Promise<string> {
const now = asDateTimestampMs(Date.now());
const expiresAt =
now === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(ZALO_OUTBOUND_MEDIA_TTL_MS, { nowMs: now });
if (expiresAt === undefined) {
throw new Error("Zalo outbound media expiry could not be resolved");
}
const routePath = resolveHostedZaloMediaRoutePath({
webhookUrl: params.webhookUrl,
webhookPath: params.webhookPath,
});
const publicBaseUrl = new URL(params.webhookUrl).origin;
return await getHostedZaloMediaStore().prepareUrl({
mediaUrl: params.mediaUrl,
routePath,
publicBaseUrl,
maxBytes: params.maxBytes,
...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}),
});
}
export async function tryHandleHostedZaloMediaRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const store = getHostedZaloMediaStore();
await store.cleanupExpired();
const method = req.method ?? "GET";
if (method !== "GET" && method !== "HEAD") {
return false;
}
let url: URL;
try {
url = new URL(req.url ?? "/", "http://localhost");
} catch {
return false;
}
const mediaPath = url.pathname;
const prefixIndex = mediaPath.lastIndexOf(ZALO_OUTBOUND_MEDIA_PREFIX);
if (prefixIndex < 0) {
return false;
}
const routePath = mediaPath.slice(0, prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
const id = mediaPath.slice(prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
if (!id || !ZALO_OUTBOUND_MEDIA_ID_RE.test(id)) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined) {
await store.delete(id);
res.statusCode = 410;
res.end("Expired");
return true;
}
const entry = await store.read(id, now);
if (!entry || entry.metadata.routePath !== routePath) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
const expiresAt = asDateTimestampMs(entry.metadata.expiresAt);
if (expiresAt === undefined || expiresAt <= now) {
await store.delete(id);
res.statusCode = 410;
res.end("Expired");
return true;
}
if (url.searchParams.get("token") !== entry.metadata.token) {
res.statusCode = 401;
res.end("Unauthorized");
return true;
}
if (entry.metadata.contentType) {
res.setHeader("Content-Type", entry.metadata.contentType);
}
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Length", String(entry.metadata.byteLength));
if (method === "HEAD") {
res.statusCode = 200;
res.end();
return true;
}
res.statusCode = 200;
res.end(entry.buffer);
await store.delete(id);
return true;
}
export async function clearHostedZaloMediaForTest(): Promise<void> {
if (!hostedZaloMediaStore) {
return;
}
await hostedZaloMediaStore.clear();
hostedZaloMediaStore = undefined;
}

View File

@@ -0,0 +1,145 @@
// Zalo tests cover outbound payload.contract plugin behavior.
import {
installChannelOutboundPayloadContractSuite,
primeChannelOutboundSendMock,
type OutboundPayloadHarnessParams,
} from "openclaw/plugin-sdk/channel-contract-testing";
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { describe, expect, it, vi } from "vitest";
import { zaloMessageAdapter, zaloPlugin } from "./channel.js";
const { sendZaloTextMock } = vi.hoisted(() => ({
sendZaloTextMock: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
sendZaloText: sendZaloTextMock,
}));
type ZaloOutbound = NonNullable<typeof zaloPlugin.outbound>;
type ZaloSendPayload = NonNullable<ZaloOutbound["sendPayload"]>;
type ZaloMessageSender = NonNullable<typeof zaloMessageAdapter.send>;
function requireZaloSendPayload(): ZaloSendPayload {
const sendPayload = zaloPlugin.outbound?.sendPayload;
if (!sendPayload) {
throw new Error("Expected Zalo outbound sendPayload");
}
return sendPayload;
}
function requireZaloTextSender(): NonNullable<ZaloMessageSender["text"]> {
const text = zaloMessageAdapter.send?.text;
if (!text) {
throw new Error("Expected Zalo message adapter text sender");
}
return text;
}
function requireZaloMediaSender(): NonNullable<ZaloMessageSender["media"]> {
const media = zaloMessageAdapter.send?.media;
if (!media) {
throw new Error("Expected Zalo message adapter media sender");
}
return media;
}
function createZaloHarness(params: OutboundPayloadHarnessParams) {
const sendZalo = vi.fn();
primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
sendZaloTextMock.mockReset().mockImplementation(
async (nextCtx: { to: string; text: string; mediaUrl?: string }) =>
await sendZalo(nextCtx.to, nextCtx.text, {
mediaUrl: nextCtx.mediaUrl,
}),
);
const ctx = {
cfg: {},
to: "123456789",
text: "",
payload: params.payload,
};
const sendPayload = requireZaloSendPayload();
return {
run: async () => await sendPayload(ctx),
sendMock: sendZalo,
to: ctx.to,
};
}
describe("Zalo outbound payload contract", () => {
installChannelOutboundPayloadContractSuite({
channel: "zalo",
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
createHarness: createZaloHarness,
});
it("declares message adapter durable text and media with receipt proofs", async () => {
sendZaloTextMock.mockReset().mockImplementation(async (ctx: { mediaUrl?: string }) =>
ctx.mediaUrl
? {
ok: true,
messageId: "zl-media-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "zalo", messageId: "zl-media-1" }],
kind: "media",
}),
}
: {
ok: true,
messageId: "zl-text-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "zalo", messageId: "zl-text-1" }],
kind: "text",
}),
},
);
const sendText = requireZaloTextSender();
const sendMedia = requireZaloMediaSender();
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "zalo",
adapter: zaloMessageAdapter,
proofs: {
text: async () => {
const result = await sendText({
cfg: {},
to: "123456789",
text: "hello",
});
expect(result.receipt.platformMessageIds).toEqual(["zl-text-1"]);
},
media: async () => {
const result = await sendMedia({
cfg: {},
to: "123456789",
text: "image",
mediaUrl: "https://example.com/image.png",
});
expect(result.receipt.platformMessageIds).toEqual(["zl-media-1"]);
},
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
},
},
});
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },
{ capability: "thread", status: "not_declared" },
{ capability: "nativeQuote", status: "not_declared" },
{ capability: "messageSendingHooks", status: "verified" },
{ capability: "batch", status: "not_declared" },
{ capability: "reconcileUnknownSend", status: "not_declared" },
{ capability: "afterSendSuccess", status: "not_declared" },
{ capability: "afterCommit", status: "not_declared" },
]);
});
});

View File

@@ -0,0 +1,46 @@
// Zalo plugin module implements probe behavior.
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
export type ZaloProbeResult = BaseProbeResult<string> & {
bot?: ZaloBotInfo;
elapsedMs: number;
};
export async function probeZalo(
token: string,
timeoutMs = 5000,
fetcher?: ZaloFetch,
): Promise<ZaloProbeResult> {
if (!token?.trim()) {
return { ok: false, error: "No token provided", elapsedMs: 0 };
}
const startTime = Date.now();
try {
const response = await getMe(token.trim(), timeoutMs, fetcher);
const elapsedMs = Date.now() - startTime;
if (response.ok && response.result) {
return { ok: true, bot: response.result, elapsedMs };
}
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
} catch (err) {
const elapsedMs = Date.now() - startTime;
if (err instanceof ZaloApiError) {
return { ok: false, error: err.description ?? err.message, elapsedMs };
}
if (err instanceof Error) {
if (err.name === "AbortError") {
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
}
return { ok: false, error: err.message, elapsedMs };
}
return { ok: false, error: String(err), elapsedMs };
}
}

View File

@@ -0,0 +1,19 @@
// Zalo plugin module implements proxy behavior.
import { makeProxyFetch } from "openclaw/plugin-sdk/fetch-runtime";
import type { ZaloFetch } from "./api.js";
const proxyCache = new Map<string, ZaloFetch>();
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) {
return undefined;
}
const cached = proxyCache.get(trimmed);
if (cached) {
return cached;
}
const fetcher = makeProxyFetch(trimmed) as ZaloFetch;
proxyCache.set(trimmed, fetcher);
return fetcher;
}

View File

@@ -0,0 +1,82 @@
// Zalo API module exposes the plugin public contract.
export {
type BaseProbeResult,
type BaseTokenResolution,
type ChannelAccountSnapshot,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
type ChannelStatusIssue,
type GroupPolicy,
type MarkdownTableMode,
type OpenClawConfig,
type OutboundReplyPayload,
type PluginRuntime,
type RegisterWebhookPluginRouteOptions,
type RegisterWebhookTargetOptions,
type ReplyPayload,
type RuntimeEnv,
type SecretInput,
type WizardPrompter,
} from "./runtime-support.js";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
createDedupeCache,
formatPairingApproveHint,
jsonResult,
normalizeAccountId,
readStringParam,
resolveClientIp,
} from "./runtime-support.js";
export {
addWildcardAllowFrom,
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
buildSingleChannelSecretPromptState,
mergeAllowFromEntries,
migrateBaseNameToDefaultAccount,
promptSingleChannelSecretInput,
runSingleChannelSecretStep,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "./runtime-support.js";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "./runtime-support.js";
export { buildTokenChannelStatusSummary, PAIRING_APPROVED_MESSAGE } from "./runtime-support.js";
export { buildBaseAccountStatusSnapshot } from "./runtime-support.js";
export { chunkTextForOutbound } from "./runtime-support.js";
export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./runtime-support.js";
export {
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "./runtime-support.js";
export { createChannelPairingController } from "./runtime-support.js";
export { createChannelMessageReplyPipeline } from "./runtime-support.js";
export { logTypingFailure } from "./runtime-support.js";
export {
deliverTextOrMediaReply,
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "./runtime-support.js";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
export { waitForAbortSignal } from "./runtime-support.js";
export {
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
applyBasicWebhookRequestGuards,
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
registerPluginHttpRoute,
registerWebhookTarget,
registerWebhookTargetWithPluginRoute,
resolveWebhookPath,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "./runtime-support.js";
export { setZaloRuntime } from "./runtime.js";

View File

@@ -0,0 +1,86 @@
// Zalo plugin module implements runtime support behavior.
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-contracts";
export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
export type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelStatusIssue,
} from "openclaw/plugin-sdk/channel-contract";
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
createDedupeCache,
formatPairingApproveHint,
jsonResult,
normalizeAccountId,
readStringParam,
resolveClientIp,
} from "openclaw/plugin-sdk/core";
export {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
buildSingleChannelSecretPromptState,
mergeAllowFromEntries,
migrateBaseNameToDefaultAccount,
promptSingleChannelSecretInput,
runSingleChannelSecretStep,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "openclaw/plugin-sdk/setup";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
export {
buildTokenChannelStatusSummary,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/channel-status";
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
formatAllowFromLowercase,
isNormalizedSenderAllowed,
} from "openclaw/plugin-sdk/allow-from";
export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup";
export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
export {
warnMissingProviderGroupPolicyFallbackOnce,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export {
deliverTextOrMediaReply,
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "openclaw/plugin-sdk/reply-payload";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime";
export {
applyBasicWebhookRequestGuards,
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
registerPluginHttpRoute,
registerWebhookTarget,
registerWebhookTargetWithPluginRoute,
resolveWebhookPath,
resolveWebhookTargetWithAuthOrRejectSync,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/webhook-ingress";
export type {
RegisterWebhookPluginRouteOptions,
RegisterWebhookTargetOptions,
} from "openclaw/plugin-sdk/webhook-ingress";

View File

@@ -0,0 +1,10 @@
// Zalo plugin module implements runtime behavior.
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-support.js";
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "zalo",
errorMessage: "Zalo runtime not initialized",
});
export { getZaloRuntime, setZaloRuntime };

View File

@@ -0,0 +1,110 @@
// Zalo plugin module implements secret contract behavior.
import {
collectConditionalChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
{
id: "channels.zalo.accounts.*.botToken",
targetType: "channels.zalo.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.zalo.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.accounts.*.webhookSecret",
targetType: "channels.zalo.accounts.*.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.zalo.accounts.*.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.botToken",
targetType: "channels.zalo.botToken",
configFile: "openclaw.json",
pathPattern: "channels.zalo.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.webhookSecret",
targetType: "channels.zalo.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.zalo.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "zalo");
if (!resolved) {
return;
}
const { channel: zalo, surface } = resolved;
collectConditionalChannelFieldAssignments({
channelKey: "zalo",
field: "botToken",
channel: zalo,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled && !hasOwnProperty(account, "botToken"),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.",
accountInactiveReason: "Zalo account is disabled.",
});
const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : "";
const accountWebhookUrl = (account: Record<string, unknown>) =>
hasOwnProperty(account, "webhookUrl")
? typeof account.webhookUrl === "string"
? account.webhookUrl.trim()
: ""
: baseWebhookUrl;
collectConditionalChannelFieldAssignments({
channelKey: "zalo",
field: "webhookSecret",
channel: zalo,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
topInactiveReason:
"no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
accountInactiveReason:
"Zalo account is disabled or webhook mode is not active for this account.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View File

@@ -0,0 +1,6 @@
// Zalo plugin module implements secret input behavior.
export {
buildSecretInputSchema,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";

View File

@@ -0,0 +1,151 @@
// Zalo tests cover send plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageMock = vi.fn();
const sendPhotoMock = vi.fn();
const resolveZaloProxyFetchMock = vi.fn();
vi.mock("./api.js", () => ({
sendMessage: (...args: unknown[]) => sendMessageMock(...args),
sendPhoto: (...args: unknown[]) => sendPhotoMock(...args),
}));
vi.mock("./proxy.js", () => ({
resolveZaloProxyFetch: (...args: unknown[]) => resolveZaloProxyFetchMock(...args),
}));
import { sendMessageZalo, sendPhotoZalo } from "./send.js";
type ZaloSendResult = Awaited<ReturnType<typeof sendMessageZalo>>;
function requireSuccessfulSend(result: ZaloSendResult, expectedMessageId: string) {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(`expected successful Zalo send: ${result.error}`);
}
expect(result.messageId).toBe(expectedMessageId);
return result;
}
function expectFailedSend(result: ZaloSendResult, expectedError: string) {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected failed Zalo send");
}
expect(result.error).toBe(expectedError);
expect(result.receipt.platformMessageIds).toStrictEqual([]);
}
describe("zalo send", () => {
beforeEach(() => {
sendMessageMock.mockReset();
sendPhotoMock.mockReset();
resolveZaloProxyFetchMock.mockReset();
resolveZaloProxyFetchMock.mockReturnValue(undefined);
});
it("sends text messages through the message API", async () => {
sendMessageMock.mockResolvedValueOnce({
ok: true,
result: { message_id: "z-msg-1" },
});
const result = await sendMessageZalo("dm-chat-1", "hello there", {
token: "zalo-token",
});
expect(sendMessageMock).toHaveBeenCalledWith(
"zalo-token",
{
chat_id: "dm-chat-1",
text: "hello there",
},
undefined,
);
expect(sendPhotoMock).not.toHaveBeenCalled();
const successful = requireSuccessfulSend(result, "z-msg-1");
expect(successful.receipt.primaryPlatformMessageId).toBe("z-msg-1");
expect(successful.receipt.platformMessageIds).toEqual(["z-msg-1"]);
expect(successful.receipt.parts).toHaveLength(1);
expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-msg-1");
expect(successful.receipt.parts[0]?.kind).toBe("text");
expect(successful.receipt.parts[0]?.raw).toEqual({
channel: "zalo",
chatId: "dm-chat-1",
messageId: "z-msg-1",
});
});
it("routes media-bearing sends through the photo API and uses text as caption", async () => {
sendPhotoMock.mockResolvedValueOnce({
ok: true,
result: { message_id: "z-photo-1" },
});
const result = await sendMessageZalo("dm-chat-2", "caption text", {
token: "zalo-token",
mediaUrl: "https://example.com/photo.jpg",
caption: "ignored fallback caption",
});
expect(sendPhotoMock).toHaveBeenCalledWith(
"zalo-token",
{
chat_id: "dm-chat-2",
photo: "https://example.com/photo.jpg",
caption: "caption text",
},
undefined,
);
expect(sendMessageMock).not.toHaveBeenCalled();
const successful = requireSuccessfulSend(result, "z-photo-1");
expect(successful.receipt.primaryPlatformMessageId).toBe("z-photo-1");
expect(successful.receipt.platformMessageIds).toEqual(["z-photo-1"]);
expect(successful.receipt.parts).toHaveLength(1);
expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-photo-1");
expect(successful.receipt.parts[0]?.kind).toBe("media");
});
it("fails fast for missing token or blank photo URLs", async () => {
const missingToken = await sendMessageZalo("dm-chat-3", "hello", {});
expectFailedSend(missingToken, "No Zalo bot token configured");
const blankPhoto = await sendPhotoZalo("dm-chat-4", " ", {
token: "zalo-token",
});
expectFailedSend(blankPhoto, "No photo URL provided");
expect(sendMessageMock).not.toHaveBeenCalled();
expect(sendPhotoMock).not.toHaveBeenCalled();
});
it("sends cfg-backed media directly without hosted-media rewrites", async () => {
sendPhotoMock.mockResolvedValueOnce({
ok: true,
result: { message_id: "z-photo-2" },
});
const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", {
cfg: {
channels: {
zalo: {
botToken: "zalo-token",
webhookUrl: "https://gateway.example.com/zalo-webhook",
},
},
} as never,
});
expect(sendPhotoMock).toHaveBeenCalledWith(
"zalo-token",
{
chat_id: "dm-chat-5",
photo: "https://example.com/photo.jpg",
caption: undefined,
},
undefined,
);
const successful = requireSuccessfulSend(result, "z-photo-2");
expect(successful.receipt.platformMessageIds).toEqual(["z-photo-2"]);
});
});

208
extensions/zalo/src/send.ts Normal file
View File

@@ -0,0 +1,208 @@
// Zalo plugin module implements send behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveZaloAccount } from "./accounts.js";
import type { ZaloFetch } from "./api.js";
import { sendMessage, sendPhoto } from "./api.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { resolveZaloToken } from "./token.js";
type ZaloSendOptions = {
token?: string;
accountId?: string;
cfg?: OpenClawConfig;
mediaUrl?: string;
caption?: string;
verbose?: boolean;
proxy?: string;
};
type ZaloSendResult = {
ok: boolean;
messageId?: string;
receipt: MessageReceipt;
error?: string;
};
function createZaloSendReceipt(params: {
messageId?: string;
chatId: string;
kind: MessageReceiptPartKind;
}): MessageReceipt {
const messageId = params.messageId?.trim();
return createMessageReceiptFromOutboundResults({
results: messageId
? [
{
channel: "zalo",
messageId,
chatId: params.chatId,
},
]
: [],
kind: params.kind,
});
}
function toZaloSendResult(
response: {
ok?: boolean;
result?: { message_id?: string };
},
params: { chatId: string; kind: MessageReceiptPartKind },
): ZaloSendResult {
if (response.ok && response.result) {
return {
ok: true,
messageId: response.result.message_id,
receipt: createZaloSendReceipt({
messageId: response.result.message_id,
chatId: params.chatId,
kind: params.kind,
}),
};
}
return {
ok: false,
error: "Failed to send message",
receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }),
};
}
async function runZaloSend(
failureMessage: string,
params: { chatId: string; kind: MessageReceiptPartKind },
send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
): Promise<ZaloSendResult> {
try {
const result = toZaloSendResult(await send(), params);
return result.ok ? result : { ok: false, error: failureMessage, receipt: result.receipt };
} catch (err) {
return {
ok: false,
error: formatErrorMessage(err),
receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }),
};
}
}
function resolveSendContext(options: ZaloSendOptions): {
token: string;
fetcher?: ZaloFetch;
} {
if (options.cfg) {
const account = resolveZaloAccount({
cfg: options.cfg,
accountId: options.accountId,
});
const token = options.token || account.token;
const proxy = options.proxy ?? account.config.proxy;
return { token, fetcher: resolveZaloProxyFetch(proxy) };
}
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
const proxy = options.proxy;
return { token, fetcher: resolveZaloProxyFetch(proxy) };
}
function resolveValidatedSendContext(
chatId: string,
options: ZaloSendOptions,
): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
const trimmedChatId = chatId?.trim();
if (!trimmedChatId) {
return { ok: false, error: "No chat_id provided" };
}
return { ok: true, chatId: trimmedChatId, token, fetcher };
}
function resolveSendContextOrFailure(
chatId: string,
options: ZaloSendOptions,
):
| { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
| { failure: ZaloSendResult } {
const context = resolveValidatedSendContext(chatId, options);
return context.ok
? { context }
: {
failure: {
ok: false,
error: context.error,
receipt: createZaloSendReceipt({ chatId, kind: "unknown" }),
},
};
}
export async function sendMessageZalo(
chatId: string,
text: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const resolved = resolveSendContextOrFailure(chatId, options);
if ("failure" in resolved) {
return resolved.failure;
}
const { context } = resolved;
if (options.mediaUrl) {
return sendPhotoZalo(context.chatId, options.mediaUrl, {
...options,
token: context.token,
caption: text || options.caption,
});
}
return await runZaloSend("Failed to send message", { chatId: context.chatId, kind: "text" }, () =>
sendMessage(
context.token,
{
chat_id: context.chatId,
text: text.slice(0, 2000),
},
context.fetcher,
),
);
}
export async function sendPhotoZalo(
chatId: string,
photoUrl: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const resolved = resolveSendContextOrFailure(chatId, options);
if ("failure" in resolved) {
return resolved.failure;
}
const { context } = resolved;
if (!photoUrl?.trim()) {
return {
ok: false,
error: "No photo URL provided",
receipt: createZaloSendReceipt({ chatId: context.chatId, kind: "media" }),
};
}
return await runZaloSend("Failed to send photo", { chatId: context.chatId, kind: "media" }, () =>
(async () =>
sendPhoto(
context.token,
{
chat_id: context.chatId,
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
},
context.fetcher,
))(),
);
}

View File

@@ -0,0 +1,33 @@
// Zalo plugin module implements session route behavior.
import {
buildChannelOutboundSessionRoute,
stripChannelTargetPrefix,
stripTargetKindPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
export function resolveZaloOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const trimmed = stripChannelTargetPrefix(params.target, "zalo", "zl");
if (!trimmed) {
return null;
}
const isGroup = normalizeLowercaseStringOrEmpty(trimmed).startsWith("group:");
const peerId = stripTargetKindPrefix(trimmed);
if (!peerId) {
return null;
}
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "zalo",
accountId: params.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
chatType: isGroup ? "group" : "direct",
from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`,
to: `zalo:${peerId}`,
});
}

View File

@@ -0,0 +1,98 @@
// Zalo plugin module implements setup allow from behavior.
import {
DEFAULT_ACCOUNT_ID,
createSetupTranslator,
formatDocsLink,
mergeAllowFromEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
const t = createSetupTranslator();
type ZaloAccountSetupConfig = {
enabled?: boolean;
};
export async function noteZaloTokenHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
): Promise<void> {
await prompter.note(
[
t("wizard.zalo.helpOpenPlatform"),
t("wizard.zalo.helpCreateBot"),
t("wizard.zalo.helpTokenFormat"),
t("wizard.zalo.helpEnvTip"),
`Docs: ${formatDocsLink("/channels/zalo", "zalo")}`,
].join("\n"),
t("wizard.zalo.botTokenTitle"),
);
}
export async function promptZaloAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
accountId?: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter } = params;
const accountId = params.accountId ?? resolveDefaultZaloAccountId(cfg);
const resolved = resolveZaloAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({
message: t("wizard.zalo.allowFromPrompt"),
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = (value ?? "").trim();
if (!raw) {
return t("common.required");
}
if (!/^\d+$/.test(raw)) {
return t("wizard.zalo.allowFromNumeric");
}
return undefined;
},
});
const normalized = entry.trim();
const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as OpenClawConfig;
}
const currentAccount = cfg.channels?.zalo?.accounts?.[accountId] as
| ZaloAccountSetupConfig
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
accounts: {
...cfg.channels?.zalo?.accounts,
[accountId]: {
...currentAccount,
enabled: currentAccount?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as OpenClawConfig;
}

View File

@@ -0,0 +1,153 @@
// Zalo plugin module implements setup core behavior.
import {
addWildcardAllowFrom,
createDelegatedSetupWizardProxy,
createPatchedAccountSetupAdapter,
createSetupInputPresenceValidator,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
createSetupTranslator,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
import { promptZaloAllowFrom } from "./setup-allow-from.js";
const t = createSetupTranslator();
const channel = "zalo" as const;
type ZaloAccountSetupConfig = {
enabled?: boolean;
dmPolicy?: string;
allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
};
export const zaloSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: createSetupInputPresenceValidator({
defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.",
whenNotUseEnv: [
{
someOf: ["token", "tokenFile"],
message: "Zalo requires token or --token-file (or --use-env).",
},
],
}),
buildPatch: (input) =>
input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {},
});
export const zaloDmPolicy: ChannelSetupDmPolicy = {
label: "Zalo",
channel,
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultZaloAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveZaloAccount({
cfg,
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) => {
const resolvedAccountId =
accountId && normalizeAccountId(accountId)
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultZaloAccountId(cfg);
const resolved = resolveZaloAccount({
cfg,
accountId: resolvedAccountId,
});
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
dmPolicy: policy,
...(policy === "open"
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
: {}),
},
},
};
}
const currentAccount = cfg.channels?.zalo?.accounts?.[resolvedAccountId] as
| ZaloAccountSetupConfig
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
accounts: {
...cfg.channels?.zalo?.accounts,
[resolvedAccountId]: {
...currentAccount,
enabled: currentAccount?.enabled ?? true,
dmPolicy: policy,
...(policy === "open"
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
: {}),
},
},
},
},
};
},
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
promptZaloAllowFrom({
cfg,
prompter,
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
}),
};
export function createZaloSetupWizardProxy(
loadWizard: () => Promise<ChannelSetupWizard>,
): ChannelSetupWizard {
return createDelegatedSetupWizardProxy({
channel,
loadWizard,
status: {
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsToken"),
configuredHint: t("wizard.channels.statusRecommendedConfigured"),
unconfiguredHint: t("wizard.channels.statusRecommendedNewcomerFriendly"),
configuredScore: 1,
unconfiguredScore: 10,
},
credentials: [],
delegateFinalize: true,
dmPolicy: zaloDmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: false,
},
},
}),
});
}

View File

@@ -0,0 +1,34 @@
// Zalo tests cover setup status plugin behavior.
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { zaloSetupWizard } from "./setup-surface.js";
const zaloGetStatus = createPluginSetupWizardStatus({
id: "zalo",
meta: {
label: "Zalo",
},
setupWizard: zaloSetupWizard,
} as never);
describe("zalo setup wizard status", () => {
it("treats SecretRef botToken as configured", async () => {
const status = await zaloGetStatus({
cfg: {
channels: {
zalo: {
botToken: {
source: "env",
provider: "default",
id: "ZALO_BOT_TOKEN",
},
},
},
} as OpenClawConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
});
});

View File

@@ -0,0 +1,194 @@
// Zalo tests cover setup surface plugin behavior.
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
import { zaloDmPolicy } from "./setup-core.js";
import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js";
const zaloSetupPlugin = {
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
blurb: "Vietnam-focused messaging platform with Bot API.",
},
capabilities: {
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
},
config: {
listAccountIds: (cfg: unknown) => listZaloAccountIds(cfg as never),
defaultAccountId: (cfg: unknown) => resolveDefaultZaloAccountId(cfg as never),
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
},
setup: zaloSetupAdapter,
setupWizard: zaloSetupWizard,
} as const;
const zaloConfigure = createPluginSetupWizardConfigure(zaloSetupPlugin);
describe("zalo setup wizard", () => {
it("configures a polling token flow", async () => {
const prompter = createTestWizardPrompter({
select: vi.fn(async () => "plaintext") as WizardPrompter["select"],
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enter Zalo bot token") {
return "12345689:abc-xyz";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Use webhook mode for Zalo?") {
return false;
}
return false;
}),
});
const result = await runSetupWizardConfigure({
configure: zaloConfigure,
cfg: {} as OpenClawConfig,
prompter,
options: { secretInputMode: "plaintext" as const },
});
expect(result.accountId).toBe("default");
const zaloConfig = result.cfg.channels?.zalo;
if (!zaloConfig) {
throw new Error("expected Zalo config");
}
expect(zaloConfig.enabled).toBe(true);
expect(zaloConfig.botToken).toBe("12345689:abc-xyz");
expect(zaloConfig.webhookUrl).toBeUndefined();
});
it("reads the named-account DM policy instead of the channel root", () => {
expect(
zaloDmPolicy.getCurrent(
{
channels: {
zalo: {
dmPolicy: "disabled",
accounts: {
work: {
botToken: "12345689:abc-xyz",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig,
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", () => {
expect(zaloDmPolicy.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual({
policyKey: "channels.zalo.accounts.work.dmPolicy",
allowFromKey: "channels.zalo.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", () => {
const cfg = {
channels: {
zalo: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["123456789"],
accounts: {
work: {
botToken: "12345689:abc-xyz",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig;
expect(zaloDmPolicy.getCurrent(cfg)).toBe("allowlist");
expect(zaloDmPolicy.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.zalo.accounts.work.dmPolicy",
allowFromKey: "channels.zalo.accounts.work.allowFrom",
});
const next = zaloDmPolicy.setPolicy(cfg, "open");
const zaloConfig = next.channels?.zalo;
if (!zaloConfig) {
throw new Error("expected Zalo config");
}
expect(zaloConfig.dmPolicy).toBe("disabled");
const workAccount = next.channels?.zalo?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
if (!workAccount) {
throw new Error("expected Zalo work account");
}
expect(workAccount.dmPolicy).toBe("open");
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
const next = zaloDmPolicy.setPolicy(
{
channels: {
zalo: {
allowFrom: ["123456789"],
accounts: {
work: {
botToken: "12345689:abc-xyz",
},
},
},
},
} as OpenClawConfig,
"open",
"work",
);
const zaloConfig = next.channels?.zalo;
if (!zaloConfig) {
throw new Error("expected Zalo config");
}
expect(zaloConfig.dmPolicy).toBeUndefined();
const workAccount = next.channels?.zalo?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
if (!workAccount) {
throw new Error("expected Zalo work account");
}
expect(workAccount.dmPolicy).toBe("open");
expect(workAccount.allowFrom).toEqual(["123456789", "*"]);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const configured = await zaloSetupWizard.status.resolveConfigured({
cfg: {
channels: {
zalo: {
defaultAccount: "work",
botToken: "root-token",
accounts: {
alerts: {
botToken: "alerts-token",
},
work: {
botToken: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
});

View File

@@ -0,0 +1,295 @@
// Zalo plugin module implements setup surface behavior.
import {
buildSingleChannelSecretPromptState,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
promptSingleChannelSecretInput,
runSingleChannelSecretStep,
type ChannelSetupWizard,
type OpenClawConfig,
type SecretInput,
createSetupTranslator,
} from "openclaw/plugin-sdk/setup";
import { resolveZaloAccount } from "./accounts.js";
import { noteZaloTokenHelp, promptZaloAllowFrom } from "./setup-allow-from.js";
import { zaloDmPolicy } from "./setup-core.js";
const t = createSetupTranslator();
const channel = "zalo" as const;
type UpdateMode = "polling" | "webhook";
function setZaloUpdateMode(
cfg: OpenClawConfig,
accountId: string,
mode: UpdateMode,
webhookUrl?: string,
webhookSecret?: SecretInput,
webhookPath?: string,
): OpenClawConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (mode === "polling") {
if (isDefault) {
const {
webhookUrl: _url,
webhookSecret: _secret,
webhookPath: _path,
...rest
} = cfg.channels?.zalo ?? {};
return {
...cfg,
channels: {
...cfg.channels,
zalo: rest,
},
} as OpenClawConfig;
}
const accounts = { ...cfg.channels?.zalo?.accounts } as Record<string, Record<string, unknown>>;
const existing = accounts[accountId] ?? {};
const { webhookUrl: _url, webhookSecret: _secret, webhookPath: _path, ...rest } = existing;
accounts[accountId] = rest;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
accounts,
},
},
} as OpenClawConfig;
}
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
webhookUrl,
webhookSecret,
webhookPath,
},
},
} as OpenClawConfig;
}
const accounts = { ...cfg.channels?.zalo?.accounts } as Record<string, Record<string, unknown>>;
accounts[accountId] = {
...accounts[accountId],
webhookUrl,
webhookSecret,
webhookPath,
};
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
accounts,
},
},
} as OpenClawConfig;
}
export { zaloSetupAdapter } from "./setup-core.js";
export const zaloSetupWizard: ChannelSetupWizard = {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "Zalo",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsToken"),
configuredHint: t("wizard.channels.statusRecommendedConfigured"),
unconfiguredHint: t("wizard.channels.statusRecommendedNewcomerFriendly"),
configuredScore: 1,
unconfiguredScore: 10,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) => {
const account = resolveZaloAccount({
cfg,
accountId,
allowUnresolvedSecretRef: true,
});
return (
Boolean(account.token) ||
hasConfiguredSecretInput(account.config.botToken) ||
Boolean(account.config.tokenFile?.trim())
);
},
}),
credentials: [],
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
let next = cfg;
const resolvedAccount = resolveZaloAccount({
cfg: next,
accountId,
allowUnresolvedSecretRef: true,
});
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const hasConfigToken = Boolean(
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
);
const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "zalo",
credentialLabel: t("wizard.zalo.botToken"),
secretInputMode: options?.secretInputMode,
accountConfigured,
hasConfigToken,
allowEnv,
envValue: process.env.ZALO_BOT_TOKEN,
envPrompt: t("wizard.zalo.tokenEnvPrompt"),
keepPrompt: t("wizard.zalo.tokenKeep"),
inputPrompt: t("wizard.zalo.tokenInput"),
preferredEnvVar: "ZALO_BOT_TOKEN",
onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
applyUseEnv: async (currentCfg) =>
accountId === DEFAULT_ACCOUNT_ID
? ({
...currentCfg,
channels: {
...currentCfg.channels,
zalo: {
...currentCfg.channels?.zalo,
enabled: true,
},
},
} as OpenClawConfig)
: currentCfg,
applySet: async (currentCfg, value) =>
accountId === DEFAULT_ACCOUNT_ID
? ({
...currentCfg,
channels: {
...currentCfg.channels,
zalo: {
...currentCfg.channels?.zalo,
enabled: true,
botToken: value,
},
},
} as OpenClawConfig)
: ({
...currentCfg,
channels: {
...currentCfg.channels,
zalo: {
...currentCfg.channels?.zalo,
enabled: true,
accounts: {
...currentCfg.channels?.zalo?.accounts,
[accountId]: {
...(currentCfg.channels?.zalo?.accounts?.[accountId] as
| Record<string, unknown>
| undefined),
enabled: true,
botToken: value,
},
},
},
},
} as OpenClawConfig),
});
next = tokenStep.cfg;
const wantsWebhook = await prompter.confirm({
message: t("wizard.zalo.webhookModePrompt"),
initialValue: Boolean(resolvedAccount.config.webhookUrl),
});
if (wantsWebhook) {
const webhookUrl = (
await prompter.text({
message: t("wizard.zalo.webhookUrlPrompt"),
initialValue: resolvedAccount.config.webhookUrl,
validate: (value) =>
value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
})
).trim();
const defaultPath = (() => {
try {
return new URL(webhookUrl).pathname || "/zalo-webhook";
} catch {
return "/zalo-webhook";
}
})();
let webhookSecretResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "zalo-webhook",
credentialLabel: t("wizard.zalo.webhookSecret"),
secretInputMode: options?.secretInputMode,
...buildSingleChannelSecretPromptState({
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
allowEnv: false,
}),
envPrompt: "",
keepPrompt: t("wizard.zalo.webhookSecretKeep"),
inputPrompt: t("wizard.zalo.webhookSecretInput"),
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
});
while (
webhookSecretResult.action === "set" &&
typeof webhookSecretResult.value === "string" &&
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
) {
await prompter.note(t("wizard.zalo.webhookSecretLength"), t("wizard.zalo.webhookTitle"));
webhookSecretResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "zalo-webhook",
credentialLabel: t("wizard.zalo.webhookSecret"),
secretInputMode: options?.secretInputMode,
...buildSingleChannelSecretPromptState({
accountConfigured: false,
hasConfigToken: false,
allowEnv: false,
}),
envPrompt: "",
keepPrompt: t("wizard.zalo.webhookSecretKeep"),
inputPrompt: t("wizard.zalo.webhookSecretInput"),
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
});
}
const webhookSecret =
webhookSecretResult.action === "set"
? webhookSecretResult.value
: resolvedAccount.config.webhookSecret;
const webhookPath = (
await prompter.text({
message: t("wizard.zalo.webhookPathPrompt"),
initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
})
).trim();
next = setZaloUpdateMode(
next,
accountId,
"webhook",
webhookUrl,
webhookSecret,
webhookPath || undefined,
);
} else {
next = setZaloUpdateMode(next, accountId, "polling");
}
if (forceAllowFrom) {
next = await promptZaloAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next };
},
dmPolicy: zaloDmPolicy,
};

View File

@@ -0,0 +1,18 @@
// Zalo tests cover status issues plugin behavior.
import { expectOpenDmPolicyConfigIssue } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, it } from "vitest";
import { collectZaloStatusIssues } from "./status-issues.js";
describe("collectZaloStatusIssues", () => {
it("warns when dmPolicy is open", () => {
expectOpenDmPolicyConfigIssue({
collectIssues: collectZaloStatusIssues,
account: {
accountId: "default",
enabled: true,
configured: true,
dmPolicy: "open",
},
});
});
});

View File

@@ -0,0 +1,38 @@
// Zalo plugin module implements status issues behavior.
import type {
ChannelAccountSnapshot,
ChannelStatusIssue,
} from "openclaw/plugin-sdk/channel-contract";
import {
coerceStatusIssueAccountId,
readStatusIssueFields,
} from "openclaw/plugin-sdk/extension-shared";
const ZALO_STATUS_FIELDS = ["accountId", "enabled", "configured", "dmPolicy"] as const;
export function collectZaloStatusIssues(accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readStatusIssueFields(entry, ZALO_STATUS_FIELDS);
if (!account) {
continue;
}
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) {
continue;
}
if (account.dmPolicy === "open") {
issues.push({
channel: "zalo",
accountId,
kind: "config",
message: 'Zalo dmPolicy is "open", allowing any user to message the bot without pairing.',
fix: 'Set channels.zalo.dmPolicy to "pairing" or "allowlist" to restrict access.',
});
}
}
return issues;
}

View File

@@ -0,0 +1,460 @@
// Zalo plugin module implements lifecycle test support behavior.
import { request as httpRequest } from "node:http";
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../types.js";
function resolveLifecycleAllowFrom(params: {
dmPolicy: "open" | "pairing";
allowFrom?: string[];
}): string[] | undefined {
return params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
}
function createLifecycleConfig(params: {
accountId: string;
dmPolicy: "open" | "pairing";
allowFrom?: string[];
webhookUrl?: string;
webhookSecret?: string;
}): OpenClawConfig {
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
const webhookSecret = params.webhookSecret ?? "supersecret";
const allowFrom = resolveLifecycleAllowFrom(params);
return {
channels: {
zalo: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
},
} as OpenClawConfig;
}
function createLifecycleAccount(params: {
accountId: string;
dmPolicy: "open" | "pairing";
allowFrom?: string[];
webhookUrl?: string;
webhookSecret?: string;
}): ResolvedZaloAccount {
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
const webhookSecret = params.webhookSecret ?? "supersecret";
const allowFrom = resolveLifecycleAllowFrom(params);
return {
accountId: params.accountId,
enabled: true,
token: "zalo-token",
tokenSource: "config",
config: {
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
} as ResolvedZaloAccount;
}
export function createLifecycleMonitorSetup(params: {
accountId: string;
dmPolicy: "open" | "pairing";
allowFrom?: string[];
webhookUrl?: string;
webhookSecret?: string;
}) {
return {
account: createLifecycleAccount(params),
config: createLifecycleConfig(params),
};
}
export function createTextUpdate(params: {
messageId: string;
userId: string;
userName: string;
chatId: string;
text?: string;
}) {
return {
event_name: "message.text.received",
message: {
from: { id: params.userId, name: params.userName },
chat: { id: params.chatId, chat_type: "PRIVATE" as const },
message_id: params.messageId,
date: Math.floor(Date.now() / 1000),
text: params.text ?? "hello from zalo",
},
};
}
export function createImageUpdate(params?: {
messageId?: string;
userId?: string;
displayName?: string;
chatId?: string;
photoUrl?: string;
caption?: string;
date?: number;
}) {
return {
event_name: "message.image.received",
message: {
date: params?.date ?? 1774086023728,
chat: { chat_type: "PRIVATE" as const, id: params?.chatId ?? "chat-123" },
caption: params?.caption ?? "",
message_id: params?.messageId ?? "msg-123",
message_type: "CHAT_PHOTO",
from: {
id: params?.userId ?? "user-123",
is_bot: false,
display_name: params?.displayName ?? "Test User",
},
photo_url: params?.photoUrl ?? "https://example.com/test-image.jpg",
},
};
}
export function createImageLifecycleCore() {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const buildChannelInboundEventContextMock = vi.fn(
(params: {
channel: string;
accountId?: string;
messageId?: string;
timestamp?: number;
from: string;
sender: { id: string; name?: string };
conversation: { kind: string; label?: string };
route: {
accountId?: string;
routeSessionKey: string;
dispatchSessionKey?: string;
};
reply: { to: string; originatingTo: string };
message: { body?: string; rawBody: string; bodyForAgent?: string; commandBody?: string };
media?: Array<{ path?: string; url?: string; contentType?: string }>;
extra?: Record<string, unknown>;
}) =>
finalizeInboundContextMock({
Body: params.message.body ?? params.message.rawBody,
BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
RawBody: params.message.rawBody,
CommandBody: params.message.commandBody ?? params.message.rawBody,
From: params.from,
To: params.reply.to,
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
AccountId: params.route.accountId ?? params.accountId,
ChatType: params.conversation.kind,
ConversationLabel: params.conversation.label,
SenderName: params.sender.name,
SenderId: params.sender.id,
Provider: params.channel,
Surface: params.channel,
MessageSid: params.messageId,
Timestamp: params.timestamp,
MediaPath: params.media?.[0]?.path,
MediaType: params.media?.[0]?.contentType,
MediaUrl: params.media?.[0]?.url ?? params.media?.[0]?.path,
OriginatingChannel: params.channel,
OriginatingTo: params.reply.originatingTo,
...params.extra,
}),
);
const recordInboundSessionMock = vi.fn(async () => undefined);
const readRemoteMediaBufferMock = vi.fn(async () => ({
buffer: Buffer.from("image-bytes"),
contentType: "image/jpeg",
}));
const saveRemoteMediaMock = vi.fn(async () => ({
path: "/tmp/zalo-photo.jpg",
contentType: "image/jpeg",
}));
const saveMediaBufferMock = vi.fn(async () => ({
path: "/tmp/zalo-photo.jpg",
contentType: "image/jpeg",
}));
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
const core = {
logging: {
shouldLogVerbose: vi.fn(
() => false,
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
},
channel: {
pairing: {
readAllowFromStore:
readAllowFromStoreMock as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest:
upsertPairingRequestMock as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:zalo:direct:chat-123",
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
session: {
resolveStorePath: vi.fn(
() => "/tmp/zalo-sessions.json",
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: vi.fn(
() => undefined,
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession:
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
},
text: {
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
},
media: createPluginRuntimeMediaMock({
readRemoteMediaBuffer:
readRemoteMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["readRemoteMediaBuffer"],
saveRemoteMedia:
saveRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["saveRemoteMedia"],
saveMediaBuffer:
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
}) as unknown as PluginRuntime["channel"]["media"],
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
resolveEnvelopeFormatOptions: vi.fn(() => ({
template: "channel+name+time",
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
formatAgentEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => undefined,
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
},
inbound: {
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["inbound"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
const resolved = await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
);
await resolved.recordInboundSession({
storePath: resolved.storePath,
sessionKey: resolved.ctxPayload.SessionKey ?? resolved.routeSessionKey,
ctx: resolved.ctxPayload,
groupResolution: resolved.record?.groupResolution,
createIfMissing: resolved.record?.createIfMissing,
updateLastRoute: resolved.record?.updateLastRoute,
onRecordError: resolved.record?.onRecordError ?? (() => undefined),
});
if ("runDispatch" in resolved) {
const dispatchResult = await resolved.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey,
dispatchResult,
};
}
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
ctx: resolved.ctxPayload,
cfg: resolved.cfg,
dispatcherOptions: {
...resolved.dispatcherOptions,
deliver: async (...args: Parameters<typeof resolved.delivery.deliver>) => {
await resolved.delivery.deliver(...args);
},
onError: resolved.delivery.onError,
},
replyOptions: resolved.replyOptions,
replyResolver: resolved.replyResolver,
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey,
dispatchResult,
};
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
dispatchReply: vi.fn(
async (params: Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0]) => {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
groupResolution: params.record?.groupResolution,
createIfMissing: params.record?.createIfMissing,
updateLastRoute: params.record?.updateLastRoute,
onRecordError: params.record?.onRecordError ?? (() => undefined),
});
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...params.dispatcherOptions,
deliver: async (...args: Parameters<typeof params.delivery.deliver>) => {
await params.delivery.deliver(...args);
},
onError: params.delivery.onError,
},
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
});
return {
admission: params.admission ?? { kind: "dispatch" as const },
dispatched: true,
ctxPayload: params.ctxPayload,
routeSessionKey: params.routeSessionKey,
dispatchResult,
};
},
) as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"],
buildContext:
buildChannelInboundEventContextMock as unknown as PluginRuntime["channel"]["inbound"]["buildContext"],
},
commands: {
shouldComputeCommandAuthorized: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
resolveCommandAuthorizedFromAuthorizers: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
},
},
} as PluginRuntime;
return {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
readAllowFromStoreMock,
upsertPairingRequestMock,
};
}
export function expectImageLifecycleDelivery(params: {
readRemoteMediaBufferMock: ReturnType<typeof vi.fn>;
saveRemoteMediaMock?: ReturnType<typeof vi.fn>;
saveMediaBufferMock: ReturnType<typeof vi.fn>;
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
recordInboundSessionMock: ReturnType<typeof vi.fn>;
photoUrl?: string;
senderName?: string;
mediaPath?: string;
mediaType?: string;
}) {
const photoUrl = params.photoUrl ?? "https://example.com/test-image.jpg";
const senderName = params.senderName ?? "Test User";
const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg";
const mediaType = params.mediaType ?? "image/jpeg";
const saveRemoteMediaMock = params.saveRemoteMediaMock ?? params.readRemoteMediaBufferMock;
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
url: photoUrl,
maxBytes: 5 * 1024 * 1024,
});
expect(params.saveMediaBufferMock).not.toHaveBeenCalled();
expect(params.finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
SenderName: senderName,
MediaPath: mediaPath,
MediaType: mediaType,
}),
);
expect(params.recordInboundSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
SenderName: senderName,
MediaPath: mediaPath,
MediaType: mediaType,
}),
}),
);
}
export async function settleAsyncWork(): Promise<void> {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
}
async function postWebhookUpdate(params: {
baseUrl: string;
path: string;
secret: string;
payload: Record<string, unknown>;
}) {
const url = new URL(params.path, params.baseUrl);
const body = JSON.stringify(params.payload);
return await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = httpRequest(
url,
{
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
"x-bot-api-secret-token": params.secret,
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
res.on("end", () => {
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString("utf8"),
});
});
},
);
req.on("error", reject);
req.write(body);
req.end();
});
}
export async function postWebhookReplay(params: {
baseUrl: string;
path: string;
secret: string;
payload: Record<string, unknown>;
settleBeforeReplay?: boolean;
}) {
const first = await postWebhookUpdate(params);
if (params.settleBeforeReplay) {
await settleAsyncWork();
}
const replay = await postWebhookUpdate(params);
return { first, replay };
}

View File

@@ -0,0 +1,209 @@
// Zalo plugin module implements monitor mocks test support behavior.
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
createEmptyPluginRegistry,
createRuntimeEnv,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { vi, type Mock } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../types.js";
type MonitorModule = typeof import("../monitor.js");
type SecretInputModule = typeof import("../secret-input.js");
type WebhookModule = typeof import("../monitor.webhook.js");
const monitorModuleUrl = new URL("../monitor.ts", import.meta.url).href;
const secretInputModuleUrl = new URL("../secret-input.ts", import.meta.url).href;
const webhookModuleUrl = new URL("../monitor.webhook.ts", import.meta.url).href;
const apiModuleId = new URL("../api.js", import.meta.url).pathname;
const runtimeModuleId = new URL("../runtime.js", import.meta.url).pathname;
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
const loadedMonitorModules = new Set<MonitorModule>();
const cachedMonitorModules = new Map<string, Promise<MonitorModule>>();
type ZaloLifecycleMocks = {
setWebhookMock: AsyncUnknownMock;
deleteWebhookMock: AsyncUnknownMock;
getWebhookInfoMock: AsyncUnknownMock;
getUpdatesMock: UnknownMock;
sendChatActionMock: AsyncUnknownMock;
sendMessageMock: AsyncUnknownMock;
sendPhotoMock: AsyncUnknownMock;
getZaloRuntimeMock: UnknownMock;
};
const lifecycleMocks = vi.hoisted(
(): ZaloLifecycleMocks => ({
setWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
deleteWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
getWebhookInfoMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
getUpdatesMock: vi.fn(() => new Promise(() => {})),
sendChatActionMock: vi.fn(async () => ({ ok: true })),
sendMessageMock: vi.fn(async () => ({
ok: true,
result: { message_id: "zalo-test-reply-1" },
})),
sendPhotoMock: vi.fn(async () => ({ ok: true })),
getZaloRuntimeMock: vi.fn(),
}),
);
const setWebhookMock = lifecycleMocks.setWebhookMock;
export const getUpdatesMock = lifecycleMocks.getUpdatesMock;
export const sendMessageMock = lifecycleMocks.sendMessageMock;
export const sendPhotoMock = lifecycleMocks.sendPhotoMock;
export const getZaloRuntimeMock: UnknownMock = lifecycleMocks.getZaloRuntimeMock;
function installLifecycleModuleMocks() {
vi.doMock(apiModuleId, async () => {
const actual = await vi.importActual<object>(apiModuleId);
return {
...actual,
deleteWebhook: lifecycleMocks.deleteWebhookMock,
getUpdates: lifecycleMocks.getUpdatesMock,
getWebhookInfo: lifecycleMocks.getWebhookInfoMock,
sendChatAction: lifecycleMocks.sendChatActionMock,
sendMessage: lifecycleMocks.sendMessageMock,
sendPhoto: lifecycleMocks.sendPhotoMock,
setWebhook: lifecycleMocks.setWebhookMock,
};
});
vi.doMock(runtimeModuleId, () => ({
getZaloRuntime: lifecycleMocks.getZaloRuntimeMock,
}));
}
async function importMonitorModule(params: {
cacheBust: string;
mocked: boolean;
}): Promise<MonitorModule> {
vi.resetModules();
if (params.mocked) {
installLifecycleModuleMocks();
} else {
vi.doUnmock(apiModuleId);
vi.doUnmock(runtimeModuleId);
}
const module = (await import(
`${monitorModuleUrl}?t=${params.cacheBust}-${Date.now()}`
)) as MonitorModule;
loadedMonitorModules.add(module);
return module;
}
async function importSecretInputModule(cacheBust: string): Promise<SecretInputModule> {
return (await import(
`${secretInputModuleUrl}?t=${cacheBust}-${Date.now()}`
)) as SecretInputModule;
}
const importCachedWebhookModule = createLazyRuntimeModule(
() => import(webhookModuleUrl) as Promise<WebhookModule>,
);
export async function resetLifecycleTestState() {
vi.clearAllMocks();
(await importCachedWebhookModule()).clearZaloWebhookSecurityStateForTest();
for (const module of loadedMonitorModules) {
module.testing.clearHostedMediaRouteRefsForTest();
}
setActivePluginRegistry(createEmptyPluginRegistry());
}
export function setLifecycleRuntimeCore(
channel: NonNullable<NonNullable<Parameters<typeof createPluginRuntimeMock>[0]>["channel"]>,
) {
getZaloRuntimeMock.mockReturnValue(
createPluginRuntimeMock({
channel,
}),
);
}
async function loadLifecycleMonitorModule(): Promise<MonitorModule> {
return await importMonitorModule({ cacheBust: "monitor", mocked: true });
}
export async function loadCachedLifecycleMonitorModule(cacheKey: string): Promise<MonitorModule> {
const key = cacheKey.trim();
if (!key) {
throw new Error("cacheKey is required");
}
const cached =
cachedMonitorModules.get(key) ??
(async () => {
installLifecycleModuleMocks();
const module = (await import(`${monitorModuleUrl}?t=${key}`)) as MonitorModule;
loadedMonitorModules.add(module);
return module;
})();
cachedMonitorModules.set(key, cached);
return await cached;
}
export async function startWebhookLifecycleMonitor(params: {
account: ResolvedZaloAccount;
config: OpenClawConfig;
token?: string;
webhookUrl?: string;
webhookSecret?: string;
cacheKey?: string;
}) {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const abort = new AbortController();
const runtime = createRuntimeEnv();
const accountWebhookUrl =
typeof params.account.config?.webhookUrl === "string"
? params.account.config.webhookUrl
: undefined;
const webhookUrl = params.webhookUrl ?? accountWebhookUrl;
const { normalizeSecretInputString } = await importSecretInputModule("secret-input");
const webhookSecret =
params.webhookSecret ?? normalizeSecretInputString(params.account.config?.webhookSecret);
const { monitorZaloProvider } = params.cacheKey
? await loadCachedLifecycleMonitorModule(params.cacheKey)
: await loadLifecycleMonitorModule();
const run = monitorZaloProvider({
token: params.token ?? "zalo-token",
account: params.account,
config: params.config,
runtime,
abortSignal: abort.signal,
useWebhook: true,
webhookUrl,
webhookSecret,
});
await vi.waitFor(() => {
const webhookRoute = registry.httpRoutes.find((route) => route.source === "zalo-webhook");
const hostedMediaRoute = registry.httpRoutes.find(
(route) => route.source === "zalo-hosted-media",
);
if (setWebhookMock.mock.calls.length !== 1 || !webhookRoute || !hostedMediaRoute) {
throw new Error("waiting for webhook registration");
}
});
const route = registry.httpRoutes.find((entry) => entry.source === "zalo-webhook");
if (!route) {
throw new Error("missing plugin HTTP route");
}
return {
abort,
registry,
route,
run,
runtime,
stop: async () => {
abort.abort();
await run;
},
};
}

View File

@@ -0,0 +1,107 @@
// Zalo tests cover token plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveZaloToken } from "./token.js";
import type { ZaloConfig } from "./types.js";
function createSymlinkedFile(targetPath: string, linkPath: string): boolean {
try {
fs.writeFileSync(targetPath, "file-token\n", "utf8");
fs.symlinkSync(targetPath, linkPath, "file");
return true;
} catch {
fs.rmSync(linkPath, { force: true });
fs.rmSync(targetPath, { force: true });
return false;
}
}
describe("resolveZaloToken", () => {
it("falls back to top-level token for non-default accounts without overrides", () => {
const cfg = {
botToken: "top-level-token",
accounts: {
work: {},
},
} as ZaloConfig;
const res = resolveZaloToken(cfg, "work");
expect(res.token).toBe("top-level-token");
expect(res.source).toBe("config");
});
it("uses accounts.default botToken for default account when configured", () => {
const cfg = {
botToken: "top-level-token",
accounts: {
default: {
botToken: "default-account-token",
},
},
} as ZaloConfig;
const res = resolveZaloToken(cfg, "default");
expect(res.token).toBe("default-account-token");
expect(res.source).toBe("config");
});
it("uses configured defaultAccount token when accountId is omitted", () => {
const cfg = {
defaultAccount: "work",
botToken: "top-level-token",
accounts: {
work: {
botToken: "work-token",
},
},
} as ZaloConfig;
const res = resolveZaloToken(cfg);
expect(res.token).toBe("work-token");
expect(res.source).toBe("config");
});
it("does not inherit top-level token when account token is explicitly blank", () => {
const cfg = {
botToken: "top-level-token",
accounts: {
work: {
botToken: "",
},
},
} as ZaloConfig;
const res = resolveZaloToken(cfg, "work");
expect(res.token).toBe("");
expect(res.source).toBe("none");
});
it("resolves account token when account key casing differs from normalized id", () => {
const cfg = {
accounts: {
Work: {
botToken: "work-token",
},
},
} as ZaloConfig;
const res = resolveZaloToken(cfg, "work");
expect(res.token).toBe("work-token");
expect(res.source).toBe("config");
});
it("rejects symlinked token files", ({ skip }) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
try {
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
if (!createSymlinkedFile(tokenFile, tokenLink)) {
skip("file symlinks are unavailable on this host");
}
const cfg = {
tokenFile: tokenLink,
} as ZaloConfig;
expect(() => resolveZaloToken(cfg)).toThrow(/Zalo token file.*must not be a symlink/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,78 @@
// Zalo plugin module implements token behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
import type { ZaloConfig } from "./types.js";
type ZaloTokenResolution = BaseTokenResolution & {
source: "env" | "config" | "configFile" | "none";
};
function readTokenFromFile(tokenFile: string | undefined): string {
return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
}
export function resolveZaloToken(
config: ZaloConfig | undefined,
accountId?: string | null,
options?: { allowUnresolvedSecretRef?: boolean },
): ZaloTokenResolution {
const resolvedAccountId = normalizeAccountId(accountId ?? config?.defaultAccount);
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
const baseConfig = config;
const accountConfig = resolveAccountEntry(
baseConfig?.accounts as Record<string, ZaloConfig> | undefined,
normalizeAccountId(resolvedAccountId),
);
const accountHasBotToken = Boolean(accountConfig && Object.hasOwn(accountConfig, "botToken"));
if (accountConfig && accountHasBotToken) {
const token = options?.allowUnresolvedSecretRef
? normalizeSecretInputString(accountConfig.botToken)
: normalizeResolvedSecretInputString({
value: accountConfig.botToken,
path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
});
if (token) {
return { token, source: "config" };
}
const fileToken = readTokenFromFile(accountConfig.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}
if (!accountHasBotToken) {
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}
if (!accountHasBotToken) {
const token = options?.allowUnresolvedSecretRef
? normalizeSecretInputString(baseConfig?.botToken)
: normalizeResolvedSecretInputString({
value: baseConfig?.botToken,
path: "channels.zalo.botToken",
});
if (token) {
return { token, source: "config" };
}
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}
if (isDefaultAccount) {
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
if (envToken) {
return { token: envToken, source: "env" };
}
}
return { token: "", source: "none" };
}

View File

@@ -0,0 +1,51 @@
// Zalo type declarations define plugin contracts.
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export type ZaloAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start this Zalo account. Default: true. */
enabled?: boolean;
/** Bot token from Zalo Bot Creator. */
botToken?: SecretInput;
/** Path to file containing the bot token. */
tokenFile?: string;
/** Webhook URL for receiving updates (HTTPS required). */
webhookUrl?: string;
/** Webhook secret token (8-256 chars) for request verification. */
webhookSecret?: SecretInput;
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
webhookPath?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
/** Allowlist for DM senders (Zalo user IDs). */
allowFrom?: Array<string | number>;
/** Group-message access policy. */
groupPolicy?: "open" | "allowlist" | "disabled";
/** Allowlist for group senders (falls back to allowFrom when unset). */
groupAllowFrom?: Array<string | number>;
/** Max inbound media size in MB. */
mediaMaxMb?: number;
/** Proxy URL for API requests. */
proxy?: string;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
};
export type ZaloConfig = {
/** Optional per-account Zalo configuration (multi-account). */
accounts?: Record<string, ZaloAccountConfig>;
/** Default account ID when multiple accounts are configured. */
defaultAccount?: string;
} & ZaloAccountConfig;
type ZaloTokenSource = "env" | "config" | "configFile" | "none";
export type ResolvedZaloAccount = {
accountId: string;
name?: string;
enabled: boolean;
token: string;
tokenSource: ZaloTokenSource;
config: ZaloAccountConfig;
};

View File

@@ -0,0 +1,2 @@
// Zalo API module exposes the plugin public contract.
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}