refactor: move channel doctor migrations to plugins

This commit is contained in:
Peter Steinberger
2026-04-22 18:54:19 +01:00
parent 9d5d2f9cdd
commit 921a5416e4
10 changed files with 578 additions and 989 deletions

View File

@@ -28,6 +28,30 @@ function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
});
}
function hasLegacyDiscordGuildChannelAllowAlias(value: unknown): boolean {
const guilds = asObjectRecord(asObjectRecord(value)?.guilds);
if (!guilds) {
return false;
}
return Object.values(guilds).some((guildValue) => {
const channels = asObjectRecord(asObjectRecord(guildValue)?.channels);
if (!channels) {
return false;
}
return Object.values(channels).some((channel) =>
Object.prototype.hasOwnProperty.call(asObjectRecord(channel) ?? {}, "allow"),
);
});
}
function hasLegacyDiscordAccountGuildChannelAllowAlias(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordGuildChannelAllowAlias(account));
}
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
@@ -103,6 +127,58 @@ function migrateLegacyTtsConfig(
return changed;
}
function normalizeDiscordGuildChannelAllowAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
const guilds = asObjectRecord(params.entry.guilds);
if (!guilds) {
return { entry: params.entry, changed: false };
}
let changed = false;
const nextGuilds = { ...guilds };
for (const [guildId, guildValue] of Object.entries(guilds)) {
const guild = asObjectRecord(guildValue);
const channels = asObjectRecord(guild?.channels);
if (!guild || !channels) {
continue;
}
let channelsChanged = false;
const nextChannels = { ...channels };
for (const [channelId, channelValue] of Object.entries(channels)) {
const channel = asObjectRecord(channelValue);
if (!channel || !Object.prototype.hasOwnProperty.call(channel, "allow")) {
continue;
}
const nextChannel = { ...channel };
if (nextChannel.enabled === undefined) {
nextChannel.enabled = channel.allow;
params.changes.push(
`Moved ${params.pathPrefix}.guilds.${guildId}.channels.${channelId}.allow → ${params.pathPrefix}.guilds.${guildId}.channels.${channelId}.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.guilds.${guildId}.channels.${channelId}.allow (${params.pathPrefix}.guilds.${guildId}.channels.${channelId}.enabled already set).`,
);
}
delete nextChannel.allow;
nextChannels[channelId] = nextChannel;
channelsChanged = true;
}
if (!channelsChanged) {
continue;
}
nextGuilds[guildId] = { ...guild, channels: nextChannels };
changed = true;
}
return changed
? { entry: { ...params.entry, guilds: nextGuilds }, changed: true }
: { entry: params.entry, changed: false };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord", "voice", "tts"],
@@ -116,6 +192,18 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
'channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: hasLegacyDiscordAccountTtsProviderKeys,
},
{
path: ["channels", "discord"],
message:
'channels.discord.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyDiscordGuildChannelAllowAlias,
},
{
path: ["channels", "discord", "accounts"],
message:
'channels.discord.accounts.<id>.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.accounts.<id>.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyDiscordAccountGuildChannelAllowAlias,
},
];
export function normalizeCompatibilityConfig({
@@ -168,6 +256,40 @@ export function normalizeCompatibilityConfig({
updated = aliases.entry;
changed = aliases.changed;
const guildAliases = normalizeDiscordGuildChannelAllowAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
});
updated = guildAliases.entry;
changed = changed || guildAliases.changed;
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
if (!account) {
continue;
}
const normalized = normalizeDiscordGuildChannelAllowAliases({
entry: account,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = normalized.entry;
accountsChanged = true;
}
if (accountsChanged) {
updated = { ...updated, accounts: nextAccounts };
changed = true;
}
}
const voice = asObjectRecord(updated.voice);
if (
voice &&

View File

@@ -115,6 +115,58 @@ describe("discord doctor", () => {
expect(mainTts?.edge).toBeUndefined();
});
it("moves legacy guild channel allow toggles into enabled", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
accounts: {
work: {
guilds: {
"200": {
channels: {
help: {
allow: true,
},
},
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.discord.guilds.100.channels.general.allow → channels.discord.guilds.100.channels.general.enabled.",
"Moved channels.discord.accounts.work.guilds.200.channels.help.allow → channels.discord.accounts.work.guilds.200.channels.help.enabled.",
]);
expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
expect(
result.config.channels?.discord?.accounts?.work?.guilds?.["200"]?.channels?.help,
).toEqual({
enabled: true,
});
});
it("finds numeric id entries across discord scopes", () => {
const cfg = {
channels: {

View File

@@ -2,3 +2,4 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -36,6 +36,10 @@ import {
type ChannelStatusIssue,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
import {
legacyConfigRules as GOOGLECHAT_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeGoogleChatCompatibilityConfig,
} from "./doctor-contract.js";
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
import { startGoogleChatGatewayAccount } from "./gateway.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
@@ -172,6 +176,8 @@ export const googlechatPlugin = createChatChannelPlugin({
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: GOOGLECHAT_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeGoogleChatCompatibilityConfig,
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
},
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("googlechat doctor contract", () => {
it("removes legacy streamMode keys", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
googlechat: {
streamMode: "append",
accounts: {
work: {
streamMode: "replace",
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Removed channels.googlechat.streamMode (legacy key no longer used).",
"Removed channels.googlechat.accounts.work.streamMode (legacy key no longer used).",
]);
expect(result.config.channels?.googlechat).toEqual({
accounts: {
work: {},
},
});
});
it("moves legacy group allow toggles into enabled", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
"spaces/bbb": {
allow: true,
enabled: false,
},
},
accounts: {
work: {
groups: {
"spaces/ccc": {
allow: true,
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.",
"Removed channels.googlechat.groups.spaces/bbb.allow (channels.googlechat.groups.spaces/bbb.enabled already set).",
"Moved channels.googlechat.accounts.work.groups.spaces/ccc.allow → channels.googlechat.accounts.work.groups.spaces/ccc.enabled.",
]);
expect(result.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: false,
});
expect(result.config.channels?.googlechat?.groups?.["spaces/bbb"]).toEqual({
enabled: false,
});
expect(result.config.channels?.googlechat?.accounts?.work?.groups?.["spaces/ccc"]).toEqual({
enabled: true,
});
});
});

View File

@@ -0,0 +1,182 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
return asObjectRecord(value)?.streamMode !== undefined;
}
function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
const groups = asObjectRecord(asObjectRecord(value)?.groups);
if (!groups) {
return false;
}
return Object.values(groups).some((group) =>
Object.prototype.hasOwnProperty.call(asObjectRecord(group) ?? {}, "allow"),
);
}
function hasLegacyAccountAliases(value: unknown, match: (entry: unknown) => boolean): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => match(account));
}
function normalizeGoogleChatGroups(params: {
groups: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { groups: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextGroups = { ...params.groups };
for (const [groupId, groupValue] of Object.entries(params.groups)) {
const group = asObjectRecord(groupValue);
if (!group || !Object.prototype.hasOwnProperty.call(group, "allow")) {
continue;
}
const nextGroup = { ...group };
if (nextGroup.enabled === undefined) {
nextGroup.enabled = group.allow;
params.changes.push(
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.${groupId}.allow (${params.pathPrefix}.${groupId}.enabled already set).`,
);
}
delete nextGroup.allow;
nextGroups[groupId] = nextGroup;
changed = true;
}
return { groups: nextGroups, changed };
}
function normalizeGoogleChatEntry(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
let changed = false;
if (updated.streamMode !== undefined) {
updated = { ...updated };
delete updated.streamMode;
params.changes.push(`Removed ${params.pathPrefix}.streamMode (legacy key no longer used).`);
changed = true;
}
const groups = asObjectRecord(updated.groups);
if (groups) {
const normalized = normalizeGoogleChatGroups({
groups,
pathPrefix: `${params.pathPrefix}.groups`,
changes: params.changes,
});
if (normalized.changed) {
updated = { ...updated, groups: normalized.groups };
changed = true;
}
}
return { entry: updated, changed };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "googlechat"],
message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.",
match: hasLegacyGoogleChatStreamMode,
},
{
path: ["channels", "googlechat", "accounts"],
message:
"channels.googlechat.accounts.<id>.streamMode is legacy and no longer used; it is removed on load.",
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatStreamMode),
},
{
path: ["channels", "googlechat"],
message:
'channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyGoogleChatGroupAllowAlias,
},
{
path: ["channels", "googlechat", "accounts"],
message:
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatGroupAllowAlias),
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord(
(cfg.channels as Record<string, unknown> | undefined)?.googlechat,
);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const root = normalizeGoogleChatEntry({
entry: updated,
pathPrefix: "channels.googlechat",
changes,
});
updated = root.entry;
changed = root.changed;
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
if (!account) {
continue;
}
const normalized = normalizeGoogleChatEntry({
entry: account,
pathPrefix: `channels.googlechat.accounts.${accountId}`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = normalized.entry;
accountsChanged = true;
}
if (accountsChanged) {
updated = { ...updated, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
googlechat: updated as GoogleChatChannelsConfig["googlechat"],
},
},
changes,
};
}

View File

@@ -15,6 +15,46 @@ function hasLegacySlackStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includeNativeTransport: true });
}
function hasLegacySlackChannelAllowAlias(value: unknown): boolean {
const channels = asObjectRecord(asObjectRecord(value)?.channels);
if (!channels) {
return false;
}
return Object.values(channels).some((channel) =>
Object.prototype.hasOwnProperty.call(asObjectRecord(channel) ?? {}, "allow"),
);
}
function normalizeSlackChannelAllowAliases(params: {
channels: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { channels: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextChannels = { ...params.channels };
for (const [channelId, channelValue] of Object.entries(params.channels)) {
const channel = asObjectRecord(channelValue);
if (!channel || !Object.prototype.hasOwnProperty.call(channel, "allow")) {
continue;
}
const nextChannel = { ...channel };
if (nextChannel.enabled === undefined) {
nextChannel.enabled = channel.allow;
params.changes.push(
`Moved ${params.pathPrefix}.${channelId}.allow → ${params.pathPrefix}.${channelId}.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.${channelId}.allow (${params.pathPrefix}.${channelId}.enabled already set).`,
);
}
delete nextChannel.allow;
nextChannels[channelId] = nextChannel;
changed = true;
}
return { channels: nextChannels, changed };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
@@ -28,6 +68,24 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
"channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacySlackStreamingAliases),
},
{
path: ["channels", "slack"],
message:
'channels.slack.channels.<id>.allow is legacy; use channels.slack.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacySlackChannelAllowAlias,
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.channels.<id>.allow is legacy; use channels.slack.accounts.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacySlackChannelAllowAlias(account));
},
},
];
export function normalizeCompatibilityConfig({
@@ -58,6 +116,46 @@ export function normalizeCompatibilityConfig({
updated = aliases.entry;
changed = aliases.changed;
const channels = asObjectRecord(updated.channels);
if (channels) {
const normalized = normalizeSlackChannelAllowAliases({
channels,
pathPrefix: "channels.slack.channels",
changes,
});
if (normalized.changed) {
updated = { ...updated, channels: normalized.channels };
changed = true;
}
}
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
const channelEntries = asObjectRecord(account?.channels);
if (!account || !channelEntries) {
continue;
}
const normalized = normalizeSlackChannelAllowAliases({
channels: channelEntries,
pathPrefix: `channels.slack.accounts.${accountId}.channels`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = { ...account, channels: normalized.channels };
accountsChanged = true;
}
if (accountsChanged) {
updated = { ...updated, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}

View File

@@ -114,4 +114,46 @@ describe("slack doctor", () => {
result.changes.filter((change) => change.includes("channels.slack.streaming.mode")),
).toEqual(["Moved channels.slack.streamMode → channels.slack.streaming.mode (progress)."]);
});
it("moves legacy channel allow toggles into enabled", () => {
const normalize = slackDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
accounts: {
work: {
channels: {
general: {
allow: true,
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.slack.channels.ops.allow → channels.slack.channels.ops.enabled.",
"Moved channels.slack.accounts.work.channels.general.allow → channels.slack.accounts.work.channels.general.enabled.",
]);
expect(result.config.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(result.config.channels?.slack?.accounts?.work?.channels?.general).toEqual({
enabled: true,
});
});
});

View File

@@ -177,312 +177,6 @@ describe("legacy migrate sandbox scope aliases", () => {
});
});
describe("legacy migrate channel streaming aliases", () => {
it("migrates Telegram and Slack preview-channel legacy streaming fields without rewriting Discord", () => {
const res = migrateLegacyConfigForTest({
channels: {
telegram: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
blockStreamingCoalesce: {
idleMs: 250,
},
},
discord: {
streaming: false,
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
maxChars: 900,
},
},
slack: {
streamMode: "status_final",
blockStreaming: true,
blockStreamingCoalesce: {
minChars: 100,
},
nativeStreaming: false,
},
},
});
expect(res.changes).toContain(
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
);
expect(res.changes).toContain(
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
);
expect(res.changes).toContain(
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
);
expect(res.changes).toContain(
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
);
expect(res.changes).toContain(
"Moved channels.telegram.blockStreamingCoalesce → channels.telegram.streaming.block.coalesce.",
);
expect(res.changes).toContain(
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
);
expect(res.changes).toContain(
"Moved channels.slack.nativeStreaming → channels.slack.streaming.nativeTransport.",
);
expect(res.config?.channels?.telegram).toMatchObject({
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
coalesce: {
idleMs: 250,
},
},
preview: {
chunk: {
minChars: 120,
},
},
},
});
expect(res.config?.channels?.discord).toMatchObject({
streaming: false,
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
maxChars: 900,
},
});
expect(res.config?.channels?.slack).toMatchObject({
streaming: {
mode: "progress",
block: {
enabled: true,
coalesce: {
minChars: 100,
},
},
nativeTransport: false,
},
});
});
it("preserves slack streaming=false when deriving nativeTransport during migration", () => {
const raw = {
channels: {
slack: {
botToken: "xoxb-test",
streaming: false,
},
},
};
const res = migrateLegacyConfigForTest(raw);
const migrated = migrateLegacyConfigForTest(raw);
expect(res.changes).toContain(
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
);
expect(migrated.config?.channels?.slack).toMatchObject({
streaming: {
mode: "off",
nativeTransport: false,
},
});
});
it("rejects legacy googlechat streamMode aliases during validation and removes them in migration", () => {
const raw = {
channels: {
googlechat: {
streamMode: "append",
accounts: {
work: {
streamMode: "replace",
},
},
},
},
};
const res = migrateLegacyConfigForTest(raw);
expect(res.changes).toContain(
"Removed channels.googlechat.streamMode (legacy key no longer used).",
);
expect(res.changes).toContain(
"Removed channels.googlechat.accounts.work.streamMode (legacy key no longer used).",
);
expect(
(res.config?.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(
(res.config?.channels?.googlechat?.accounts?.work as Record<string, unknown> | undefined)
?.streamMode,
).toBeUndefined();
});
});
describe("legacy migrate nested channel enabled aliases", () => {
it("rejects legacy allow aliases during validation and normalizes them in migration", () => {
const raw = {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: true,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
},
},
};
const migrated = migrateLegacyConfigForTest(raw);
expect(migrated.config?.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(migrated.config?.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: true,
});
expect(migrated.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
it("moves legacy allow toggles into enabled for slack, googlechat, and discord", () => {
const res = migrateLegacyConfigForTest({
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
accounts: {
work: {
channels: {
general: {
allow: true,
},
},
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
},
accounts: {
work: {
groups: {
"spaces/bbb": {
allow: true,
},
},
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
accounts: {
work: {
guilds: {
"200": {
channels: {
help: {
allow: true,
},
},
},
},
},
},
},
},
});
expect(res.changes).toContain(
"Moved channels.slack.channels.ops.allow → channels.slack.channels.ops.enabled.",
);
expect(res.changes).toContain(
"Moved channels.slack.accounts.work.channels.general.allow → channels.slack.accounts.work.channels.general.enabled.",
);
expect(res.changes).toContain(
"Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.",
);
expect(res.changes).toContain(
"Moved channels.googlechat.accounts.work.groups.spaces/bbb.allow → channels.googlechat.accounts.work.groups.spaces/bbb.enabled.",
);
expect(res.changes).toContain(
"Moved channels.discord.guilds.100.channels.general.allow → channels.discord.guilds.100.channels.general.enabled.",
);
expect(res.changes).toContain(
"Moved channels.discord.accounts.work.guilds.200.channels.help.allow → channels.discord.accounts.work.guilds.200.channels.help.enabled.",
);
expect(res.config?.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(res.config?.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: false,
});
expect(res.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
it("drops legacy allow when enabled is already set", () => {
const res = migrateLegacyConfigForTest({
channels: {
slack: {
channels: {
ops: {
allow: true,
enabled: false,
},
},
},
},
});
expect(res.changes).toContain(
"Removed channels.slack.channels.ops.allow (channels.slack.channels.ops.enabled already set).",
);
expect(res.config?.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
});
});
describe("legacy migrate x_search auth", () => {
it("moves only legacy x_search auth into plugin-owned xai config", () => {
const res = migrateLegacyConfigForTest({

View File

@@ -4,115 +4,11 @@ import {
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "../../../config/legacy.shared.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
type StreamingMode = "off" | "partial" | "block" | "progress";
type TelegramPreviewStreamMode = "off" | "partial" | "block";
type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(target, key);
}
function normalizeStreamingMode(value: unknown): string | null {
return normalizeOptionalLowercaseString(value) ?? null;
}
function parseStreamingMode(value: unknown): StreamingMode | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseTelegramPreviewStreamMode(value: unknown): TelegramPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null {
const normalized = normalizeStreamingMode(value);
if (normalized === "replace" || normalized === "status_final" || normalized === "append") {
return normalized;
}
return null;
}
function mapSlackLegacyDraftStreamModeToStreaming(mode: SlackLegacyDraftStreamMode): StreamingMode {
if (mode === "append") {
return "block";
}
if (mode === "status_final") {
return "progress";
}
return "partial";
}
function resolveTelegramPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): TelegramPreviewStreamMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming === "progress" ? "partial" : parsedStreaming;
}
const legacy = parseTelegramPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}
function resolveSlackStreamingMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): StreamingMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode);
if (legacyStreamMode) {
return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode);
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}
function resolveSlackNativeStreaming(
params: {
nativeStreaming?: unknown;
streaming?: unknown;
} = {},
): boolean {
if (typeof params.nativeStreaming === "boolean") {
return params.nativeStreaming;
}
if (typeof params.streaming === "boolean") {
return params.streaming;
}
return true;
}
function hasLegacyThreadBindingTtl(value: unknown): boolean {
const threadBindings = getRecord(value);
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
@@ -174,282 +70,6 @@ function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean {
});
}
function hasLegacyTelegramStreamingKeys(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) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
hasOwnKey(entry, "chunkMode") ||
hasOwnKey(entry, "blockStreaming") ||
hasOwnKey(entry, "blockStreamingCoalesce") ||
hasOwnKey(entry, "nativeStreaming")
);
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = getRecord(owner[key]);
if (existing) {
return existing;
}
const created: Record<string, unknown> = {};
owner[key] = created;
return created;
}
function moveLegacyStreamingShapeForPath(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
resolveMode?: (entry: Record<string, unknown>) => string;
resolveNativeTransport?: (entry: Record<string, unknown>) => boolean;
}): boolean {
let changed = false;
const legacyStreaming = params.entry.streaming;
const legacyStreamingInput = {
...params.entry,
streaming: legacyStreaming,
};
const legacyNativeTransportInput = {
nativeStreaming: params.entry.nativeStreaming,
streaming: legacyStreaming,
};
const hadLegacyStreamMode = hasOwnKey(params.entry, "streamMode");
const hadLegacyStreamingScalar =
typeof legacyStreaming === "string" || typeof legacyStreaming === "boolean";
if (params.resolveMode && (hadLegacyStreamMode || hadLegacyStreamingScalar)) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "mode")) {
const resolvedMode = params.resolveMode(legacyStreamingInput);
streaming.mode = resolvedMode;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
}
if (typeof legacyStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
} else if (typeof legacyStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
}
} else {
params.changes.push(
`Removed legacy ${params.pathPrefix}.streaming mode aliases (${params.pathPrefix}.streaming.mode already set).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
delete params.entry.streamMode;
changed = true;
}
if (hadLegacyStreamingScalar) {
if (!getRecord(params.entry.streaming)) {
params.entry.streaming = {};
}
changed = true;
}
if (hasOwnKey(params.entry, "chunkMode")) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "chunkMode")) {
streaming.chunkMode = params.entry.chunkMode;
params.changes.push(
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.chunkMode (${params.pathPrefix}.streaming.chunkMode already set).`,
);
}
delete params.entry.chunkMode;
changed = true;
}
if (hasOwnKey(params.entry, "blockStreaming")) {
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
if (!hasOwnKey(block, "enabled")) {
block.enabled = params.entry.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.blockStreaming (${params.pathPrefix}.streaming.block.enabled already set).`,
);
}
delete params.entry.blockStreaming;
changed = true;
}
if (hasOwnKey(params.entry, "draftChunk")) {
const preview = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "preview");
if (!hasOwnKey(preview, "chunk")) {
preview.chunk = params.entry.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.draftChunk (${params.pathPrefix}.streaming.preview.chunk already set).`,
);
}
delete params.entry.draftChunk;
changed = true;
}
if (hasOwnKey(params.entry, "blockStreamingCoalesce")) {
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
if (!hasOwnKey(block, "coalesce")) {
block.coalesce = params.entry.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.blockStreamingCoalesce (${params.pathPrefix}.streaming.block.coalesce already set).`,
);
}
delete params.entry.blockStreamingCoalesce;
changed = true;
}
if (params.resolveNativeTransport && hasOwnKey(params.entry, "nativeStreaming")) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "nativeTransport")) {
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
params.changes.push(
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.nativeStreaming (${params.pathPrefix}.streaming.nativeTransport already set).`,
);
}
delete params.entry.nativeStreaming;
changed = true;
} else if (params.resolveNativeTransport && typeof legacyStreaming === "boolean") {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "nativeTransport")) {
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
}
}
return changed;
}
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
return false;
}
return entry.streamMode !== undefined;
}
function hasLegacyKeysInAccounts(
value: unknown,
matchEntry: (entry: Record<string, unknown>) => boolean,
): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) => matchEntry(getRecord(entry) ?? {}));
}
function hasLegacyAllowAlias(entry: Record<string, unknown>): boolean {
return hasOwnKey(entry, "allow");
}
function migrateAllowAliasForPath(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): boolean {
if (!hasLegacyAllowAlias(params.entry)) {
return false;
}
const legacyAllow = params.entry.allow;
const hadEnabled = params.entry.enabled !== undefined;
if (!hadEnabled) {
params.entry.enabled = legacyAllow;
}
delete params.entry.allow;
if (hadEnabled) {
params.changes.push(
`Removed ${params.pathPrefix}.allow (${params.pathPrefix}.enabled already set).`,
);
} else {
params.changes.push(`Moved ${params.pathPrefix}.allow → ${params.pathPrefix}.enabled.`);
}
return true;
}
function hasLegacySlackChannelAllowAlias(value: unknown): boolean {
const entry = getRecord(value);
const channels = getRecord(entry?.channels);
if (!channels) {
return false;
}
return Object.values(channels).some((channel) => hasLegacyAllowAlias(getRecord(channel) ?? {}));
}
function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
const entry = getRecord(value);
const groups = getRecord(entry?.groups);
if (!groups) {
return false;
}
return Object.values(groups).some((group) => hasLegacyAllowAlias(getRecord(group) ?? {}));
}
function hasLegacyDiscordGuildChannelAllowAlias(value: unknown): boolean {
const entry = getRecord(value);
const guilds = getRecord(entry?.guilds);
if (!guilds) {
return false;
}
return Object.values(guilds).some((guildValue) => {
const channels = getRecord(getRecord(guildValue)?.channels);
if (!channels) {
return false;
}
return Object.values(channels).some((channel) => hasLegacyAllowAlias(getRecord(channel) ?? {}));
});
}
const THREAD_BINDING_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
@@ -465,86 +85,6 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
},
];
const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
{
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} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTelegramStreamingKeys(value),
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyTelegramStreamingKeys),
},
{
path: ["channels", "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} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacySlackStreamingKeys(value),
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackStreamingKeys),
},
];
const CHANNEL_ENABLED_ALIAS_RULES: LegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
'channels.slack.channels.<id>.allow is legacy; use channels.slack.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacySlackChannelAllowAlias(value),
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.channels.<id>.allow is legacy; use channels.slack.accounts.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackChannelAllowAlias),
},
{
path: ["channels", "googlechat"],
message:
'channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyGoogleChatGroupAllowAlias(value),
},
{
path: ["channels", "googlechat", "accounts"],
message:
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyGoogleChatGroupAllowAlias),
},
{
path: ["channels", "discord"],
message:
'channels.discord.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyDiscordGuildChannelAllowAlias(value),
},
{
path: ["channels", "discord", "accounts"],
message:
'channels.discord.accounts.<id>.guilds.<id>.channels.<id>.allow is legacy; use channels.discord.accounts.<id>.guilds.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordGuildChannelAllowAlias),
},
];
const GOOGLECHAT_STREAMMODE_RULES: LegacyConfigRule[] = [
{
path: ["channels", "googlechat"],
message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.",
match: (value) => hasLegacyGoogleChatStreamMode(value),
},
{
path: ["channels", "googlechat", "accounts"],
message:
"channels.googlechat.accounts.<id>.streamMode is legacy and no longer used; it is removed on load.",
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyGoogleChatStreamMode),
},
];
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "thread-bindings.ttlHours->idleHours",
@@ -599,227 +139,4 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
raw.channels = channels;
},
}),
defineLegacyConfigMigration({
id: "channels.streaming-keys->channels.streaming",
describe: "Normalize legacy streaming keys to channels.<provider>.streaming (Telegram/Slack)",
legacyRules: CHANNEL_STREAMING_RULES,
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
if (!channels) {
return;
}
const migrateProviderEntry = (params: {
provider: "telegram" | "discord" | "slack";
entry: Record<string, unknown>;
pathPrefix: string;
}) => {
if (params.provider === "telegram") {
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveTelegramPreviewStreamMode,
});
return;
}
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveSlackStreamingMode,
resolveNativeTransport: resolveSlackNativeStreaming,
});
};
const migrateProvider = (provider: "telegram" | "slack") => {
const providerEntry = getRecord(channels[provider]);
if (!providerEntry) {
return;
}
migrateProviderEntry({
provider,
entry: providerEntry,
pathPrefix: `channels.${provider}`,
});
const accounts = getRecord(providerEntry.accounts);
if (!accounts) {
return;
}
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = getRecord(accountValue);
if (!account) {
continue;
}
migrateProviderEntry({
provider,
entry: account,
pathPrefix: `channels.${provider}.accounts.${accountId}`,
});
}
};
migrateProvider("telegram");
migrateProvider("slack");
},
}),
defineLegacyConfigMigration({
id: "channels.allow->channels.enabled",
describe:
"Normalize legacy nested channel allow toggles to enabled (Slack/Google Chat/Discord)",
legacyRules: CHANNEL_ENABLED_ALIAS_RULES,
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
if (!channels) {
return;
}
const migrateSlackEntry = (entry: Record<string, unknown>, pathPrefix: string) => {
const channelEntries = getRecord(entry.channels);
if (!channelEntries) {
return;
}
for (const [channelId, channelRaw] of Object.entries(channelEntries)) {
const channel = getRecord(channelRaw);
if (!channel) {
continue;
}
migrateAllowAliasForPath({
entry: channel,
pathPrefix: `${pathPrefix}.channels.${channelId}`,
changes,
});
channelEntries[channelId] = channel;
}
entry.channels = channelEntries;
};
const migrateGoogleChatEntry = (entry: Record<string, unknown>, pathPrefix: string) => {
const groups = getRecord(entry.groups);
if (!groups) {
return;
}
for (const [groupId, groupRaw] of Object.entries(groups)) {
const group = getRecord(groupRaw);
if (!group) {
continue;
}
migrateAllowAliasForPath({
entry: group,
pathPrefix: `${pathPrefix}.groups.${groupId}`,
changes,
});
groups[groupId] = group;
}
entry.groups = groups;
};
const migrateDiscordEntry = (entry: Record<string, unknown>, pathPrefix: string) => {
const guilds = getRecord(entry.guilds);
if (!guilds) {
return;
}
for (const [guildId, guildRaw] of Object.entries(guilds)) {
const guild = getRecord(guildRaw);
if (!guild) {
continue;
}
const channelEntries = getRecord(guild.channels);
if (!channelEntries) {
guilds[guildId] = guild;
continue;
}
for (const [channelId, channelRaw] of Object.entries(channelEntries)) {
const channel = getRecord(channelRaw);
if (!channel) {
continue;
}
migrateAllowAliasForPath({
entry: channel,
pathPrefix: `${pathPrefix}.guilds.${guildId}.channels.${channelId}`,
changes,
});
channelEntries[channelId] = channel;
}
guild.channels = channelEntries;
guilds[guildId] = guild;
}
entry.guilds = guilds;
};
const migrateProviderAccounts = (
provider: "slack" | "googlechat" | "discord",
migrateEntry: (entry: Record<string, unknown>, pathPrefix: string) => void,
) => {
const providerEntry = getRecord(channels[provider]);
if (!providerEntry) {
return;
}
migrateEntry(providerEntry, `channels.${provider}`);
const accounts = getRecord(providerEntry.accounts);
if (!accounts) {
channels[provider] = providerEntry;
return;
}
for (const [accountId, accountRaw] of Object.entries(accounts)) {
const account = getRecord(accountRaw);
if (!account) {
continue;
}
migrateEntry(account, `channels.${provider}.accounts.${accountId}`);
accounts[accountId] = account;
}
providerEntry.accounts = accounts;
channels[provider] = providerEntry;
};
migrateProviderAccounts("slack", migrateSlackEntry);
migrateProviderAccounts("googlechat", migrateGoogleChatEntry);
migrateProviderAccounts("discord", migrateDiscordEntry);
raw.channels = channels;
},
}),
defineLegacyConfigMigration({
id: "channels.googlechat.streamMode->remove",
describe: "Remove legacy Google Chat streamMode keys that are no longer used",
legacyRules: GOOGLECHAT_STREAMMODE_RULES,
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
if (!channels) {
return;
}
const migrateEntry = (entry: Record<string, unknown>, pathPrefix: string) => {
if (entry.streamMode === undefined) {
return;
}
delete entry.streamMode;
changes.push(`Removed ${pathPrefix}.streamMode (legacy key no longer used).`);
};
const googlechat = getRecord(channels.googlechat);
if (!googlechat) {
return;
}
migrateEntry(googlechat, "channels.googlechat");
const accounts = getRecord(googlechat.accounts);
if (accounts) {
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = getRecord(accountValue);
if (!account) {
continue;
}
migrateEntry(account, `channels.googlechat.accounts.${accountId}`);
accounts[accountId] = account;
}
googlechat.accounts = accounts;
}
channels.googlechat = googlechat;
raw.channels = channels;
},
}),
];