Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,82 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveZaloToken } from "./token.js";
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
export type { ResolvedZaloAccount };
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean);
}
export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
if (zaloConfig?.defaultAccount?.trim()) {
return zaloConfig.defaultAccount.trim();
}
const ids = listZaloAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): ZaloAccountConfig | undefined {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId] as ZaloAccountConfig | undefined;
}
function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
export function resolveZaloAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedZaloAccount {
const accountId = normalizeAccountId(params.accountId);
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,
);
return {
accountId,
name: merged.name?.trim() || undefined,
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,56 @@
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
import { listEnabledZaloAccounts } from "./accounts.js";
import { sendMessageZalo } from "./send.js";
const providerId = "zalo";
function listEnabledAccounts(cfg: OpenClawConfig) {
return listEnabledZaloAccounts(cfg).filter(
(account) => account.enabled && account.tokenSource !== "none",
);
}
export const zaloMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg);
if (accounts.length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
},
supportsButtons: () => false,
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 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,208 @@
/**
* 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;
avatar?: string;
};
chat: {
id: string;
chat_type: "PRIVATE" | "GROUP";
};
date: number;
text?: string;
photo?: string;
caption?: string;
sticker?: 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 ZaloSetWebhookParams = {
url: string;
secret_token: string;
};
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 });
}
/**
* 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<boolean>> {
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
}
/**
* Delete webhook configuration
*/
export async function deleteWebhook(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
}
/**
* Get current webhook info
*/
export async function getWebhookInfo(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
}

View File

