fix(doctor): preserve discord streaming downgrade compatibility

This commit is contained in:
Vincent Koc
2026-04-12 17:07:38 +01:00
parent 1f0431cd11
commit 43cb94a39a
8 changed files with 82 additions and 259 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- WhatsApp/outbound: fall back to the first `mediaUrls` entry when `mediaUrl` is empty so gateway media sends stop silently dropping attachments that already have a resolved media list. (#64394) Thanks @eric-fr4 and @vincentkoc.
- Doctor/Discord: stop `openclaw doctor --fix` from rewriting legacy Discord preview-streaming config into the nested modern shape, so downgrades can still recover without hand-editing `channels.discord.streaming`. (#65035) Thanks @vincentkoc.
- Gateway/auth: blank the shipped example gateway credential in `.env.example` and fail startup when a copied placeholder token or password is still configured, so operators cannot accidentally launch with a publicly known secret. (#64586) Thanks @navarrotech and @vincentkoc.
- Memory/active-memory+dreaming: keep active-memory recall runs on the strongest resolved channel, consume managed dreaming heartbeat events exactly once, stop dreaming from re-ingesting its own narrative transcripts, and add explicit repair/dedupe recovery flows in CLI, doctor, and the Dreams UI.
- Gateway/keepalive: stop marking WebSocket tick broadcasts as droppable so slow or backpressured clients do not self-disconnect with `tick timeout` while long-running work is still alive. (#65256) Thanks @100yenadmin and @vincentkoc.
@@ -26,7 +27,6 @@ Docs: https://docs.openclaw.ai
- Gateway/plugins: always send a non-empty `idempotencyKey` for plugin subagent runs, so dreaming narrative jobs stop failing gateway schema validation. (#65354) Thanks @CodeForgeNet and @vincentkoc.
- Cron/isolated sessions: persist the right transcript path for each isolated run, including fresh session rollovers, so cron runs stop appending to stale session files. Thanks @samrusani and @vincentkoc.
- Dreaming/cron: wake managed dreaming jobs immediately instead of waiting for the next heartbeat, so scheduled dreaming runs start when the cron fires. (#65053) Thanks @l0cka and @vincentkoc.
<<<<<<< HEAD
- QA/packaging: stop packaged QA helpers from crashing when optional scenario execution config is unavailable, so npm distributions can skip the repo-only scenario pack without breaking completion-cache and startup paths. (#65118) Thanks @EdderTalmor and @vincentkoc.
- Media/audio transcription: surface the real provider failure when every audio transcription attempt fails, so status output and the CLI stop collapsing those errors into generic skips. (#65096) Thanks @l0cka and @vincentkoc.

View File

@@ -3,18 +3,7 @@ import type {
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includePreviewChunk: true });
}
import { asObjectRecord, normalizeLegacyDmAliases } from "openclaw/plugin-sdk/runtime-doctor";
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
@@ -114,18 +103,6 @@ function migrateLegacyTtsConfig(
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyDiscordStreamingAliases),
},
{
path: ["channels", "discord", "voice", "tts"],
message:
@@ -164,18 +141,6 @@ export function normalizeCompatibilityConfig({
updated = dm.entry;
changed = changed || dm.changed;
const streaming = normalizeLegacyStreamingAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
includePreviewChunk: true,
resolvedMode: resolveDiscordPreviewStreamMode(updated),
offModeLegacyNotice: (pathPrefix) =>
`${pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
@@ -194,17 +159,6 @@ export function normalizeCompatibilityConfig({
});
accountEntry = accountDm.entry;
accountChanged = accountDm.changed;
const accountStreaming = normalizeLegacyStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
includePreviewChunk: true,
resolvedMode: resolveDiscordPreviewStreamMode(accountEntry),
offModeLegacyNotice: (pathPrefix) =>
`${pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
});
accountEntry = accountStreaming.entry;
accountChanged = accountChanged || accountStreaming.changed;
const accountVoice = asObjectRecord(accountEntry.voice);
if (
accountVoice &&

View File

@@ -1,46 +1,5 @@
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.draftChunk !== undefined ||
entry.blockStreamingCoalesce !== undefined
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
export const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordAccountStreamingAliases,
},
];
// Runtime config loading already normalizes these aliases without rewriting the
// source file. Keep doctor non-destructive so downgrade paths remain recoverable.
export const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [];

View File

@@ -8,7 +8,7 @@ import {
} from "./doctor.js";
describe("discord doctor", () => {
it("normalizes legacy discord streaming aliases into the nested streaming shape", () => {
it("leaves legacy discord streaming aliases untouched during doctor normalization", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
@@ -38,62 +38,23 @@ describe("discord doctor", () => {
} as never,
});
expect(result.config.channels?.discord?.streaming).toEqual({
mode: "block",
expect(result.config.channels?.discord).toEqual({
streamMode: "block",
chunkMode: "newline",
block: {
enabled: true,
blockStreaming: true,
draftChunk: {
minChars: 120,
},
preview: {
chunk: {
minChars: 120,
},
},
});
expect(result.config.channels?.discord?.accounts?.work?.streaming).toEqual({
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
"Moved channels.discord.chunkMode → channels.discord.streaming.chunkMode.",
"Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.",
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
"Moved channels.discord.accounts.work.blockStreamingCoalesce → channels.discord.accounts.work.streaming.block.coalesce.",
]),
);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
discord: {
streamMode: "block",
streaming: false,
accounts: {
work: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
},
} as never,
},
});
expect(result.config.channels?.discord?.streaming).toEqual({
mode: "block",
});
expect(
result.changes.filter((change) => change.includes("channels.discord.streaming.mode")),
).toEqual(["Moved channels.discord.streamMode → channels.discord.streaming.mode (block)."]);
expect(result.changes).toEqual([]);
});
it("moves account voice.tts.edge into providers.microsoft", () => {

View File

@@ -166,11 +166,6 @@ vi.mock("./doctor/shared/channel-doctor.js", () => {
function collectCompatibilityMutations(cfg: { channels?: Record<string, unknown> }) {
const next = structuredClone(cfg);
const changes: string[] = [];
const discord = asRecord(next.channels?.discord);
if (discord && typeof discord.streaming === "boolean") {
discord.streaming = { mode: discord.streaming ? "partial" : "off" };
changes.push("Normalized channels.discord.streaming legacy scalar.");
}
const telegram = asRecord(next.channels?.telegram);
if (telegram && "groupMentionsOnly" in telegram) {
const groups = asRecord(telegram.groups) ?? {};
@@ -948,14 +943,6 @@ describe("doctor config flow", () => {
message.includes("channels.telegram.streamMode, channels.telegram.streaming"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.discord:") &&
message.includes("channels.discord.streamMode, channels.discord.streaming"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
@@ -977,6 +964,55 @@ describe("doctor config flow", () => {
}
});
it("keeps discord streaming aliases on disk during repair so downgrades stay recoverable", async () => {
await withTempHome(
async (home) => {
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
discord: {
streaming: false,
chunkMode: "newline",
blockStreaming: true,
},
},
},
null,
2,
),
"utf-8",
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
channels?: {
discord?: {
streaming?: unknown;
chunkMode?: unknown;
blockStreaming?: unknown;
};
};
};
expect(persisted.channels?.discord).toEqual({
streaming: false,
chunkMode: "newline",
blockStreaming: true,
});
},
{ skipSessionCleanup: true },
);
});
it("repairs legacy googlechat streamMode by removing it", async () => {
const result = await runDoctorConfigWithInput({
config: {

View File

@@ -150,19 +150,16 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "partial" });
expect(res.config.channels?.discord?.streaming).toBe(true);
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.config.channels?.discord?.accounts?.work?.streaming).toEqual({ mode: "off" });
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe(false);
expect(
getLegacyProperty(res.config.channels?.discord?.accounts?.work, "streamMode"),
).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
]);
expect(res.changes).toEqual([]);
});
it("migrates Discord legacy streamMode into nested streaming.mode", () => {
it("keeps Discord legacy streamMode untouched", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -174,11 +171,9 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "block" });
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
]);
expect(res.config.channels?.discord?.streaming).toBe(false);
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBe("block");
expect(res.changes).toEqual([]);
});
it("migrates Telegram streamMode into nested streaming.mode", () => {

View File

@@ -178,7 +178,7 @@ describe("legacy migrate sandbox scope aliases", () => {
});
describe("legacy migrate channel streaming aliases", () => {
it("migrates preview-channel legacy streaming fields into the nested streaming shape", () => {
it("migrates Telegram and Slack preview-channel legacy streaming fields without rewriting Discord", () => {
const res = migrateLegacyConfigForTest({
channels: {
telegram: {
@@ -226,12 +226,6 @@ describe("legacy migrate channel streaming aliases", () => {
expect(res.changes).toContain(
"Moved channels.telegram.blockStreamingCoalesce → channels.telegram.streaming.block.coalesce.",
);
expect(res.changes).toContain(
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
);
expect(res.changes).toContain(
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
);
expect(res.changes).toContain(
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
);
@@ -256,17 +250,11 @@ describe("legacy migrate channel streaming aliases", () => {
},
});
expect(res.config?.channels?.discord).toMatchObject({
streaming: {
mode: "off",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
maxChars: 900,
},
},
streaming: false,
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
maxChars: 900,
},
});
expect(res.config?.channels?.slack).toMatchObject({

View File

@@ -7,7 +7,6 @@ import {
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
type StreamingMode = "off" | "partial" | "block" | "progress";
type DiscordPreviewStreamMode = "off" | "partial" | "block";
type TelegramPreviewStreamMode = "off" | "partial" | "block";
type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
@@ -32,14 +31,6 @@ function parseStreamingMode(value: unknown): StreamingMode | null {
return null;
}
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
function parseTelegramPreviewStreamMode(value: unknown): TelegramPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
@@ -87,27 +78,6 @@ function resolveTelegramPreviewStreamMode(
return "partial";
}
function resolveDiscordPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "off";
}
function resolveSlackStreamingMode(
params: {
streamMode?: unknown;
@@ -220,22 +190,6 @@ function hasLegacyTelegramStreamingKeys(value: unknown): boolean {
);
}
function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
hasOwnKey(entry, "chunkMode") ||
hasOwnKey(entry, "blockStreaming") ||
hasOwnKey(entry, "draftChunk") ||
hasOwnKey(entry, "blockStreamingCoalesce")
);
}
function hasLegacySlackStreamingKeys(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
@@ -524,18 +478,6 @@ const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
'channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyTelegramStreamingKeys),
},
{
path: ["channels", "discord"],
message:
'channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyDiscordStreamingKeys(value),
},
{
path: ["channels", "discord", "accounts"],
message:
'channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordStreamingKeys),
},
{
path: ["channels", "slack"],
message:
@@ -659,8 +601,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
}),
defineLegacyConfigMigration({
id: "channels.streaming-keys->channels.streaming",
describe:
"Normalize legacy streaming keys to channels.<provider>.streaming (Telegram/Discord/Slack)",
describe: "Normalize legacy streaming keys to channels.<provider>.streaming (Telegram/Slack)",
legacyRules: CHANNEL_STREAMING_RULES,
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
@@ -683,16 +624,6 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
return;
}
if (params.provider === "discord") {
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveDiscordPreviewStreamMode,
});
return;
}
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
@@ -702,7 +633,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
});
};
const migrateProvider = (provider: "telegram" | "discord" | "slack") => {
const migrateProvider = (provider: "telegram" | "slack") => {
const providerEntry = getRecord(channels[provider]);
if (!providerEntry) {
return;
@@ -730,7 +661,6 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
};
migrateProvider("telegram");
migrateProvider("discord");
migrateProvider("slack");
},
}),