refactor(config): unify streaming config across channels

This commit is contained in:
Peter Steinberger
2026-02-21 19:53:23 +01:00
parent 747bb581b3
commit 2c14b0cf4c
26 changed files with 885 additions and 156 deletions

View File

@@ -68,6 +68,42 @@ describe("doctor config flow", () => {
});
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
discord: {
streaming: true,
lifecycle: {
enabled: true,
reactions: {
queued: "⏳",
thinking: "🧠",
tool: "🔧",
done: "✅",
error: "❌",
},
},
},
},
},
});
const cfg = result.cfg as {
channels: {
discord: {
streamMode?: string;
streaming?: string;
lifecycle?: unknown;
};
};
};
expect(cfg.channels.discord.streaming).toBe("partial");
expect(cfg.channels.discord.streamMode).toBeUndefined();
expect(cfg.channels.discord.lifecycle).toBeUndefined();
});
it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
const fetchSpy = vi.fn(async (url: string) => {
const u = String(url);

View File

@@ -145,4 +145,81 @@ describe("normalizeLegacyConfigValues", () => {
"Moved channels.discord.accounts.work.dm.allowFrom → channels.discord.accounts.work.allowFrom.",
]);
});
it("migrates Discord streaming boolean alias to streaming enum", () => {
const res = normalizeLegacyConfigValues({
channels: {
discord: {
streaming: true,
accounts: {
work: {
streaming: false,
},
},
},
},
});
expect(res.config.channels?.discord?.streaming).toBe("partial");
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
expect(res.changes).toEqual([
"Normalized channels.discord.streaming boolean → enum (partial).",
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
]);
});
it("migrates Discord legacy streamMode into streaming enum", () => {
const res = normalizeLegacyConfigValues({
channels: {
discord: {
streaming: false,
streamMode: "block",
},
},
});
expect(res.config.channels?.discord?.streaming).toBe("block");
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming (block).",
"Normalized channels.discord.streaming boolean → enum (block).",
]);
});
it("migrates Telegram streamMode into streaming enum", () => {
const res = normalizeLegacyConfigValues({
channels: {
telegram: {
streamMode: "block",
},
},
});
expect(res.config.channels?.telegram?.streaming).toBe("block");
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
]);
});
it("migrates Slack legacy streaming keys to unified config", () => {
const res = normalizeLegacyConfigValues({
channels: {
slack: {
streaming: false,
streamMode: "status_final",
},
},
});
expect(res.config.channels?.slack?.streaming).toBe("progress");
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
expect(res.config.channels?.slack?.streamMode).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.slack.streamMode → channels.slack.streaming (progress).",
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
]);
});
});

View File

@@ -1,4 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import {
resolveDiscordPreviewStreamMode,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
@@ -90,20 +97,178 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
return { entry: updated, changed };
};
const normalizeProvider = (provider: "slack" | "discord") => {
const normalizeTelegramStreamingAliases = (params: {
entry: Record<string, unknown>;
pathPrefix: string;
}): { entry: Record<string, unknown>; changed: boolean } => {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveTelegramPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
return { entry: updated, changed };
};
const normalizeDiscordStreamingAliases = (params: {
entry: Record<string, unknown>;
pathPrefix: string;
}): { entry: Record<string, unknown>; changed: boolean } => {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
return { entry: updated, changed };
};
const normalizeSlackStreamingAliases = (params: {
entry: Record<string, unknown>;
pathPrefix: string;
}): { entry: Record<string, unknown>; changed: boolean } => {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const legacyStreaming = updated.streaming;
const beforeStreaming = updated.streaming;
const beforeNativeStreaming = updated.nativeStreaming;
const resolvedStreaming = resolveSlackStreamingMode(updated);
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof legacyStreaming === "boolean" ||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolvedStreaming) {
updated = { ...updated, streaming: resolvedStreaming };
changed = true;
}
if (
typeof beforeNativeStreaming !== "boolean" ||
beforeNativeStreaming !== resolvedNativeStreaming
) {
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolvedStreaming}).`,
);
}
if (typeof legacyStreaming === "boolean") {
changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`,
);
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
changes.push(
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
);
}
return { entry: updated, changed };
};
const normalizeProvider = (provider: "telegram" | "slack" | "discord") => {
const channels = next.channels as Record<string, unknown> | undefined;
const rawEntry = channels?.[provider];
if (!isRecord(rawEntry)) {
return;
}
const base = normalizeDmAliases({
provider,
entry: rawEntry,
pathPrefix: `channels.${provider}`,
});
let updated = base.entry;
let changed = base.changed;
let updated = rawEntry;
let changed = false;
if (provider !== "telegram") {
const base = normalizeDmAliases({
provider,
entry: rawEntry,
pathPrefix: `channels.${provider}`,
});
updated = base.entry;
changed = base.changed;
}
if (provider === "telegram") {
const streaming = normalizeTelegramStreamingAliases({
entry: updated,
pathPrefix: `channels.${provider}`,
});
updated = streaming.entry;
changed = changed || streaming.changed;
} else if (provider === "discord") {
const streaming = normalizeDiscordStreamingAliases({
entry: updated,
pathPrefix: `channels.${provider}`,
});
updated = streaming.entry;
changed = changed || streaming.changed;
} else if (provider === "slack") {
const streaming = normalizeSlackStreamingAliases({
entry: updated,
pathPrefix: `channels.${provider}`,
});
updated = streaming.entry;
changed = changed || streaming.changed;
}
const rawAccounts = updated.accounts;
if (isRecord(rawAccounts)) {
@@ -113,13 +278,41 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
if (!isRecord(rawAccount)) {
continue;
}
const res = normalizeDmAliases({
provider,
entry: rawAccount,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
if (res.changed) {
accounts[accountId] = res.entry;
let accountEntry = rawAccount;
let accountChanged = false;
if (provider !== "telegram") {
const res = normalizeDmAliases({
provider,
entry: rawAccount,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
accountEntry = res.entry;
accountChanged = res.changed;
}
if (provider === "telegram") {
const streaming = normalizeTelegramStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
accountEntry = streaming.entry;
accountChanged = accountChanged || streaming.changed;
} else if (provider === "discord") {
const streaming = normalizeDiscordStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
accountEntry = streaming.entry;
accountChanged = accountChanged || streaming.changed;
} else if (provider === "slack") {
const streaming = normalizeSlackStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
accountEntry = streaming.entry;
accountChanged = accountChanged || streaming.changed;
}
if (accountChanged) {
accounts[accountId] = accountEntry;
accountsChanged = true;
}
}
@@ -140,6 +333,7 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
}
};
normalizeProvider("telegram");
normalizeProvider("slack");
normalizeProvider("discord");