diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 4660213dcd..b3fe5944bb 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -80,6 +80,7 @@ messaging_platforms: - slack - wechat - whatsapp + - zalo # ── Inference ─────────────────────────────────────────────────── inference: diff --git a/nemoclaw-blueprint/policies/presets/zalo.yaml b/nemoclaw-blueprint/policies/presets/zalo.yaml new file mode 100644 index 0000000000..6e8a4639ff --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/zalo.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: zalo + description: "Zalo Bot API access (long-polling)" + +network_policies: + zalo: + name: zalo + endpoints: + # Zalo Bot API. Long-polling (getUpdates) is GET; sending messages is POST. + # The bot.zaloplatforms.com portal is only used to create the bot, not at runtime. + - host: bot-api.zaloplatforms.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 70453cf33e..cdd4a5b821 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -54,6 +54,7 @@ describe("agent definitions", () => { "slack", "wechat", "whatsapp", + "zalo", ]); expect(openclaw.inferenceProviderOptions).toEqual([]); // #5027: openclaw.json must be declared as a durable state file so diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 2d1cb5a05d..f3f1ddb6e1 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -22,10 +22,12 @@ describe("messaging channel config", () => { "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", + "ZALO_ALLOWED_IDS", "TELEGRAM_GROUP_POLICY", "WECHAT_ACCOUNT_ID", "WECHAT_BASE_URL", "WECHAT_USER_ID", + "ZALO_GROUP_POLICY", ]); }); diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index 441e224122..f5d5af82ec 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -8,12 +8,14 @@ import { slackManifest } from "./slack/manifest"; import { telegramManifest } from "./telegram/manifest"; import { wechatManifest } from "./wechat/manifest"; import { whatsappManifest } from "./whatsapp/manifest"; +import { zaloManifest } from "./zalo/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; +export { zaloManifest } from "./zalo/manifest"; export const BUILT_IN_CHANNEL_MANIFESTS = [ telegramManifest, @@ -21,6 +23,7 @@ export const BUILT_IN_CHANNEL_MANIFESTS = [ wechatManifest, slackManifest, whatsappManifest, + zaloManifest, ] as const; export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 9654133aab..d45f2eb428 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -24,6 +24,7 @@ import { telegramManifest, wechatManifest, whatsappManifest, + zaloManifest, } from "./index"; import { SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, @@ -202,6 +203,7 @@ describe("built-in channel manifests", () => { "wechat", "slack", "whatsapp", + "zalo", ]); expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ "telegram", @@ -238,6 +240,9 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts", "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", + "src/lib/messaging/channels/zalo/manifest.ts", + "src/lib/messaging/channels/zalo/hooks/index.ts", + "src/lib/messaging/channels/zalo/hooks/openclaw-bridge-health.ts", "src/lib/messaging/hooks/common/config-prompt.ts", "src/lib/messaging/hooks/common/token-paste.ts", ]; @@ -649,4 +654,54 @@ describe("built-in channel manifests", () => { expect(JSON.stringify(whatsappManifest.runtime?.openclaw)).toContain("whatsapp-qr-compact"); expectOpenClawRuntimeVisibility(whatsappManifest, ["whatsapp"], ["whatsapp"]); }); + + it("declares Zalo as an OpenClaw-only flat-render channel with allowlist config", () => { + const botToken = findInput(zaloManifest, "botToken"); + const allowedIds = findInput(zaloManifest, "allowedIds"); + const groupPolicy = findInput(zaloManifest, "groupPolicy"); + expect(zaloManifest.supportedAgents).toEqual(["openclaw"]); + expect(botToken.envKey).toBe("ZALO_BOT_TOKEN"); + expect(allowedIds.envKey).toBe("ZALO_ALLOWED_IDS"); + expect(allowedIds.statePath).toBe("allowedIds.zalo"); + expect(groupPolicy).toMatchObject({ + kind: "config", + envKey: "ZALO_GROUP_POLICY", + statePath: "zaloConfig.groupPolicy", + defaultValue: "allowlist", + validValues: ["open", "allowlist", "disabled"], + }); + expect(policyPresetNames(zaloManifest)).toEqual(["zalo"]); + expect(zaloManifest.credentials).toEqual([ + { + id: "zaloBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-zalo-bridge", + providerEnvKey: "ZALO_BOT_TOKEN", + placeholder: "openshell:resolve:env:ZALO_BOT_TOKEN", + }, + ]); + // @openclaw/zalo rejects the shared accounts.default shape, so the OpenClaw + // render must stay a flat channels.zalo object. + expect(renderJson(zaloManifest)).toContain('"path":"channels.zalo"'); + expect(renderJson(zaloManifest)).not.toContain('"accounts"'); + expect(renderJson(zaloManifest)).toContain('"path":"plugins.entries.zalo"'); + // OpenClaw-only: no Hermes render targets. + expect(zaloManifest.render.every((entry) => entry.agent === "openclaw")).toBe(true); + expect(zaloManifest.agentPackages).toContainEqual({ + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/zalo@{{openclaw.version}}", + pin: true, + required: true, + }); + expectTokenPasteEnrollHook(zaloManifest, ["botToken"]); + expectConfigPromptEnrollHook(zaloManifest, ["allowedIds", "groupPolicy"]); + expectOpenClawBridgeHealthHook( + zaloManifest, + "zalo-openclaw-bridge-health", + "zalo.openclawBridgeHealth", + ); + expectOpenClawRuntimeVisibility(zaloManifest, ["zalo"], ["zalo"]); + }); }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 1e498d9110..494e3a6d08 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -30,6 +30,7 @@ describe("built-in messaging channel metadata", () => { "wechat", "slack", "whatsapp", + "zalo", ]); expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([ "telegram", @@ -78,6 +79,8 @@ describe("built-in messaging channel metadata", () => { "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", + "ZALO_ALLOWED_IDS", + "ZALO_GROUP_POLICY", ]); expect(getMessagingConfigEnvAliases()).toEqual({ DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], @@ -110,6 +113,7 @@ describe("built-in messaging channel metadata", () => { "openclaw-weixin", "slack", "whatsapp", + "zalo", ]); expect( Object.fromEntries( @@ -134,6 +138,7 @@ describe("built-in messaging channel metadata", () => { wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", slack: "npm:@openclaw/slack@{{openclaw.version}}", whatsapp: "npm:@openclaw/whatsapp@{{openclaw.version}}", + zalo: "npm:@openclaw/zalo@{{openclaw.version}}", }); expect(listMessagingPackageInstallSpecs({ agent: "hermes" })).toEqual([]); }); diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 62cdb3d28d..5142c681d0 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -316,7 +316,7 @@ export function listMessagingPackageInstallSpecs( } function selectManifests(options: MessagingManifestMetadataOptions): ChannelManifest[] { - const manifests = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS; + const manifests: readonly ChannelManifest[] = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS; const agent = options.agent; const selected = agent ? manifests.filter((manifest) => manifest.supportedAgents.includes(agent)) diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 6e93187fe0..960b6468bc 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -7,6 +7,7 @@ import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils"; import { resolveWechatTemplateReference } from "./wechat/template-resolver"; import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver"; +import { resolveZaloTemplateReference } from "./zalo/template-resolver"; const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResolver[] = [ resolveTelegramTemplateReference, @@ -14,6 +15,7 @@ const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResol resolveWechatTemplateReference, resolveSlackTemplateReference, resolveWhatsappTemplateReference, + resolveZaloTemplateReference, ]; export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver { diff --git a/src/lib/messaging/channels/zalo/hooks/index.ts b/src/lib/messaging/channels/zalo/hooks/index.ts new file mode 100644 index 0000000000..a73363db49 --- /dev/null +++ b/src/lib/messaging/channels/zalo/hooks/index.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; +import { createZaloOpenClawBridgeHealthHookRegistration } from "./openclaw-bridge-health"; + +export * from "./openclaw-bridge-health"; + +export interface ZaloHookOptions { + readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; +} + +export function createZaloHookRegistrations( + options: ZaloHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [createZaloOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth)] as const; +} diff --git a/src/lib/messaging/channels/zalo/hooks/openclaw-bridge-health.ts b/src/lib/messaging/channels/zalo/hooks/openclaw-bridge-health.ts new file mode 100644 index 0000000000..df6ea556f4 --- /dev/null +++ b/src/lib/messaging/channels/zalo/hooks/openclaw-bridge-health.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, +} from "../../openclaw-bridge-health"; + +export type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; + +export const ZALO_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID = "zalo.openclawBridgeHealth"; + +export function createZaloOpenClawBridgeHealthHookRegistration( + options: OpenClawBridgeHealthHookOptions = {}, +) { + return createOpenClawBridgeHealthHookRegistration( + { + channelId: "zalo", + handlerId: ZALO_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID, + }, + options, + ); +} diff --git a/src/lib/messaging/channels/zalo/manifest.ts b/src/lib/messaging/channels/zalo/manifest.ts new file mode 100644 index 0000000000..d47cef4b04 --- /dev/null +++ b/src/lib/messaging/channels/zalo/manifest.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const zaloManifest = { + schemaVersion: 1, + id: "zalo", + displayName: "Zalo", + description: "Zalo bot messaging (Bot API)", + enrollmentNotes: [ + "Create a bot at the Zalo Bot Platform (https://bot.zaloplatforms.com) and copy its token (format id:secret).", + "Unknown senders are paired first; approve from the sandbox with 'openclaw pairing approve zalo '.", + ], + supportedAgents: ["openclaw"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "ZALO_BOT_TOKEN", + formatPattern: "^\\d+:.+$", + formatHint: "Zalo bot tokens are : from the Zalo Bot Platform.", + prompt: { + label: "Zalo Bot Token", + help: "Create a bot at the Zalo Bot Platform (https://bot.zaloplatforms.com), then copy the token (format id:secret).", + }, + }, + { + id: "allowedIds", + kind: "config", + required: false, + envKey: "ZALO_ALLOWED_IDS", + statePath: "allowedIds.zalo", + formatPattern: "^[A-Za-z0-9]+(,[A-Za-z0-9]+)*$", + formatHint: "Comma-separated Zalo user IDs (alphanumeric, e.g. a1b2c3d4e5f6a7b8).", + prompt: { + label: "Zalo User ID (for DM access)", + help: "Alphanumeric Zalo user IDs allowed to DM the bot. Zalo has no username lookup, so use the raw user ID (e.g. a1b2c3d4e5f6a7b8).", + emptyValueMessage: "bot will require manual pairing", + }, + }, + { + id: "groupPolicy", + kind: "config", + required: false, + envKey: "ZALO_GROUP_POLICY", + statePath: "zaloConfig.groupPolicy", + validValues: ["open", "allowlist", "disabled"], + defaultValue: "allowlist", + prompt: { + label: "Zalo group policy", + help: "Controls OpenClaw Zalo group access: open to all, allowlist only, or disabled.", + }, + }, + ], + credentials: [ + { + id: "zaloBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-zalo-bridge", + providerEnvKey: "ZALO_BOT_TOKEN", + placeholder: "openshell:resolve:env:ZALO_BOT_TOKEN", + }, + ], + policyPresets: [ + { + name: "zalo", + validationWarningLines: [ + "For Zalo preset validation, do not use curl as the success signal:", + "curl is not in the preset binary allowlist, so curl probes can fail even", + "when the policy is working. Use Node HTTPS against", + "https://bot-api.zaloplatforms.com to validate the configured messaging bridge path.", + ], + }, + ], + render: [ + { + id: "zalo-openclaw-channel", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + // The @openclaw/zalo plugin validates channels.zalo as a flat + // single-account object; it rejects the Telegram-style + // accounts.default nesting ("must not have additional properties"). + path: "channels.zalo", + value: { + enabled: true, + botToken: "{{credential.zaloBotToken.placeholder}}", + proxy: "{{zaloProxyUrl}}", + groupPolicy: "{{zalo.groupPolicy}}", + dmPolicy: "{{zalo.allowedUsers.dmPolicy}}", + allowFrom: "{{zalo.allowedUsers.values}}", + }, + }, + }, + { + id: "zalo-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.zalo", + value: { + enabled: true, + }, + }, + }, + ], + runtime: { + openclaw: { + channelName: "zalo", + visibility: { + configKeys: ["zalo"], + logPatterns: ["zalo"], + }, + }, + }, + // @openclaw/zalo is the trusted upstream OpenClaw plugin implementing the Zalo + // Bot API bridge. The spec is coupled to {{openclaw.version}} because the plugin's + // config schema tracks the OpenClaw core release, and pin: true installs exactly + // that version for a reproducible, schema-matched build. Treat this as a build-time + // trusted-code boundary: do not loosen the spec, drop the pin, or change the package + // source without re-reviewing it. + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/zalo@{{openclaw.version}}", + pin: true, + required: true, + }, + ], + state: { + persist: { + allowedIds: ["allowedIds"], + zaloConfig: ["groupPolicy"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.zalo", + env: "ZALO_ALLOWED_IDS", + }, + { + statePath: "zaloConfig.groupPolicy", + env: "ZALO_GROUP_POLICY", + }, + ], + }, + hooks: [ + { + id: "zalo-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + { + id: "zalo-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + { + id: "groupPolicy", + kind: "config", + }, + ], + }, + { + id: "zalo-openclaw-bridge-health", + phase: "health-check", + handler: "zalo.openclawBridgeHealth", + agents: ["openclaw"], + onFailure: "abort", + }, + ], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/zalo/template-resolver.test.ts b/src/lib/messaging/channels/zalo/template-resolver.test.ts new file mode 100644 index 0000000000..42945d7769 --- /dev/null +++ b/src/lib/messaging/channels/zalo/template-resolver.test.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingInputReference } from "../../manifest"; +import { resolveZaloTemplateReference } from "./template-resolver"; + +describe("Zalo template resolver", () => { + it.each([ + ["open", "open"], + ["allowlist", "allowlist"], + ["disabled", "disabled"], + ["garbage", "allowlist"], + ["", "allowlist"], + ] as const)("resolves group policy %s -> %s", (value, expected) => { + const inputs: SandboxMessagingInputReference[] = [ + { + channelId: "zalo", + inputId: "groupPolicy", + kind: "config", + required: false, + statePath: "zaloConfig.groupPolicy", + value, + }, + ]; + + expect(resolveZaloTemplateReference("zalo.groupPolicy", { inputs })?.value).toBe(expected); + }); + + it("dedups allowed users and derives the allowlist dm policy", () => { + const inputs: SandboxMessagingInputReference[] = [ + { + channelId: "zalo", + inputId: "allowedIds", + kind: "config", + required: false, + statePath: "allowedIds.zalo", + value: "123,456,123", + }, + ]; + + expect(resolveZaloTemplateReference("zalo.allowedUsers.values", { inputs })?.value).toEqual([ + "123", + "456", + ]); + expect(resolveZaloTemplateReference("zalo.allowedUsers.dmPolicy", { inputs })?.value).toBe( + "allowlist", + ); + }); +}); diff --git a/src/lib/messaging/channels/zalo/template-resolver.ts b/src/lib/messaging/channels/zalo/template-resolver.ts new file mode 100644 index 0000000000..146d3d7d48 --- /dev/null +++ b/src/lib/messaging/channels/zalo/template-resolver.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyString, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +const DEFAULT_PROXY_HOST = "10.200.0.1"; +const DEFAULT_PROXY_PORT = "3128"; +const DEFAULT_ZALO_GROUP_POLICY = "allowlist"; +const ZALO_GROUP_POLICIES = new Set(["open", "allowlist", "disabled"]); + +export const resolveZaloTemplateReference: BuiltInRenderTemplateResolver = (reference, context) => { + if (reference === "zaloProxyUrl") return resolvedRenderTemplateReference(proxyUrl(context.env)); + + switch (reference) { + case "zalo.allowedUsers.values": + return resolvedRenderTemplateReference(nonEmptyArray(zaloAllowedUsers(context))); + case "zalo.allowedUsers.dmPolicy": + return resolvedRenderTemplateReference( + zaloAllowedUsers(context).length > 0 ? "allowlist" : undefined, + ); + case "zalo.groupPolicy": + return resolvedRenderTemplateReference(zaloGroupPolicy(context)); + default: + return undefined; + } +}; + +function zaloAllowedUsers(context: RenderTemplateContext): string[] { + return [...new Set(allowedIds(context, "zalo"))]; +} + +function zaloGroupPolicy(context: RenderTemplateContext): string { + const value = nonEmptyString(stateValue(context, "zaloConfig.groupPolicy")); + return value && ZALO_GROUP_POLICIES.has(value) ? value : DEFAULT_ZALO_GROUP_POLICY; +} + +function proxyUrl(env: RenderTemplateContext["env"]): string { + const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; + const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; + return `http://${host}:${port}`; +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 08b20dd93c..f07c176be8 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -11,6 +11,7 @@ import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../ho import { type ChannelManifest, ChannelManifestRegistry, + type SandboxMessagingJsonRenderPlan, type SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -22,6 +23,7 @@ const TEST_CREDENTIALS: Readonly> = { WECHAT_BOT_TOKEN: "test-wechat-token", SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", + ZALO_BOT_TOKEN: "987654321:test-zalo-token", }; const TEST_WECHAT_LOGIN = { token: "test-wechat-token", @@ -127,17 +129,18 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], + configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp", "zalo"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, DISCORD_BOT_TOKEN: true, WECHAT_BOT_TOKEN: true, SLACK_BOT_TOKEN: true, SLACK_APP_TOKEN: true, + ZALO_BOT_TOKEN: true, }, }); - expect(plan.channels.map((channel) => channel.channelId)).toEqual(ALL_CHANNELS); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([...ALL_CHANNELS, "zalo"]); expect(plan.channels.every((channel) => channel.active)).toBe(true); expect(plan.credentialBindings.map((binding) => binding.providerName)).toEqual([ "demo-telegram-bridge", @@ -145,6 +148,7 @@ describe("ManifestCompiler", () => { "demo-wechat-bridge", "demo-slack-bridge", "demo-slack-app", + "demo-zalo-bridge", ]); expect(plan.credentialBindings.map((binding) => binding.placeholder)).toEqual([ "openshell:resolve:env:TELEGRAM_BOT_TOKEN", @@ -152,6 +156,7 @@ describe("ManifestCompiler", () => { "openshell:resolve:env:WECHAT_BOT_TOKEN", "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + "openshell:resolve:env:ZALO_BOT_TOKEN", ]); expect(plan.networkPolicy.entries).toEqual([ { @@ -184,6 +189,12 @@ describe("ManifestCompiler", () => { policyKeys: ["whatsapp"], source: "manifest", }, + { + channelId: "zalo", + presetName: "zalo", + policyKeys: ["zalo"], + source: "manifest", + }, ]); expect(plan.agentRender.map((render) => `${render.channelId}:${render.renderId}`)).toEqual([ "telegram:telegram-openclaw-channel", @@ -196,6 +207,8 @@ describe("ManifestCompiler", () => { "slack:slack-openclaw-plugin", "whatsapp:whatsapp-openclaw-channel", "whatsapp:whatsapp-openclaw-plugin", + "zalo:zalo-openclaw-channel", + "zalo:zalo-openclaw-plugin", ]); expect(plan.agentRender.every((render) => render.handler === "common.staticOutputs")).toBe( true, @@ -250,6 +263,12 @@ describe("ManifestCompiler", () => { outputId: "openclawPluginPackage", required: true, }, + { + channelId: "zalo", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + }, ]); expect(plan.buildSteps).toEqual( expect.arrayContaining([ @@ -304,12 +323,37 @@ describe("ManifestCompiler", () => { requiredBefore: "lifecycle-success", hookIds: ["slack-openclaw-bridge-health"], }, + { + channelId: "zalo", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: ["zalo-openclaw-bridge-health"], + }, ]); expect( plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", )?.templateRefs, ).toEqual([]); + + // Zalo renders a flat channels.zalo fragment (no accounts.default nesting, + // which @openclaw/zalo rejects); dmPolicy/allowFrom stay omitted without an allowlist. + const zaloRender = plan.agentRender.find( + (render): render is SandboxMessagingJsonRenderPlan => + render.channelId === "zalo" && + render.renderId === "zalo-openclaw-channel" && + render.kind === "json-fragment", + ); + expect(zaloRender).toBeDefined(); + const zaloValue = zaloRender?.value as Record; + expect(zaloValue).not.toHaveProperty("accounts"); + expect(zaloValue).toMatchObject({ + enabled: true, + botToken: "openshell:resolve:env:ZALO_BOT_TOKEN", + groupPolicy: "allowlist", + }); + expect(zaloValue).not.toHaveProperty("dmPolicy"); + expect(zaloValue).not.toHaveProperty("allowFrom"); }); it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 79497f108f..61026450a5 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -15,6 +15,7 @@ describe("messaging channel diagnostics", () => { "wechat", "slack", "whatsapp", + "zalo", ]); expect(specs.find((spec) => spec.channelId === "telegram")).toMatchObject({ policyPresets: ["telegram"], diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index 0e8ad9e11b..b1622866dd 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -9,6 +9,7 @@ import { type TelegramHookOptions, } from "../channels/telegram/hooks"; import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; +import { createZaloHookRegistrations, type ZaloHookOptions } from "../channels/zalo/hooks"; import { type CommonHookOptions, createCommonHookRegistrations } from "./common"; import { MessagingHookRegistry } from "./registry"; import type { MessagingHookRegistration } from "./types"; @@ -20,6 +21,7 @@ export interface BuiltInMessagingHookOptions { readonly slack?: SlackHookOptions; readonly telegram?: TelegramHookOptions; readonly wechat?: WechatHookOptions; + readonly zalo?: ZaloHookOptions; } export function createBuiltInMessagingHookRegistrations( @@ -37,6 +39,9 @@ export function createBuiltInMessagingHookRegistrations( withOpenClawBridgeHealthOptions(options.telegram, options.openclawBridgeHealth), ), ...createWechatHookRegistrations(options.wechat), + ...createZaloHookRegistrations( + withOpenClawBridgeHealthOptions(options.zalo, options.openclawBridgeHealth), + ), ]; } diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 835599aecc..f4694cba02 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -49,6 +49,7 @@ describe("MessagingHookRegistry", () => { "wechat.ilinkLogin", "wechat.seedOpenClawAccount", "wechat.healthCheck", + "zalo.openclawBridgeHealth", ]); }); diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index cbc28d332d..855e7cd9a2 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -148,6 +148,7 @@ describe("prepareCreateSandboxMessaging", () => { "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "WECHAT_BOT_TOKEN", + "ZALO_BOT_TOKEN", ]); expect(result.reusableMessagingProviders).toEqual([]); expect(result.reusableMessagingChannels).toEqual([]); diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 27b3b98b03..30e5304dbf 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -16,8 +16,15 @@ import { } from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { - it("covers telegram, discord, wechat, slack, and whatsapp", () => { - expect(knownChannelNames()).toEqual(["telegram", "discord", "wechat", "slack", "whatsapp"]); + it("covers telegram, discord, wechat, slack, whatsapp, and zalo", () => { + expect(knownChannelNames()).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "zalo", + ]); }); it("exposes the primary bot-token env var for token-based channels", () => { @@ -163,7 +170,14 @@ describe("sandbox-channels token-shape helpers", () => { describe("sandbox-channels listChannels", () => { it("materialises an array with the name merged into each entry", () => { const list = listChannels(); - expect(list.map((c) => c.name)).toEqual(["telegram", "discord", "wechat", "slack", "whatsapp"]); + expect(list.map((c) => c.name)).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "zalo", + ]); const telegram = list.find((c) => c.name === "telegram"); expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(telegram?.allowIdsMode).toBe("dm"); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 15b1f7bcf7..2b3de65d10 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -34,6 +34,7 @@ function runScript( SLACK_BOT_TOKEN: "xoxb-slack-bot-token-for-test", SLACK_APP_TOKEN: "xapp-slack-app-token-for-test", DISCORD_BOT_TOKEN: "test-discord-token", + ZALO_BOT_TOKEN: "123456:test-zalo-token", NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", ...extraEnv, }, @@ -61,7 +62,7 @@ function parseResultPayload = Record> // a console.log marker, so the test can assert the ordering invariant // (apply MUST precede rebuild) function buildPreamble({ - presetNamesAvailable = ["telegram", "slack", "discord", "npm", "github"], + presetNamesAvailable = ["telegram", "slack", "discord", "zalo", "npm", "github"], applyPresetResult = true, appliedPresets = [] as string[], sandboxAgent = "openclaw", @@ -307,12 +308,12 @@ const ctx = module.exports; isInteractive: false, configuredChannels: ["slack"], disabledChannels: [], - supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp"], + supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp", "zalo"], }, ]); }); - for (const channel of ["telegram", "slack", "discord"]) { + for (const channel of ["telegram", "slack", "discord", "zalo"]) { it(`applies the '${channel}' preset before triggering rebuild`, () => { const script = `${buildPreamble()} const ctx = module.exports; @@ -358,7 +359,6 @@ const ctx = module.exports; ); }); } - it("applies the tokenless WhatsApp preset for Hermes before triggering rebuild", () => { const script = `${buildPreamble({ presetNamesAvailable: ["telegram", "slack", "discord", "whatsapp", "npm", "github"], diff --git a/test/policies.test.ts b/test/policies.test.ts index a1b23e17be..2a7d02da2f 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -148,11 +148,11 @@ describe("policies", () => { "weather", "wechat", "whatsapp", + "zalo", ]; expect(names).toEqual(expected); }); }); - describe("loadPreset", () => { it("loads existing preset", () => { const content = requirePresetContent(policies.loadPreset("outlook")); diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts index f301ebd9b3..a9a38069ad 100644 --- a/test/sandbox-provider-cleanup.test.ts +++ b/test/sandbox-provider-cleanup.test.ts @@ -38,6 +38,7 @@ describe("SANDBOX_PROVIDER_SUFFIXES", () => { "wechat-bridge", "slack-bridge", "slack-app", + "zalo-bridge", "brave-search", ].sort(), );