From 33b043b92074da8150d535f3fd237bda9ad5f142 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 22:43:07 +0100 Subject: [PATCH] fix(discord): migrate channel agent route config --- CHANGELOG.md | 1 + extensions/discord/src/doctor-contract.ts | 168 +++++++++++++++++++++- extensions/discord/src/doctor.test.ts | 135 +++++++++++++++++ 3 files changed, 302 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d707101d22b..0ed468bd1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24. - Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc. - Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx. +- Discord/doctor: migrate unsupported per-channel `agentId` entries under guild channel config into top-level `bindings[]` routes, so `openclaw doctor --fix` preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit. ## 2026.4.30 diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index a29604f42c2..d15a746d3c1 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -7,6 +7,7 @@ import { asObjectRecord, normalizeLegacyChannelAliases } from "openclaw/plugin-s import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const; +type AgentBindingConfig = NonNullable[number]; function hasLegacyTtsProviderKeys(value: unknown): boolean { const tts = asObjectRecord(value); @@ -44,6 +45,22 @@ function hasLegacyDiscordGuildChannelAllowAlias(value: unknown): boolean { }); } +function hasLegacyDiscordGuildChannelAgentId(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) ?? {}, "agentId"), + ); + }); +} + function hasLegacyDiscordAccountGuildChannelAllowAlias(value: unknown): boolean { const accounts = asObjectRecord(value); if (!accounts) { @@ -52,6 +69,14 @@ function hasLegacyDiscordAccountGuildChannelAllowAlias(value: unknown): boolean return Object.values(accounts).some((account) => hasLegacyDiscordGuildChannelAllowAlias(account)); } +function hasLegacyDiscordAccountGuildChannelAgentId(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyDiscordGuildChannelAgentId(account)); +} + function mergeMissing(target: Record, source: Record) { for (const [key, value] of Object.entries(source)) { if (value === undefined) { @@ -179,6 +204,108 @@ function normalizeDiscordGuildChannelAllowAliases(params: { : { entry: params.entry, changed: false }; } +function isDiscordChannelAgentBinding( + value: unknown, + match: { accountId?: string; guildId: string; channelId: string }, +): value is Record { + const binding = asObjectRecord(value); + const bindingMatch = asObjectRecord(binding?.match); + const peer = asObjectRecord(bindingMatch?.peer); + if (!binding || !bindingMatch || !peer) { + return false; + } + return ( + bindingMatch.channel === "discord" && + bindingMatch.guildId === match.guildId && + (match.accountId === undefined || bindingMatch.accountId === match.accountId) && + peer.kind === "channel" && + peer.id === match.channelId + ); +} + +function normalizeDiscordGuildChannelAgentIds(params: { + cfg: OpenClawConfig; + entry: Record; + pathPrefix: string; + accountId?: string; + changes: string[]; + bindingsToAdd: AgentBindingConfig[]; +}): { entry: Record; changed: boolean } { + const guilds = asObjectRecord(params.entry.guilds); + if (!guilds) { + return { entry: params.entry, changed: false }; + } + + const existingBindings = Array.isArray(params.cfg.bindings) ? params.cfg.bindings : []; + 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, "agentId")) { + continue; + } + const nextChannel = { ...channel }; + const rawAgentId = nextChannel.agentId; + delete nextChannel.agentId; + nextChannels[channelId] = nextChannel; + channelsChanged = true; + + const path = `${params.pathPrefix}.guilds.${guildId}.channels.${channelId}.agentId`; + const agentId = typeof rawAgentId === "string" ? rawAgentId.trim() : ""; + if (!agentId) { + params.changes.push( + `Removed ${path}; configure top-level bindings[] for per-channel Discord agent routing.`, + ); + continue; + } + + const match = { accountId: params.accountId, guildId, channelId }; + const existingBinding = existingBindings.find((binding) => + isDiscordChannelAgentBinding(binding, match), + ); + if (existingBinding) { + params.changes.push( + `Removed ${path}; a matching top-level bindings[] route already exists for Discord channel ${channelId}.`, + ); + continue; + } + + const bindingMatch: AgentBindingConfig["match"] = { + channel: "discord", + guildId, + peer: { kind: "channel", id: channelId }, + }; + if (params.accountId) { + bindingMatch.accountId = params.accountId; + } + params.bindingsToAdd.push({ + agentId, + match: bindingMatch, + }); + params.changes.push( + `Moved ${path} → top-level bindings[] route for Discord channel ${channelId}.`, + ); + } + 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"], @@ -204,6 +331,18 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ 'channels.discord.accounts..guilds..channels..allow is legacy; use channels.discord.accounts..guilds..channels..enabled instead. Run "openclaw doctor --fix".', match: hasLegacyDiscordAccountGuildChannelAllowAlias, }, + { + path: ["channels", "discord"], + message: + 'channels.discord.guilds..channels..agentId is legacy; use top-level bindings[] for per-channel Discord agent routing. Run "openclaw doctor --fix".', + match: hasLegacyDiscordGuildChannelAgentId, + }, + { + path: ["channels", "discord", "accounts"], + message: + 'channels.discord.accounts..guilds..channels..agentId is legacy; use top-level bindings[] with match.accountId for per-channel Discord agent routing. Run "openclaw doctor --fix".', + match: hasLegacyDiscordAccountGuildChannelAgentId, + }, ]; export function normalizeCompatibilityConfig({ @@ -219,6 +358,7 @@ export function normalizeCompatibilityConfig({ const changes: string[] = []; let updated = rawEntry; let changed = false; + const bindingsToAdd: AgentBindingConfig[] = []; const aliases = normalizeLegacyChannelAliases({ entry: rawEntry, @@ -262,6 +402,16 @@ export function normalizeCompatibilityConfig({ updated = guildAliases.entry; changed = changed || guildAliases.changed; + const channelAgentIds = normalizeDiscordGuildChannelAgentIds({ + cfg, + entry: updated, + pathPrefix: "channels.discord", + changes, + bindingsToAdd, + }); + updated = channelAgentIds.entry; + changed = changed || channelAgentIds.changed; + const accounts = asObjectRecord(updated.accounts); if (accounts) { let accountsChanged = false; @@ -276,10 +426,22 @@ export function normalizeCompatibilityConfig({ pathPrefix: `channels.discord.accounts.${accountId}`, changes, }); - if (!normalized.changed) { + let nextAccount = normalized.entry; + let accountChanged = normalized.changed; + const normalizedAgentIds = normalizeDiscordGuildChannelAgentIds({ + cfg, + entry: nextAccount, + pathPrefix: `channels.discord.accounts.${accountId}`, + accountId, + changes, + bindingsToAdd, + }); + nextAccount = normalizedAgentIds.entry; + accountChanged = accountChanged || normalizedAgentIds.changed; + if (!accountChanged) { continue; } - nextAccounts[accountId] = normalized.entry; + nextAccounts[accountId] = nextAccount; accountsChanged = true; } if (accountsChanged) { @@ -307,6 +469,8 @@ export function normalizeCompatibilityConfig({ ...cfg.channels, discord: updated, } as OpenClawConfig["channels"], + bindings: + bindingsToAdd.length > 0 ? [...(cfg.bindings ?? []), ...bindingsToAdd] : cfg.bindings, }, changes, }; diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts index cef4cc31213..17018f5b699 100644 --- a/extensions/discord/src/doctor.test.ts +++ b/extensions/discord/src/doctor.test.ts @@ -167,6 +167,141 @@ describe("discord doctor", () => { }); }); + it("moves legacy guild channel agentId into a top-level route binding", () => { + const normalize = discordDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + discord: { + guilds: { + "100": { + channels: { + "200": { + requireMention: false, + agentId: "video", + }, + }, + }, + }, + }, + }, + } as never, + }); + + expect(result.changes).toEqual([ + "Moved channels.discord.guilds.100.channels.200.agentId → top-level bindings[] route for Discord channel 200.", + ]); + expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.["200"]).toEqual({ + requireMention: false, + }); + expect(result.config.bindings).toEqual([ + { + agentId: "video", + match: { + channel: "discord", + guildId: "100", + peer: { kind: "channel", id: "200" }, + }, + }, + ]); + }); + + it("moves account-scoped guild channel agentId into an account-scoped route binding", () => { + const normalize = discordDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + discord: { + accounts: { + work: { + guilds: { + "100": { + channels: { + "200": { + agentId: "support", + }, + }, + }, + }, + }, + }, + }, + }, + bindings: [{ agentId: "main", match: { channel: "discord" } }], + } as never, + }); + + expect(result.changes).toEqual([ + "Moved channels.discord.accounts.work.guilds.100.channels.200.agentId → top-level bindings[] route for Discord channel 200.", + ]); + expect( + result.config.channels?.discord?.accounts?.work?.guilds?.["100"]?.channels?.["200"], + ).toEqual({}); + expect(result.config.bindings).toEqual([ + { agentId: "main", match: { channel: "discord" } }, + { + agentId: "support", + match: { + channel: "discord", + accountId: "work", + guildId: "100", + peer: { kind: "channel", id: "200" }, + }, + }, + ]); + }); + + it("removes legacy guild channel agentId when a matching route binding already exists", () => { + const normalize = discordDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const existingBinding = { + agentId: "video", + match: { + channel: "discord", + guildId: "100", + peer: { kind: "channel", id: "200" }, + }, + }; + const result = normalize({ + cfg: { + channels: { + discord: { + guilds: { + "100": { + channels: { + "200": { + agentId: "video", + }, + }, + }, + }, + }, + }, + bindings: [existingBinding], + } as never, + }); + + expect(result.changes).toEqual([ + "Removed channels.discord.guilds.100.channels.200.agentId; a matching top-level bindings[] route already exists for Discord channel 200.", + ]); + expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.["200"]).toEqual({}); + expect(result.config.bindings).toEqual([existingBinding]); + }); + it("finds numeric id entries across discord scopes", () => { const cfg = { channels: {