重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

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.

View file

@ -0,0 +1,2 @@
export { zaloPlugin } from "./src/channel.js";
export * from "./setup-api.js";

View file

@ -0,0 +1,5 @@
export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,15 @@
import { describe } from "vitest";
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
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",
});
});

View file

@ -0,0 +1,20 @@
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: "./api.js",
exportName: "zaloPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setZaloRuntime",
},
});

View file

@ -0,0 +1,12 @@
{
"id": "zalo",
"channels": ["zalo"],
"channelEnvVars": {
"zalo": ["ZALO_BOT_TOKEN", "ZALO_WEBHOOK_SECRET"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,55 @@
{
"name": "@openclaw/zalo",
"version": "2026.4.20",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {
"undici": "8.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
},
"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.4.20"
},
"build": {
"openclawVersion": "2026.4.20"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View file

@ -0,0 +1,19 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/plugins/jiti-runtime-api.js";
describe("zalo runtime api", () => {
it("loads the narrow runtime api without reentering setup surfaces", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "zalo", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: ["setZaloRuntime"],
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/runtime-store"],
}),
).toEqual({
setZaloRuntime: "function",
});
});
});

View file

@ -0,0 +1,4 @@
// Private runtime barrel for the bundled Zalo extension.
// Keep this barrel thin and free of channel plugin exports so direct runtime
// imports do not re-enter the full channel/setup surface.
export * from "./src/runtime-api.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,34 @@
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 { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
export const zaloSetupWizard: SetupSurfaceModule["zaloSetupWizard"] = createLazyObjectValue(
() => loadSetupSurfaceModule().zaloSetupWizard as object,
) as SetupSurfaceModule["zaloSetupWizard"];

View file

@ -0,0 +1,13 @@
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,70 @@
import { describe, expect, it } from "vitest";
import { 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");
});
});

View file

@ -0,0 +1,60 @@
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-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveZaloToken } from "./token.js";
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
export type { ResolvedZaloAccount };
const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
createAccountListHelpers("zalo");
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,5 @@
import { sendMessageZalo as sendMessageZaloImpl } from "./send.js";
export const zaloActionsRuntime = {
sendMessageZalo: sendMessageZaloImpl,
};

View file

@ -0,0 +1,32 @@
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,62 @@
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-runtime";
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: 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,58 @@
import { describe, expect, it, vi } from "vitest";
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
function createOkFetcher() {
return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
}
async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
const fetcher = createOkFetcher();
await run("test-token", fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
const [, init] = fetcher.mock.calls[0] ?? [];
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
}
describe("Zalo API request methods", () => {
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>((_, 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] = fetcher.mock.calls[0] ?? [];
expect(init?.signal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
});

View file

@ -0,0 +1,241 @@
/**
* Zalo Bot API client
* @see https://bot.zaloplatforms.com/docs
*/
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
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;
name: string;
avatar?: string;
};
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;
}
}
/**
* Call the Zalo Bot API
*/
export async function callZaloApi<T = unknown>(
method: string,
token: string,
body?: Record<string, unknown>,
options?: { timeoutMs?: number; fetch?: ZaloFetch },
): Promise<ZaloApiResponse<T>> {
const url = `${ZALO_API_BASE}/bot${token}/${method}`;
const controller = new AbortController();
const timeoutId = options?.timeoutMs
? setTimeout(() => controller.abort(), options.timeoutMs)
: undefined;
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 response.json()) as ZaloApiResponse<T>;
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>> {
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { 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,17 @@
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,25 @@
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,59 @@
import { describe, expect, it } from "vitest";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "../../../test/helpers/plugins/directory.js";
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;
await expect(
directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ 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.toEqual([]);
}
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,93 @@
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
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?.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,101 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
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";
function buildAccount(): ResolvedZaloAccount {
return {
accountId: "default",
enabled: true,
token: "test-token",
tokenSource: "config",
config: {},
};
}
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: zaloPlugin.gateway!.startAccount!,
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.probeZalo, hoisted.monitorZaloProvider),
isSettled,
abort,
task,
});
expectLifecyclePatch(patches, { accountId: "default" });
expect(isSettled()).toBe(true);
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
expect.objectContaining({
token: "test-token",
account: expect.objectContaining({ accountId: "default" }),
abortSignal: abort.signal,
useWebhook: false,
}),
);
});
});

View file

@ -0,0 +1,274 @@
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 {
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-runtime";
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,
}),
});
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: {
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),
},
},
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"),
}),
...zaloRawSendResultAdapter,
},
});

View file

