fix(discord): read channel.parentId through safe accessor on partial thread channels

The Carbon `GuildThreadChannel.parentId` getter throws "Cannot access rawData on partial Channel" whenever Discord delivers a partial thread (for example when an interaction channel is unhydrated). The existing `"parentId" in channel` guard did not help because the `in` operator returns true for prototype getters without invoking them, so the read still crashed `/new` and similar slash commands, guild reactions, and the native model picker when invoked from inside a thread.

Expose a `resolveDiscordChannelParentIdSafe` helper alongside the other channel accessors and use it everywhere we currently read `channel.parentId` from the inbound Discord channel. When the getter throws, the helper returns `undefined`, and the downstream code already falls back to re-fetching the thread id via `resolveDiscordChannelInfo`, keeping authorization/config lookups on the same inputs as before.

Add a regression test that installs a throwing `parentId` getter on a partial guild thread channel and asserts the slash-command path still defers and dispatches instead of surfacing an unauthorized reply.

Fixes #69861
This commit is contained in:
Neerav Makwana
2026-04-21 21:04:56 -04:00
committed by Peter Steinberger
parent b0734664f8
commit dbf8fd0db7
6 changed files with 48 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted `> Reasoning:` text, preventing `/reasoning on` from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.
- Discord: read `channel.parentId` through a safe accessor in the slash-command, reaction, and model-picker paths so partial `GuildThreadChannel` prototype getters no longer throw `Cannot access rawData on partial Channel` when commands like `/new` run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.
- Browser/Chrome MCP: reset cached existing-session control sessions when a `navigate_page` call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.
- Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.
- OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)

View File

@@ -42,6 +42,10 @@ export function resolveDiscordChannelTopicSafe(channel: unknown): string | undef
return resolveDiscordChannelStringPropertySafe(channel, "topic");
}
export function resolveDiscordChannelParentIdSafe(channel: unknown): string | undefined {
return resolveDiscordChannelStringPropertySafe(channel, "parentId");
}
export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelInfoSafe {
const parent = readDiscordChannelPropertySafe(channel, "parent");
return {

View File

@@ -32,7 +32,10 @@ import {
resolveDiscordGuildEntry,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
import {
resolveDiscordChannelInfoSafe,
resolveDiscordChannelParentIdSafe,
} from "./channel-access.js";
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { setPresence } from "./presence-cache.js";
@@ -487,7 +490,7 @@ async function handleDiscordReactionEvent(
return;
}
}
let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
let parentId = resolveDiscordChannelParentIdSafe(channel);
let parentName: string | undefined;
let parentSlug = "";
let reactionBase: { baseText: string; contextKey: string } | null = null;

View File

@@ -34,7 +34,10 @@ import {
normalizeOptionalString,
withTimeout,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import {
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentIdSafe,
} from "./channel-access.js";
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import {
@@ -258,7 +261,7 @@ async function resolveDiscordModelPickerRouteState(params: {
threadChannel: {
id: rawChannelId,
name: resolveDiscordChannelNameSafe(channel),
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parentId: resolveDiscordChannelParentIdSafe(channel),
parent: undefined,
},
channelInfo,

View File

@@ -192,6 +192,29 @@ describe("Discord native slash commands with commands.allowFrom", () => {
expectNotUnauthorizedReply(interaction);
});
it("tolerates partial guild thread channels whose parentId getter throws", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
mutateInteraction: (currentInteraction) => {
currentInteraction.channel = {
type: ChannelType.PublicThread,
id: currentInteraction.channel.id,
} as MockCommandInteraction["channel"];
Object.defineProperty(currentInteraction.channel, "parentId", {
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) => {

View File

@@ -65,7 +65,11 @@ import {
resolveDiscordOwnerAccess,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelNameSafe, resolveDiscordChannelTopicSafe } from "./channel-access.js";
import {
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentIdSafe,
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";
@@ -466,7 +470,7 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: {
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parentId: resolveDiscordChannelParentIdSafe(channel),
parent: undefined,
},
channelInfo,
@@ -859,7 +863,7 @@ async function dispatchDiscordCommandInteraction(params: {
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parentId: resolveDiscordChannelParentIdSafe(channel),
parent: undefined,
},
channelInfo,
@@ -1072,7 +1076,9 @@ async function dispatchDiscordCommandInteraction(params: {
interaction.channel?.type === ChannelType.AnnouncementThread;
const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined;
const threadParentId =
!isDirectMessage && isThreadChannel ? (interaction.channel.parentId ?? undefined) : undefined;
!isDirectMessage && isThreadChannel
? resolveDiscordChannelParentIdSafe(interaction.channel)
: undefined;
const { effectiveRoute } = await getNativeRouteState();
const pluginReply = await executePluginCommandImpl({
command: pluginMatch.command,