fix(discord): normalize legacy streaming aliases

This commit is contained in:
Peter Steinberger
2026-04-12 11:52:33 -07:00
parent 2c590bdbc4
commit cb5a25d8d8
3 changed files with 178 additions and 15 deletions

View File

@@ -3,7 +3,12 @@ import type {
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { asObjectRecord, normalizeLegacyDmAliases } from "openclaw/plugin-sdk/runtime-doctor";
import {
asObjectRecord,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
@@ -141,6 +146,16 @@ export function normalizeCompatibilityConfig({
updated = dm.entry;
changed = changed || dm.changed;
const streaming = normalizeLegacyStreamingAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
resolvedMode: resolveDiscordPreviewStreamMode(updated),
includePreviewChunk: true,
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
@@ -159,6 +174,15 @@ export function normalizeCompatibilityConfig({
});
accountEntry = accountDm.entry;
accountChanged = accountDm.changed;
const accountStreaming = normalizeLegacyStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
resolvedMode: resolveDiscordPreviewStreamMode(accountEntry),
includePreviewChunk: true,
});
accountEntry = accountStreaming.entry;
accountChanged = accountChanged || accountStreaming.changed;
const accountVoice = asObjectRecord(accountEntry.voice);
if (
accountVoice &&

View File

@@ -8,7 +8,7 @@ import {
} from "./doctor.js";
describe("discord doctor", () => {
it("leaves legacy discord streaming aliases untouched during doctor normalization", () => {
it("normalizes legacy discord streaming aliases for runtime config", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
@@ -39,22 +39,39 @@ describe("discord doctor", () => {
});
expect(result.config.channels?.discord).toEqual({
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
},
accounts: {
work: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
streaming: {
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
},
},
},
});
expect(result.changes).toEqual([]);
expect(result.changes).toEqual([
"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("moves account voice.tts.edge into providers.microsoft", () => {

View File

@@ -77,6 +77,122 @@ vi.mock("../plugins/doctor-contract-registry.js", () => {
);
}
function resolveDiscordStreamMode(entry: Record<string, unknown>): string {
if (
entry.streamMode === "block" ||
entry.streamMode === "partial" ||
entry.streamMode === "off"
) {
return entry.streamMode;
}
if (entry.streaming === true) {
return "partial";
}
if (entry.streaming === false) {
return "off";
}
return "off";
}
function normalizeDiscordStreamingEntry(
entry: Record<string, unknown>,
pathPrefix: string,
changes: string[],
): boolean {
const hasLegacyStreaming =
"streamMode" in entry ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
"chunkMode" in entry ||
"blockStreaming" in entry ||
"draftChunk" in entry ||
"blockStreamingCoalesce" in entry;
if (!hasLegacyStreaming) {
return false;
}
let changed = false;
const streaming = asRecord(entry.streaming) ?? {};
if (!("mode" in streaming) && ("streamMode" in entry || typeof entry.streaming !== "object")) {
const mode = resolveDiscordStreamMode(entry);
streaming.mode = mode;
changes.push(
"streamMode" in entry
? `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming.mode (${mode}).`
: `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.streaming.mode (${mode}).`,
);
changed = true;
}
if ("streamMode" in entry) {
delete entry.streamMode;
changed = true;
}
if ("chunkMode" in entry && !("chunkMode" in streaming)) {
streaming.chunkMode = entry.chunkMode;
delete entry.chunkMode;
changes.push(`Moved ${pathPrefix}.chunkMode → ${pathPrefix}.streaming.chunkMode.`);
changed = true;
}
const block = asRecord(streaming.block) ?? {};
if ("blockStreaming" in entry && !("enabled" in block)) {
block.enabled = entry.blockStreaming;
delete entry.blockStreaming;
changes.push(`Moved ${pathPrefix}.blockStreaming → ${pathPrefix}.streaming.block.enabled.`);
changed = true;
}
if ("blockStreamingCoalesce" in entry && !("coalesce" in block)) {
block.coalesce = entry.blockStreamingCoalesce;
delete entry.blockStreamingCoalesce;
changes.push(
`Moved ${pathPrefix}.blockStreamingCoalesce → ${pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
const preview = asRecord(streaming.preview) ?? {};
if ("draftChunk" in entry && !("chunk" in preview)) {
preview.chunk = entry.draftChunk;
delete entry.draftChunk;
changes.push(`Moved ${pathPrefix}.draftChunk → ${pathPrefix}.streaming.preview.chunk.`);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
entry.streaming = streaming;
return changed;
}
function normalizeDiscordStreamingAliasesForTest(cfg: unknown): {
config: unknown;
changes: string[];
} {
const root = asRecord(cfg);
const discord = asRecord(asRecord(root?.channels)?.discord);
if (!root || !discord) {
return { config: cfg, changes: [] };
}
const next = structuredClone(root);
const nextDiscord = asRecord(asRecord(next.channels)?.discord);
if (!nextDiscord) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
normalizeDiscordStreamingEntry(nextDiscord, "channels.discord", changes);
const accounts = asRecord(nextDiscord.accounts);
for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) {
const account = asRecord(accountRaw);
if (account) {
normalizeDiscordStreamingEntry(account, `channels.discord.accounts.${accountId}`, changes);
}
}
return changes.length > 0 ? { config: next, changes } : { config: cfg, changes: [] };
}
return {
collectRelevantDoctorPluginIds: (raw: unknown): string[] => {
const ids = new Set<string>();
@@ -92,7 +208,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => {
}
return [...ids].toSorted();
},
applyPluginDoctorCompatibilityMigrations: (cfg: unknown) => ({ config: cfg, changes: [] }),
applyPluginDoctorCompatibilityMigrations: normalizeDiscordStreamingAliasesForTest,
listPluginDoctorLegacyConfigRules: () => [
{
path: ["channels", "telegram", "groupMentionsOnly"],
@@ -404,6 +520,10 @@ vi.mock("./doctor-config-preflight.js", async () => {
await import("../plugins/doctor-contract-registry.js");
const { findLegacyConfigIssues }: typeof import("../config/legacy.js") =
await import("../config/legacy.js");
const {
applyRuntimeLegacyConfigMigrations,
}: typeof import("./doctor/shared/runtime-compat-api.js") =
await import("./doctor/shared/runtime-compat-api.js");
function resolveConfigPath() {
const stateDir =
@@ -430,18 +550,20 @@ vi.mock("./doctor-config-preflight.js", async () => {
pluginIds: collectRelevantDoctorPluginIds(parsed),
}),
);
const compat = applyRuntimeLegacyConfigMigrations(parsed);
const effectiveConfig = compat.next ?? parsed;
return {
snapshot: {
exists,
path: configPath,
parsed,
config: parsed,
sourceConfig: parsed,
config: effectiveConfig,
sourceConfig: effectiveConfig,
valid: legacyIssues.length === 0,
warnings: [],
legacyIssues,
},
baseConfig: parsed,
baseConfig: effectiveConfig,
};
}),
};