@ -0,0 +1,30 @@
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,29 @@
import {
AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "openclaw/plugin-sdk/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,49 @@
import { isNormalizedSenderAllowed } from "openclaw/plugin-sdk/allow-from";
import {
evaluateSenderGroupAccess,
resolveOpenProviderRuntimeGroupPolicy,
type GroupPolicy,
type SenderGroupAccessDecision,
} from "openclaw/plugin-sdk/group-access";
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean {
return isNormalizedSenderAllowed({
senderId,
allowFrom,
stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE,
});
}
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,
});
}
export function evaluateZaloGroupAccess(params: {
providerConfigPresent: boolean;
configuredGroupPolicy?: GroupPolicy;
defaultGroupPolicy?: GroupPolicy;
groupAllowFrom: string[];
senderId: string;
}): SenderGroupAccessDecision {
return evaluateSenderGroupAccess({
providerConfigPresent: params.providerConfigPresent,
configuredGroupPolicy: params.configuredGroupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
groupAllowFrom: params.groupAllowFrom,
senderId: params.senderId,
isSenderAllowed: isZaloSenderAllowed,
});
}

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./monitor.js";
describe("zalo group policy access", () => {
it("blocks all group messages when policy is disabled", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "disabled",
defaultGroupPolicy: "open",
groupAllowFrom: ["zalo:123"],
senderId: "123",
});
expect(decision).toMatchObject({
allowed: false,
groupPolicy: "disabled",
reason: "disabled",
});
});
it("blocks group messages on allowlist policy with empty allowlist", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "allowlist",
defaultGroupPolicy: "open",
groupAllowFrom: [],
senderId: "attacker",
});
expect(decision).toMatchObject({
allowed: false,
groupPolicy: "allowlist",
reason: "empty_allowlist",
});
});
it("blocks sender not in group allowlist", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "allowlist",
defaultGroupPolicy: "open",
groupAllowFrom: ["zalo:victim-user-001"],
senderId: "attacker-user-999",
});
expect(decision).toMatchObject({
allowed: false,
groupPolicy: "allowlist",
reason: "sender_not_allowlisted",
});
});
it("allows sender in group allowlist", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "allowlist",
defaultGroupPolicy: "open",
groupAllowFrom: ["zl:12345"],
senderId: "12345",
});
expect(decision).toMatchObject({
allowed: true,
groupPolicy: "allowlist",
reason: "allowed",
});
});
it("allows any sender with wildcard allowlist", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "allowlist",
defaultGroupPolicy: "open",
groupAllowFrom: ["*"],
senderId: "random-user",
});
expect(decision).toMatchObject({
allowed: true,
groupPolicy: "allowlist",
reason: "allowed",
});
});
it("allows all group senders on open policy", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,
configuredGroupPolicy: "open",
defaultGroupPolicy: "allowlist",
groupAllowFrom: [],
senderId: "attacker-user-999",
});
expect(decision).toMatchObject({
allowed: true,
groupPolicy: "open",
reason: "allowed",
});
});
});

View file

@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import {
createImageLifecycleCore,
createImageUpdate,
createLifecycleMonitorSetup,
expectImageLifecycleDelivery,
} from "../test-support/lifecycle-test-support.js";
import {
getUpdatesMock,
getZaloRuntimeMock,
loadLifecycleMonitorModule,
resetLifecycleTestState,
sendMessageMock,
} from "../test-support/monitor-mocks-test-support.js";
describe("Zalo polling image handling", () => {
const {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
fetchRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
beforeEach(async () => {
await resetLifecycleTestState();
getZaloRuntimeMock.mockReturnValue(core);
});
afterEach(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 loadLifecycleMonitorModule();
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(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
expectImageLifecycleDelivery({
fetchRemoteMediaMock,
saveMediaBufferMock,
finalizeInboundContextMock,
recordInboundSessionMock,
});
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 loadLifecycleMonitorModule();
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 vi.waitFor(() => expect(sendMessageMock).toHaveBeenCalledTimes(1));
expect(fetchRemoteMediaMock).not.toHaveBeenCalled();
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
expect(recordInboundSessionMock).not.toHaveBeenCalled();
abort.abort();
await run;
});
});

View file

@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
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 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 vi.waitFor(() => 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(
expect.stringContaining("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 vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("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 vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("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 resolveDeleteWebhook: (() => void) | undefined;
deleteWebhookMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
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 vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
expect(registry.httpRoutes).toHaveLength(1);
abort.abort();
await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
expect(settled).toBe(false);
expect(registry.httpRoutes).toHaveLength(1);
resolveDeleteWebhook?.();
await monitoredRun;
expect(settled).toBe(true);
expect(registry.httpRoutes).toHaveLength(0);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Zalo provider stopped mode=webhook"),
);
});
});

View file

@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withServer } from "../../../test/helpers/http-test-server.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 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),
},
});
});
afterEach(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());
try {
await withServer(
(req, res) => 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(
expect.objectContaining({
channel: "zalo",
accountId: "acct-zalo-pairing",
}),
);
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
accountId: "acct-zalo-pairing",
id: "user-unauthorized",
}),
);
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock).toHaveBeenCalledWith(
"zalo-token",
expect.objectContaining({
chat_id: "dm-pairing-1",
text: expect.stringContaining("PAIRCODE"),
}),
undefined,
);
} 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());
try {
await withServer(
(req, res) => 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,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withServer } from "../../../test/helpers/http-test-server.js";
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 () => 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"],
},
});
});
afterEach(async () => {
await resetLifecycleTestState();
});
function createReplyOnceMonitorSetup() {
return createLifecycleMonitorSetup({
accountId: "acct-zalo-lifecycle",
dmPolicy: "open",
});
}
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());
try {
await withServer(
(req, res) => 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(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
AccountId: "acct-zalo-lifecycle",
SessionKey: "agent:main:zalo:direct:dm-chat-1",
MessageSid: expect.stringContaining("zalo-replay-"),
From: "zalo:user-1",
To: "zalo:dm-chat-1",
}),
);
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
expect(recordInboundSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:zalo:direct:dm-chat-1",
}),
);
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock).toHaveBeenCalledWith(
"zalo-token",
expect.objectContaining({
chat_id: "dm-chat-1",
text: "zalo reply once",
}),
undefined,
);
} 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());
try {
await withServer(
(req, res) => 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(
expect.stringContaining("Zalo webhook failed: Error: post-send failure"),
);
} finally {
await monitor.stop();
}
});
});

View file

@ -0,0 +1,806 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
getWebhookInfo,
getUpdates,
sendChatAction,
sendMessage,
sendPhoto,
setWebhook,
type ZaloFetch,
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import {
evaluateZaloGroupAccess,
isZaloSenderAllowed,
resolveZaloRuntimeGroupPolicy,
} from "./group-access.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import {
createChannelPairingController,
createChannelReplyPipeline,
deliverTextOrMediaReply,
resolveWebhookPath,
logTypingFailure,
resolveDefaultGroupPolicy,
resolveDirectDmAuthorizationOutcome,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveSenderCommandAuthorizationWithRuntime,
waitForAbortSignal,
warnMissingProviderGroupPolicyFallbackOnce,
} from "./runtime-api.js";
import { getZaloRuntime } from "./runtime.js";
export type { ZaloRuntimeEnv } from "./monitor.types.js";
import type { ZaloRuntimeEnv } from "./monitor.types.js";
export type ZaloMonitorOptions = {
token: string;
account: ResolvedZaloAccount;
config: OpenClawConfig;
runtime: ZaloRuntimeEnv;
abortSignal: AbortSignal;
useWebhook?: boolean;
webhookUrl?: string;
webhookSecret?: string;
webhookPath?: string;
fetcher?: ZaloFetch;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
const ZALO_TYPING_TIMEOUT_MS = 5_000;
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
type ZaloWebhookModule = typeof import("./monitor.webhook.js");
type ZaloProcessingContext = {
token: string;
account: ResolvedZaloAccount;
config: OpenClawConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
statusSink?: ZaloStatusSink;
fetcher?: ZaloFetch;
};
type ZaloPollingLoopParams = ZaloProcessingContext & {
abortSignal: AbortSignal;
isStopped: () => boolean;
mediaMaxMb: number;
};
type ZaloUpdateProcessingParams = ZaloProcessingContext & {
update: ZaloUpdate;
mediaMaxMb: number;
};
let zaloWebhookModulePromise: Promise<ZaloWebhookModule> | undefined;
function loadZaloWebhookModule(): Promise<ZaloWebhookModule> {
zaloWebhookModulePromise ??= import("./monitor.webhook.js");
return zaloWebhookModulePromise;
}
type ZaloMessagePipelineParams = ZaloProcessingContext & {
message: ZaloMessage;
text?: string;
mediaPath?: string;
mediaType?: string;
authorization?: ZaloMessageAuthorizationResult;
};
type ZaloImageMessageParams = ZaloProcessingContext & {
message: ZaloMessage;
mediaMaxMb: number;
};
type ZaloMessageAuthorizationResult = {
chatId: string;
commandAuthorized: boolean | undefined;
isGroup: boolean;
rawBody: string;
senderId: string;
senderName: string | undefined;
};
function formatZaloError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`;
}
return String(error);
}
function describeWebhookTarget(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return rawUrl;
}
}
function normalizeWebhookUrl(url: string | undefined): string | undefined {
const trimmed = url?.trim();
return trimmed ? trimmed : undefined;
}
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[zalo] ${message}`);
}
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } =
await loadZaloWebhookModule();
return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
await processUpdate({
update,
token: target.token,
account: target.account,
config: target.config,
runtime: target.runtime,
core: target.core as ZaloCoreRuntime,
mediaMaxMb: target.mediaMaxMb,
statusSink: target.statusSink,
fetcher: target.fetcher,
});
});
}
function startPollingLoop(params: ZaloPollingLoopParams) {
const {
token,
account,
config,
runtime,
core,
abortSignal,
isStopped,
mediaMaxMb,
statusSink,
fetcher,
} = params;
const pollTimeout = 30;
const processingContext = {
token,
account,
config,
runtime,
core,
mediaMaxMb,
statusSink,
fetcher,
};
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
const poll = async (): Promise<void> => {
if (isStopped() || abortSignal.aborted) {
return undefined;
}
try {
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
if (response.ok && response.result) {
statusSink?.({ lastInboundAt: Date.now() });
await processUpdate({
update: response.result,
...processingContext,
});
}
} catch (err) {
if (err instanceof ZaloApiError && err.isPollingTimeout) {
// no updates
} else if (!isStopped() && !abortSignal.aborted) {
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
if (!isStopped() && !abortSignal.aborted) {
setImmediate(poll);
}
};
void poll();
}
async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
const { event_name, message } = update;
const sharedContext = { token, account, config, runtime, core, statusSink, fetcher };
if (!message) {
return undefined;
}
switch (event_name) {
case "message.text.received":
await handleTextMessage({
message,
...sharedContext,
});
break;
case "message.image.received":
await handleImageMessage({
message,
...sharedContext,
mediaMaxMb,
});
break;
case "message.sticker.received":
logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
break;
case "message.unsupported.received":
logVerbose(
core,
runtime,
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
);
break;
}
}
async function handleTextMessage(
params: ZaloProcessingContext & { message: ZaloMessage },
): Promise<void> {
const { message } = params;
const { text } = message;
if (!text?.trim()) {
return undefined;
}
await processMessageWithPipeline({
...params,
text,
mediaPath: undefined,
mediaType: undefined,
});
}
async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
const { message, mediaMaxMb, account, core, runtime } = params;
const { photo_url, caption } = message;
const authorization = await authorizeZaloMessage({
...params,
text: caption,
// Use a sentinel so auth sees this as an inbound image before the download happens.
mediaPath: photo_url ? "__pending_media__" : undefined,
mediaType: undefined,
});
if (!authorization) {
return;
}
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (photo_url) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
);
mediaPath = saved.path;
mediaType = saved.contentType;
} catch (err) {
runtime.error?.(`[${account.accountId}] Failed to download Zalo image: ${String(err)}`);
}
}
await processMessageWithPipeline({
...params,
authorization,
text: caption,
mediaPath,
mediaType,
});
}
async function authorizeZaloMessage(
params: ZaloMessagePipelineParams,
): Promise<ZaloMessageAuthorizationResult | undefined> {
const { message, account, config, runtime, core, text, mediaPath, token, statusSink, fetcher } =
params;
const pairing = createChannelPairingController({
core,
channel: "zalo",
accountId: account.accountId,
});
const { from, chat } = message;
const isGroup = chat.chat_type === "GROUP";
const chatId = chat.id;
const senderId = from.id;
const senderName = from.display_name ?? from.name;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
const groupAllowFrom =
configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const groupAccess = isGroup
? evaluateZaloGroupAccess({
providerConfigPresent: config.channels?.zalo !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
groupAllowFrom,
senderId,
})
: undefined;
if (groupAccess) {
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied,
providerKey: "zalo",
accountId: account.accountId,
log: (message) => logVerbose(core, runtime, message),
});
if (!groupAccess.allowed) {
if (groupAccess.reason === "disabled") {
logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`);
} else if (groupAccess.reason === "empty_allowlist") {
logVerbose(
core,
runtime,
`zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`,
);
} else if (groupAccess.reason === "sender_not_allowlisted") {
logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
}
return undefined;
}
}
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const { senderAllowedForCommands, commandAuthorized } =
await resolveSenderCommandAuthorizationWithRuntime({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: pairing.readAllowFromStore,
runtime: core.channel.commands,
});
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
isGroup,
dmPolicy,
senderAllowedForCommands,
});
if (directDmOutcome === "disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return undefined;
}
if (directDmOutcome === "unauthorized") {
if (dmPolicy === "pairing") {
await pairing.issueChallenge({
senderId,
senderIdLine: `Your Zalo user id: ${senderId}`,
meta: { name: senderName ?? undefined },
onCreated: () => {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
},
sendPairingReply: async (text) => {
await sendMessage(
token,
{
chat_id: chatId,
text,
},
fetcher,
);
statusSink?.({ lastOutboundAt: Date.now() });
},
onReplyError: (err) => {
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
},
});
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return undefined;
}
return {
chatId,
commandAuthorized,
isGroup,
rawBody,
senderId,
senderName,
};
}
async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
const {
message,
token,
account,
config,
runtime,
core,
mediaPath,
mediaType,
statusSink,
fetcher,
authorization: authorizationOverride,
} = params;
const { message_id, date } = message;
const authorization =
authorizationOverride ??
(await authorizeZaloMessage({
...params,
mediaPath,
mediaType,
}));
if (!authorization) {
return;
}
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? ("group" as const) : ("direct" as const),
id: chatId,
},
runtime: core.channel,
sessionStore: config.session?.store,
});
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
return;
}
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
CommandAuthorized: commandAuthorized,
Provider: "zalo",
Surface: "zalo",
MessageSid: message_id,
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
OriginatingChannel: "zalo",
OriginatingTo: `zalo:${chatId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId,
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "zalo",
accountId: account.accountId,
typing: {
start: async () => {
await sendChatAction(
token,
{
chat_id: chatId,
action: "typing",
},
fetcher,
ZALO_TYPING_TIMEOUT_MS,
);
},
onStartError: (err) => {
logTypingFailure({
log: (message) => logVerbose(core, runtime, message),
channel: "zalo",
action: "start",
target: chatId,
error: err,
});
},
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...replyPipeline,
deliver: async (payload) => {
await deliverZaloReply({
payload,
token,
chatId,
runtime,
core,
config,
accountId: account.accountId,
statusSink,
fetcher,
tableMode,
});
},
onError: (err, info) => {
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
onModelSelected,
},
});
}
async function deliverZaloReply(params: {
payload: OutboundReplyPayload;
token: string;
chatId: string;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
config: OpenClawConfig;
accountId?: string;
statusSink?: ZaloStatusSink;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const reply = resolveSendableOutboundReplyParts(payload, {
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) =>
core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
sendText: async (chunk) => {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo message send failed: ${String(err)}`);
}
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onMediaError: (error) => {
runtime.error?.(
`Zalo photo send failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
},
});
}
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
const {
token,
account,
config,
runtime,
abortSignal,
useWebhook,
webhookUrl,
webhookSecret,
webhookPath,
statusSink,
fetcher: fetcherOverride,
} = options;
const core = getZaloRuntime();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
const mode = useWebhook ? "webhook" : "polling";
let stopped = false;
const stopHandlers: Array<() => void> = [];
let cleanupWebhook: (() => Promise<void>) | undefined;
const stop = () => {
if (stopped) {
return;
}
stopped = true;
for (const handler of stopHandlers) {
handler();
}
};
runtime.log?.(
`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
);
try {
if (useWebhook) {
const { registerZaloWebhookTarget } = await loadZaloWebhookModule();
if (!webhookUrl || !webhookSecret) {
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
}
if (!webhookUrl.startsWith("https://")) {
throw new Error("Zalo webhook URL must use HTTPS");
}
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
throw new Error("Zalo webhook secret must be 8-256 characters");
}
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
if (!path) {
throw new Error("Zalo webhookPath could not be derived");
}
runtime.log?.(
`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
);
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
let webhookCleanupPromise: Promise<void> | undefined;
cleanupWebhook = async () => {
if (!webhookCleanupPromise) {
webhookCleanupPromise = (async () => {
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
try {
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
} catch (err) {
const detail =
err instanceof Error && err.name === "AbortError"
? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
: formatZaloError(err);
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
}
})();
}
await webhookCleanupPromise;
};
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
const unregister = registerZaloWebhookTarget(
{
token,
account,
config,
runtime,
core,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
mediaMaxMb: effectiveMediaMaxMb,
fetcher,
},
{
route: {
auth: "plugin",
match: "exact",
pluginId: "zalo",
source: "zalo-webhook",
accountId: account.accountId,
log: runtime.log,
handler: async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
},
);
stopHandlers.push(unregister);
await waitForAbortSignal(abortSignal);
return;
}
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
try {
try {
const currentWebhookUrl = normalizeWebhookUrl(
(await getWebhookInfo(token, fetcher)).result?.url,
);
if (!currentWebhookUrl) {
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
} else {
runtime.log?.(
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
);
await deleteWebhook(token, fetcher);
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
}
} catch (err) {
if (err instanceof ZaloApiError && err.errorCode === 404) {
// Some Zalo environments do not expose webhook inspection for polling bots.
runtime.log?.(
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
);
} else {
throw err;
}
}
} catch (err) {
runtime.error?.(
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
);
}
startPollingLoop({
token,
account,
config,
runtime,
core,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher,
});
await waitForAbortSignal(abortSignal);
} catch (err) {
runtime.error?.(
`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
);
throw err;
} finally {
await cleanupWebhook?.();
stop();
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
}
}
export const __testing = {
evaluateZaloGroupAccess,
resolveZaloRuntimeGroupPolicy,
};

View file

@ -0,0 +1,4 @@
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};

View file

@ -0,0 +1,800 @@
import type { RequestListener } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { withServer } from "../../../test/helpers/http-test-server.js";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import {
createImageLifecycleCore,
createImageUpdate,
createTextUpdate,
expectImageLifecycleDelivery,
postWebhookReplay,
} from "../test-support/lifecycle-test-support.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 type { ResolvedZaloAccount } from "./types.js";
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
return async (req, res) => {
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,
mediaMaxMb: 5,
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("does not throw when replay metadata is partially 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,
fetchRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
const unregister = registerTarget({
path: "/hook-image",
core,
account: {
...DEFAULT_ACCOUNT,
config: {
dmPolicy: "open",
},
},
});
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(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
expectImageLifecycleDelivery({
fetchRemoteMediaMock,
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).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
accountId: "work",
}),
);
expect(upsertPairingRequest).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
id: "123",
accountId: "work",
}),
);
});
});

View file

@ -0,0 +1,275 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
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;
mediaMaxMb: number;
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 timingSafeEquals(left: string, right: string): boolean {
return safeEqualSecret(left, right);
}
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) => timingSafeEquals(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) => {
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
});
res.statusCode = 200;
res.end("ok");
return true;
},
});
}

View file

@ -0,0 +1,45 @@
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,24 @@
import type { RequestInit as UndiciRequestInit } from "undici";
import { ProxyAgent, fetch as undiciFetch } from "undici";
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 agent = new ProxyAgent(trimmed);
const fetcher: ZaloFetch = (input, init) =>
undiciFetch(input, {
...init,
dispatcher: agent,
} as UndiciRequestInit) as unknown as Promise<Response>;
proxyCache.set(trimmed, fetcher);
return fetcher;
}

View file

@ -0,0 +1,5 @@
// Internal runtime barrel. Keep this independent from the public top-level
// runtime barrel so local imports do not loop back through the plugin export
// surface during entry loading.
export * from "./runtime-support.js";
export { setZaloRuntime } from "./runtime.js";

View file

@ -0,0 +1,90 @@
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";
export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
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 { SenderGroupAccessDecision } from "openclaw/plugin-sdk/group-access";
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 { evaluateSenderGroupAccess } from "openclaw/plugin-sdk/group-access";
export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
export {
warnMissingProviderGroupPolicyFallbackOnce,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export {
deliverTextOrMediaReply,
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "openclaw/plugin-sdk/reply-payload";
export {
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
} from "openclaw/plugin-sdk/command-auth";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime";
export {
applyBasicWebhookRequestGuards,
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
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,9 @@
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,109 @@
import {
collectConditionalChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = [
{
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,
},
] satisfies SecretTargetRegistryEntry[];
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 @@
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";

View file

@ -0,0 +1,91 @@
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";
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();
expect(result).toEqual({ ok: true, 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();
expect(result).toEqual({ ok: true, messageId: "z-photo-1" });
});
it("fails fast for missing token or blank photo URLs", async () => {
await expect(sendMessageZalo("dm-chat-3", "hello", {})).resolves.toEqual({
ok: false,
error: "No Zalo bot token configured",
});
await expect(
sendPhotoZalo("dm-chat-4", " ", {
token: "zalo-token",
}),
).resolves.toEqual({
ok: false,
error: "No photo URL provided",
});
expect(sendMessageMock).not.toHaveBeenCalled();
expect(sendPhotoMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,152 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
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";
export type ZaloSendOptions = {
token?: string;
accountId?: string;
cfg?: OpenClawConfig;
mediaUrl?: string;
caption?: string;
verbose?: boolean;
proxy?: string;
};
export type ZaloSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
function toZaloSendResult(response: {
ok?: boolean;
result?: { message_id?: string };
}): ZaloSendResult {
if (response.ok && response.result) {
return { ok: true, messageId: response.result.message_id };
}
return { ok: false, error: "Failed to send message" };
}
async function runZaloSend(
failureMessage: string,
send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
): Promise<ZaloSendResult> {
try {
const result = toZaloSendResult(await send());
return result.ok ? result : { ok: false, error: failureMessage };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
}
}
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 },
};
}
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", () =>
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" };
}
return await runZaloSend("Failed to send photo", () =>
sendPhoto(
context.token,
{
chat_id: context.chatId,
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
},
context.fetcher,
),
);
}

View file

@ -0,0 +1,32 @@
import {
buildChannelOutboundSessionRoute,
stripChannelTargetPrefix,
stripTargetKindPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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,94 @@
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
type ZaloAccountSetupConfig = {
enabled?: boolean;
};
export async function noteZaloTokenHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
): Promise<void> {
await prompter.note(
[
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
"2) Create a bot and get the token",
"3) Token looks like 12345689:abc-xyz",
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
`Docs: ${formatDocsLink("/channels/zalo", "zalo")}`,
].join("\n"),
"Zalo bot token",
);
}
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: "Zalo allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = (value ?? "").trim();
if (!raw) {
return "Required";
}
if (!/^\d+$/.test(raw)) {
return "Use a numeric Zalo user id";
}
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,149 @@
import {
addWildcardAllowFrom,
createDelegatedSetupWizardProxy,
createPatchedAccountSetupAdapter,
createSetupInputPresenceValidator,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
import { promptZaloAllowFrom } from "./setup-allow-from.js";
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: 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: 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: "configured",
unconfiguredLabel: "needs token",
configuredHint: "recommended · configured",
unconfiguredHint: "recommended · newcomer-friendly",
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,33 @@
import { describe, expect, it } from "vitest";
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
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,175 @@
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import { describe, expect, it, vi } from "vitest";
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/plugins/setup-wizard.js";
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");
expect(result.cfg.channels?.zalo?.enabled).toBe(true);
expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz");
expect(result.cfg.channels?.zalo?.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");
expect(next.channels?.zalo?.dmPolicy).toBe("disabled");
const workAccount = next.channels?.zalo?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
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",
);
expect(next.channels?.zalo?.dmPolicy).toBeUndefined();
const workAccount = next.channels?.zalo?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
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,291 @@
import {
buildSingleChannelSecretPromptState,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
promptSingleChannelSecretInput,
runSingleChannelSecretStep,
type ChannelSetupWizard,
type OpenClawConfig,
type SecretInput,
} 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 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: "configured",
unconfiguredLabel: "needs token",
configuredHint: "recommended · configured",
unconfiguredHint: "recommended · newcomer-friendly",
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: "bot token",
secretInputMode: options?.secretInputMode,
accountConfigured,
hasConfigToken,
allowEnv,
envValue: process.env.ZALO_BOT_TOKEN,
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
keepPrompt: "Zalo token already configured. Keep it?",
inputPrompt: "Enter Zalo bot token",
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: "Use webhook mode for Zalo?",
initialValue: Boolean(resolvedAccount.config.webhookUrl),
});
if (wantsWebhook) {
const webhookUrl = (
await prompter.text({
message: "Webhook URL (https://...) ",
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: "webhook secret",
secretInputMode: options?.secretInputMode,
...buildSingleChannelSecretPromptState({
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
allowEnv: false,
}),
envPrompt: "",
keepPrompt: "Zalo webhook secret already configured. Keep it?",
inputPrompt: "Webhook secret (8-256 chars)",
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
});
while (
webhookSecretResult.action === "set" &&
typeof webhookSecretResult.value === "string" &&
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
) {
await prompter.note("Webhook secret must be between 8 and 256 characters.", "Zalo webhook");
webhookSecretResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "zalo-webhook",
credentialLabel: "webhook secret",
secretInputMode: options?.secretInputMode,
...buildSingleChannelSecretPromptState({
accountConfigured: false,
hasConfigToken: false,
allowEnv: false,
}),
envPrompt: "",
keepPrompt: "Zalo webhook secret already configured. Keep it?",
inputPrompt: "Webhook secret (8-256 chars)",
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
});
}
const webhookSecret =
webhookSecretResult.action === "set"
? webhookSecretResult.value
: resolvedAccount.config.webhookSecret;
const webhookPath = (
await prompter.text({
message: "Webhook path (optional)",
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,17 @@
import { describe, it } from "vitest";
import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/plugins/status-issues.js";
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,37 @@
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,92 @@
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";
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.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
const cfg = {
tokenFile: tokenLink,
} as ZaloConfig;
const res = resolveZaloToken(cfg);
expect(res.token).toBe("");
expect(res.source).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});

View file

@ -0,0 +1,79 @@
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";
export 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.prototype.hasOwnProperty.call(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,50 @@
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;
export 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 @@
export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";

View file

@ -0,0 +1,300 @@
import { request as httpRequest } from "node:http";
import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
export 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";
return {
channels: {
zalo: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(params.allowFrom ? { allowFrom: params.allowFrom } : {}),
},
},
},
},
} as OpenClawConfig;
}
export 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";
return {
accountId: params.accountId,
enabled: true,
token: "zalo-token",
tokenSource: "config",
config: {
webhookUrl,
webhookSecret, // pragma: allowlist secret
dmPolicy: params.dmPolicy,
...(params.allowFrom ? { allowFrom: params.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;
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: "",
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 recordInboundSessionMock = vi.fn(async () => undefined);
const fetchRemoteMediaMock = vi.fn(async () => ({
buffer: Buffer.from("image-bytes"),
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: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
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"],
},
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,
fetchRemoteMediaMock,
saveMediaBufferMock,
readAllowFromStoreMock,
upsertPairingRequestMock,
};
}
export function expectImageLifecycleDelivery(params: {
fetchRemoteMediaMock: 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";
expect(params.fetchRemoteMediaMock).toHaveBeenCalledWith({
url: photoUrl,
maxBytes: 5 * 1024 * 1024,
});
expect(params.saveMediaBufferMock).toHaveBeenCalledTimes(1);
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));
}
}
export 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,176 @@
import { vi, type Mock } from "vitest";
import {
createEmptyPluginRegistry,
setActivePluginRegistry,
} from "../../../test/helpers/plugins/plugin-registry.js";
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
type MonitorModule = typeof import("../src/monitor.js");
type SecretInputModule = typeof import("../src/secret-input.js");
type WebhookModule = typeof import("../src/monitor.webhook.js");
const monitorModuleUrl = new URL("../src/monitor.ts", import.meta.url).href;
const secretInputModuleUrl = new URL("../src/secret-input.ts", import.meta.url).href;
const webhookModuleUrl = new URL("../src/monitor.webhook.ts", import.meta.url).href;
const apiModuleId = new URL("../src/api.js", import.meta.url).pathname;
const runtimeModuleId = new URL("../src/runtime.js", import.meta.url).pathname;
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
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(),
}),
);
export const setWebhookMock = lifecycleMocks.setWebhookMock;
export const deleteWebhookMock = lifecycleMocks.deleteWebhookMock;
export const getWebhookInfoMock = lifecycleMocks.getWebhookInfoMock;
export const getUpdatesMock = lifecycleMocks.getUpdatesMock;
export const sendChatActionMock = lifecycleMocks.sendChatActionMock;
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);
}
return (await import(`${monitorModuleUrl}?t=${params.cacheBust}-${Date.now()}`)) as MonitorModule;
}
async function importSecretInputModule(cacheBust: string): Promise<SecretInputModule> {
return (await import(
`${secretInputModuleUrl}?t=${cacheBust}-${Date.now()}`
)) as SecretInputModule;
}
async function importWebhookModule(cacheBust: string): Promise<WebhookModule> {
return (await import(`${webhookModuleUrl}?t=${cacheBust}-${Date.now()}`)) as WebhookModule;
}
export async function resetLifecycleTestState() {
vi.clearAllMocks();
(await importWebhookModule("reset-webhook")).clearZaloWebhookSecurityStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
}
export function setLifecycleRuntimeCore(
channel: NonNullable<NonNullable<Parameters<typeof createPluginRuntimeMock>[0]>["channel"]>,
) {
getZaloRuntimeMock.mockReturnValue(
createPluginRuntimeMock({
channel,
}),
);
}
export async function loadLifecycleMonitorModule(): Promise<MonitorModule> {
return await importMonitorModule({ cacheBust: "monitor", mocked: true });
}
export async function startWebhookLifecycleMonitor(params: {
account: ResolvedZaloAccount;
config: OpenClawConfig;
token?: string;
webhookUrl?: string;
webhookSecret?: 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 } = 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(() => {
if (setWebhookMock.mock.calls.length !== 1 || registry.httpRoutes.length !== 1) {
throw new Error("waiting for webhook registration");
}
});
const route = registry.httpRoutes[0];
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,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"
]
}