diff --git a/.github/labeler.yml b/.github/labeler.yml index a169ec108c3a..a9b5bded95f4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -171,6 +171,10 @@ - any-glob-to-any-file: - "extensions/zalo/**" - "docs/channels/zalo.md" +"channel: zaloclawbot": + - changed-files: + - any-glob-to-any-file: + - "docs/channels/zaloclawbot.md" "channel: zalouser": - changed-files: - any-glob-to-any-file: diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 6400109cc21e..ee83578940b6 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1194,5 +1194,9 @@ { "source": "cohere", "target": "cohere" + }, + { + "source": "Zalo ClawBot", + "target": "Zalo ClawBot" } ] diff --git a/docs/channels/index.md b/docs/channels/index.md index 6a8e5899c8a8..f0b1d2f8dd2f 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -52,6 +52,7 @@ Text is supported everywhere; media and reactions vary by channel. - [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing. - [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin). - [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin). +- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin). - [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin). ## Notes diff --git a/docs/channels/zaloclawbot.md b/docs/channels/zaloclawbot.md new file mode 100644 index 000000000000..ff5769f690e2 --- /dev/null +++ b/docs/channels/zaloclawbot.md @@ -0,0 +1,95 @@ +--- +summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin" +read_when: + - You want a personal Zalo assistant bot with QR-code login + - You are installing or troubleshooting the openclaw-zaloclawbot channel plugin +title: "Zalo ClawBot" +--- + +OpenClaw connects to Zalo ClawBot through the catalog-listed external +`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR +code. + +## Compatibility + +| Plugin Version | OpenClaw Version | npm dist-tag | Status | +| -------------- | ---------------- | ------------ | ------------- | +| 0.1.x | >=2026.4.10 | `latest` | Active / Beta | + +## Prerequisites + +- Node.js **>= 22** +- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available). +- A Zalo account on a mobile device to scan the login QR code. + +## Install with onboard (recommended) + +Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu: + +```bash +openclaw onboard +``` + +The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed. + +## Manual Installation + +To add the channel to an already-onboarded gateway, follow these steps: + +### 1. Install the plugin + +```bash +openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4" +``` + +Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install. + +### 2. Enable the plugin in config + +```bash +openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true +``` + +### 3. Generate QR code and log in + +```bash +openclaw channels login --channel openclaw-zaloclawbot +``` + +Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session. + +### 4. Restart the gateway + +```bash +openclaw gateway restart +``` + +--- + +## How It Works + +Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure: + +1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID. +2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure. +3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of + browser or web-session automation. + +## Under the Hood + +The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime: + +- Long-poll connections utilize the `getUpdates` endpoint. +- Webhooks are disabled by default for local desktop/terminal gateway runs. +- Messages are processed client-side and mapped directly to your local agent runtime. + +The external plugin manages bot credentials under the OpenClaw state directory. +Treat that directory as sensitive and include it in the same access-control and +backup policy as the rest of your OpenClaw state. + +--- + +## Troubleshooting + +- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one. +- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger. diff --git a/docs/docs.json b/docs/docs.json index 612b2228ef80..09a18a7d513d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -316,6 +316,10 @@ "source": "/providers/zalo", "destination": "/channels/zalo" }, + { + "source": "/channels/openclaw-zaloclawbot", + "destination": "/channels/zaloclawbot" + }, { "source": "/providers/whatsapp", "destination": "/channels/whatsapp" @@ -1132,6 +1136,7 @@ "channels/feishu", "channels/yuanbao", "channels/zalo", + "channels/zaloclawbot", "channels/zalouser" ] }, diff --git a/docs/providers/xai.md b/docs/providers/xai.md index cafbfd9df729..836285a2e132 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -504,9 +504,10 @@ Legacy aliases still normalize to the canonical bundled ids: sign-in URL. xAI decides which accounts can receive OAuth API tokens, and the consent page may show Grok Build even though OpenClaw does not require the Grok Build app. - - `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the - normal xAI provider path because it requires a different upstream API - surface than the standard OpenClaw xAI transport. + - OpenClaw does not currently expose the xAI multi-agent model family. xAI + serves these models through the Responses API, but they do not accept the + client-side or custom tools used by OpenClaw's shared agent loop. See the + [xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations). - xAI Realtime voice is not registered as an OpenClaw provider yet. It needs a different bidirectional voice session contract than batch STT or streaming transcription. diff --git a/extensions/discord/src/send.webhook.proxy.test.ts b/extensions/discord/src/send.webhook.proxy.test.ts index 05a738fa4df6..8a55bd941429 100644 --- a/extensions/discord/src/send.webhook.proxy.test.ts +++ b/extensions/discord/src/send.webhook.proxy.test.ts @@ -16,6 +16,28 @@ vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => { }; }); +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + describe("sendWebhookMessageDiscord proxy support", () => { beforeEach(() => { makeProxyFetchMock.mockReset(); @@ -208,4 +230,39 @@ describe("sendWebhookMessageDiscord proxy support", () => { expect(error.rawBody).toEqual({ message: "upstream unavailable" }); globalFetchMock.mockRestore(); }); + + it("bounds webhook error bodies without using response.text()", async () => { + const tracked = cancelTrackedResponse(`${"upstream unavailable ".repeat(1024)}tail`, { + status: 503, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + const globalFetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(tracked.response); + + const cfg = { + channels: { + discord: { + token: "Bot test-token", + }, + }, + } as OpenClawConfig; + + const thrown = await sendWebhookMessageDiscord("hello", { + cfg, + accountId: "default", + webhookId: "123", + webhookToken: "abc", + wait: true, + }).then( + () => undefined, + (error: unknown) => error, + ); + expect(thrown).toBeInstanceOf(DiscordError); + const error = thrown as DiscordError; + expect(error.message).toContain("upstream unavailable"); + expect(JSON.stringify(error.rawBody)).not.toContain("tail"); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + globalFetchMock.mockRestore(); + }); }); diff --git a/extensions/discord/src/send.webhook.ts b/extensions/discord/src/send.webhook.ts index 706e430a0c7d..45b90abc82fb 100644 --- a/extensions/discord/src/send.webhook.ts +++ b/extensions/discord/src/send.webhook.ts @@ -1,6 +1,7 @@ // Discord plugin module implements send.webhook behavior. import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDiscordClientAccountContext } from "./client.js"; import { @@ -14,6 +15,8 @@ import { rewriteDiscordKnownMentions } from "./mentions.js"; import { createDiscordSendResult } from "./send.receipt.js"; import type { DiscordSendResult } from "./send.types.js"; +const DISCORD_WEBHOOK_ERROR_BODY_LIMIT_BYTES = 8 * 1024; + type DiscordWebhookSendOpts = { cfg: OpenClawConfig; webhookId: string; @@ -54,7 +57,9 @@ function coerceWebhookErrorBody(raw: string): unknown { } async function throwWebhookResponseError(response: Response): Promise { - const raw = await response.text().catch(() => ""); + const raw = await readResponseTextLimited(response, DISCORD_WEBHOOK_ERROR_BODY_LIMIT_BYTES).catch( + () => "", + ); const parsed = coerceWebhookErrorBody(raw); if (response.status === 429) { throw new RateLimitError(response, { diff --git a/extensions/whatsapp/index.test.ts b/extensions/whatsapp/index.test.ts index 81e697d8eaa1..b56b22e3b17f 100644 --- a/extensions/whatsapp/index.test.ts +++ b/extensions/whatsapp/index.test.ts @@ -15,7 +15,7 @@ describe("whatsapp bundled entries", () => { it("declares account config as channel-restart reload metadata", () => { expect(whatsappPlugin.reload).toEqual({ - configPrefixes: ["web", "channels.whatsapp.accounts"], + configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], noopPrefixes: ["channels.whatsapp"], }); }); diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index c0b8591be2de..441adcac3394 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -181,7 +181,7 @@ export function createWhatsAppPluginBase(params: { // the broad `channels.whatsapp` noop prefix below otherwise swallows it as a // hot no-op and leaves the account connected until a full restart. reload: { - configPrefixes: ["web", "channels.whatsapp.accounts"], + configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], noopPrefixes: ["channels.whatsapp"], }, gatewayMethodDescriptors: [{ name: "web.login.start" }, { name: "web.login.wait" }], diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index bd4141880618..9f28d1f10109 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -31,6 +31,45 @@ } } }, + "modelCatalog": { + "suppressions": [ + { + "provider": "xai", + "model": "grok-4.20-multi-agent-0309", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent-latest", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent-beta-latest", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent-experimental-beta-0304", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent-experimental-beta-latest", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + }, + { + "provider": "xai", + "model": "grok-4.20-multi-agent-beta-0309", + "reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai." + } + ] + }, "syntheticAuthRefs": ["xai"], "setup": { "providers": [ diff --git a/extensions/xai/openclaw.plugin.test.ts b/extensions/xai/openclaw.plugin.test.ts new file mode 100644 index 000000000000..05aa914bd398 --- /dev/null +++ b/extensions/xai/openclaw.plugin.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const manifest = JSON.parse( + readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), +) as { + modelCatalog?: { + suppressions?: Array<{ provider?: string; model?: string }>; + }; +}; + +const XAI_MULTI_AGENT_MODELS = [ + "grok-4.20-multi-agent-0309", + "grok-4.20-multi-agent", + "grok-4.20-multi-agent-latest", + "grok-4.20-multi-agent-beta-latest", + "grok-4.20-multi-agent-experimental-beta-0304", + "grok-4.20-multi-agent-experimental-beta-latest", + "grok-4.20-multi-agent-beta-0309", +] as const; + +describe("xAI plugin manifest", () => { + it("suppresses the unsupported multi-agent model aliases", () => { + const suppressionRefs = new Set( + (manifest.modelCatalog?.suppressions ?? []).map( + (suppression) => `${suppression.provider}/${suppression.model}`, + ), + ); + + for (const model of XAI_MULTI_AGENT_MODELS) { + expect(suppressionRefs).toContain(`xai/${model}`); + } + }); +}); diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 9d7b42e4dd95..5f0a52b4ed8b 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -121,6 +121,45 @@ } } }, + { + "name": "@zalo-platforms/openclaw-zaloclawbot", + "description": "OpenClaw Zalo ClawBot channel plugin by the Zalo Platforms team.", + "source": "external", + "kind": "channel", + "openclaw": { + "plugin": { + "id": "openclaw-zaloclawbot", + "label": "Zalo ClawBot" + }, + "channel": { + "id": "openclaw-zaloclawbot", + "label": "Zalo ClawBot", + "selectionLabel": "Zalo ClawBot (QR)", + "detailLabel": "Zalo ClawBot", + "docsPath": "/channels/zaloclawbot", + "docsLabel": "zaloclawbot", + "blurb": "Personal Zalo assistant bot via QR-code login — owner-bound, no setup.", + "aliases": ["zaloclawbot", "zalo-clawbot"], + "order": 82 + }, + "channelConfigs": { + "openclaw-zaloclawbot": { + "label": "Zalo ClawBot", + "description": "Personal Zalo assistant — QR-onboarded, owner-bound.", + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "install": { + "npmSpec": "@zalo-platforms/openclaw-zaloclawbot@0.1.4", + "defaultChoice": "npm", + "expectedIntegrity": "sha512-5IxZriHJYACLLGqkCPPsTP9tas62kXEOFqTFAFMdunAM3SPhIJwVFRp0WvoP/m7L2PX85weD0g8LOtxM93VDYg==", + "minHostVersion": ">=2026.4.10" + } + } + }, { "name": "@openclaw/discord", "description": "OpenClaw Discord channel plugin", diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 319c4ebe81fd..9ddbb21e3f01 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -61,6 +61,10 @@ vi.mock("../model-suppression.js", () => { return undefined; } + function isUnsupportedXaiMultiAgentModel(provider?: string, id?: string): boolean { + return provider === "xai" && id?.trim().toLowerCase() === "grok-4.20-multi-agent-0309"; + } + return { shouldSuppressBuiltInModel: ({ provider, @@ -79,6 +83,9 @@ vi.mock("../model-suppression.js", () => { ) { return true; } + if (isUnsupportedXaiMultiAgentModel(provider, id)) { + return true; + } return ( (provider === "qwen" || provider === "modelstudio") && id?.trim().toLowerCase() === "qwen3.6-plus" && @@ -92,7 +99,7 @@ vi.mock("../model-suppression.js", () => { ) { return true; } - return false; + return isUnsupportedXaiMultiAgentModel(provider, id); }, buildSuppressedBuiltInModelError: ({ provider, @@ -116,6 +123,9 @@ vi.mock("../model-suppression.js", () => { ) { return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is available only through ChatGPT/Codex OAuth. Run \`openclaw models auth login --provider openai\` and use openai/gpt-5.3-codex-spark with that OAuth profile; OpenAI API-key auth cannot use this model.`; } + if (isUnsupportedXaiMultiAgentModel(provider, id)) { + return "Unknown model: xai/grok-4.20-multi-agent-0309. OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."; + } return undefined; }, }; @@ -3451,6 +3461,27 @@ describe("resolveModel", () => { ); }); + it("does not build a configured fallback for unsupported xAI multi-agent models", () => { + const cfg = { + models: { + providers: { + xai: { + baseUrl: "https://api.x.ai/v1", + api: "openai-completions", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModelForTest("xai", "grok-4.20-multi-agent-0309", "/tmp/agent", cfg); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe( + "Unknown model: xai/grok-4.20-multi-agent-0309. OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai.", + ); + }); + it("rejects stale openai gpt-5.3-codex-spark discovery rows", () => { mockDiscoveredModel(discoverModels, { provider: "openai", diff --git a/src/channels/plugins/contracts/channel-catalog.contract.test.ts b/src/channels/plugins/contracts/channel-catalog.contract.test.ts index 274300c44b41..389783c78980 100644 --- a/src/channels/plugins/contracts/channel-catalog.contract.test.ts +++ b/src/channels/plugins/contracts/channel-catalog.contract.test.ts @@ -49,3 +49,9 @@ describeChannelCatalogEntryContract({ npmSpec: "openclaw-plugin-yuanbao@2.13.1", alias: "yb", }); + +describeChannelCatalogEntryContract({ + channelId: "openclaw-zaloclawbot", + npmSpec: "@zalo-platforms/openclaw-zaloclawbot@0.1.4", + alias: "zaloclawbot", +}); diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index aba0ccaff74e..048a1d8efbb0 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -160,7 +160,7 @@ describe("buildGatewayReloadPlan", () => { resolveAccount: () => ({}), }, reload: { - configPrefixes: ["web", "channels.whatsapp.accounts"], + configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], noopPrefixes: ["channels.whatsapp"], }, }; @@ -235,6 +235,14 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toStrictEqual([]); }); + it("restarts the WhatsApp channel when selfChatMode changes (configPrefix wins over broad noop prefix)", () => { + const plan = buildGatewayReloadPlan(["channels.whatsapp.selfChatMode"]); + expect(plan.restartGateway).toBe(false); + expect(plan.restartChannels).toEqual(new Set(["whatsapp"])); + expect(plan.hotReasons).toContain("channels.whatsapp.selfChatMode"); + expect(plan.noopPaths).toStrictEqual([]); + }); + it("keeps other channels.whatsapp.* changes as hot no-ops", () => { const plan = buildGatewayReloadPlan(["channels.whatsapp.replyToMode"]); expect(plan.restartGateway).toBe(false); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 8232fb86ebaf..c92b0eb50005 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -5,8 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ConfigWriteNotification } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { consumeGatewaySigusr1RestartIntent } from "../infra/restart.js"; +import { + pinActivePluginChannelRegistry, + releasePinnedPluginChannelRegistry, +} from "../plugins/runtime.js"; import { createEmptyRuntimeWebToolsMetadata } from "../secrets/runtime-fast-path.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { diffConfigPaths } from "./config-diff.js"; import { buildGatewayReloadPlan, @@ -141,7 +146,13 @@ vi.mock("../agents/agent-bundle-mcp-tools.js", () => ({ disposeAllSessionMcpRuntimes: hoisted.disposeAllSessionMcpRuntimes, })); -function createReloadHandlersForTest(logReload = { info: vi.fn(), warn: vi.fn() }) { +function createReloadHandlersForTest( + logReload = { info: vi.fn(), warn: vi.fn() }, + channels?: { + start: (channel: ChannelKind) => Promise; + stop: (channel: ChannelKind) => Promise; + }, +) { const cron = { start: vi.fn(async () => {}), stop: vi.fn() }; const heartbeatRunner = { stop: vi.fn(), @@ -158,8 +169,8 @@ function createReloadHandlersForTest(logReload = { info: vi.fn(), warn: vi.fn() channelHealthMonitor: null, }), setState: vi.fn(), - startChannel: vi.fn(async () => {}), - stopChannel: vi.fn(async () => {}), + startChannel: channels?.start ?? vi.fn(async () => {}), + stopChannel: channels?.stop ?? vi.fn(async () => {}), stopPostReadySidecars: vi.fn(), reloadPlugins: vi.fn( async (): Promise => ({ @@ -889,6 +900,42 @@ describe("gateway channel hot reload handlers", () => { } } + it("restarts WhatsApp when the planner receives a selfChatMode change", async () => { + const whatsappPlugin = { + ...createChannelTestPluginBase({ id: "whatsapp" }), + reload: { + configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], + noopPrefixes: ["channels.whatsapp"], + }, + }; + const registry = createTestRegistry([ + { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, + ]); + const events: string[] = []; + const channels = { + stop: vi.fn(async (channel: ChannelKind) => { + events.push(`stop:${channel}`); + }), + start: vi.fn(async (channel: ChannelKind) => { + events.push(`start:${channel}`); + }), + }; + + pinActivePluginChannelRegistry(registry); + try { + const plan = buildGatewayReloadPlan(["channels.whatsapp.selfChatMode"]); + const { applyHotReload } = createReloadHandlersForTest(undefined, channels); + + expect(plan.restartGateway).toBe(false); + expect(plan.restartChannels).toEqual(new Set(["whatsapp"])); + await withChannelReloadsEnabled(() => applyHotReload(plan, {})); + + expect(events).toEqual(["stop:whatsapp", "start:whatsapp"]); + } finally { + releasePinnedPluginChannelRegistry(registry); + } + }); + it("continues restarting later channels after a hot-reload stop failure", async () => { const events: string[] = []; const setState = vi.fn();