@@ -0,0 +1,53 @@
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import { zaloPlugin } from "./channel.js";
describe("zalo directory", () => {
const runtimeEnv: RuntimeEnv = {
log: () => {},
error: () => {},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
it("lists peers from allowFrom", async () => {
const cfg = {
channels: {
zalo: {
allowFrom: ["zalo:123", "zl:234", "345"],
},
},
} as unknown as OpenClawConfig;
expect(zaloPlugin.directory).toBeTruthy();
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
await expect(
zaloPlugin.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(
zaloPlugin.directory!.listGroups!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual([]);
});
});

View File

@@ -0,0 +1,398 @@
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
chunkTextForOutbound,
formatAllowFromLowercase,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
resolveChannelAccountConfigBasePath,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
import {
listZaloAccountIds,
resolveDefaultZaloAccountId,
resolveZaloAccount,
type ResolvedZaloAccount,
} from "./accounts.js";
import { zaloMessageActions } from "./actions.js";
import { ZaloConfigSchema } from "./config-schema.js";
import { zaloOnboardingAdapter } from "./onboarding.js";
import { probeZalo } from "./probe.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { sendMessageZalo } from "./send.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, "");
}
export const zaloDock: ChannelDock = {
id: "zalo",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
id: "zalo",
meta,
onboarding: zaloOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "zalo",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "zalo",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const basePath = resolveChannelAccountConfigBasePath({
cfg,
channelKey: "zalo",
accountId: resolvedAccountId,
});
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("zalo"),
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.zalo !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}
const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
String(entry),
);
const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
const effectiveAllowFrom =
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
if (effectiveAllowFrom.length > 0) {
return [
`- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
];
}
return [
`- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
];
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
actions: zaloMessageActions,
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return /^\d{3,}$/.test(trimmed);
},
hint: "<chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
(account.config.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
),
)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "zalo",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "ZALO_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Zalo requires token or --token-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "zalo",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "zalo",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
...(input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
} as OpenClawConfig;
}
return {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
accounts: {
...next.channels?.zalo?.accounts,
[accountId]: {
...next.channels?.zalo?.accounts?.[accountId],
enabled: true,
...(input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
},
},
} as OpenClawConfig;
},
},
pairing: {
idLabel: "zaloUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZaloAccount({ cfg: cfg });
if (!account.token) {
throw new Error("Zalo token not configured");
}
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
cfg: cfg,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectZaloStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.token?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: account.config.webhookUrl ? "webhook" : "polling",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
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})`;
}
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
});
} catch {
// ignore probe errors
}
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
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: account.config.webhookSecret,
webhookPath: account.config.webhookPath,
fetcher,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
},
},
};

View File

@@ -0,0 +1,27 @@
import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZaloConfigSchema = zaloAccountSchema.extend({
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
defaultAccount: z.string().optional(),
});

View File

@@ -0,0 +1,48 @@
import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
import {
evaluateSenderGroupAccess,
isNormalizedSenderAllowed,
resolveOpenProviderRuntimeGroupPolicy,
} from "openclaw/plugin-sdk";
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,106 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./monitor.js";
describe("zalo group policy access", () => {
it("defaults missing provider config to allowlist", () => {
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
providerConfigPresent: false,
groupPolicy: undefined,
defaultGroupPolicy: "open",
});
expect(resolved).toEqual({
groupPolicy: "allowlist",
providerMissingFallbackApplied: true,
});
});
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,677 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
import {
createScopedPairingAccess,
createReplyPrefixOptions,
resolveSenderCommandAuthorization,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
sendMediaWithLeadingCaption,
resolveWebhookPath,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
getUpdates,
sendMessage,
sendPhoto,
setWebhook,
type ZaloFetch,
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import {
evaluateZaloGroupAccess,
isZaloSenderAllowed,
resolveZaloRuntimeGroupPolicy,
} from "./group-access.js";
import {
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
type ZaloWebhookTarget,
} from "./monitor.webhook.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { getZaloRuntime } from "./runtime.js";
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
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;
};
export type ZaloMonitorResult = {
stop: () => void;
};
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[zalo] ${message}`);
}
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerZaloWebhookTargetInternal(target);
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
await processUpdate(
update,
target.token,
target.account,
target.config,
target.runtime,
target.core as ZaloCoreRuntime,
target.mediaMaxMb,
target.statusSink,
target.fetcher,
);
});
}
function startPollingLoop(params: {
token: string;
account: ResolvedZaloAccount;
config: OpenClawConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
abortSignal: AbortSignal;
isStopped: () => boolean;
mediaMaxMb: number;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}) {
const {
token,
account,
config,
runtime,
core,
abortSignal,
isStopped,
mediaMaxMb,
statusSink,
fetcher,
} = params;
const pollTimeout = 30;
const poll = async () => {
if (isStopped() || abortSignal.aborted) {
return;
}
try {
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
if (response.ok && response.result) {
statusSink?.({ lastInboundAt: Date.now() });
await processUpdate(
response.result,
token,
account,
config,
runtime,
core,
mediaMaxMb,
statusSink,
fetcher,
);
}
} catch (err) {
if (err instanceof ZaloApiError && err.isPollingTimeout) {
// no updates
} else if (!isStopped() && !abortSignal.aborted) {
console.error(`[${account.accountId}] Zalo polling error:`, err);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
if (!isStopped() && !abortSignal.aborted) {
setImmediate(poll);
}
};
void poll();
}
async function processUpdate(
update: ZaloUpdate,
token: string,
account: ResolvedZaloAccount,
config: OpenClawConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { event_name, message } = update;
if (!message) {
return;
}
switch (event_name) {
case "message.text.received":
await handleTextMessage(message, token, account, config, runtime, core, statusSink, fetcher);
break;
case "message.image.received":
await handleImageMessage(
message,
token,
account,
config,
runtime,
core,
mediaMaxMb,
statusSink,
fetcher,
);
break;
case "message.sticker.received":
console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
break;
case "message.unsupported.received":
console.log(
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
);
break;
}
}
async function handleTextMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: OpenClawConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { text } = message;
if (!text?.trim()) {
return;
}
await processMessageWithPipeline({
message,
token,
account,
config,
runtime,
core,
text,
mediaPath: undefined,
mediaType: undefined,
statusSink,
fetcher,
});
}
async function handleImageMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: OpenClawConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { photo, caption } = message;
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (photo) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
);
mediaPath = saved.path;
mediaType = saved.contentType;
} catch (err) {
console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
}
}
await processMessageWithPipeline({
message,
token,
account,
config,
runtime,
core,
text: caption,
mediaPath,
mediaType,
statusSink,
fetcher,
});
}
async function processMessageWithPipeline(params: {
message: ZaloMessage;
token: string;
account: ResolvedZaloAccount;
config: OpenClawConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
text?: string;
mediaPath?: string;
mediaType?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}): Promise<void> {
const {
message,
token,
account,
config,
runtime,
core,
text,
mediaPath,
mediaType,
statusSink,
fetcher,
} = params;
const pairing = createScopedPairingAccess({
core,
channel: "zalo",
accountId: account.accountId,
});
const { from, chat, message_id, date } = message;
const isGroup = chat.chat_type === "GROUP";
const chatId = chat.id;
const senderId = from.id;
const senderName = 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;
}
}
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: pairing.readAllowFromStore,
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName ?? undefined },
});
if (created) {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: core.channel.pairing.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
},
fetcher,
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
core,
runtime,
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: chatId,
},
});
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 = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
previousTimestamp,
envelope: envelopeOptions,
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, ...prefixOptions } = createReplyPrefixOptions({
cfg: config,
agentId: route.agentId,
channel: "zalo",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...prefixOptions,
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?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
caption: text,
send: async ({ mediaUrl, caption }) => {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (error) => {
runtime.error?.(`Zalo photo send failed: ${String(error)}`);
},
});
if (sentMedia) {
return;
}
if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo message send failed: ${String(err)}`);
}
}
}
}
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
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);
let stopped = false;
const stopHandlers: Array<() => void> = [];
const stop = () => {
stopped = true;
for (const handler of stopHandlers) {
handler();
}
};
if (useWebhook) {
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");
}
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
const unregister = registerZaloWebhookTarget({
token,
account,
config,
runtime,
core,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
mediaMaxMb: effectiveMediaMaxMb,
fetcher,
});
stopHandlers.push(unregister);
abortSignal.addEventListener(
"abort",
() => {
void deleteWebhook(token, fetcher).catch(() => {});
},
{ once: true },
);
return { stop };
}
try {
await deleteWebhook(token, fetcher);
} catch {
// ignore
}
startPollingLoop({
token,
account,
config,
runtime,
core,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher,
});
return { stop };
}
export const __testing = {
evaluateZaloGroupAccess,
resolveZaloRuntimeGroupPolicy,
};

View File

@@ -0,0 +1,199 @@
import { createServer, type RequestListener } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
import type { ResolvedZaloAccount } from "./types.js";
async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
try {
await fn(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const webhookRequestHandler: RequestListener = async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
};
function registerTarget(params: {
path: string;
secret?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): () => void {
return registerZaloWebhookTarget({
token: "tok",
account: DEFAULT_ACCOUNT,
config: {} as OpenClawConfig,
runtime: {},
core: {} as PluginRuntime,
secret: params.secret ?? "secret",
path: params.path,
mediaMaxMb: 5,
statusSink: params.statusSink,
});
}
describe("handleZaloWebhookRequest", () => {
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 by event_name + message_id", async () => {
const sink = vi.fn();
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
const payload = {
event_name: "message.text.received",
message: {
from: { id: "123" },
chat: { id: "123", chat_type: "PRIVATE" },
message_id: "msg-replay-1",
date: Math.floor(Date.now() / 1000),
text: "hello",
},
};
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const first = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const second = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(sink).toHaveBeenCalledTimes(1);
});
} finally {
unregister();
}
});
it("returns 429 when per-path request rate exceeds threshold", async () => {
const unregister = registerTarget({ path: "/hook-rate" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-rate`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
}
expect(saw429).toBe(true);
});
} finally {
unregister();
}
});
});

