diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8d1a2e76f..63ba6b8cb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 3f4549c2c59..aaf68a22176 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -189,6 +189,7 @@ That stages grounded durable candidates into the short-term dreaming store while - `routing.groupChat.requireMention` → `channels.whatsapp/telegram/imessage.groups."*".requireMention` - `routing.groupChat.historyLimit` → `messages.groupChat.historyLimit` - `routing.groupChat.mentionPatterns` → `messages.groupChat.mentionPatterns` + - `channels.telegram.requireMention` → `channels.telegram.groups."*".requireMention` - configured-channel configs missing visible reply policy → `messages.groupChat.visibleReplies: "message_tool"` - `routing.queue` → `messages.queue` - `routing.bindings` → top-level `bindings` diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 8e422ce2647..2497f26a455 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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 | 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 | 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.', + ); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts index fb470a1e94e..ae53796654f 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts @@ -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 | 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: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index 65b0c8826a6..2eee9cd6e71 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -1,5 +1,6 @@ import { defineLegacyConfigMigration, + ensureRecord, getRecord, type LegacyConfigMigrationSpec, type LegacyConfigRule, @@ -9,6 +10,196 @@ function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } +function cleanupEmptyRecord(parent: Record, key: string): void { + const value = getRecord(parent[key]); + if (value && Object.keys(value).length === 0) { + delete parent[key]; + } +} + +function resolveCompatibleDefaultGroupEntry(section: Record): { + groups: Record; + entry: Record; +} | 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; + 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, 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; + routing: Record; + groupChat: Record; + 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; + groupChat: Record; + 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, 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, 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..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: