fix(slack): retain partial stream participation

This commit is contained in:
Peter Steinberger
2026-06-19 13:48:47 +01:00
committed by GitHub
parent a3155ca99a
commit 0d8d75918d
17 changed files with 387 additions and 11 deletions
+4
View File
@@ -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:
+4
View File
@@ -1194,5 +1194,9 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]
+1
View File
@@ -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
+95
View File
@@ -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.
+5
View File
@@ -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"
]
},
+4 -3
View File
@@ -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.
@@ -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<Uint8Array>({
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();
});
});
+6 -1
View File
@@ -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<never> {
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, {
+1 -1
View File
@@ -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"],
});
});
+1 -1
View File
@@ -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" }],
+39
View File
@@ -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": [
+34
View File
@@ -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}`);
}
});
});
@@ -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",
+32 -1
View File
@@ -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",
@@ -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",
});
+9 -1
View File
@@ -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);
+50 -3
View File
@@ -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<void>;
stop: (channel: ChannelKind) => Promise<void>;
},
) {
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<GatewayPluginReloadResult> => ({
@@ -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();