View File

@@ -0,0 +1,219 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createDedupeCache,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
requestBodyErrorToText,
resolveSingleWebhookTarget,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.js";
type WebhookRateLimitState = { count: number; windowStartMs: number };
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
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 webhookRateLimits = new Map<string, WebhookRateLimitState>();
const recentWebhookEvents = createDedupeCache({
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
maxSize: 5000,
});
const webhookStatusCounters = new Map<string, number>();
function isJsonContentType(value: string | string[] | undefined): boolean {
const first = Array.isArray(value) ? value[0] : value;
if (!first) {
return false;
}
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
}
function timingSafeEquals(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
const length = Math.max(1, leftBuffer.length, rightBuffer.length);
const paddedLeft = Buffer.alloc(length);
const paddedRight = Buffer.alloc(length);
leftBuffer.copy(paddedLeft);
rightBuffer.copy(paddedRight);
timingSafeEqual(paddedLeft, paddedRight);
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
}
function isWebhookRateLimited(key: string, nowMs: number): boolean {
const state = webhookRateLimits.get(key);
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
return false;
}
state.count += 1;
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
return true;
}
return false;
}
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
const messageId = update.message?.message_id;
if (!messageId) {
return false;
}
const key = `${update.event_name}:${messageId}`;
return recentWebhookEvents.check(key, nowMs);
}
function recordWebhookStatus(
runtime: ZaloRuntimeEnv | undefined,
path: string,
statusCode: number,
): void {
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
return;
}
const key = `${path}:${statusCode}`;
const next = (webhookStatusCounters.get(key) ?? 0) + 1;
webhookStatusCounters.set(key, next);
if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
runtime?.log?.(
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
);
}
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerWebhookTarget(webhookTargets, target).unregister;
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
processUpdate: ZaloWebhookProcessUpdate,
): Promise<boolean> {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
timingSafeEquals(entry.secret, headerToken),
);
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
return true;
}
const target = matchedTarget.target;
const path = req.url ?? "<unknown>";
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
const nowMs = Date.now();
if (isWebhookRateLimited(rateLimitKey, nowMs)) {
res.statusCode = 429;
res.end("Too Many Requests");
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
if (!isJsonContentType(req.headers["content-type"])) {
res.statusCode = 415;
res.end("Unsupported Media Type");
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
const body = await readJsonBodyWithLimit(req, {
maxBytes: 1024 * 1024,
timeoutMs: 30_000,
emptyObjectOnEmpty: false,
});
if (!body.ok) {
res.statusCode =
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
const message =
body.code === "PAYLOAD_TOO_LARGE"
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
: body.code === "REQUEST_BODY_TIMEOUT"
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
: "Bad Request";
res.end(message);
recordWebhookStatus(target.runtime, path, res.statusCode);
return true;
}
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
const raw = body.value;
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;
}
if (isReplayEvent(update, nowMs)) {
res.statusCode = 200;
res.end("ok");
return true;
}
target.statusSink?.({ lastInboundAt: Date.now() });
processUpdate({ update, target }).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,398 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
OpenClawConfig,
WizardPrompter,
} from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
mergeAllowFromEntries,
normalizeAccountId,
promptAccountId,
} from "openclaw/plugin-sdk";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
const channel = "zalo" as const;
type UpdateMode = "polling" | "webhook";
function setZaloDmPolicy(
cfg: OpenClawConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
) {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
} as OpenClawConfig;
}
function setZaloUpdateMode(
cfg: OpenClawConfig,
accountId: string,
mode: UpdateMode,
webhookUrl?: string,
webhookSecret?: string,
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;
}
async function noteZaloTokenHelp(prompter: WizardPrompter): 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: https://docs.openclaw.ai/channels/zalo",
].join("\n"),
"Zalo bot token",
);
}
async function promptZaloAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
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 = String(value ?? "").trim();
if (!raw) {
return "Required";
}
if (!/^\d+$/.test(raw)) {
return "Use a numeric Zalo user id";
}
return undefined;
},
});
const normalized = String(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;
}
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
accounts: {
...cfg.channels?.zalo?.accounts,
[accountId]: {
...cfg.channels?.zalo?.accounts?.[accountId],
enabled: cfg.channels?.zalo?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as OpenClawConfig;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo",
channel,
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultZaloAccountId(cfg);
return promptZaloAllowFrom({
cfg: cfg,
prompter,
accountId: id,
});
},
};
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listZaloAccountIds(cfg).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg, accountId }).token),
);
return {
channel,
configured,
statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const zaloOverride = accountOverrides.zalo?.trim();
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg);
let zaloAccountId = zaloOverride ? normalizeAccountId(zaloOverride) : defaultZaloAccountId;
if (shouldPromptAccountIds && !zaloOverride) {
zaloAccountId = await promptAccountId({
cfg: cfg,
prompter,
label: "Zalo",
currentId: zaloAccountId,
listAccountIds: listZaloAccountIds,
defaultAccountId: defaultZaloAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
const hasConfigToken = Boolean(
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
);
let token: string | null = null;
if (!accountConfigured) {
await noteZaloTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botToken) {
const keepEnv = await prompter.confirm({
message: "ZALO_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
},
},
} as OpenClawConfig;
} else {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Zalo token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
botToken: token,
},
},
} as OpenClawConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
accounts: {
...next.channels?.zalo?.accounts,
[zaloAccountId]: {
...next.channels?.zalo?.accounts?.[zaloAccountId],
enabled: true,
botToken: token,
},
},
},
},
} as OpenClawConfig;
}
}
const wantsWebhook = await prompter.confirm({
message: "Use webhook mode for Zalo?",
initialValue: false,
});
if (wantsWebhook) {
const webhookUrl = String(
await prompter.text({
message: "Webhook URL (https://...) ",
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";
}
})();
const webhookSecret = String(
await prompter.text({
message: "Webhook secret (8-256 chars)",
validate: (value) => {
const raw = String(value ?? "");
if (raw.length < 8 || raw.length > 256) {
return "8-256 chars";
}
return undefined;
},
}),
).trim();
const webhookPath = String(
await prompter.text({
message: "Webhook path (optional)",
initialValue: defaultPath,
}),
).trim();
next = setZaloUpdateMode(
next,
zaloAccountId,
"webhook",
webhookUrl,
webhookSecret,
webhookPath || undefined,
);
} else {
next = setZaloUpdateMode(next, zaloAccountId, "polling");
}
if (forceAllowFrom) {
next = await promptZaloAllowFrom({
cfg: next,
prompter,
accountId: zaloAccountId,
});
}
return { cfg: next, accountId: zaloAccountId };
},
};

