From a8a023779d0ef027ea73763f8b5119861c5989fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 03:58:00 +0100 Subject: [PATCH] fix(discord): preserve fetched thread parent for plugin commands (#69908) (thanks @neeravmakwana) --- .../native-command.plugin-dispatch.test.ts | 91 +++++++++++++++++++ .../discord/src/monitor/native-command.ts | 7 +- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index c8077c5c98a..be45d3c65e1 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -542,10 +542,12 @@ describe("Discord native plugin command dispatch", () => { "thread-123": { enabled: true, requireMention: false, + users: ["owner"], }, "parent-456": { enabled: true, requireMention: false, + users: ["owner"], }, }, }, @@ -596,6 +598,95 @@ describe("Discord native plugin command dispatch", () => { ); }); + it("preserves fetched thread parent metadata when interaction parentId getter throws", async () => { + const cfg = { + commands: { + useAccessGroups: false, + }, + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "partial-thread-123": { + enabled: true, + requireMention: false, + users: ["owner"], + }, + "partial-parent-456": { + enabled: true, + requireMention: false, + users: ["owner"], + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const commandSpec: NativeCommandSpec = { + name: "cron_jobs", + description: "List cron jobs", + acceptsArgs: false, + }; + const interaction = createInteraction({ + channelType: ChannelType.PublicThread, + channelId: "partial-thread-123", + guildId: "345678901234567890", + guildName: "Test Guild", + }); + Object.defineProperty(interaction.channel, "parentId", { + configurable: true, + enumerable: true, + get() { + throw new Error("Cannot access rawData on partial Channel. Use fetch() to populate data."); + }, + }); + (interaction.client as { fetchChannel: ReturnType }).fetchChannel = vi.fn( + async (channelId: string) => { + if (channelId === "partial-thread-123") { + return { + id: "partial-thread-123", + type: ChannelType.PublicThread, + parentId: "partial-parent-456", + }; + } + if (channelId === "partial-parent-456") { + return { id: "partial-parent-456", type: ChannelType.GuildText, name: "Parent" }; + } + return null; + }, + ); + const pluginMatch = { + command: { + name: "cron_jobs", + description: "List cron jobs", + pluginId: "cron-jobs", + acceptsArgs: false, + handler: vi.fn().mockResolvedValue({ text: "jobs" }), + }, + args: undefined, + }; + + runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never); + const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({ + text: "direct plugin output", + }); + const command = await createNativeCommand(cfg, commandSpec); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + from: "discord:channel:partial-thread-123", + messageThreadId: "partial-thread-123", + threadParentId: "partial-parent-456", + }), + ); + }); + it("routes native slash commands through configured ACP Discord channel bindings", async () => { const { cfg, interaction } = createConfiguredAcpCase({ channelType: ChannelType.GuildText, diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 297b20a65f2..e8968b1bbc8 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -1075,10 +1075,7 @@ async function dispatchDiscordCommandInteraction(params: { interaction.channel?.type === ChannelType.PrivateThread || interaction.channel?.type === ChannelType.AnnouncementThread; const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined; - const threadParentId = - !isDirectMessage && isThreadChannel - ? resolveDiscordChannelParentIdSafe(interaction.channel) - : undefined; + const pluginThreadParentId = !isDirectMessage && isThreadChannel ? threadParentId : undefined; const { effectiveRoute } = await getNativeRouteState(); const pluginReply = await executePluginCommandImpl({ command: pluginMatch.command, @@ -1098,7 +1095,7 @@ async function dispatchDiscordCommandInteraction(params: { to: `slash:${user.id}`, accountId, messageThreadId, - threadParentId, + threadParentId: pluginThreadParentId, }); if (!hasRenderableReplyPayload(pluginReply)) { await respond("Done.");