mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor(config): retire flat streaming keys from the last six channel schemas via doctor migration (#105709)
* refactor(config): retire flat streaming keys from the last six channel schemas
signal, irc, nextcloud-talk, whatsapp, googlechat, and mattermost now accept
only the nested streaming.{chunkMode,block.enabled,block.coalesce} shape
(mattermost also drops scalar/boolean streaming); flat spellings migrate via
each channel's defineChannelAliasMigration doctor contract with root seeding
for their wholesale-replace account merges.
* feat(channels): warn once per key when the deprecated flat streaming fallback is used
Bundled schemas now reject the flat delivery keys, so the streaming.ts
fallback only serves external SDK plugin configs; emit a once-per-process
per-key subsystem warning and pin the removal plan to the next release train.
* chore(config): regenerate bundled channel config metadata for nested-only streaming
* docs: describe nested-only channel streaming config and the SDK flat-key deprecation window
* fix(whatsapp): seed migrated named-account streaming from the accounts.default layer
WhatsApp resolution layers accounts.default shared config between root and
named accounts, so doctor-materialized account streaming objects now inherit
default-account settings over root ones; mattermost schema test moves to the
nested-only shape with explicit rejection coverage; scope flat-key deprecation
notes to the pending Matrix/Feishu migrations.
* fix(whatsapp): resolve the default account case-insensitively when seeding migrated streaming
* chore(plugin-sdk): repin API baseline for nested-only channel streaming types
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
0eb8e2e8ebfe48bbf269d6e0100e38b8c85c387651be7a3bc3d67f549a13768f plugin-sdk-api-baseline.json
|
||||
f6e5dc7474a7cb54bdcde544f135656ac8e501679e18129607f4f42fec7cd791 plugin-sdk-api-baseline.jsonl
|
||||
75edbe496d57c19d3ea53e51908c6c7aa37ed8a5c7311234c7ce17eea0775db8 plugin-sdk-api-baseline.json
|
||||
f21d330f28d93248bd6a7d96780452d6a969f5d1fcca1b9246ceb5a68e589b9f plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -294,13 +294,13 @@ Notes:
|
||||
|
||||
Mattermost streams thinking, tool activity, and partial reply text into a **draft preview post** that finalizes in place when the final answer is safe to send. In `partial` mode the preview updates on the same post id instead of spamming the channel with per-chunk messages. In `block` mode the preview rotates between completed text and tool-activity blocks, so earlier blocks stay visible as their own posts instead of being overwritten by the next one. Media/error finals cancel pending preview edits and use normal delivery instead of flushing a throwaway preview post.
|
||||
|
||||
Preview streaming is **on by default** in `partial` mode. Configure via `channels.mattermost.streaming` (a mode string, boolean, or an object like `{ mode: "progress" }`):
|
||||
Preview streaming is **on by default** in `partial` mode. Configure via `channels.mattermost.streaming.mode` (legacy scalar/boolean `streaming` values are migrated by `openclaw doctor --fix`):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
streaming: "partial", // off | partial | block | progress
|
||||
streaming: { mode: "partial" }, // off | partial | block | progress
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -311,7 +311,7 @@ Preview streaming is **on by default** in `partial` mode. Configure via `channel
|
||||
- `partial` (default): one preview post that is edited as the reply grows, then finalized with the complete answer.
|
||||
- `block` rotates the preview between completed text and tool-activity blocks, so each block stays visible as its own post instead of being overwritten in place. Parallel and consecutive tool updates share the current tool-activity post.
|
||||
- `progress` shows a status preview while generating and only posts the final answer at completion.
|
||||
- `off` disables preview streaming. With `blockStreaming: true`, completed assistant blocks are still delivered as normal block replies (separate posts) rather than a single coalesced final post.
|
||||
- `off` disables preview streaming. With `streaming.block.enabled: true`, completed assistant blocks are still delivered as normal block replies (separate posts) rather than a single coalesced final post.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Streaming behavior notes">
|
||||
|
||||
@@ -156,9 +156,9 @@ Provider options:
|
||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dms`: per-DM overrides keyed by user ID (`historyLimit`).
|
||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size in chars (default: 4000).
|
||||
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||
- `channels.nextcloud-talk.streaming.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.nextcloud-talk.streaming.block.enabled`: enable or disable block streaming for this channel.
|
||||
- `channels.nextcloud-talk.streaming.block.coalesce`: block streaming coalesce tuning.
|
||||
- `channels.nextcloud-talk.responsePrefix`: outbound reply prefix.
|
||||
- `channels.nextcloud-talk.markdown.tables`: markdown table rendering mode (`off | bullets | code | block`).
|
||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||
|
||||
@@ -256,7 +256,7 @@ Groups:
|
||||
## Media + limits
|
||||
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- Optional newline chunking: set `channels.signal.streaming.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- Attachments are supported (base64 fetched from `signal-cli`).
|
||||
- Voice-note attachments use the `signal-cli` filename as a MIME fallback when `contentType` is missing, so audio transcription can still classify AAC voice memos.
|
||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||
@@ -440,12 +440,12 @@ Provider options:
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size in characters (default 4000).
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.signal.streaming.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap in MB (default 8).
|
||||
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive` (default `minimal`). See [Reactions](#reactions-message-tool).
|
||||
- `channels.signal.reactionNotifications`: `off | own | all | allowlist` (default `own`) - when the agent is notified of incoming reactions from others.
|
||||
- `channels.signal.reactionAllowlist`: senders whose reactions notify the agent when `reactionNotifications: "allowlist"`.
|
||||
- `channels.signal.blockStreaming`, `channels.signal.blockStreamingCoalesce`: block-mode streaming controls shared across channels. See [Streaming](/concepts/streaming).
|
||||
- `channels.signal.streaming.block.enabled`, `channels.signal.streaming.block.coalesce`: block-mode streaming controls shared across channels. See [Streaming](/concepts/streaming).
|
||||
|
||||
Related global options:
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ When the linked self number is also present in `allowFrom`, self-chat safeguards
|
||||
<AccordionGroup>
|
||||
<Accordion title="Text chunking">
|
||||
- default chunk limit: `channels.whatsapp.textChunkLimit = 4000`
|
||||
- `channels.whatsapp.chunkMode = "length" | "newline"`; `newline` prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
|
||||
- `channels.whatsapp.streaming.chunkMode = "length" | "newline"`; `newline` prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -677,7 +677,7 @@ Primary reference: [Configuration reference - WhatsApp](/gateway/config-channels
|
||||
| Area | Fields |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Access | `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` |
|
||||
| Delivery | `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
|
||||
| Delivery | `textChunkLimit`, `streaming.chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
|
||||
| Multi-account | `accounts.<id>.enabled`, `accounts.<id>.authDir`, and other per-account overrides |
|
||||
| Operations | `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` |
|
||||
| Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit` |
|
||||
|
||||
@@ -125,7 +125,7 @@ Block streaming sends partial replies as the model produces text blocks; chunkin
|
||||
- `agents.defaults.blockStreamingChunk` (`minChars|maxChars|breakPreference`)
|
||||
- `agents.defaults.blockStreamingCoalesce` (idle-based batching)
|
||||
- `agents.defaults.humanDelay` (human-like pause between block replies)
|
||||
- Channel overrides: `*.streaming.block.enabled` and `*.streaming.block.coalesce` on nested-streaming channels (Telegram, Discord, Slack, iMessage, Microsoft Teams); flat `*.blockStreaming` / `*.blockStreamingCoalesce` on channels without a nested streaming config. Block streaming is off unless explicitly enabled, on every channel including Telegram.
|
||||
- Channel overrides: `*.streaming.block.enabled` and `*.streaming.block.coalesce` on bundled channels (Matrix and Feishu still use the flat `*.blockStreaming` / `*.blockStreamingCoalesce` spellings); stale flat keys elsewhere are migrated by `openclaw doctor --fix`. Block streaming is off unless explicitly enabled, on every channel including Telegram.
|
||||
|
||||
Details: [Streaming + chunking](/concepts/streaming).
|
||||
|
||||
|
||||
+20
-18
@@ -41,22 +41,23 @@ Model output
|
||||
| `blockStreamingBreak` | `"text_end"` / `"message_end"` | - |
|
||||
| `blockStreamingChunk` | `{ minChars, maxChars, breakPreference? }` | - |
|
||||
| `blockStreamingCoalesce` | `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send) | - |
|
||||
| `*.blockStreaming` (channel override) | `true` / `false`, forces block streaming per channel (and per account) | - |
|
||||
| `*.streaming.block.enabled` (channel override) | `true` / `false`, forces block streaming per channel (and per account) | - |
|
||||
| `*.textChunkLimit` (e.g. `channels.whatsapp.textChunkLimit`) | number, hard cap | 4000 |
|
||||
| `*.chunkMode` | `"length"` / `"newline"` | `"length"` |
|
||||
| `*.streaming.chunkMode` | `"length"` / `"newline"` | `"length"` |
|
||||
| `channels.discord.maxLinesPerMessage` | number, soft line cap that splits tall replies to avoid UI clipping | 17 |
|
||||
|
||||
`chunkMode: "newline"` splits on blank lines (paragraph boundaries), not every
|
||||
newline, before falling back to length chunking once the text exceeds the
|
||||
limit.
|
||||
`streaming.chunkMode: "newline"` splits on blank lines (paragraph boundaries),
|
||||
not every newline, before falling back to length chunking once the text
|
||||
exceeds the limit.
|
||||
|
||||
Channels with a nested `streaming` config (Telegram, Discord, Slack, iMessage,
|
||||
Microsoft Teams) spell these overrides as
|
||||
`channels.<id>.streaming.{chunkMode,block.enabled,block.coalesce}`; the flat
|
||||
`*.chunkMode` / `*.blockStreaming` / `*.blockStreamingCoalesce` spellings apply
|
||||
to channels without one (for example Signal, IRC, Google Chat, WhatsApp,
|
||||
Mattermost). Stale flat keys on nested-streaming channels are migrated by
|
||||
`openclaw doctor --fix` and are not read at runtime.
|
||||
Bundled channels spell these overrides as
|
||||
`channels.<id>.streaming.{chunkMode,block.enabled,block.coalesce}`. The flat
|
||||
`*.chunkMode` / `*.blockStreaming` / `*.blockStreamingCoalesce` spellings are
|
||||
legacy everywhere except Matrix and Feishu (their nested migration is still
|
||||
pending): `openclaw doctor --fix` migrates them into the nested shape, and
|
||||
migrated channel schemas reject them. External SDK plugin configs that still
|
||||
use the flat spellings keep working through a deprecated fallback (with a
|
||||
runtime warning) until the next release train.
|
||||
|
||||
**Boundary semantics** for `blockStreamingBreak`:
|
||||
|
||||
@@ -104,7 +105,7 @@ progressive output.
|
||||
(final flush always sends remaining text).
|
||||
- Joiner is derived from `blockStreamingChunk.breakPreference`: `paragraph` ->
|
||||
`\n\n`, `newline` -> `\n`, `sentence` -> space.
|
||||
- Channel overrides are available via `*.blockStreamingCoalesce` (including
|
||||
- Channel overrides are available via `*.streaming.block.coalesce` (including
|
||||
per-account configs).
|
||||
- Discord, Signal, and Slack default coalesce to `{ minChars: 1500, idleMs: 1000 }`
|
||||
unless overridden.
|
||||
@@ -126,15 +127,16 @@ replies**, not final replies or tool summaries.
|
||||
## "Stream chunks or everything"
|
||||
|
||||
- **Stream chunks:** `blockStreamingDefault: "on"` + `blockStreamingBreak: "text_end"`
|
||||
(emit as you go). Non-Telegram channels also need `*.blockStreaming: true`.
|
||||
(emit as you go). Non-Telegram channels also need
|
||||
`*.streaming.block.enabled: true`.
|
||||
- **Stream everything at end:** `blockStreamingBreak: "message_end"` (flush
|
||||
once, possibly multiple chunks if very long).
|
||||
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
|
||||
|
||||
Block streaming is **off unless** `*.blockStreaming` is explicitly set to
|
||||
`true`. Channels can stream a live preview (`channels.<channel>.streaming`)
|
||||
without block replies. The `blockStreaming*` defaults live under
|
||||
`agents.defaults`, not the config root.
|
||||
Block streaming is **off unless** `*.streaming.block.enabled` is explicitly
|
||||
set to `true`. Channels can stream a live preview
|
||||
(`channels.<channel>.streaming.mode`) without block replies. The
|
||||
`blockStreaming*` defaults live under `agents.defaults`, not the config root.
|
||||
|
||||
## Preview streaming modes
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "+447700900123"],
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length", // length | newline
|
||||
streaming: { chunkMode: "length" }, // length | newline
|
||||
mediaMaxMb: 50,
|
||||
sendReadReceipts: true, // blue ticks (false in self-chat mode)
|
||||
groups: {
|
||||
@@ -202,7 +202,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
historyLimit: 50,
|
||||
replyToMode: "first", // off | first | all | batched
|
||||
linkPreview: true,
|
||||
streaming: "partial", // off | partial | block | progress (default: partial)
|
||||
streaming: { mode: "partial" }, // off | partial | block | progress (default: partial)
|
||||
actions: { reactions: true, sendMessage: true },
|
||||
reactionNotifications: "own", // off | own | all
|
||||
mediaMaxMb: 100,
|
||||
@@ -576,7 +576,7 @@ Check [npmjs.com/package/@openclaw/mattermost](https://www.npmjs.com/package/@op
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
streaming: { chunkMode: "length" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Googlechat tests cover doctor contract plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
|
||||
describe("googlechat doctor contract", () => {
|
||||
it("removes legacy streamMode keys", () => {
|
||||
@@ -73,4 +73,41 @@ describe("googlechat doctor contract", () => {
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("matches flat streaming aliases in legacy rules but not the nested shape", () => {
|
||||
const rootRule = legacyConfigRules.find(
|
||||
(rule) => rule.path.join(".") === "channels.googlechat" && rule.message.includes("chunkMode"),
|
||||
);
|
||||
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("moves flat delivery aliases at root and account level with root seeding", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
streamMode: "append",
|
||||
chunkMode: "newline",
|
||||
accounts: {
|
||||
work: { blockStreaming: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const googlechat = result.config.channels?.googlechat as unknown as Record<string, unknown>;
|
||||
expect(googlechat.streamMode).toBeUndefined();
|
||||
expect(googlechat.streaming).toEqual({ chunkMode: "newline" });
|
||||
expect(googlechat.chunkMode).toBeUndefined();
|
||||
const work = (googlechat.accounts as Record<string, Record<string, unknown>>).work;
|
||||
// Google Chat's account merge replaces root streaming wholesale, so the
|
||||
// migrated account object carries the inherited root chunk mode.
|
||||
expect(work?.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
|
||||
expect(work?.blockStreaming).toBeUndefined();
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: result.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,22 @@ import type {
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
|
||||
|
||||
// Google Chat's nested streaming schema is delivery-only ({chunkMode, block});
|
||||
// it has no preview mode (legacy streamMode is removed outright above), so
|
||||
// only the delivery flat aliases migrate. Account merge replaces the root
|
||||
// streaming object wholesale (resolveMergedAccountConfig without a streaming
|
||||
// deep-merge), so migration seeds materialized account objects with the
|
||||
// inherited root settings.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "googlechat",
|
||||
streaming: { defaultMode: "partial", deliveryOnly: true },
|
||||
accountStreamingReplacesRoot: true,
|
||||
});
|
||||
|
||||
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
|
||||
return asObjectRecord(value)?.streamMode !== undefined;
|
||||
}
|
||||
@@ -113,13 +125,10 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatGroupAllowAlias),
|
||||
},
|
||||
...streamingAliasMigration.legacyConfigRules,
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
function normalizeRetiredGoogleChatKeys(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord(
|
||||
(cfg.channels as Record<string, unknown> | undefined)?.googlechat,
|
||||
);
|
||||
@@ -179,3 +188,15 @@ export function normalizeCompatibilityConfig({
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const retired = normalizeRetiredGoogleChatKeys(cfg);
|
||||
return streamingAliasMigration.normalizeChannelConfig({
|
||||
cfg: retired.config,
|
||||
changes: retired.changes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Irc tests cover doctor contract api plugin behavior.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
||||
|
||||
function ircConfig(entry: Record<string, unknown>): OpenClawConfig {
|
||||
return { channels: { irc: entry } } as never;
|
||||
}
|
||||
|
||||
describe("irc streaming legacy config rules", () => {
|
||||
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.irc");
|
||||
const accountRule = legacyConfigRules.find(
|
||||
(rule) => rule.path.join(".") === "channels.irc.accounts",
|
||||
);
|
||||
|
||||
it("matches flat delivery aliases at root and account level", () => {
|
||||
expect(rootRule?.match?.({ blockStreaming: false }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { block: { enabled: false } } }, {})).toBe(false);
|
||||
expect(accountRule?.match?.({ libera: { chunkMode: "newline" } }, {})).toBe(true);
|
||||
expect(accountRule?.match?.({ libera: { streaming: { chunkMode: "newline" } } }, {})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("irc normalizeCompatibilityConfig streaming aliases", () => {
|
||||
it("moves flat delivery aliases and seeds materialized account objects from root", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: ircConfig({
|
||||
blockStreaming: true,
|
||||
accounts: {
|
||||
libera: { chunkMode: "newline" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const irc = result.config.channels?.irc as unknown as Record<string, unknown>;
|
||||
expect(irc.streaming).toEqual({ block: { enabled: true } });
|
||||
expect(irc.blockStreaming).toBeUndefined();
|
||||
const libera = expectDefined(
|
||||
(irc.accounts as Record<string, Record<string, unknown>>).libera,
|
||||
"libera irc account",
|
||||
);
|
||||
// IRC's account merge replaces the root streaming object wholesale, so the
|
||||
// migrated account object must carry the inherited root block settings.
|
||||
expect(libera.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
|
||||
expect(libera.chunkMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is idempotent: a second run reports no changes", () => {
|
||||
const first = normalizeCompatibilityConfig({
|
||||
cfg: ircConfig({ chunkMode: "length", blockStreamingCoalesce: { minChars: 10 } }),
|
||||
});
|
||||
expect(first.changes.length).toBeGreaterThan(0);
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
expect(second.config).toBe(first.config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Irc API module exposes the plugin doctor contract.
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
// IRC's nested streaming schema is delivery-only ({chunkMode, block}); it has
|
||||
// no preview mode, so only the delivery flat aliases are legal legacy input.
|
||||
// Account merge replaces the root streaming object wholesale
|
||||
// (resolveMergedAccountConfig without a streaming deep-merge), so migration
|
||||
// seeds materialized account objects with the inherited root settings.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "irc",
|
||||
streaming: { defaultMode: "partial", deliveryOnly: true },
|
||||
accountStreamingReplacesRoot: true,
|
||||
});
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
|
||||
streamingAliasMigration.legacyConfigRules;
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
return streamingAliasMigration.normalizeChannelConfig({ cfg });
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
@@ -394,6 +395,7 @@ export async function handleIrcInbound(params: {
|
||||
});
|
||||
|
||||
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
@@ -449,9 +451,7 @@ export async function handleIrcInbound(params: {
|
||||
replyOptions: {
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
|
||||
},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Irc type declarations define plugin contracts.
|
||||
import type { ChannelDeliveryStreamingConfig } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
@@ -60,9 +60,7 @@ export type IrcAccountConfig = {
|
||||
dmHistoryLimit?: number;
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
streaming?: ChannelDeliveryStreamingConfig;
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
@@ -100,19 +100,15 @@ const MattermostStreamingBlockSchema = z
|
||||
coalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const MattermostStreamingSchema = z.union([
|
||||
MattermostStreamingModeSchema,
|
||||
z.boolean(),
|
||||
z
|
||||
.object({
|
||||
mode: MattermostStreamingModeSchema.optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
preview: MattermostStreamingPreviewSchema.optional(),
|
||||
progress: MattermostStreamingProgressSchema.optional(),
|
||||
block: MattermostStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
const MattermostStreamingSchema = z
|
||||
.object({
|
||||
mode: MattermostStreamingModeSchema.optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
preview: MattermostStreamingPreviewSchema.optional(),
|
||||
progress: MattermostStreamingProgressSchema.optional(),
|
||||
block: MattermostStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MattermostReplyToModeSchema = z.enum(["off", "first", "all", "batched"]);
|
||||
const MattermostReplyToModeByChatTypeSchema = z
|
||||
@@ -141,10 +137,7 @@ const MattermostAccountSchemaBase = z
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
streaming: MattermostStreamingSchema.optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
replyToMode: MattermostReplyToModeSchema.optional(),
|
||||
replyToModeByChatType: MattermostReplyToModeByChatTypeSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
|
||||
@@ -68,13 +68,22 @@ describe("MattermostConfigSchema", () => {
|
||||
},
|
||||
accounts: {
|
||||
quiet: {
|
||||
streaming: "off",
|
||||
streaming: { mode: "off" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects retired scalar streaming and flat delivery keys", () => {
|
||||
// Scalar/boolean streaming and the flat delivery keys are doctor-migrated
|
||||
// legacy input; the runtime schema accepts the nested shape only.
|
||||
expect(MattermostConfigSchema.safeParse({ streaming: "partial" }).success).toBe(false);
|
||||
expect(MattermostConfigSchema.safeParse({ streaming: false }).success).toBe(false);
|
||||
expect(MattermostConfigSchema.safeParse({ blockStreaming: true }).success).toBe(false);
|
||||
expect(MattermostConfigSchema.safeParse({ chunkMode: "newline" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts groups with requireMention", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
groups: {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Mattermost tests cover doctor contract plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
|
||||
function mattermostConfig(entry: Record<string, unknown>): OpenClawConfig {
|
||||
return { channels: { mattermost: entry } } as never;
|
||||
}
|
||||
|
||||
describe("mattermost streaming legacy config rules", () => {
|
||||
const rootRule = legacyConfigRules.find(
|
||||
(rule) => rule.path.join(".") === "channels.mattermost" && rule.message.includes("chunkMode"),
|
||||
);
|
||||
|
||||
it("matches scalar streaming and flat delivery aliases but not the nested shape", () => {
|
||||
expect(rootRule?.match?.({ streaming: "block" }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: false }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { mode: "block" } }, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mattermost normalizeCompatibilityConfig streaming aliases", () => {
|
||||
it("moves scalar streaming into streaming.mode alongside flat delivery keys", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: mattermostConfig({
|
||||
streaming: "progress",
|
||||
chunkMode: "newline",
|
||||
blockStreamingCoalesce: { idleMs: 100 },
|
||||
}),
|
||||
});
|
||||
|
||||
const mattermost = result.config.channels?.mattermost as unknown as Record<string, unknown>;
|
||||
expect(mattermost.streaming).toEqual({
|
||||
mode: "progress",
|
||||
chunkMode: "newline",
|
||||
block: { coalesce: { idleMs: 100 } },
|
||||
});
|
||||
expect(mattermost.chunkMode).toBeUndefined();
|
||||
expect(mattermost.blockStreamingCoalesce).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates boolean streaming off and seeds materialized account objects from root", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: mattermostConfig({
|
||||
streaming: false,
|
||||
accounts: {
|
||||
work: { blockStreaming: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const mattermost = result.config.channels?.mattermost as unknown as Record<string, unknown>;
|
||||
expect(mattermost.streaming).toEqual({ mode: "off" });
|
||||
const work = (mattermost.accounts as Record<string, Record<string, unknown>>).work;
|
||||
// Mattermost's account merge replaces root streaming wholesale, so the
|
||||
// migrated account object carries the inherited root mode.
|
||||
expect(work?.streaming).toEqual({ mode: "off", block: { enabled: true } });
|
||||
expect(work?.blockStreaming).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is idempotent: a second run reports no changes", () => {
|
||||
const first = normalizeCompatibilityConfig({
|
||||
cfg: mattermostConfig({ streaming: "partial", blockStreaming: true }),
|
||||
});
|
||||
expect(first.changes.length).toBeGreaterThan(0);
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
expect(second.config).toBe(first.config);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,38 @@
|
||||
// Mattermost plugin module implements doctor contract behavior.
|
||||
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const contract = createLegacyPrivateNetworkDoctorContract({
|
||||
const networkContract = createLegacyPrivateNetworkDoctorContract({
|
||||
channelKey: "mattermost",
|
||||
});
|
||||
|
||||
export const legacyConfigRules = contract.legacyConfigRules;
|
||||
// Mattermost has a preview stream mode; runtime resolves it with a "partial"
|
||||
// default (resolveChannelPreviewStreamMode(merged, "partial") in accounts.ts),
|
||||
// so scalar/boolean `streaming` values migrate through the mode path. Account
|
||||
// merge replaces the root streaming object wholesale (resolveMergedAccountConfig
|
||||
// without a streaming deep-merge), so migration seeds materialized account
|
||||
// objects with the inherited root settings.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "mattermost",
|
||||
streaming: { defaultMode: "partial" },
|
||||
accountStreamingReplacesRoot: true,
|
||||
});
|
||||
|
||||
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
||||
export const legacyConfigRules = [
|
||||
...networkContract.legacyConfigRules,
|
||||
...streamingAliasMigration.legacyConfigRules,
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const network = networkContract.normalizeCompatibilityConfig({ cfg });
|
||||
return streamingAliasMigration.normalizeChannelConfig({
|
||||
cfg: network.config,
|
||||
changes: network.changes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
resolveChannelStreamingChunkMode,
|
||||
resolveChannelPreviewStreamMode,
|
||||
type StreamingMode,
|
||||
type TextChunkMode,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { BlockStreamingCoalesceConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
||||
import type {
|
||||
@@ -39,10 +41,10 @@ export type ResolvedMattermostAccount = {
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: MattermostAccountConfig["chunkMode"];
|
||||
chunkMode?: TextChunkMode;
|
||||
streamingMode: StreamingMode;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
};
|
||||
|
||||
const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Mattermost type declarations define plugin contracts.
|
||||
import type {
|
||||
ChannelPreviewStreamingConfig,
|
||||
StreamingMode,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./runtime-api.js";
|
||||
import type { ChannelPreviewStreamingConfig } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
|
||||
import type { SecretInput } from "./secret-input.js";
|
||||
|
||||
export type MattermostReplyToMode = "off" | "first" | "all" | "batched";
|
||||
@@ -54,14 +51,8 @@ export type MattermostAccountConfig = {
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Preview streaming mode/config. */
|
||||
streaming?: StreamingMode | boolean | ChannelPreviewStreamingConfig;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Preview streaming config (nested-only; scalar modes migrate via doctor). */
|
||||
streaming?: ChannelPreviewStreamingConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Nextcloud Talk tests cover doctor contract plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
|
||||
function talkConfig(entry: Record<string, unknown>): OpenClawConfig {
|
||||
return { channels: { "nextcloud-talk": entry } } as never;
|
||||
}
|
||||
|
||||
describe("nextcloud-talk streaming legacy config rules", () => {
|
||||
const rootRule = legacyConfigRules.find(
|
||||
(rule) =>
|
||||
rule.path.join(".") === "channels.nextcloud-talk" && rule.message.includes("chunkMode"),
|
||||
);
|
||||
|
||||
it("matches flat delivery aliases but not the nested shape", () => {
|
||||
expect(rootRule?.match?.({ chunkMode: "newline" }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { chunkMode: "newline" } }, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextcloud-talk normalizeCompatibilityConfig streaming aliases", () => {
|
||||
it("moves flat delivery aliases at root and account level with root seeding", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: talkConfig({
|
||||
chunkMode: "newline",
|
||||
accounts: {
|
||||
home: { blockStreaming: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const talk = result.config.channels?.["nextcloud-talk"] as unknown as Record<string, unknown>;
|
||||
expect(talk.streaming).toEqual({ chunkMode: "newline" });
|
||||
expect(talk.chunkMode).toBeUndefined();
|
||||
const home = (talk.accounts as Record<string, Record<string, unknown>>).home;
|
||||
// Account merge replaces root streaming wholesale, so the migrated account
|
||||
// object carries the inherited root chunk mode.
|
||||
expect(home?.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
|
||||
expect(home?.blockStreaming).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still runs the legacy private-network migration and stays idempotent", () => {
|
||||
const first = normalizeCompatibilityConfig({
|
||||
cfg: talkConfig({ allowPrivateNetwork: true, blockStreaming: false }),
|
||||
});
|
||||
const talk = first.config.channels?.["nextcloud-talk"] as unknown as Record<string, unknown>;
|
||||
expect(talk.allowPrivateNetwork).toBeUndefined();
|
||||
expect(talk.network).toEqual({ dangerouslyAllowPrivateNetwork: true });
|
||||
expect(talk.streaming).toEqual({ block: { enabled: false } });
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,37 @@
|
||||
// Nextcloud Talk plugin module implements doctor contract behavior.
|
||||
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const contract = createLegacyPrivateNetworkDoctorContract({
|
||||
const networkContract = createLegacyPrivateNetworkDoctorContract({
|
||||
channelKey: "nextcloud-talk",
|
||||
});
|
||||
|
||||
export const legacyConfigRules = contract.legacyConfigRules;
|
||||
// Nextcloud Talk's nested streaming schema is delivery-only ({chunkMode,
|
||||
// block}); it has no preview mode, so only the delivery flat aliases are
|
||||
// legal legacy input. Account merge replaces the root streaming object
|
||||
// wholesale (resolveMergedAccountConfig without a streaming deep-merge), so
|
||||
// migration seeds materialized account objects with inherited root settings.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "nextcloud-talk",
|
||||
streaming: { defaultMode: "partial", deliveryOnly: true },
|
||||
accountStreamingReplacesRoot: true,
|
||||
});
|
||||
|
||||
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
||||
export const legacyConfigRules = [
|
||||
...networkContract.legacyConfigRules,
|
||||
...streamingAliasMigration.legacyConfigRules,
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const network = networkContract.normalizeCompatibilityConfig({ cfg });
|
||||
return streamingAliasMigration.normalizeChannelConfig({
|
||||
cfg: network.config,
|
||||
changes: network.changes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
channelIngressRoutes,
|
||||
resolveStableChannelMessageIngress,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
@@ -325,6 +326,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
});
|
||||
|
||||
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
@@ -387,9 +389,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
replyOptions: {
|
||||
skillFilter: roomConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
|
||||
},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// Nextcloud Talk type declarations define plugin contracts.
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
SecretInput,
|
||||
} from "../runtime-api.js";
|
||||
ChannelDeliveryStreamingConfig,
|
||||
MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { DmConfig, DmPolicy, GroupPolicy, SecretInput } from "../runtime-api.js";
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
@@ -70,12 +67,8 @@ export type NextcloudTalkAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Delivery streaming config: chunk mode plus block streaming controls. */
|
||||
streaming?: ChannelDeliveryStreamingConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Media upload max size in MB. */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Signal tests cover doctor contract api plugin behavior.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
||||
|
||||
function signalConfig(entry: Record<string, unknown>): OpenClawConfig {
|
||||
return { channels: { signal: entry } } as never;
|
||||
}
|
||||
|
||||
describe("signal streaming legacy config rules", () => {
|
||||
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.signal");
|
||||
const accountRule = legacyConfigRules.find(
|
||||
(rule) => rule.path.join(".") === "channels.signal.accounts",
|
||||
);
|
||||
|
||||
it("matches flat delivery aliases at root and account level", () => {
|
||||
expect(rootRule?.match?.({ chunkMode: "newline" }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { chunkMode: "newline" } }, {})).toBe(false);
|
||||
expect(accountRule?.match?.({ personal: { blockStreamingCoalesce: { idleMs: 5 } } }, {})).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
accountRule?.match?.({ personal: { streaming: { block: { enabled: true } } } }, {}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal normalizeCompatibilityConfig streaming aliases", () => {
|
||||
it("moves flat delivery aliases and seeds materialized account objects from root", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: signalConfig({
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
accounts: {
|
||||
personal: {
|
||||
blockStreamingCoalesce: { idleMs: 250 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const signal = result.config.channels?.signal as unknown as Record<string, unknown>;
|
||||
expect(signal.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
|
||||
expect(signal.chunkMode).toBeUndefined();
|
||||
expect(signal.blockStreaming).toBeUndefined();
|
||||
const personal = expectDefined(
|
||||
(signal.accounts as Record<string, Record<string, unknown>>).personal,
|
||||
"personal signal account",
|
||||
);
|
||||
// Signal's account merge replaces the root streaming object wholesale, so
|
||||
// the account object migration materializes must carry the inherited root
|
||||
// settings or `doctor --fix` would silently drop them for this account.
|
||||
expect(personal.streaming).toEqual({
|
||||
chunkMode: "newline",
|
||||
block: { enabled: true, coalesce: { idleMs: 250 } },
|
||||
});
|
||||
expect(personal.blockStreamingCoalesce).toBeUndefined();
|
||||
expect(result.changes).toContain(
|
||||
"Copied channels.signal.streaming into channels.signal.accounts.personal.streaming to keep inherited settings while migrating flat streaming keys.",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run reports no changes", () => {
|
||||
const first = normalizeCompatibilityConfig({
|
||||
cfg: signalConfig({
|
||||
chunkMode: "newline",
|
||||
accounts: { personal: { blockStreaming: false } },
|
||||
}),
|
||||
});
|
||||
expect(first.changes.length).toBeGreaterThan(0);
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
expect(second.config).toBe(first.config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Signal API module exposes the plugin doctor contract.
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
// Signal's nested streaming schema is delivery-only ({chunkMode, block}); it
|
||||
// has no preview mode, so only the delivery flat aliases are legal legacy
|
||||
// input. Account merge replaces the root streaming object wholesale
|
||||
// (resolveMergedAccountConfig without a streaming deep-merge), so migration
|
||||
// seeds materialized account objects with the inherited root settings.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "signal",
|
||||
streaming: { defaultMode: "partial", deliveryOnly: true },
|
||||
accountStreamingReplacesRoot: true,
|
||||
});
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
|
||||
streamingAliasMigration.legacyConfigRules;
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
return streamingAliasMigration.normalizeChannelConfig({ cfg });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Signal plugin module implements monitor behavior.
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
@@ -702,7 +703,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
accountId: accountInfo.accountId,
|
||||
blockStreaming: accountInfo.config.blockStreaming,
|
||||
blockStreaming: resolveChannelStreamingBlockEnabled(accountInfo.config),
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
textLimit,
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
export { normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
|
||||
// WhatsApp currently exposes doctor compatibility fixes without extra legacy
|
||||
// rule scans. Keep that empty answer on a lightweight contract surface so
|
||||
// config validation stays off the broad contract-api import path.
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [];
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
|
||||
@@ -65,11 +65,5 @@ export function resolveMergedWhatsAppAccountConfig(params: {
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? base
|
||||
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
|
||||
return {
|
||||
accountId,
|
||||
...merged,
|
||||
// WhatsApp has no nested streaming schema; the flat keys are canonical.
|
||||
chunkMode: merged.chunkMode,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
};
|
||||
return { accountId, ...merged };
|
||||
}
|
||||
|
||||
@@ -38,9 +38,8 @@ export type ResolvedWhatsAppAccount = {
|
||||
dmPolicy?: DmPolicy;
|
||||
historyLimit?: number;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: "length" | "newline";
|
||||
streaming?: WhatsAppAccountConfig["streaming"];
|
||||
mediaMaxMb?: number;
|
||||
blockStreaming?: boolean;
|
||||
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
||||
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
|
||||
groups?: WhatsAppAccountConfig["groups"];
|
||||
@@ -146,9 +145,8 @@ export function resolveWhatsAppAccount(params: {
|
||||
mentionPatterns: merged.mentionPatterns,
|
||||
historyLimit: merged.historyLimit,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
chunkMode: merged.chunkMode,
|
||||
streaming: merged.streaming,
|
||||
mediaMaxMb: merged.mediaMaxMb,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
ackReaction: merged.ackReaction,
|
||||
reactionLevel: merged.reactionLevel,
|
||||
groups: merged.groups,
|
||||
|
||||
@@ -94,9 +94,11 @@ function resolveWebMonitorConfigSnapshot(params: {
|
||||
groupAllowFrom: account.groupAllowFrom,
|
||||
groupPolicy: account.groupPolicy,
|
||||
textChunkLimit: account.textChunkLimit,
|
||||
chunkMode: account.chunkMode,
|
||||
// Account merge replaces `streaming` wholesale, so pinning the
|
||||
// account-resolved object here keeps downstream root-level resolver
|
||||
// reads (chunk mode, block enable/coalesce) on this account's config.
|
||||
streaming: account.streaming,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
blockStreaming: account.blockStreaming,
|
||||
groups: account.groups,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -312,7 +312,7 @@ function unacceptedDeliveryResult() {
|
||||
|
||||
async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
|
||||
const params: BufferedReplyParams = {
|
||||
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
||||
cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never,
|
||||
connectionId: "conn",
|
||||
context: { Body: "hi" },
|
||||
deliverReply: async () => acceptedDeliveryResult(),
|
||||
@@ -1108,21 +1108,21 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => {
|
||||
it("maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false", async () => {
|
||||
await dispatchBufferedReply();
|
||||
|
||||
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("maps WhatsApp blockStreaming=false to disableBlockStreaming=true", async () => {
|
||||
it("maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true", async () => {
|
||||
await dispatchBufferedReply({
|
||||
cfg: { channels: { whatsapp: { blockStreaming: false } } } as never,
|
||||
cfg: { channels: { whatsapp: { streaming: { block: { enabled: false } } } } } as never,
|
||||
});
|
||||
|
||||
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves disableBlockStreaming undefined when WhatsApp blockStreaming is unset", async () => {
|
||||
it("leaves disableBlockStreaming undefined when WhatsApp block streaming is unset", async () => {
|
||||
await dispatchBufferedReply({
|
||||
cfg: { channels: { whatsapp: {} } } as never,
|
||||
});
|
||||
@@ -1155,7 +1155,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
it("delivers authorized WhatsApp group text slash command replies visibly", async () => {
|
||||
await dispatchBufferedReply({
|
||||
cfg: {
|
||||
channels: { whatsapp: { blockStreaming: true } },
|
||||
channels: { whatsapp: { streaming: { block: { enabled: true } } } },
|
||||
messages: { groupChat: { visibleReplies: "message_tool" } },
|
||||
} as never,
|
||||
context: {
|
||||
@@ -1180,7 +1180,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
it("honors automatic visible replies for WhatsApp groups", async () => {
|
||||
await dispatchBufferedReply({
|
||||
cfg: {
|
||||
channels: { whatsapp: { blockStreaming: true } },
|
||||
channels: { whatsapp: { streaming: { block: { enabled: true } } } },
|
||||
messages: { groupChat: { visibleReplies: "automatic" } },
|
||||
} as never,
|
||||
context: { Body: "hi", ChatType: "group" },
|
||||
@@ -1327,7 +1327,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
|
||||
await expect(
|
||||
dispatchWhatsAppBufferedReply({
|
||||
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
||||
cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never,
|
||||
connectionId: "conn",
|
||||
context: { Body: "hi" },
|
||||
deliverReply,
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
toInboundMediaFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history";
|
||||
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -169,10 +172,10 @@ function resolveWhatsAppDurableReplyToId(params: {
|
||||
}
|
||||
|
||||
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
|
||||
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return !cfg.channels.whatsapp.blockStreaming;
|
||||
// The monitor snapshot pins the account-resolved streaming object onto the
|
||||
// root channel entry, so this root-level read is already account-scoped.
|
||||
const enabled = resolveChannelStreamingBlockEnabled(cfg.channels?.whatsapp);
|
||||
return typeof enabled === "boolean" ? !enabled : undefined;
|
||||
}
|
||||
|
||||
function resolveWhatsAppDeliverablePayload(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Whatsapp tests cover doctor contract plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
|
||||
function whatsappConfig(entry: Record<string, unknown>): OpenClawConfig {
|
||||
return { channels: { whatsapp: entry } } as never;
|
||||
}
|
||||
|
||||
describe("whatsapp streaming legacy config rules", () => {
|
||||
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.whatsapp");
|
||||
|
||||
it("matches flat delivery aliases but not the nested shape", () => {
|
||||
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
|
||||
expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => {
|
||||
it("moves flat delivery aliases at root and account level with root seeding", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: whatsappConfig({
|
||||
chunkMode: "newline",
|
||||
blockStreaming: false,
|
||||
accounts: {
|
||||
personal: { blockStreamingCoalesce: { minChars: 20 } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
|
||||
expect(whatsapp.streaming).toEqual({ chunkMode: "newline", block: { enabled: false } });
|
||||
expect(whatsapp.chunkMode).toBeUndefined();
|
||||
expect(whatsapp.blockStreaming).toBeUndefined();
|
||||
const personal = (whatsapp.accounts as Record<string, Record<string, unknown>>).personal;
|
||||
// WhatsApp's account merge replaces root streaming wholesale, so the
|
||||
// migrated account object carries the inherited root delivery settings.
|
||||
expect(personal?.streaming).toEqual({
|
||||
chunkMode: "newline",
|
||||
block: { enabled: false, coalesce: { minChars: 20 } },
|
||||
});
|
||||
expect(personal?.blockStreamingCoalesce).toBeUndefined();
|
||||
});
|
||||
|
||||
it("seeds named accounts from accounts.default over root (layered inheritance)", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: whatsappConfig({
|
||||
chunkMode: "length",
|
||||
accounts: {
|
||||
default: { blockStreaming: true },
|
||||
work: { chunkMode: "newline" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
|
||||
const accounts = whatsapp.accounts as Record<string, Record<string, unknown>>;
|
||||
expect(whatsapp.streaming).toEqual({ chunkMode: "length" });
|
||||
expect(accounts.default?.streaming).toEqual({
|
||||
chunkMode: "length",
|
||||
block: { enabled: true },
|
||||
});
|
||||
// The old flat keys resolved per key across named > accounts.default >
|
||||
// root, so the materialized work object must inherit the default
|
||||
// account's block setting, not just the root chunk mode.
|
||||
expect(accounts.work?.streaming).toEqual({
|
||||
chunkMode: "newline",
|
||||
block: { enabled: true },
|
||||
});
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: result.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves the default account case-insensitively when seeding named accounts", () => {
|
||||
// resolveAccountEntry matches account keys case-insensitively, so
|
||||
// `accounts.Default` is the runtime default account too.
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: whatsappConfig({
|
||||
accounts: {
|
||||
Default: { blockStreaming: true },
|
||||
work: { chunkMode: "newline" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
|
||||
const accounts = whatsapp.accounts as Record<string, Record<string, unknown>>;
|
||||
expect(accounts.work?.streaming).toEqual({
|
||||
chunkMode: "newline",
|
||||
block: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legacy ackReaction migration and stays idempotent", () => {
|
||||
const first = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
messages: { ackReaction: "👀" },
|
||||
channels: { whatsapp: { blockStreaming: true } },
|
||||
} as never,
|
||||
});
|
||||
const whatsapp = first.config.channels?.whatsapp as unknown as Record<string, unknown>;
|
||||
expect(whatsapp.ackReaction).toEqual({ emoji: "👀", direct: false, group: "mentions" });
|
||||
expect(whatsapp.streaming).toEqual({ block: { enabled: true } });
|
||||
|
||||
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
||||
expect(second.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,152 @@
|
||||
// Whatsapp plugin module implements doctor contract behavior.
|
||||
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeCompatibilityConfig as normalizeCompatibilityConfigImpl } from "./doctor.js";
|
||||
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { normalizeCompatibilityConfig as normalizeAckReactionConfig } from "./doctor.js";
|
||||
|
||||
// WhatsApp's nested streaming schema is delivery-only ({chunkMode, block});
|
||||
// it has no preview mode, so only the delivery flat aliases are legal legacy
|
||||
// input. Seeding is handled below instead of via accountStreamingReplacesRoot
|
||||
// because WhatsApp resolution layers accounts.default shared config between
|
||||
// the channel root and named accounts (account-config.ts), so a materialized
|
||||
// named-account object must inherit default-account settings over root ones.
|
||||
const streamingAliasMigration = defineChannelAliasMigration({
|
||||
channelId: "whatsapp",
|
||||
streaming: { defaultMode: "partial", deliveryOnly: true },
|
||||
});
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
|
||||
streamingAliasMigration.legacyConfigRules;
|
||||
|
||||
/** Deep-fills fields missing from target with copies of source values. */
|
||||
function fillMissingStreamingFields(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): { value: Record<string, unknown>; filled: boolean } {
|
||||
let filled = false;
|
||||
const value = { ...target };
|
||||
for (const [key, sourceValue] of Object.entries(source)) {
|
||||
if (sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const existing = value[key];
|
||||
if (existing === undefined) {
|
||||
value[key] = structuredClone(sourceValue);
|
||||
filled = true;
|
||||
continue;
|
||||
}
|
||||
const existingRecord = asObjectRecord(existing);
|
||||
const sourceRecord = asObjectRecord(sourceValue);
|
||||
if (!existingRecord || !sourceRecord) {
|
||||
continue;
|
||||
}
|
||||
const merged = fillMissingStreamingFields(existingRecord, sourceRecord);
|
||||
if (merged.filled) {
|
||||
value[key] = merged.value;
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
return { value, filled };
|
||||
}
|
||||
|
||||
// The runtime merge replaces `streaming` wholesale per layer (named account >
|
||||
// accounts.default > root), while the retired flat keys resolved per key
|
||||
// across those layers. Account objects that migration materializes must carry
|
||||
// the settings the account previously inherited, or `doctor --fix` silently
|
||||
// changes effective delivery behavior for that account.
|
||||
function seedMigratedAccountStreaming(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountsWithoutStreamingBefore: ReadonlySet<string>;
|
||||
changes: string[];
|
||||
}): OpenClawConfig {
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const entry = asObjectRecord(channels?.whatsapp);
|
||||
const accounts = asObjectRecord(entry?.accounts);
|
||||
if (!entry || !accounts) {
|
||||
return params.cfg;
|
||||
}
|
||||
const rootStreaming = asObjectRecord(entry.streaming);
|
||||
// Account lookup treats keys case-insensitively (resolveAccountEntry), so
|
||||
// `accounts.Default` is the default account too.
|
||||
const defaultKey = Object.hasOwn(accounts, "default")
|
||||
? "default"
|
||||
: Object.keys(accounts).find((key) => key.trim().toLowerCase() === "default");
|
||||
|
||||
let accountsChanged = false;
|
||||
const nextAccounts = { ...accounts };
|
||||
// Seed the default account first: its final object is the inheritance
|
||||
// source for named accounts (default replaces root wholesale when set).
|
||||
const seedOrder = Object.keys(accounts).toSorted((left, right) =>
|
||||
left === defaultKey ? -1 : right === defaultKey ? 1 : left.localeCompare(right),
|
||||
);
|
||||
for (const accountId of seedOrder) {
|
||||
const account = asObjectRecord(nextAccounts[accountId]);
|
||||
const created = asObjectRecord(account?.streaming);
|
||||
if (!account || !created || !params.accountsWithoutStreamingBefore.has(accountId)) {
|
||||
continue;
|
||||
}
|
||||
const defaultStreaming = defaultKey
|
||||
? asObjectRecord(asObjectRecord(nextAccounts[defaultKey])?.streaming)
|
||||
: null;
|
||||
const inheritedSource =
|
||||
accountId === defaultKey ? rootStreaming : (defaultStreaming ?? rootStreaming);
|
||||
if (!inheritedSource) {
|
||||
continue;
|
||||
}
|
||||
const seeded = fillMissingStreamingFields(created, inheritedSource);
|
||||
if (!seeded.filled) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = { ...account, streaming: seeded.value };
|
||||
accountsChanged = true;
|
||||
const sourcePath =
|
||||
accountId === defaultKey
|
||||
? "channels.whatsapp.streaming"
|
||||
: "effective channels.whatsapp streaming defaults";
|
||||
params.changes.push(
|
||||
`Copied ${sourcePath} into channels.whatsapp.accounts.${accountId}.streaming to keep inherited settings while migrating flat streaming keys.`,
|
||||
);
|
||||
}
|
||||
if (!accountsChanged) {
|
||||
return params.cfg;
|
||||
}
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
whatsapp: { ...entry, accounts: nextAccounts },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
return normalizeCompatibilityConfigImpl({ cfg });
|
||||
const ackReaction = normalizeAckReactionConfig({ cfg });
|
||||
const accountsBefore = asObjectRecord(
|
||||
asObjectRecord((ackReaction.config.channels as Record<string, unknown> | undefined)?.whatsapp)
|
||||
?.accounts,
|
||||
);
|
||||
const accountsWithoutStreamingBefore = new Set(
|
||||
Object.entries(accountsBefore ?? {})
|
||||
.filter(([, account]) => asObjectRecord(account)?.streaming === undefined)
|
||||
.map(([accountId]) => accountId),
|
||||
);
|
||||
const aliases = streamingAliasMigration.normalizeChannelConfig({
|
||||
cfg: ackReaction.config,
|
||||
changes: ackReaction.changes,
|
||||
});
|
||||
return {
|
||||
config: seedMigratedAccountStreaming({
|
||||
cfg: aliases.config,
|
||||
accountsWithoutStreamingBefore,
|
||||
changes: aliases.changes,
|
||||
}),
|
||||
changes: aliases.changes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -628,16 +628,17 @@ describe("chunkMarkdownTextWithMode", () => {
|
||||
});
|
||||
|
||||
describe("resolveChunkMode", () => {
|
||||
// Flat chunkMode stays canonical for channels without a nested streaming
|
||||
// schema (signal, irc, googlechat, whatsapp); nested-only channels are
|
||||
// covered by the imessage rows below.
|
||||
const providerCfg = { channels: { signal: { chunkMode: "newline" as const } } };
|
||||
// All bundled channels are nested-only now; the flat chunkMode row below
|
||||
// covers the deprecated SDK-plugin fallback that streaming.ts still reads.
|
||||
const providerCfg = {
|
||||
channels: { signal: { streaming: { chunkMode: "newline" as const } } },
|
||||
};
|
||||
const accountCfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
chunkMode: "length" as const,
|
||||
streaming: { chunkMode: "length" as const },
|
||||
accounts: {
|
||||
primary: { chunkMode: "newline" as const },
|
||||
primary: { streaming: { chunkMode: "newline" as const } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -677,8 +678,8 @@ describe("resolveChunkMode", () => {
|
||||
accountId: undefined,
|
||||
expected: "length",
|
||||
},
|
||||
// Mattermost's schema accepts both shapes; the nested streaming config
|
||||
// wins over the flat key when both are present.
|
||||
// Deprecated SDK fallback: nested config wins over a stale flat key, and
|
||||
// a flat-only entry still resolves until the SDK deprecation window ends.
|
||||
{
|
||||
cfg: {
|
||||
channels: {
|
||||
@@ -692,6 +693,12 @@ describe("resolveChunkMode", () => {
|
||||
accountId: undefined,
|
||||
expected: "newline",
|
||||
},
|
||||
{
|
||||
cfg: { channels: { "sdk-plugin": { chunkMode: "newline" as const } } },
|
||||
provider: "sdk-plugin",
|
||||
accountId: undefined,
|
||||
expected: "newline",
|
||||
},
|
||||
] as const)(
|
||||
"resolves default/provider/account/internal chunk mode for $provider $accountId",
|
||||
({ cfg, provider, accountId, expected }) => {
|
||||
|
||||
@@ -122,9 +122,10 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
|
||||
).toMatchObject({ minChars: 25, maxChars: 80, idleMs: 2 });
|
||||
});
|
||||
|
||||
it("merges flat account block coalescing over channel nested config for flat-canonical channels", () => {
|
||||
// Mattermost's plugin schema still accepts flat blockStreamingCoalesce as
|
||||
// canonical config, so the account-level flat read must keep working.
|
||||
it("merges flat account block coalescing over channel nested config for SDK-plugin configs", () => {
|
||||
// No bundled schema accepts flat blockStreamingCoalesce anymore, but the
|
||||
// resolver keeps the account-level flat read for external SDK plugin
|
||||
// configs until the deprecation window closes.
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Covers the once-per-process deprecation warning for flat streaming key fallbacks.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetFlatStreamingKeyDeprecationWarningsForTest } from "./streaming-flat-key-deprecation.js";
|
||||
import {
|
||||
resolveChannelStreamingBlockCoalesce,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
resolveChannelStreamingPreviewChunk,
|
||||
} from "./streaming.js";
|
||||
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
warn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: vi.fn(() => loggerMocks),
|
||||
}));
|
||||
|
||||
describe("flat streaming key deprecation warning", () => {
|
||||
beforeEach(() => {
|
||||
resetFlatStreamingKeyDeprecationWarningsForTest();
|
||||
loggerMocks.warn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFlatStreamingKeyDeprecationWarningsForTest();
|
||||
});
|
||||
|
||||
it("warns once per key when the flat fallback is actually used", () => {
|
||||
expect(resolveChannelStreamingChunkMode({ chunkMode: "newline" })).toBe("newline");
|
||||
expect(resolveChannelStreamingChunkMode({ chunkMode: "newline" })).toBe("newline");
|
||||
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMocks.warn.mock.calls[0]?.[0]).toContain('"chunkMode"');
|
||||
expect(loggerMocks.warn.mock.calls[0]?.[0]).toContain("streaming.chunkMode");
|
||||
|
||||
expect(resolveChannelStreamingBlockEnabled({ blockStreaming: true })).toBe(true);
|
||||
expect(resolveChannelStreamingBlockCoalesce({ blockStreamingCoalesce: { idleMs: 5 } })).toEqual(
|
||||
{ idleMs: 5 },
|
||||
);
|
||||
expect(resolveChannelStreamingPreviewChunk({ draftChunk: { minChars: 10 } })).toEqual({
|
||||
minChars: 10,
|
||||
});
|
||||
expect(loggerMocks.warn).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("stays silent when nested config wins or no flat key is set", () => {
|
||||
expect(
|
||||
resolveChannelStreamingChunkMode({
|
||||
streaming: { chunkMode: "length" },
|
||||
chunkMode: "newline",
|
||||
}),
|
||||
).toBe("length");
|
||||
expect(resolveChannelStreamingBlockEnabled({ streaming: { block: { enabled: false } } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveChannelStreamingBlockCoalesce({})).toBeUndefined();
|
||||
expect(loggerMocks.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// Warn-once state for the deprecated flat streaming key fallback in streaming.ts.
|
||||
// Lives outside streaming.ts so the test-only reset stays off the public SDK
|
||||
// surface (openclaw/plugin-sdk/channel-outbound re-exports all of streaming.ts).
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("channels/streaming");
|
||||
const warnedFlatStreamingKeys = new Set<string>();
|
||||
|
||||
/** @internal Test-only reset for the flat streaming key deprecation warning cache. */
|
||||
export function resetFlatStreamingKeyDeprecationWarningsForTest(): void {
|
||||
warnedFlatStreamingKeys.clear();
|
||||
}
|
||||
|
||||
/** Warns once per process per flat key when a resolver used the flat fallback. */
|
||||
export function warnFlatStreamingKeyFallback(flatKey: string, nestedPath: string): void {
|
||||
if (warnedFlatStreamingKeys.has(flatKey)) {
|
||||
return;
|
||||
}
|
||||
warnedFlatStreamingKeys.add(flatKey);
|
||||
log.warn(
|
||||
`Flat channel streaming key "${flatKey}" is deprecated; move it to streaming.${nestedPath}. The flat fallback is removed after the next release train.`,
|
||||
);
|
||||
}
|
||||
+54
-30
@@ -14,6 +14,7 @@ import type {
|
||||
TextChunkMode,
|
||||
} from "../config/types.base.js";
|
||||
import { asBoolean } from "../utils/boolean.js";
|
||||
import { warnFlatStreamingKeyFallback } from "./streaming-flat-key-deprecation.js";
|
||||
|
||||
export type {
|
||||
ChannelDeliveryStreamingConfig,
|
||||
@@ -30,8 +31,8 @@ export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
|
||||
|
||||
export type StreamingCompatEntry = {
|
||||
/**
|
||||
* Canonical nested streaming config. Some channel schemas (for example
|
||||
* Mattermost) also accept a scalar mode string or boolean here.
|
||||
* Canonical nested streaming config. External SDK plugin configs may still
|
||||
* carry a scalar mode string or boolean here; bundled schemas reject those.
|
||||
*/
|
||||
streaming?: unknown;
|
||||
chunkMode?: unknown;
|
||||
@@ -40,15 +41,15 @@ export type StreamingCompatEntry = {
|
||||
draftChunk?: unknown;
|
||||
};
|
||||
|
||||
// Nested streaming config wins. The flat delivery keys (chunkMode,
|
||||
// blockStreaming, blockStreamingCoalesce, draftChunk) are still canonical for
|
||||
// channels without a nested streaming schema (Mattermost, WhatsApp, Google
|
||||
// Chat, IRC, Signal) and for external SDK plugins, so these public resolvers
|
||||
// keep reading them; dropping the fallback here silently disables configured
|
||||
// chunking/block delivery for those consumers. Remove the flat reads only
|
||||
// after the remaining bundled channels migrate to nested schemas + doctor
|
||||
// rules and the SDK deprecation window closes. Mode-family aliases
|
||||
// (streamMode, scalar/boolean streaming) are doctor-only and stay unread here.
|
||||
// Nested streaming config wins. Every bundled channel except Matrix and
|
||||
// Feishu now uses a nested-only streaming schema with doctor migrating the
|
||||
// flat spellings, so in-tree the flat delivery keys (chunkMode,
|
||||
// blockStreaming, blockStreamingCoalesce, draftChunk) are legacy config. The
|
||||
// fallback reads below serve external SDK plugin configs plus the pending
|
||||
// Matrix/Feishu migrations and emit a once-per-key deprecation warning;
|
||||
// remove them (and the flat StreamingCompatEntry fields) once those channels
|
||||
// migrate and the next release train closes the SDK deprecation window.
|
||||
// Mode-family aliases (streamMode) are doctor-only and stay unread here.
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -777,40 +778,63 @@ export function getChannelStreamingConfigObject(
|
||||
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
|
||||
}
|
||||
|
||||
function resolveWithFlatFallback<T>(params: {
|
||||
nested: T | undefined;
|
||||
flat: T | undefined;
|
||||
flatKey: string;
|
||||
nestedPath: string;
|
||||
}): T | undefined {
|
||||
if (params.nested !== undefined) {
|
||||
return params.nested;
|
||||
}
|
||||
if (params.flat !== undefined) {
|
||||
warnFlatStreamingKeyFallback(params.flatKey, params.nestedPath);
|
||||
}
|
||||
return params.flat;
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingChunkMode(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): TextChunkMode | undefined {
|
||||
return (
|
||||
asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode) ??
|
||||
asTextChunkMode(entry?.chunkMode)
|
||||
);
|
||||
return resolveWithFlatFallback({
|
||||
nested: asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode),
|
||||
flat: asTextChunkMode(entry?.chunkMode),
|
||||
flatKey: "chunkMode",
|
||||
nestedPath: "chunkMode",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingBlockEnabled(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): boolean | undefined {
|
||||
return (
|
||||
asBoolean(getChannelStreamingConfigObject(entry)?.block?.enabled) ??
|
||||
asBoolean(entry?.blockStreaming)
|
||||
);
|
||||
return resolveWithFlatFallback({
|
||||
nested: asBoolean(getChannelStreamingConfigObject(entry)?.block?.enabled),
|
||||
flat: asBoolean(entry?.blockStreaming),
|
||||
flatKey: "blockStreaming",
|
||||
nestedPath: "block.enabled",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingBlockCoalesce(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): BlockStreamingCoalesceConfig | undefined {
|
||||
return (
|
||||
asBlockStreamingCoalesceConfig(getChannelStreamingConfigObject(entry)?.block?.coalesce) ??
|
||||
asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce)
|
||||
);
|
||||
return resolveWithFlatFallback({
|
||||
nested: asBlockStreamingCoalesceConfig(getChannelStreamingConfigObject(entry)?.block?.coalesce),
|
||||
flat: asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce),
|
||||
flatKey: "blockStreamingCoalesce",
|
||||
nestedPath: "block.coalesce",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingPreviewChunk(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): BlockStreamingChunkConfig | undefined {
|
||||
return (
|
||||
asBlockStreamingChunkConfig(getChannelStreamingConfigObject(entry)?.preview?.chunk) ??
|
||||
asBlockStreamingChunkConfig(entry?.draftChunk)
|
||||
);
|
||||
return resolveWithFlatFallback({
|
||||
nested: asBlockStreamingChunkConfig(getChannelStreamingConfigObject(entry)?.preview?.chunk),
|
||||
flat: asBlockStreamingChunkConfig(entry?.draftChunk),
|
||||
flatKey: "draftChunk",
|
||||
nestedPath: "preview.chunk",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingPreviewToolProgress(
|
||||
@@ -896,9 +920,9 @@ export function resolveChannelPreviewStreamMode(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
defaultMode: "off" | "partial",
|
||||
): StreamingMode {
|
||||
// Scalar `streaming` (mode string or boolean) stays supported here: channel
|
||||
// schemas that never adopted the nested-only shape (for example Mattermost)
|
||||
// still accept it as canonical config, not as a legacy alias.
|
||||
// Scalar `streaming` (mode string or boolean) is rejected by every bundled
|
||||
// channel schema and doctor-migrated to streaming.mode; the read here stays
|
||||
// only for external SDK plugin configs that predate the nested shape.
|
||||
const parsedStreaming = parsePreviewStreamingMode(
|
||||
getChannelStreamingConfigObject(entry)?.mode ?? entry?.streaming,
|
||||
);
|
||||
|
||||
@@ -200,7 +200,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
|
||||
docsPath: "/channels/channel-routing",
|
||||
tests: ["src/commands/doctor/shared/channel-legacy-config-migrate.test.ts"],
|
||||
notes:
|
||||
"Runtime no longer reads the flat aliases (src/channels/streaming.ts is nested-only); doctor keeps this migration to move shipped configs during upgrade. Channels without a nested streaming schema (signal, irc, googlechat, whatsapp) still treat flat keys as canonical.",
|
||||
"Bundled channel schemas are nested-only except Matrix and Feishu (migration pending); doctor keeps this migration to move shipped configs during upgrade. src/channels/streaming.ts retains a warn-once flat-key fallback for those channels and external SDK plugin configs until the next release train.",
|
||||
}),
|
||||
deprecatedCompatRecord({
|
||||
code: "doctor-webchat-channel-config",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Defines common channel messaging configuration types.
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelDeliveryStreamingConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
@@ -48,11 +48,8 @@ export type CommonChannelMessagingConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Delivery streaming config: chunk mode plus block streaming controls. */
|
||||
streaming?: ChannelDeliveryStreamingConfig;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Channel health monitor overrides for this channel/account. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Defines Google Chat channel configuration types.
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelDeliveryStreamingConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
ReplyToMode,
|
||||
@@ -96,11 +96,8 @@ export type GoogleChatAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Delivery streaming config: chunk mode plus block streaming controls. */
|
||||
streaming?: ChannelDeliveryStreamingConfig;
|
||||
mediaMaxMb?: number;
|
||||
/** Control reply threading when reply tags are present (off|first|all|batched). */
|
||||
replyToMode?: ReplyToMode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Defines WhatsApp channel configuration types.
|
||||
import type { ReactionLevel } from "../utils/reaction-level.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelDeliveryStreamingConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
@@ -85,14 +85,10 @@ type WhatsAppSharedConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Delivery streaming config: chunk mode plus block streaming controls. */
|
||||
streaming?: ChannelDeliveryStreamingConfig;
|
||||
/** Maximum media file size in MB. Default: 50. */
|
||||
mediaMaxMb?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
groups?: Record<string, WhatsAppGroupConfig>;
|
||||
/** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */
|
||||
direct?: Record<string, WhatsAppDirectConfig>;
|
||||
|
||||
@@ -676,15 +676,30 @@ export const BlockStreamingCoalesceSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TextChunkModeSchema = z.enum(["length", "newline"]);
|
||||
|
||||
export const ChannelStreamingBlockSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
coalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Delivery-only nested streaming config for channels without preview modes. */
|
||||
export const ChannelDeliveryStreamingConfigSchema = z
|
||||
.object({
|
||||
chunkMode: TextChunkModeSchema.optional(),
|
||||
block: ChannelStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ReplyRuntimeConfigSchemaShape = {
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
contextVisibility: ContextVisibilityModeSchema.optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from "./zod-schema.channels.js";
|
||||
import {
|
||||
BlockStreamingChunkSchema,
|
||||
BlockStreamingCoalesceSchema,
|
||||
ChannelDeliveryStreamingConfigSchema,
|
||||
ChannelStreamingBlockSchema,
|
||||
ContextVisibilityModeSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
SecretInputSchema,
|
||||
ReplyToModeSchema,
|
||||
RetryConfigSchema,
|
||||
TextChunkModeSchema,
|
||||
TtsConfigSchema,
|
||||
requireAllowlistAllowFrom,
|
||||
requireOpenAllowFrom,
|
||||
@@ -75,14 +77,7 @@ const TelegramCapabilitiesSchema = z.union([
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
const TextChunkModeSchema = z.enum(["length", "newline"]);
|
||||
const UnifiedStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]);
|
||||
const ChannelStreamingBlockSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
coalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const ChannelStreamingPreviewSchema = z
|
||||
.object({
|
||||
chunk: BlockStreamingChunkSchema.optional(),
|
||||
@@ -107,12 +102,6 @@ const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema;
|
||||
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
|
||||
nativeTaskCards: z.boolean().optional(),
|
||||
}).strict();
|
||||
const ChannelDeliveryStreamingConfigSchema = z
|
||||
.object({
|
||||
chunkMode: TextChunkModeSchema.optional(),
|
||||
block: ChannelStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ChannelPreviewStreamingConfigSchema = z
|
||||
.object({
|
||||
@@ -1214,9 +1203,7 @@ export const SignalAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
replyToModeByChatType: DirectGroupReplyToModeByChatTypeSchema.optional(),
|
||||
@@ -1338,9 +1325,7 @@ export const IrcAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
healthMonitor: ChannelHealthMonitorSchema,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { ChannelBotLoopProtectionSchema } from "./zod-schema.channels-config.js";
|
||||
import { ChannelHealthMonitorSchema } from "./zod-schema.channels.js";
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
ChannelDeliveryStreamingConfigSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
@@ -81,9 +81,7 @@ export const GoogleChatAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
actions: z
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ChannelHeartbeatVisibilitySchema,
|
||||
} from "./zod-schema.channels.js";
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
ChannelDeliveryStreamingConfigSchema,
|
||||
ContextVisibilityModeSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
@@ -94,9 +94,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
|
||||
groups: WhatsAppGroupsSchema,
|
||||
direct: WhatsAppDirectSchema,
|
||||
ackReaction: WhatsAppAckReactionSchema,
|
||||
|
||||
Reference in New Issue
Block a user