mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor: move channel doctor migrations to plugins
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -2,3 +2,4 @@ export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
|
||||
@@ -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>({
|
||||
|
||||
75
extensions/googlechat/src/doctor-contract.test.ts
Normal file
75
extensions/googlechat/src/doctor-contract.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
182
extensions/googlechat/src/doctor-contract.ts
Normal file
182
extensions/googlechat/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user