diff --git a/CHANGELOG.md b/CHANGELOG.md index df4e8d2a958..416a7ebde93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index f31bca06e68..5ae6fc6233d 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -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..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..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 && diff --git a/extensions/discord/src/doctor-shared.ts b/extensions/discord/src/doctor-shared.ts index bd5126f9234..e87bddb79a1 100644 --- a/extensions/discord/src/doctor-shared.ts +++ b/extensions/discord/src/doctor-shared.ts @@ -1,46 +1,5 @@ import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract"; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : 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..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..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[] = []; diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts index 54a9e4e0b80..082ca0aea15 100644 --- a/extensions/discord/src/doctor.test.ts +++ b/extensions/discord/src/doctor.test.ts @@ -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", () => { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 0deb98663ed..20e8d9edc2f 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -166,11 +166,6 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { function collectCompatibilityMutations(cfg: { channels?: Record }) { 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: { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index c29ac1c8cb6..e61de14cc0f 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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", () => { diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 7e7d358bb1c..cbd03f54725 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index d0d9414a28a..b793cf24d0e 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -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..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts..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..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..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..streaming (Telegram/Discord/Slack)", + describe: "Normalize legacy streaming keys to channels..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"); }, }),