fix(doctor): restore group config drift migrations (#77465)

This commit is contained in:
scoootscooob
2026-05-04 12:00:05 -07:00
committed by GitHub
parent de4903ec7a
commit ee314e4236
5 changed files with 331 additions and 6 deletions

View File

@@ -182,7 +182,56 @@ describe("legacy migrate audio transcription", () => {
});
describe("legacy migrate mention routing", () => {
it("does not rewrite removed routing.groupChat.requireMention migrations", () => {
it("moves legacy routing group chat settings into current channel and message config", () => {
const res = migrateLegacyConfigForTest({
routing: {
allowFrom: ["+15550001111"],
groupChat: {
requireMention: false,
historyLimit: 12,
mentionPatterns: ["@openclaw"],
},
},
channels: {
whatsapp: {},
telegram: {
groups: {
"*": { requireMention: true },
},
},
imessage: {},
},
});
const migratedConfig = res.config as Record<string, unknown> | null;
expect(migratedConfig?.routing).toBeUndefined();
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]);
expect(res.config?.channels?.whatsapp?.groups).toEqual({
"*": { requireMention: false },
});
expect(res.config?.channels?.telegram?.groups).toEqual({
"*": { requireMention: true },
});
expect(res.config?.channels?.imessage?.groups).toEqual({
"*": { requireMention: false },
});
expect(res.config?.messages?.groupChat).toEqual({
historyLimit: 12,
mentionPatterns: ["@openclaw"],
});
expect(res.changes).toEqual(
expect.arrayContaining([
"Moved routing.allowFrom → channels.whatsapp.allowFrom.",
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
'Removed routing.groupChat.requireMention (channels.telegram.groups."*" already set).',
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
"Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.",
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
]),
);
});
it("removes legacy routing requireMention when no compatible channel exists", () => {
const res = migrateLegacyConfigForTest({
routing: {
groupChat: {
@@ -191,11 +240,14 @@ describe("legacy migrate mention routing", () => {
},
});
expect(res.changes).toEqual([]);
expect(res.config).toBeNull();
const migratedConfig = res.config as Record<string, unknown> | null;
expect(migratedConfig?.routing).toBeUndefined();
expect(res.changes).toEqual([
"Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).",
]);
});
it("does not rewrite removed channels.telegram.requireMention migrations", () => {
it("moves channels.telegram.requireMention into the wildcard group default", () => {
const res = migrateLegacyConfigForTest({
channels: {
telegram: {
@@ -204,8 +256,14 @@ describe("legacy migrate mention routing", () => {
},
});
expect(res.changes).toEqual([]);
expect(res.config).toBeNull();
expect(res.config?.channels?.telegram).toEqual({
groups: {
"*": { requireMention: false },
},
});
expect(res.changes).toContain(
'Moved channels.telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
});
});

View File

@@ -2,6 +2,41 @@ import { describe, expect, it } from "vitest";
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
describe("legacy config migrate validation", () => {
it("returns valid migrated config for legacy group chat routing drift", () => {
const res = migrateLegacyConfig({
routing: {
allowFrom: ["+15550001111"],
groupChat: {
requireMention: false,
historyLimit: 8,
mentionPatterns: ["@openclaw"],
},
},
channels: {
whatsapp: {},
telegram: {},
},
});
expect(res.partiallyValid).toBeUndefined();
const migratedConfig = res.config as Record<string, unknown> | null;
expect(migratedConfig?.routing).toBeUndefined();
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]);
expect(res.config?.channels?.whatsapp?.groups).toEqual({
"*": { requireMention: false },
});
expect(res.config?.channels?.telegram?.groups).toEqual({
"*": { requireMention: false },
});
expect(res.config?.messages?.groupChat).toEqual({
historyLimit: 8,
mentionPatterns: ["@openclaw"],
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
});
it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => {
const res = migrateLegacyConfig({
agents: {

View File

@@ -1,5 +1,6 @@
import {
defineLegacyConfigMigration,
ensureRecord,
getRecord,
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
@@ -9,6 +10,196 @@ function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(target, key);
}
function cleanupEmptyRecord(parent: Record<string, unknown>, key: string): void {
const value = getRecord(parent[key]);
if (value && Object.keys(value).length === 0) {
delete parent[key];
}
}
function resolveCompatibleDefaultGroupEntry(section: Record<string, unknown>): {
groups: Record<string, unknown>;
entry: Record<string, unknown>;
} | null {
const existingGroups = section.groups;
if (existingGroups !== undefined && !getRecord(existingGroups)) {
return null;
}
const groups = getRecord(existingGroups) ?? {};
const defaultKey = "*";
const existingEntry = groups[defaultKey];
if (existingEntry !== undefined && !getRecord(existingEntry)) {
return null;
}
const entry = getRecord(existingEntry) ?? {};
return { groups, entry };
}
function migrateChannelDefaultRequireMention(params: {
section: Record<string, unknown>;
channelId: string;
legacyPath: string;
requireMention: unknown;
changes: string[];
}): boolean {
const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(params.section);
if (!defaultGroupEntry) {
params.changes.push(
`Removed ${params.legacyPath} (channels.${params.channelId}.groups has an incompatible shape; fix remaining issues manually).`,
);
return false;
}
const { groups, entry } = defaultGroupEntry;
if (entry.requireMention === undefined) {
entry.requireMention = params.requireMention;
groups["*"] = entry;
params.section.groups = groups;
params.changes.push(
`Moved ${params.legacyPath} → channels.${params.channelId}.groups."*".requireMention.`,
);
return true;
}
params.changes.push(
`Removed ${params.legacyPath} (channels.${params.channelId}.groups."*" already set).`,
);
return false;
}
function migrateRoutingAllowFrom(raw: Record<string, unknown>, changes: string[]): void {
const routing = getRecord(raw.routing);
if (!routing || routing.allowFrom === undefined) {
return;
}
const channels = getRecord(raw.channels);
const whatsapp = getRecord(channels?.whatsapp);
if (!channels || !whatsapp) {
delete routing.allowFrom;
cleanupEmptyRecord(raw, "routing");
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = routing.allowFrom;
changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
} else {
changes.push("Removed routing.allowFrom (channels.whatsapp.allowFrom already set).");
}
delete routing.allowFrom;
channels.whatsapp = whatsapp;
raw.channels = channels;
cleanupEmptyRecord(raw, "routing");
}
function migrateRoutingGroupChatMessages(params: {
raw: Record<string, unknown>;
routing: Record<string, unknown>;
groupChat: Record<string, unknown>;
changes: string[];
}): void {
const migrateMessageGroupField = (field: "historyLimit" | "mentionPatterns") => {
const value = params.groupChat[field];
if (value === undefined) {
return;
}
const messages = ensureRecord(params.raw, "messages");
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup[field] === undefined) {
messagesGroup[field] = value;
params.changes.push(`Moved routing.groupChat.${field} → messages.groupChat.${field}.`);
} else {
params.changes.push(
`Removed routing.groupChat.${field} (messages.groupChat.${field} already set).`,
);
}
delete params.groupChat[field];
};
migrateMessageGroupField("historyLimit");
migrateMessageGroupField("mentionPatterns");
if (Object.keys(params.groupChat).length === 0) {
delete params.routing.groupChat;
} else {
params.routing.groupChat = params.groupChat;
}
}
function migrateRoutingGroupChatRequireMention(params: {
raw: Record<string, unknown>;
groupChat: Record<string, unknown>;
changes: string[];
}): void {
const requireMention = params.groupChat.requireMention;
if (requireMention === undefined) {
return;
}
const channels = getRecord(params.raw.channels);
let matchedChannel = false;
if (channels) {
for (const channelId of ["whatsapp", "telegram", "imessage"]) {
const section = getRecord(channels[channelId]);
if (!section) {
continue;
}
matchedChannel = true;
migrateChannelDefaultRequireMention({
section,
channelId,
legacyPath: "routing.groupChat.requireMention",
requireMention,
changes: params.changes,
});
channels[channelId] = section;
}
params.raw.channels = channels;
}
if (!matchedChannel) {
params.changes.push(
"Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).",
);
}
delete params.groupChat.requireMention;
}
function migrateRoutingGroupChat(raw: Record<string, unknown>, changes: string[]): void {
const routing = getRecord(raw.routing);
const groupChat = getRecord(routing?.groupChat);
if (!routing || !groupChat) {
return;
}
migrateRoutingGroupChatRequireMention({ raw, groupChat, changes });
migrateRoutingGroupChatMessages({ raw, routing, groupChat, changes });
cleanupEmptyRecord(raw, "routing");
}
function migrateTelegramRequireMention(raw: Record<string, unknown>, changes: string[]): void {
const channels = getRecord(raw.channels);
const telegram = getRecord(channels?.telegram);
if (!channels || !telegram || telegram.requireMention === undefined) {
return;
}
migrateChannelDefaultRequireMention({
section: telegram,
channelId: "telegram",
legacyPath: "channels.telegram.requireMention",
requireMention: telegram.requireMention,
changes,
});
delete telegram.requireMention;
channels.telegram = telegram;
raw.channels = channels;
}
function hasLegacyThreadBindingTtl(value: unknown): boolean {
const threadBindings = getRecord(value);
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
@@ -190,7 +381,46 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
},
];
const GROUP_ROUTING_RULES: LegacyConfigRule[] = [
{
path: ["routing", "allowFrom"],
message:
'routing.allowFrom was removed; use channels.whatsapp.allowFrom instead. Run "openclaw doctor --fix".',
},
{
path: ["routing", "groupChat", "requireMention"],
message:
'routing.groupChat.requireMention was removed; use channels.<channel>.groups."*".requireMention instead. Run "openclaw doctor --fix".',
},
{
path: ["routing", "groupChat", "historyLimit"],
message:
'routing.groupChat.historyLimit was moved; use messages.groupChat.historyLimit instead. Run "openclaw doctor --fix".',
},
{
path: ["routing", "groupChat", "mentionPatterns"],
message:
'routing.groupChat.mentionPatterns was moved; use messages.groupChat.mentionPatterns instead. Run "openclaw doctor --fix".',
},
{
path: ["channels", "telegram", "requireMention"],
message:
'channels.telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".',
},
];
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "legacy-group-routing->channel-groups",
describe:
"Move legacy routing group chat settings to current channel group and messages config",
legacyRules: GROUP_ROUTING_RULES,
apply: (raw, changes) => {
migrateRoutingAllowFrom(raw, changes);
migrateRoutingGroupChat(raw, changes);
migrateTelegramRequireMention(raw, changes);
},
}),
defineLegacyConfigMigration({
id: "thread-bindings.ttlHours->idleHours",
describe: