diff --git a/CHANGELOG.md b/CHANGELOG.md index e288dfaeac3..d23efb41678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Responses: strip orphaned reasoning blocks before outbound Responses API calls so compacted or restored histories no longer fail on standalone reasoning items. (#55787) Thanks @suboss87. - Cron/CLI: parse PowerShell-style `--tools` allow-lists the same way as comma-separated input, so `cron add` and `cron edit` no longer persist `exec read write` as one combined tool entry on Windows. (#68858) Thanks @chen-zhang-cs-code. - Browser/user-profile: let existing-session `profile="user"` tool calls auto-route to a connected browser node or use explicit `target="node"`, while still honoring explicit `target="host"` pinning. (#48677) +- Discord/slash commands: tolerate partial Discord channel metadata in slash-command and model-picker flows so partial channel objects no longer crash when channel names, topics, or thread parent metadata are unavailable. (#68953) Thanks @dutifulbob. ## 2026.4.19-beta.2 diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index cfbbc648ddb..73860e69d21 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -39,6 +39,7 @@ import { resolveDiscordOwnerAccess, resolveGroupDmAllow, } from "./allow-list.js"; +import { resolveDiscordChannelInfoSafe } from "./channel-access.js"; import { formatDiscordUserTag } from "./format.js"; export const AGENT_BUTTON_KEY = "agent"; @@ -182,22 +183,20 @@ export function resolveDiscordChannelContext( interaction: AgentComponentInteraction, ): DiscordChannelContext { const channel = interaction.channel; - const channelName = channel && "name" in channel ? (channel.name as string) : undefined; + const channelInfo = resolveDiscordChannelInfoSafe(channel); + const channelName = channelInfo.name; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelType = channel && "type" in channel ? (channel.type as number) : undefined; + const channelType = channelInfo.type; const isThread = isThreadChannelType(channelType); let parentId: string | undefined; let parentName: string | undefined; let parentSlug = ""; - if (isThread && channel && "parentId" in channel) { - parentId = (channel.parentId as string) ?? undefined; - if ("parent" in channel) { - const parent = (channel as { parent?: { name?: string } }).parent; - if (parent?.name) { - parentName = parent.name; - parentSlug = normalizeDiscordSlug(parentName); - } + if (isThread) { + parentId = channelInfo.parentId; + parentName = channelInfo.parentName; + if (parentName) { + parentSlug = normalizeDiscordSlug(parentName); } } diff --git a/extensions/discord/src/monitor/channel-access.ts b/extensions/discord/src/monitor/channel-access.ts new file mode 100644 index 00000000000..4ac98a715ec --- /dev/null +++ b/extensions/discord/src/monitor/channel-access.ts @@ -0,0 +1,55 @@ +function readDiscordChannelPropertySafe(channel: unknown, key: string): unknown { + if (!channel || typeof channel !== "object" || !(key in channel)) { + return undefined; + } + try { + return (channel as Record)[key]; + } catch { + return undefined; + } +} + +function resolveDiscordChannelStringPropertySafe( + channel: unknown, + key: string, +): string | undefined { + const value = readDiscordChannelPropertySafe(channel, key); + return typeof value === "string" ? value : undefined; +} + +function resolveDiscordChannelNumberPropertySafe( + channel: unknown, + key: string, +): number | undefined { + const value = readDiscordChannelPropertySafe(channel, key); + return typeof value === "number" ? value : undefined; +} + +export type DiscordChannelInfoSafe = { + name?: string; + topic?: string; + type?: number; + parentId?: string; + ownerId?: string; + parentName?: string; +}; + +export function resolveDiscordChannelNameSafe(channel: unknown): string | undefined { + return resolveDiscordChannelStringPropertySafe(channel, "name"); +} + +export function resolveDiscordChannelTopicSafe(channel: unknown): string | undefined { + return resolveDiscordChannelStringPropertySafe(channel, "topic"); +} + +export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelInfoSafe { + const parent = readDiscordChannelPropertySafe(channel, "parent"); + return { + name: resolveDiscordChannelNameSafe(channel), + topic: resolveDiscordChannelTopicSafe(channel), + type: resolveDiscordChannelNumberPropertySafe(channel, "type"), + parentId: resolveDiscordChannelStringPropertySafe(channel, "parentId"), + ownerId: resolveDiscordChannelStringPropertySafe(channel, "ownerId"), + parentName: resolveDiscordChannelNameSafe(parent), + }; +} diff --git a/extensions/discord/src/monitor/inbound-job.ts b/extensions/discord/src/monitor/inbound-job.ts index 087ca41b6cb..177b2bdd99d 100644 --- a/extensions/discord/src/monitor/inbound-job.ts +++ b/extensions/discord/src/monitor/inbound-job.ts @@ -1,3 +1,4 @@ +import { resolveDiscordChannelNameSafe } from "./channel-access.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js"; type DiscordInboundJobRuntimeField = @@ -108,7 +109,7 @@ function normalizeDiscordThreadChannel( parent: threadChannel.parent ? { id: threadChannel.parent.id, - name: threadChannel.parent.name, + name: resolveDiscordChannelNameSafe(threadChannel.parent), } : undefined, ownerId: threadChannel.ownerId, diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 8a873d5485d..f6688d9c764 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -32,6 +32,7 @@ import { resolveDiscordGuildEntry, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; +import { resolveDiscordChannelInfoSafe } from "./channel-access.js"; import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; import { setPresence } from "./presence-cache.js"; @@ -445,9 +446,10 @@ async function handleDiscordReactionEvent( if (!channel) { return; } - const channelName = "name" in channel ? (channel.name ?? undefined) : undefined; + const channelInfo = resolveDiscordChannelInfoSafe(channel); + const channelName = channelInfo.name; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelType = "type" in channel ? channel.type : undefined; + const channelType = channelInfo.type; const isDirectMessage = channelType === ChannelType.DM; const isGroupDm = channelType === ChannelType.GroupDM; const isThreadChannel = diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 8431d1ecac1..e3ab1e38255 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -32,6 +32,7 @@ import { resolveDiscordShouldRequireMention, resolveGroupDmAllow, } from "./allow-list.js"; +import { resolveDiscordChannelNameSafe } from "./channel-access.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; import { @@ -575,9 +576,7 @@ export async function preflightDiscordMessage( // Resolve thread parent early for binding inheritance const channelName = channelInfo?.name ?? - ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel - ? message.channel.name - : undefined); + (isGuildMessage || isGroupDm ? resolveDiscordChannelNameSafe(message.channel) : undefined); const { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } = await loadDiscordThreadingRuntime(); const earlyThreadChannel = resolveDiscordThreadChannel({ diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts index f49e4f12064..2abfdda30a9 100644 --- a/extensions/discord/src/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -10,6 +10,7 @@ import { normalizeOptionalString, normalizeOptionalStringifiedId, } from "openclaw/plugin-sdk/text-runtime"; +import { resolveDiscordChannelInfoSafe } from "./channel-access.js"; import { mergeAbortSignals } from "./timeouts.js"; const DISCORD_CDN_HOSTNAMES = [ @@ -158,16 +159,13 @@ export async function resolveDiscordChannelInfo( }); return null; } - const name = "name" in channel ? (channel.name ?? undefined) : undefined; - const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; - const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; - const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; + const channelInfo = resolveDiscordChannelInfoSafe(channel); const payload: DiscordChannelInfo = { - type: channel.type, - name, - topic, - parentId, - ownerId, + type: (channelInfo.type as ChannelType | undefined) ?? channel.type, + name: channelInfo.name, + topic: channelInfo.topic, + parentId: channelInfo.parentId, + ownerId: channelInfo.ownerId, }; DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value: payload, diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 0b5ae24af53..edabe1cbd7f 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -34,6 +34,7 @@ import { normalizeOptionalString, withTimeout, } from "openclaw/plugin-sdk/text-runtime"; +import { resolveDiscordChannelNameSafe } from "./channel-access.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; import { readDiscordModelPickerRecentModels, @@ -254,7 +255,7 @@ async function resolveDiscordModelPickerRouteState(params: { client: interaction.client, threadChannel: { id: rawChannelId, - name: "name" in channel ? channel.name : undefined, + name: resolveDiscordChannelNameSafe(channel), parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined, parent: undefined, }, diff --git a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts index afc50773f7f..99d342a0d84 100644 --- a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts +++ b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts @@ -83,11 +83,13 @@ async function runGuildSlashCommand(params?: { userId?: string; mutateConfig?: (cfg: OpenClawConfig) => void; runtimeDiscordConfig?: DiscordAccountConfig; + mutateInteraction?: (interaction: MockCommandInteraction) => void; }) { const cfg = createConfig(); params?.mutateConfig?.(cfg); const command = createCommand(cfg, params?.runtimeDiscordConfig); const interaction = createInteraction({ userId: params?.userId }); + params?.mutateInteraction?.(interaction); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); @@ -152,6 +154,44 @@ describe("Discord native slash commands with commands.allowFrom", () => { expectNotUnauthorizedReply(interaction); }); + it("tolerates partial guild channels whose name getter throws", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateInteraction: (currentInteraction) => { + Object.defineProperty(currentInteraction.channel, "name", { + configurable: true, + enumerable: true, + get() { + throw new Error( + "Cannot access rawData on partial Channel. Use fetch() to populate data.", + ); + }, + }); + }, + }); + expect(interaction.defer).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectNotUnauthorizedReply(interaction); + }); + + it("tolerates partial guild channels whose topic getter throws", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateInteraction: (currentInteraction) => { + Object.defineProperty(currentInteraction.channel, "topic", { + configurable: true, + enumerable: true, + get() { + throw new Error( + "Cannot access rawData on partial Channel. Use fetch() to populate data.", + ); + }, + }); + }, + }); + expect(interaction.defer).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectNotUnauthorizedReply(interaction); + }); + it("authorizes guild slash commands from an allowlisted channel when commands.allowFrom is not configured", async () => { const { dispatchSpy, interaction } = await runGuildSlashCommand({ mutateConfig: (cfg) => { diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index aeeb60553e9..eb7e43ca8d6 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import * as globalsModule from "openclaw/plugin-sdk/runtime-env"; import * as commandTextModule from "openclaw/plugin-sdk/text-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveDiscordChannelContext } from "./agent-components-helpers.js"; import * as modelPickerPreferencesModule from "./model-picker-preferences.js"; import * as modelPickerModule from "./model-picker.js"; import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js"; @@ -104,6 +105,20 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc }; } +function makePartialChannelThrow( + target: T, + key: keyof T & string, + message = "Cannot access rawData on partial Channel. Use fetch() to populate data.", +) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + throw new Error(message); + }, + }); +} + function createDefaultModelPickerData(): ModelsProviderData { return createModelsProviderData({ openai: ["gpt-4.1", "gpt-4o"], @@ -332,6 +347,90 @@ describe("Discord model picker interactions", () => { }); }); + it("applies the selected model even when component channel.name throws on a partial channel", async () => { + const context = createModelPickerContext(); + const pickerData = createDefaultModelPickerData(); + const modelCommand = createModelCommandDefinition(); + + vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); + mockModelCommandPipeline(modelCommand); + + const dispatchSpy = createDispatchSpy(); + const submitInteraction = createInteraction({ userId: "owner" }); + makePartialChannelThrow(submitInteraction.channel, "name"); + + const button = createModelPickerFallbackButton(context, dispatchSpy); + await button.run( + submitInteraction as unknown as PickerButtonInteraction, + createModelsViewSubmitData(), + ); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectDispatchedModelSelection({ + dispatchSpy, + model: "openai/gpt-4o", + }); + }); + + it("applies the selected model even when component thread parent.name throws on a partial channel", async () => { + const context = createModelPickerContext(); + const pickerData = createDefaultModelPickerData(); + const modelCommand = createModelCommandDefinition(); + + vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); + mockModelCommandPipeline(modelCommand); + + const dispatchSpy = createDispatchSpy(); + const submitInteraction = createInteraction({ userId: "owner" }); + submitInteraction.guild = { id: "guild-1" }; + const threadChannel = { + type: ChannelType.PublicThread, + id: "thread-1", + parentId: "parent-1", + parent: { id: "parent-1", name: "parent-name" }, + } as { + type: ChannelType; + id: string; + parentId: string; + parent?: { id?: string; name?: string }; + }; + submitInteraction.channel = threadChannel as MockInteraction["channel"]; + makePartialChannelThrow(threadChannel.parent as { id?: string; name?: string }, "name"); + + const button = createModelPickerFallbackButton(context, dispatchSpy); + await button.run( + submitInteraction as unknown as PickerButtonInteraction, + createModelsViewSubmitData(), + ); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectDispatchedModelSelection({ + dispatchSpy, + model: "openai/gpt-4o", + }); + }); + + it("ignores category parent metadata for non-thread component channels", () => { + const interaction = createInteraction({ userId: "owner" }); + interaction.guild = { id: "guild-1" }; + interaction.channel = { + type: ChannelType.GuildText, + id: "channel-1", + name: "general", + parentId: "category-1", + parent: { id: "category-1", name: "category-name" }, + } as MockInteraction["channel"] & { parent?: { id?: string; name?: string } }; + + const channelCtx = resolveDiscordChannelContext( + interaction as unknown as Parameters[0], + ); + + expect(channelCtx.isThread).toBe(false); + expect(channelCtx.parentId).toBeUndefined(); + expect(channelCtx.parentName).toBeUndefined(); + expect(channelCtx.parentSlug).toBe(""); + }); + it("shows timeout status and skips recents write when apply is still processing", async () => { const context = createModelPickerContext(); const pickerData = createDefaultModelPickerData(); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index c9bc0c19ade..d7711fdf000 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -66,6 +66,7 @@ import { resolveDiscordOwnerAccess, resolveGroupDmAllow, } from "./allow-list.js"; +import { resolveDiscordChannelNameSafe, resolveDiscordChannelTopicSafe } from "./channel-access.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; @@ -412,7 +413,7 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: { channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread; - const channelName = channel && "name" in channel ? (channel.name as string) : undefined; + const channelName = resolveDiscordChannelNameSafe(channel); const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; const rawChannelId = channel?.id ?? ""; const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) @@ -801,7 +802,7 @@ async function dispatchDiscordCommandInteraction(params: { channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread; - const channelName = channel && "name" in channel ? (channel.name as string) : undefined; + const channelName = resolveDiscordChannelNameSafe(channel); const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; const rawChannelId = channel?.id ?? ""; const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) @@ -1186,7 +1187,7 @@ async function dispatchDiscordCommandInteraction(params: { memberRoleIds, guildId: interaction.guild?.id, guildName: interaction.guild?.name, - channelTopic: channel && "topic" in channel ? (channel.topic ?? undefined) : undefined, + channelTopic: resolveDiscordChannelTopicSafe(channel), channelConfig, guildInfo, allowNameMatching, diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index d436b37b6ca..b57fabc07a1 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -14,6 +14,7 @@ import { truncateUtf16Safe, } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; +import { resolveDiscordChannelNameSafe } from "./channel-access.js"; import type { DiscordMessageEvent } from "./listeners.js"; import { resolveDiscordChannelInfo, @@ -207,7 +208,7 @@ export async function resolveDiscordThreadParentInfo(params: { if (!parentId) { return {}; } - let parentName = threadChannel.parent?.name; + let parentName = resolveDiscordChannelNameSafe(threadChannel.parent); const parentInfo = await resolveDiscordChannelInfo(client, parentId); parentName = parentName ?? parentInfo?.name; const parentType = parentInfo?.type;