mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(discord): migrate channel agent route config
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<OpenClawConfig["bindings"]>[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<string, unknown>, source: Record<string, unknown>) {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
pathPrefix: string;
|
||||
accountId?: string;
|
||||
changes: string[];
|
||||
bindingsToAdd: AgentBindingConfig[];
|
||||
}): { entry: Record<string, unknown>; 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.<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,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
'channels.discord.guilds.<id>.channels.<id>.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.<id>.guilds.<id>.channels.<id>.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,
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user