View File

@@ -0,0 +1,45 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
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 { Dispatcher, 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,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setZaloRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZaloRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalo runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,124 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
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 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) };
}
export async function sendMessageZalo(
chatId: string,
text: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
}
if (options.mediaUrl) {
return sendPhotoZalo(chatId, options.mediaUrl, {
...options,
token,
caption: text || options.caption,
});
}
try {
const response = await sendMessage(
token,
{
chat_id: chatId.trim(),
text: text.slice(0, 2000),
},
fetcher,
);
if (response.ok && response.result) {
return { ok: true, messageId: response.result.message_id };
}
return { ok: false, error: "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendPhotoZalo(
chatId: string,
photoUrl: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
}
if (!photoUrl?.trim()) {
return { ok: false, error: "No photo URL provided" };
}
try {
const response = await sendPhoto(
token,
{
chat_id: chatId.trim(),
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
},
fetcher,
);
if (response.ok && response.result) {
return { ok: true, messageId: response.result.message_id };
}
return { ok: false, error: "Failed to send photo" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

View File

@@ -0,0 +1,53 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk";
type ZaloAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
dmPolicy?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const asString = (value: unknown): string | undefined =>
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
dmPolicy: value.dmPolicy,
};
}
export function collectZaloStatusIssues(accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readZaloAccountStatus(entry);
if (!account) {
continue;
}
const accountId = asString(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,62 @@
import { readFileSync } from "node:fs";
import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
import type { ZaloConfig } from "./types.js";
export type ZaloTokenResolution = BaseTokenResolution & {
source: "env" | "config" | "configFile" | "none";
};
export function resolveZaloToken(
config: ZaloConfig | undefined,
accountId?: string | null,
): ZaloTokenResolution {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
const baseConfig = config;
const accountConfig =
resolvedAccountId !== DEFAULT_ACCOUNT_ID
? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
: undefined;
if (accountConfig) {
const token = accountConfig.botToken?.trim();
if (token) {
return { token, source: "config" };
}
const tokenFile = accountConfig.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
} catch {
// ignore read failures
}
}
}
if (isDefaultAccount) {
const token = baseConfig?.botToken?.trim();
if (token) {
return { token, source: "config" };
}
const tokenFile = baseConfig?.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
} catch {
// ignore read failures
}
}
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
if (envToken) {
return { token: envToken, source: "env" };
}
}
return { token: "", source: "none" };
}

View File

@@ -0,0 +1,48 @@
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?: string;
/** 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?: string;
/** 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;
};