diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 2e7a307882a..afcb71a9b64 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -3,6 +3,11 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyStreamingAliases, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, +} from "openclaw/plugin-sdk/runtime-doctor"; import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; function asObjectRecord(value: unknown): Record | null { @@ -11,226 +16,8 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - -function normalizeDiscordDmAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - let changed = false; - let updated: Record = params.entry; - const rawDm = updated.dm; - const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; - let dmChanged = false; - const shouldPromoteLegacyAllowFrom = !( - params.pathPrefix === "channels.discord" && asObjectRecord(updated.accounts) - ); - - const allowFromEqual = (a: unknown, b: unknown): boolean => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - const na = a.map((v) => String(v).trim()).filter(Boolean); - const nb = b.map((v) => String(v).trim()).filter(Boolean); - if (na.length !== nb.length) { - return false; - } - return na.every((v, i) => v === nb[i]); - }; - - const topDmPolicy = updated.dmPolicy; - const legacyDmPolicy = dm?.policy; - if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { - updated = { ...updated, dmPolicy: legacyDmPolicy }; - changed = true; - if (dm) { - delete dm.policy; - dmChanged = true; - } - params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); - } else if ( - topDmPolicy !== undefined && - legacyDmPolicy !== undefined && - topDmPolicy === legacyDmPolicy - ) { - if (dm) { - delete dm.policy; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); - } - } - - const topAllowFrom = updated.allowFrom; - const legacyAllowFrom = dm?.allowFrom; - if (shouldPromoteLegacyAllowFrom) { - if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { - updated = { ...updated, allowFrom: legacyAllowFrom }; - changed = true; - if (dm) { - delete dm.allowFrom; - dmChanged = true; - } - params.changes.push( - `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, - ); - } else if ( - topAllowFrom !== undefined && - legacyAllowFrom !== undefined && - allowFromEqual(topAllowFrom, legacyAllowFrom) - ) { - if (dm) { - delete dm.allowFrom; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); - } - } - } - - if (dm && asObjectRecord(rawDm) && dmChanged) { - const keys = Object.keys(dm); - if (keys.length === 0) { - if (updated.dm !== undefined) { - const { dm: _ignored, ...rest } = updated; - updated = rest; - changed = true; - params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); - } - } else { - updated = { ...updated, dm }; - changed = true; - } - } - - return { entry: updated, changed }; -} - -function normalizeDiscordStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.draftChunk !== undefined || - params.entry.blockStreamingCoalesce !== undefined; - const resolved = resolveDiscordPreviewStreamMode(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - const preview = ensureNestedRecord(streaming, "preview"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolved; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } - if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.draftChunk !== undefined && preview.chunk === undefined) { - preview.chunk = updated.draftChunk; - delete updated.draftChunk; - params.changes.push( - `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - if (Object.keys(preview).length > 0) { - streaming.preview = preview; - } - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - if ( - params.pathPrefix.startsWith("channels.discord") && - resolved === "off" && - hadLegacyStreamMode - ) { - params.changes.push( - `${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`, - ); - } - return { entry: updated, changed }; -} - 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 - ); + return hasLegacyStreamingAliases(value, { includePreviewChunk: true }); } function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean { @@ -378,19 +165,25 @@ export function normalizeCompatibilityConfig({ const changes: string[] = []; let updated = rawEntry; let changed = false; + const shouldPromoteRootDmAllowFrom = !asObjectRecord(updated.accounts); - const dm = normalizeDiscordDmAliases({ + const dm = normalizeLegacyDmAliases({ entry: updated, pathPrefix: "channels.discord", changes, + promoteAllowFrom: shouldPromoteRootDmAllowFrom, }); updated = dm.entry; changed = changed || dm.changed; - const streaming = normalizeDiscordStreamingAliases({ + 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; @@ -406,17 +199,21 @@ export function normalizeCompatibilityConfig({ } let accountEntry = account; let accountChanged = false; - const accountDm = normalizeDiscordDmAliases({ + const accountDm = normalizeLegacyDmAliases({ entry: accountEntry, pathPrefix: `channels.discord.accounts.${accountId}`, changes, }); accountEntry = accountDm.entry; accountChanged = accountDm.changed; - const accountStreaming = normalizeDiscordStreamingAliases({ + 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; diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts index db742a799ed..cb479745f42 100644 --- a/extensions/discord/src/doctor.ts +++ b/extensions/discord/src/doctor.ts @@ -1,11 +1,8 @@ -import { - type ChannelDoctorAdapter, - type ChannelDoctorConfigMutation, -} from "openclaw/plugin-sdk/channel-contract"; +import { type ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor"; +import { normalizeCompatibilityConfig as normalizeDiscordCompatibilityConfig } from "./doctor-contract.js"; import { DISCORD_LEGACY_CONFIG_RULES } from "./doctor-shared.js"; -import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; type DiscordNumericIdHit = { path: string; entry: number; safe: boolean }; @@ -21,14 +18,6 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - function sanitizeForLog(value: string): string { return value.replace(/\p{Cc}+/gu, " ").trim(); } @@ -54,275 +43,6 @@ function isDiscordMutableAllowEntry(raw: string): boolean { return true; } -function normalizeDiscordDmAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - let changed = false; - let updated: Record = params.entry; - const rawDm = updated.dm; - const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; - let dmChanged = false; - - const allowFromEqual = (a: unknown, b: unknown): boolean => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - const na = a.map((v) => String(v).trim()).filter(Boolean); - const nb = b.map((v) => String(v).trim()).filter(Boolean); - if (na.length !== nb.length) { - return false; - } - return na.every((v, i) => v === nb[i]); - }; - - const topDmPolicy = updated.dmPolicy; - const legacyDmPolicy = dm?.policy; - if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { - updated = { ...updated, dmPolicy: legacyDmPolicy }; - changed = true; - if (dm) { - delete dm.policy; - dmChanged = true; - } - params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); - } else if ( - topDmPolicy !== undefined && - legacyDmPolicy !== undefined && - topDmPolicy === legacyDmPolicy - ) { - if (dm) { - delete dm.policy; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); - } - } - - const topAllowFrom = updated.allowFrom; - const legacyAllowFrom = dm?.allowFrom; - if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { - updated = { ...updated, allowFrom: legacyAllowFrom }; - changed = true; - if (dm) { - delete dm.allowFrom; - dmChanged = true; - } - params.changes.push( - `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, - ); - } else if ( - topAllowFrom !== undefined && - legacyAllowFrom !== undefined && - allowFromEqual(topAllowFrom, legacyAllowFrom) - ) { - if (dm) { - delete dm.allowFrom; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); - } - } - - if (dm && asObjectRecord(rawDm) && dmChanged) { - const keys = Object.keys(dm); - if (keys.length === 0) { - if (updated.dm !== undefined) { - const { dm: _ignored, ...rest } = updated; - updated = rest; - changed = true; - params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); - } - } else { - updated = { ...updated, dm }; - changed = true; - } - } - - return { entry: updated, changed }; -} - -function normalizeDiscordStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.draftChunk !== undefined || - params.entry.blockStreamingCoalesce !== undefined; - const resolved = resolveDiscordPreviewStreamMode(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - const preview = ensureNestedRecord(streaming, "preview"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolved; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.draftChunk !== undefined && preview.chunk === undefined) { - preview.chunk = updated.draftChunk; - delete updated.draftChunk; - params.changes.push( - `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - if (Object.keys(preview).length > 0) { - streaming.preview = preview; - } - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - if ( - params.pathPrefix.startsWith("channels.discord") && - resolved === "off" && - hadLegacyStreamMode - ) { - params.changes.push( - `${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`, - ); - } - return { entry: updated, changed }; -} - -function normalizeDiscordCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.discord); - if (!rawEntry) { - return { config: cfg, changes: [] }; - } - - const changes: string[] = []; - let updated = rawEntry; - let changed = false; - - const base = normalizeDiscordDmAliases({ - entry: rawEntry, - pathPrefix: "channels.discord", - changes, - }); - updated = base.entry; - changed = base.changed; - - const streaming = normalizeDiscordStreamingAliases({ - entry: updated, - pathPrefix: "channels.discord", - changes, - }); - updated = streaming.entry; - changed = changed || streaming.changed; - - const rawAccounts = asObjectRecord(updated.accounts); - if (rawAccounts) { - let accountsChanged = false; - const accounts = { ...rawAccounts }; - for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { - const account = asObjectRecord(rawAccount); - if (!account) { - continue; - } - let accountEntry = account; - let accountChanged = false; - const dm = normalizeDiscordDmAliases({ - entry: account, - pathPrefix: `channels.discord.accounts.${accountId}`, - changes, - }); - accountEntry = dm.entry; - accountChanged = dm.changed; - const accountStreaming = normalizeDiscordStreamingAliases({ - entry: accountEntry, - pathPrefix: `channels.discord.accounts.${accountId}`, - changes, - }); - accountEntry = accountStreaming.entry; - accountChanged = accountChanged || accountStreaming.changed; - if (accountChanged) { - accounts[accountId] = accountEntry; - accountsChanged = true; - } - } - if (accountsChanged) { - updated = { ...updated, accounts }; - changed = true; - } - } - - if (!changed) { - return { config: cfg, changes: [] }; - } - return { - config: { - ...cfg, - channels: { - ...cfg.channels, - discord: updated, - } as OpenClawConfig["channels"], - }, - changes, - }; -} - function collectDiscordAccountScopes( cfg: OpenClawConfig, ): Array<{ prefix: string; account: Record }> { @@ -604,7 +324,7 @@ export const discordDoctor: ChannelDoctorAdapter = { groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES, - normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg), + normalizeCompatibilityConfig: normalizeDiscordCompatibilityConfig, collectPreviewWarnings: ({ cfg, doctorFixCommand }) => collectDiscordNumericIdWarnings({ hits: scanDiscordNumericIdEntries(cfg), diff --git a/extensions/slack/src/doctor-contract.ts b/extensions/slack/src/doctor-contract.ts index 9399109c3c8..21a5794438e 100644 --- a/extensions/slack/src/doctor-contract.ts +++ b/extensions/slack/src/doctor-contract.ts @@ -3,6 +3,11 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyStreamingAliases, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, +} from "openclaw/plugin-sdk/runtime-doctor"; import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js"; function asObjectRecord(value: unknown): Record | null { @@ -11,130 +16,8 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - -function normalizeSlackStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.blockStreamingCoalesce !== undefined || - params.entry.nativeStreaming !== undefined; - const resolvedStreaming = resolveSlackStreamingMode(params.entry); - const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolvedStreaming; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } - if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) { - streaming.nativeTransport = resolvedNativeStreaming; - delete updated.nativeStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`, - ); - changed = true; - } else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) { - streaming.nativeTransport = resolvedNativeStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`, - ); - changed = true; - } - - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - - return { entry: updated, changed }; -} - function hasLegacySlackStreamingAliases(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.blockStreamingCoalesce !== undefined || - entry.nativeStreaming !== undefined - ); + return hasLegacyStreamingAliases(value, { includeNativeTransport: true }); } function hasLegacySlackAccountStreamingAliases(value: unknown): boolean { @@ -174,13 +57,23 @@ export function normalizeCompatibilityConfig({ let updated = rawEntry; let changed = false; - const baseStreaming = normalizeSlackStreamingAliases({ + const dm = normalizeLegacyDmAliases({ entry: updated, pathPrefix: "channels.slack", changes, }); - updated = baseStreaming.entry; - changed = changed || baseStreaming.changed; + updated = dm.entry; + changed = changed || dm.changed; + + const streaming = normalizeLegacyStreamingAliases({ + entry: updated, + pathPrefix: "channels.slack", + changes, + resolvedMode: resolveSlackStreamingMode(updated), + resolvedNativeTransport: resolveSlackNativeStreaming(updated), + }); + updated = streaming.entry; + changed = changed || streaming.changed; const rawAccounts = asObjectRecord(updated.accounts); if (rawAccounts) { @@ -191,13 +84,26 @@ export function normalizeCompatibilityConfig({ if (!account) { continue; } - const streaming = normalizeSlackStreamingAliases({ - entry: account, + let accountEntry = account; + let accountChanged = false; + const accountDm = normalizeLegacyDmAliases({ + entry: accountEntry, pathPrefix: `channels.slack.accounts.${accountId}`, changes, }); - if (streaming.changed) { - accounts[accountId] = streaming.entry; + accountEntry = accountDm.entry; + accountChanged = accountDm.changed; + const accountStreaming = normalizeLegacyStreamingAliases({ + entry: accountEntry, + pathPrefix: `channels.slack.accounts.${accountId}`, + changes, + resolvedMode: resolveSlackStreamingMode(accountEntry), + resolvedNativeTransport: resolveSlackNativeStreaming(accountEntry), + }); + accountEntry = accountStreaming.entry; + accountChanged = accountChanged || accountStreaming.changed; + if (accountChanged) { + accounts[accountId] = accountEntry; accountsChanged = true; } } diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index bf8596cdbb2..ab0d0ce0a95 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -1,12 +1,11 @@ -import { - type ChannelDoctorAdapter, - type ChannelDoctorConfigMutation, - type ChannelDoctorLegacyConfigRule, -} from "openclaw/plugin-sdk/channel-contract"; +import { type ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor"; +import { + legacyConfigRules as SLACK_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig as normalizeSlackCompatibilityConfig, +} from "./doctor-contract.js"; import { isSlackMutableAllowEntry } from "./security-doctor.js"; -import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js"; function asObjectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -14,283 +13,9 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - function sanitizeForLog(value: string): string { return value.replace(/\p{Cc}+/gu, " ").trim(); } - -function normalizeSlackDmAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - let changed = false; - let updated: Record = params.entry; - const rawDm = updated.dm; - const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; - let dmChanged = false; - - const allowFromEqual = (a: unknown, b: unknown): boolean => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - const na = a.map((v) => String(v).trim()).filter(Boolean); - const nb = b.map((v) => String(v).trim()).filter(Boolean); - if (na.length !== nb.length) { - return false; - } - return na.every((v, i) => v === nb[i]); - }; - - const topDmPolicy = updated.dmPolicy; - const legacyDmPolicy = dm?.policy; - if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { - updated = { ...updated, dmPolicy: legacyDmPolicy }; - changed = true; - if (dm) { - delete dm.policy; - dmChanged = true; - } - params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); - } else if ( - topDmPolicy !== undefined && - legacyDmPolicy !== undefined && - topDmPolicy === legacyDmPolicy - ) { - if (dm) { - delete dm.policy; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); - } - } - - const topAllowFrom = updated.allowFrom; - const legacyAllowFrom = dm?.allowFrom; - if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { - updated = { ...updated, allowFrom: legacyAllowFrom }; - changed = true; - if (dm) { - delete dm.allowFrom; - dmChanged = true; - } - params.changes.push( - `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, - ); - } else if ( - topAllowFrom !== undefined && - legacyAllowFrom !== undefined && - allowFromEqual(topAllowFrom, legacyAllowFrom) - ) { - if (dm) { - delete dm.allowFrom; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); - } - } - - if (dm && asObjectRecord(rawDm) && dmChanged) { - const keys = Object.keys(dm); - if (keys.length === 0) { - if (updated.dm !== undefined) { - const { dm: _ignored, ...rest } = updated; - updated = rest; - changed = true; - params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); - } - } else { - updated = { ...updated, dm }; - changed = true; - } - } - - return { entry: updated, changed }; -} - -function normalizeSlackStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.blockStreamingCoalesce !== undefined || - params.entry.nativeStreaming !== undefined; - const resolvedStreaming = resolveSlackStreamingMode(params.entry); - const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolvedStreaming; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } else if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) { - streaming.nativeTransport = resolvedNativeStreaming; - delete updated.nativeStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`, - ); - changed = true; - } else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) { - streaming.nativeTransport = resolvedNativeStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`, - ); - changed = true; - } - - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - - return { entry: updated, changed }; -} - -function normalizeSlackCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.slack); - if (!rawEntry) { - return { config: cfg, changes: [] }; - } - - const changes: string[] = []; - let updated = rawEntry; - let changed = false; - - const base = normalizeSlackDmAliases({ - entry: rawEntry, - pathPrefix: "channels.slack", - changes, - }); - updated = base.entry; - changed = base.changed; - - const baseStreaming = normalizeSlackStreamingAliases({ - entry: updated, - pathPrefix: "channels.slack", - changes, - }); - updated = baseStreaming.entry; - changed = changed || baseStreaming.changed; - - const rawAccounts = asObjectRecord(updated.accounts); - if (rawAccounts) { - let accountsChanged = false; - const accounts = { ...rawAccounts }; - for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { - const account = asObjectRecord(rawAccount); - if (!account) { - continue; - } - let accountEntry = account; - let accountChanged = false; - const dm = normalizeSlackDmAliases({ - entry: account, - pathPrefix: `channels.slack.accounts.${accountId}`, - changes, - }); - accountEntry = dm.entry; - accountChanged = dm.changed; - const streaming = normalizeSlackStreamingAliases({ - entry: accountEntry, - pathPrefix: `channels.slack.accounts.${accountId}`, - changes, - }); - accountEntry = streaming.entry; - accountChanged = accountChanged || streaming.changed; - if (accountChanged) { - accounts[accountId] = accountEntry; - accountsChanged = true; - } - } - if (accountsChanged) { - updated = { ...updated, accounts }; - changed = true; - } - } - - if (!changed) { - return { config: cfg, changes: [] }; - } - return { - config: { - ...cfg, - channels: { - ...cfg.channels, - slack: updated as unknown as NonNullable["slack"], - } as OpenClawConfig["channels"], - }, - changes, - }; -} - export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { const hits: Array<{ path: string; entry: string }> = []; const addHits = (pathLabel: string, list: unknown) => { @@ -344,51 +69,12 @@ export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): strin ]; } -function hasLegacySlackStreamingAliases(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.blockStreamingCoalesce !== undefined || - entry.nativeStreaming !== undefined - ); -} - -function hasLegacySlackAccountStreamingAliases(value: unknown): boolean { - const accounts = asObjectRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((account) => hasLegacySlackStreamingAliases(account)); -} - -const SLACK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "slack"], - message: - "channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.", - match: hasLegacySlackStreamingAliases, - }, - { - path: ["channels", "slack", "accounts"], - message: - "channels.slack.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts..streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.", - match: hasLegacySlackAccountStreamingAliases, - }, -]; - export const slackDoctor: ChannelDoctorAdapter = { dmAllowFromMode: "topOrNested", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, legacyConfigRules: SLACK_LEGACY_CONFIG_RULES, - normalizeCompatibilityConfig: ({ cfg }) => normalizeSlackCompatibilityConfig(cfg), + normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig, collectMutableAllowlistWarnings: ({ cfg }) => collectSlackMutableAllowlistWarnings(cfg), }; diff --git a/extensions/telegram/src/doctor-contract.ts b/extensions/telegram/src/doctor-contract.ts index 3a3f910044f..cc61cbdaca2 100644 --- a/extensions/telegram/src/doctor-contract.ts +++ b/extensions/telegram/src/doctor-contract.ts @@ -3,6 +3,10 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyStreamingAliases, + normalizeLegacyStreamingAliases, +} from "openclaw/plugin-sdk/runtime-doctor"; import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js"; function asObjectRecord(value: unknown): Record | null { @@ -11,125 +15,8 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - -function normalizeTelegramStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.draftChunk !== undefined || - params.entry.blockStreamingCoalesce !== undefined; - const resolved = resolveTelegramPreviewStreamMode(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - const preview = ensureNestedRecord(streaming, "preview"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolved; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.draftChunk !== undefined && preview.chunk === undefined) { - preview.chunk = updated.draftChunk; - delete updated.draftChunk; - params.changes.push( - `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - - if (Object.keys(preview).length > 0) { - streaming.preview = preview; - } - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - return { entry: updated, changed }; -} - function hasLegacyTelegramStreamingAliases(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 - ); + return hasLegacyStreamingAliases(value, { includePreviewChunk: true }); } function hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean { @@ -218,13 +105,15 @@ export function normalizeCompatibilityConfig({ } } - const base = normalizeTelegramStreamingAliases({ + const streaming = normalizeLegacyStreamingAliases({ entry: updated, pathPrefix: "channels.telegram", changes, + includePreviewChunk: true, + resolvedMode: resolveTelegramPreviewStreamMode(updated), }); - updated = base.entry; - changed = changed || base.changed; + updated = streaming.entry; + changed = changed || streaming.changed; const rawAccounts = asObjectRecord(updated.accounts); if (rawAccounts) { @@ -235,10 +124,12 @@ export function normalizeCompatibilityConfig({ if (!account) { continue; } - const accountStreaming = normalizeTelegramStreamingAliases({ + const accountStreaming = normalizeLegacyStreamingAliases({ entry: account, pathPrefix: `channels.telegram.accounts.${accountId}`, changes, + includePreviewChunk: true, + resolvedMode: resolveTelegramPreviewStreamMode(account), }); if (accountStreaming.changed) { accounts[accountId] = accountStreaming.entry; diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index 67e3e6f9ed3..0f4e35ed5ba 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -1,15 +1,16 @@ import { type ChannelDoctorAdapter, - type ChannelDoctorConfigMutation, type ChannelDoctorEmptyAllowlistAccountContext, - type ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; import { lookupTelegramChatId } from "./api-fetch.js"; -import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js"; +import { + legacyConfigRules as TELEGRAM_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig as normalizeTelegramCompatibilityConfig, +} from "./doctor-contract.js"; type TelegramAllowFromUsernameHit = { path: string; entry: string }; type DoctorAllowFromList = Array; @@ -27,14 +28,6 @@ function asObjectRecord(value: unknown): Record | null { : null; } -function ensureNestedRecord(owner: Record, key: string): Record { - const existing = asObjectRecord(owner[key]); - if (existing) { - return { ...existing }; - } - return {}; -} - function sanitizeForLog(value: string): string { return value.replace(/\p{Cc}+/gu, " ").trim(); } @@ -43,161 +36,6 @@ function describeUnknownError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function normalizeTelegramStreamingAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const beforeStreaming = params.entry.streaming; - const hadLegacyStreamMode = params.entry.streamMode !== undefined; - const hasLegacyFlatFields = - params.entry.chunkMode !== undefined || - params.entry.blockStreaming !== undefined || - params.entry.draftChunk !== undefined || - params.entry.blockStreamingCoalesce !== undefined; - const resolved = resolveTelegramPreviewStreamMode(params.entry); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string" || - hasLegacyFlatFields; - if (!shouldNormalize) { - return { entry: params.entry, changed: false }; - } - - let updated = { ...params.entry }; - let changed = false; - const streaming = ensureNestedRecord(updated, "streaming"); - const block = ensureNestedRecord(streaming, "block"); - const preview = ensureNestedRecord(streaming, "preview"); - - if ( - (hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - typeof beforeStreaming === "string") && - streaming.mode === undefined - ) { - streaming.mode = resolved; - if (hadLegacyStreamMode) { - params.changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "boolean") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } else if (typeof beforeStreaming === "string") { - params.changes.push( - `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`, - ); - } - changed = true; - } - if (hadLegacyStreamMode) { - delete updated.streamMode; - changed = true; - } - if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { - streaming.chunkMode = updated.chunkMode; - delete updated.chunkMode; - params.changes.push( - `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, - ); - changed = true; - } - if (updated.blockStreaming !== undefined && block.enabled === undefined) { - block.enabled = updated.blockStreaming; - delete updated.blockStreaming; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, - ); - changed = true; - } - if (updated.draftChunk !== undefined && preview.chunk === undefined) { - preview.chunk = updated.draftChunk; - delete updated.draftChunk; - params.changes.push( - `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, - ); - changed = true; - } - if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { - block.coalesce = updated.blockStreamingCoalesce; - delete updated.blockStreamingCoalesce; - params.changes.push( - `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, - ); - changed = true; - } - - if (Object.keys(preview).length > 0) { - streaming.preview = preview; - } - if (Object.keys(block).length > 0) { - streaming.block = block; - } - updated.streaming = streaming; - return { entry: updated, changed }; -} - -function normalizeTelegramCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.telegram); - if (!rawEntry) { - return { config: cfg, changes: [] }; - } - - const changes: string[] = []; - let updated = rawEntry; - let changed = false; - - const base = normalizeTelegramStreamingAliases({ - entry: rawEntry, - pathPrefix: "channels.telegram", - changes, - }); - updated = base.entry; - changed = base.changed; - - const rawAccounts = asObjectRecord(updated.accounts); - if (rawAccounts) { - let accountsChanged = false; - const accounts = { ...rawAccounts }; - for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { - const account = asObjectRecord(rawAccount); - if (!account) { - continue; - } - const accountStreaming = normalizeTelegramStreamingAliases({ - entry: account, - pathPrefix: `channels.telegram.accounts.${accountId}`, - changes, - }); - if (accountStreaming.changed) { - accounts[accountId] = accountStreaming.entry; - accountsChanged = true; - } - } - if (accountsChanged) { - updated = { ...updated, accounts }; - changed = true; - } - } - - if (!changed) { - return { config: cfg, changes: [] }; - } - return { - config: { - ...cfg, - channels: { - ...cfg.channels, - telegram: updated as unknown as NonNullable["telegram"], - } as OpenClawConfig["channels"], - }, - changes, - }; -} - function hasAllowFromEntries(values?: DoctorAllowFromList): boolean { return Array.isArray(values) && values.some((entry) => String(entry).trim()); } @@ -515,48 +353,9 @@ export function collectTelegramEmptyAllowlistExtraWarnings( : []; } -function hasLegacyTelegramStreamingAliases(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 hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean { - const accounts = asObjectRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((account) => hasLegacyTelegramStreamingAliases(account)); -} - -const TELEGRAM_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "telegram"], - message: - "channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", - match: hasLegacyTelegramStreamingAliases, - }, - { - path: ["channels", "telegram", "accounts"], - message: - "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}.", - match: hasLegacyTelegramAccountStreamingAliases, - }, -]; - export const telegramDoctor: ChannelDoctorAdapter = { legacyConfigRules: TELEGRAM_LEGACY_CONFIG_RULES, - normalizeCompatibilityConfig: ({ cfg }) => normalizeTelegramCompatibilityConfig(cfg), + normalizeCompatibilityConfig: normalizeTelegramCompatibilityConfig, collectPreviewWarnings: ({ cfg, doctorFixCommand }) => collectTelegramAllowFromUsernameWarnings({ hits: scanTelegramAllowFromUsernameEntries(cfg), diff --git a/src/config/channel-compat-normalization.ts b/src/config/channel-compat-normalization.ts new file mode 100644 index 00000000000..bc1445f7e3c --- /dev/null +++ b/src/config/channel-compat-normalization.ts @@ -0,0 +1,262 @@ +type CompatMutationResult = { + entry: Record; + changed: boolean; +}; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function ensureNestedRecord(owner: Record, key: string): Record { + const existing = asObjectRecord(owner[key]); + if (existing) { + return { ...existing }; + } + return {}; +} + +function allowFromListsMatch(left: unknown, right: unknown): boolean { + if (!Array.isArray(left) || !Array.isArray(right)) { + return false; + } + const normalizedLeft = left.map((value) => String(value).trim()).filter(Boolean); + const normalizedRight = right.map((value) => String(value).trim()).filter(Boolean); + if (normalizedLeft.length !== normalizedRight.length) { + return false; + } + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +export function normalizeLegacyDmAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; + promoteAllowFrom?: boolean; +}): CompatMutationResult { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; + let dmChanged = false; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if ( + topDmPolicy !== undefined && + legacyDmPolicy !== undefined && + topDmPolicy === legacyDmPolicy + ) { + if (dm) { + delete dm.policy; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } + + if (params.promoteAllowFrom !== false) { + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + params.changes.push( + `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, + ); + } else if ( + topAllowFrom !== undefined && + legacyAllowFrom !== undefined && + allowFromListsMatch(topAllowFrom, legacyAllowFrom) + ) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } + } + + if (dm && asObjectRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; +} + +export function normalizeLegacyStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; + resolvedMode: string; + includePreviewChunk?: boolean; + resolvedNativeTransport?: unknown; + offModeLegacyNotice?: (pathPrefix: string) => string; +}): CompatMutationResult { + const beforeStreaming = params.entry.streaming; + const hadLegacyStreamMode = params.entry.streamMode !== undefined; + const hasLegacyFlatFields = + params.entry.chunkMode !== undefined || + params.entry.blockStreaming !== undefined || + params.entry.blockStreamingCoalesce !== undefined || + (params.includePreviewChunk === true && params.entry.draftChunk !== undefined) || + params.entry.nativeStreaming !== undefined; + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + typeof beforeStreaming === "string" || + hasLegacyFlatFields; + if (!shouldNormalize) { + return { entry: params.entry, changed: false }; + } + + let updated = { ...params.entry }; + let changed = false; + const streaming = ensureNestedRecord(updated, "streaming"); + const block = ensureNestedRecord(streaming, "block"); + const preview = ensureNestedRecord(streaming, "preview"); + + if ( + (hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + typeof beforeStreaming === "string") && + streaming.mode === undefined + ) { + streaming.mode = params.resolvedMode; + if (hadLegacyStreamMode) { + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } else if (typeof beforeStreaming === "boolean") { + params.changes.push( + `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } else if (typeof beforeStreaming === "string") { + params.changes.push( + `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } + changed = true; + } + if (hadLegacyStreamMode) { + delete updated.streamMode; + changed = true; + } + if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { + streaming.chunkMode = updated.chunkMode; + delete updated.chunkMode; + params.changes.push( + `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, + ); + changed = true; + } + if (updated.blockStreaming !== undefined && block.enabled === undefined) { + block.enabled = updated.blockStreaming; + delete updated.blockStreaming; + params.changes.push( + `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, + ); + changed = true; + } + if ( + params.includePreviewChunk === true && + updated.draftChunk !== undefined && + preview.chunk === undefined + ) { + preview.chunk = updated.draftChunk; + delete updated.draftChunk; + params.changes.push( + `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, + ); + changed = true; + } + if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { + block.coalesce = updated.blockStreamingCoalesce; + delete updated.blockStreamingCoalesce; + params.changes.push( + `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, + ); + changed = true; + } + if ( + updated.nativeStreaming !== undefined && + streaming.nativeTransport === undefined && + params.resolvedNativeTransport !== undefined + ) { + streaming.nativeTransport = params.resolvedNativeTransport; + delete updated.nativeStreaming; + params.changes.push( + `Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`, + ); + changed = true; + } else if ( + typeof beforeStreaming === "boolean" && + streaming.nativeTransport === undefined && + params.resolvedNativeTransport !== undefined + ) { + streaming.nativeTransport = params.resolvedNativeTransport; + params.changes.push( + `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`, + ); + changed = true; + } + + if (Object.keys(preview).length > 0) { + streaming.preview = preview; + } + if (Object.keys(block).length > 0) { + streaming.block = block; + } + updated.streaming = streaming; + if ( + hadLegacyStreamMode && + params.resolvedMode === "off" && + params.offModeLegacyNotice !== undefined + ) { + params.changes.push(params.offModeLegacyNotice(params.pathPrefix)); + } + return { entry: updated, changed }; +} + +export function hasLegacyStreamingAliases( + value: unknown, + options?: { includePreviewChunk?: boolean; includeNativeTransport?: boolean }, +): 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.blockStreamingCoalesce !== undefined || + (options?.includePreviewChunk === true && entry.draftChunk !== undefined) || + (options?.includeNativeTransport === true && entry.nativeStreaming !== undefined) + ); +} diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index 6e3352d2be0..e095647a277 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -1,4 +1,9 @@ export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; +export { + hasLegacyStreamingAliases, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, +} from "../config/channel-compat-normalization.js"; export { detectPluginInstallPathIssue, formatPluginInstallPathIssue,