mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 08:30:50 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
50
openclaw/extensions/zalo/README.md
Normal file
50
openclaw/extensions/zalo/README.md
Normal 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.
|
||||
2
openclaw/extensions/zalo/api.ts
Normal file
2
openclaw/extensions/zalo/api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { zaloPlugin } from "./src/channel.js";
|
||||
export * from "./setup-api.js";
|
||||
5
openclaw/extensions/zalo/contract-api.ts
Normal file
5
openclaw/extensions/zalo/contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
15
openclaw/extensions/zalo/index.test.ts
Normal file
15
openclaw/extensions/zalo/index.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
20
openclaw/extensions/zalo/index.ts
Normal file
20
openclaw/extensions/zalo/index.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
12
openclaw/extensions/zalo/openclaw.plugin.json
Normal file
12
openclaw/extensions/zalo/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "zalo",
|
||||
"channels": ["zalo"],
|
||||
"channelEnvVars": {
|
||||
"zalo": ["ZALO_BOT_TOKEN", "ZALO_WEBHOOK_SECRET"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
55
openclaw/extensions/zalo/package.json
Normal file
55
openclaw/extensions/zalo/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
openclaw/extensions/zalo/runtime-api.test.ts
Normal file
19
openclaw/extensions/zalo/runtime-api.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
4
openclaw/extensions/zalo/runtime-api.ts
Normal file
4
openclaw/extensions/zalo/runtime-api.ts
Normal 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";
|
||||
5
openclaw/extensions/zalo/secret-contract-api.ts
Normal file
5
openclaw/extensions/zalo/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
34
openclaw/extensions/zalo/setup-api.ts
Normal file
34
openclaw/extensions/zalo/setup-api.ts
Normal 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"];
|
||||
13
openclaw/extensions/zalo/setup-entry.ts
Normal file
13
openclaw/extensions/zalo/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
70
openclaw/extensions/zalo/src/accounts.test.ts
Normal file
70
openclaw/extensions/zalo/src/accounts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
60
openclaw/extensions/zalo/src/accounts.ts
Normal file
60
openclaw/extensions/zalo/src/accounts.ts
Normal 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);
|
||||
}
|
||||
5
openclaw/extensions/zalo/src/actions.runtime.ts
Normal file
5
openclaw/extensions/zalo/src/actions.runtime.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { sendMessageZalo as sendMessageZaloImpl } from "./send.js";
|
||||
|
||||
export const zaloActionsRuntime = {
|
||||
sendMessageZalo: sendMessageZaloImpl,
|
||||
};
|
||||
32
openclaw/extensions/zalo/src/actions.test.ts
Normal file
32
openclaw/extensions/zalo/src/actions.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
62
openclaw/extensions/zalo/src/actions.ts
Normal file
62
openclaw/extensions/zalo/src/actions.ts
Normal 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}.`);
|
||||
},
|
||||
};
|
||||
58
openclaw/extensions/zalo/src/api.test.ts
Normal file
58
openclaw/extensions/zalo/src/api.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
241
openclaw/extensions/zalo/src/api.ts
Normal file
241
openclaw/extensions/zalo/src/api.ts
Normal 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 });
|
||||
}
|
||||
17
openclaw/extensions/zalo/src/approval-auth.test.ts
Normal file
17
openclaw/extensions/zalo/src/approval-auth.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
25
openclaw/extensions/zalo/src/approval-auth.ts
Normal file
25
openclaw/extensions/zalo/src/approval-auth.ts
Normal 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),
|
||||
});
|
||||
59
openclaw/extensions/zalo/src/channel.directory.test.ts
Normal file
59
openclaw/extensions/zalo/src/channel.directory.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
93
openclaw/extensions/zalo/src/channel.runtime.ts
Normal file
93
openclaw/extensions/zalo/src/channel.runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
101
openclaw/extensions/zalo/src/channel.startup.test.ts
Normal file
101
openclaw/extensions/zalo/src/channel.startup.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
274
openclaw/extensions/zalo/src/channel.ts
Normal file
274
openclaw/extensions/zalo/src/channel.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
30
openclaw/extensions/zalo/src/config-schema.test.ts
Normal file
30
openclaw/extensions/zalo/src/config-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
openclaw/extensions/zalo/src/config-schema.ts
Normal file
29
openclaw/extensions/zalo/src/config-schema.ts
Normal 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);
|
||||
49
openclaw/extensions/zalo/src/group-access.ts
Normal file
49
openclaw/extensions/zalo/src/group-access.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
94
openclaw/extensions/zalo/src/monitor.group-policy.test.ts
Normal file
94
openclaw/extensions/zalo/src/monitor.group-policy.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
107
openclaw/extensions/zalo/src/monitor.image.polling.test.ts
Normal file
107
openclaw/extensions/zalo/src/monitor.image.polling.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
169
openclaw/extensions/zalo/src/monitor.lifecycle.test.ts
Normal file
169
openclaw/extensions/zalo/src/monitor.lifecycle.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
135
openclaw/extensions/zalo/src/monitor.pairing.lifecycle.test.ts
Normal file
135
openclaw/extensions/zalo/src/monitor.pairing.lifecycle.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
806
openclaw/extensions/zalo/src/monitor.ts
Normal file
806
openclaw/extensions/zalo/src/monitor.ts
Normal 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,
|
||||
};
|
||||
4
openclaw/extensions/zalo/src/monitor.types.ts
Normal file
4
openclaw/extensions/zalo/src/monitor.types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type ZaloRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
800
openclaw/extensions/zalo/src/monitor.webhook.test.ts
Normal file
800
openclaw/extensions/zalo/src/monitor.webhook.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
275
openclaw/extensions/zalo/src/monitor.webhook.ts
Normal file
275
openclaw/extensions/zalo/src/monitor.webhook.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
45
openclaw/extensions/zalo/src/probe.ts
Normal file
45
openclaw/extensions/zalo/src/probe.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
24
openclaw/extensions/zalo/src/proxy.ts
Normal file
24
openclaw/extensions/zalo/src/proxy.ts
Normal 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;
|
||||
}
|
||||
5
openclaw/extensions/zalo/src/runtime-api.ts
Normal file
5
openclaw/extensions/zalo/src/runtime-api.ts
Normal 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";
|
||||
90
openclaw/extensions/zalo/src/runtime-support.ts
Normal file
90
openclaw/extensions/zalo/src/runtime-support.ts
Normal 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";
|
||||
9
openclaw/extensions/zalo/src/runtime.ts
Normal file
9
openclaw/extensions/zalo/src/runtime.ts
Normal 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 };
|
||||
109
openclaw/extensions/zalo/src/secret-contract.ts
Normal file
109
openclaw/extensions/zalo/src/secret-contract.ts
Normal 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,
|
||||
};
|
||||
6
openclaw/extensions/zalo/src/secret-input.ts
Normal file
6
openclaw/extensions/zalo/src/secret-input.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
91
openclaw/extensions/zalo/src/send.test.ts
Normal file
91
openclaw/extensions/zalo/src/send.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
152
openclaw/extensions/zalo/src/send.ts
Normal file
152
openclaw/extensions/zalo/src/send.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
32
openclaw/extensions/zalo/src/session-route.ts
Normal file
32
openclaw/extensions/zalo/src/session-route.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
94
openclaw/extensions/zalo/src/setup-allow-from.ts
Normal file
94
openclaw/extensions/zalo/src/setup-allow-from.ts
Normal 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;
|
||||
}
|
||||
149
openclaw/extensions/zalo/src/setup-core.ts
Normal file
149
openclaw/extensions/zalo/src/setup-core.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
33
openclaw/extensions/zalo/src/setup-status.test.ts
Normal file
33
openclaw/extensions/zalo/src/setup-status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
openclaw/extensions/zalo/src/setup-surface.test.ts
Normal file
175
openclaw/extensions/zalo/src/setup-surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
291
openclaw/extensions/zalo/src/setup-surface.ts
Normal file
291
openclaw/extensions/zalo/src/setup-surface.ts
Normal 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,
|
||||
};
|
||||
17
openclaw/extensions/zalo/src/status-issues.test.ts
Normal file
17
openclaw/extensions/zalo/src/status-issues.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
37
openclaw/extensions/zalo/src/status-issues.ts
Normal file
37
openclaw/extensions/zalo/src/status-issues.ts
Normal 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;
|
||||
}
|
||||
92
openclaw/extensions/zalo/src/token.test.ts
Normal file
92
openclaw/extensions/zalo/src/token.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/zalo/src/token.ts
Normal file
79
openclaw/extensions/zalo/src/token.ts
Normal 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" };
|
||||
}
|
||||
50
openclaw/extensions/zalo/src/types.ts
Normal file
50
openclaw/extensions/zalo/src/types.ts
Normal 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;
|
||||
};
|
||||
1
openclaw/extensions/zalo/test-api.ts
Normal file
1
openclaw/extensions/zalo/test-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
|
||||
300
openclaw/extensions/zalo/test-support/lifecycle-test-support.ts
Normal file
300
openclaw/extensions/zalo/test-support/lifecycle-test-support.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
16
openclaw/extensions/zalo/tsconfig.json
Normal file
16
openclaw/extensions/zalo/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue