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 63b8850eca6..c0a2969f0f6 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -39,6 +39,7 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { function createInteraction(params?: { channelType?: ChannelType; channelId?: string; + threadParentId?: string | null; guildId?: string; guildName?: string; }): MockCommandInteraction { @@ -48,6 +49,7 @@ function createInteraction(params?: { globalName: "Tester", channelType: params?.channelType ?? ChannelType.DM, channelId: params?.channelId ?? "dm-1", + threadParentId: params?.threadParentId, guildId: params?.guildId ?? null, guildName: params?.guildName, interactionId: "interaction-1", @@ -501,6 +503,73 @@ describe("Discord native plugin command dispatch", () => { ); }); + it("forwards Discord thread metadata into direct plugin command execution", async () => { + const cfg = { + commands: { + useAccessGroups: false, + }, + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "thread-123": { + allow: true, + requireMention: false, + }, + "parent-456": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const commandSpec: NativeCommandSpec = { + name: "cron_jobs", + description: "List cron jobs", + acceptsArgs: false, + }; + const interaction = createInteraction({ + channelType: ChannelType.PublicThread, + channelId: "thread-123", + threadParentId: "parent-456", + guildId: "345678901234567890", + guildName: "Test Guild", + }); + 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:thread-123", + to: "slash:owner", + messageThreadId: "thread-123", + threadParentId: "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.test-helpers.ts b/extensions/discord/src/monitor/native-command.test-helpers.ts index fe6ab6e1252..64140a1aaf8 100644 --- a/extensions/discord/src/monitor/native-command.test-helpers.ts +++ b/extensions/discord/src/monitor/native-command.test-helpers.ts @@ -3,7 +3,7 @@ import { vi } from "vitest"; export type MockCommandInteraction = { user: { id: string; username: string; globalName: string }; - channel: { type: ChannelType; id: string }; + channel: { type: ChannelType; id: string; parentId?: string | null }; guild: { id: string; name?: string } | null; rawData: { id: string; member: { roles: string[] } }; options: { @@ -22,6 +22,7 @@ type CreateMockCommandInteractionParams = { globalName?: string; channelType?: ChannelType; channelId?: string; + threadParentId?: string | null; guildId?: string | null; guildName?: string; interactionId?: string; @@ -42,6 +43,7 @@ export function createMockCommandInteraction( channel: { type: params.channelType ?? ChannelType.DM, id: params.channelId ?? "dm-1", + parentId: params.threadParentId, }, guild, rawData: { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index e8affbcd376..2b9e7b50779 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -917,6 +917,13 @@ async function dispatchDiscordCommandInteraction(params: { return; } const channelId = rawChannelId || "unknown"; + const isThreadChannel = + interaction.channel?.type === ChannelType.PublicThread || + interaction.channel?.type === ChannelType.PrivateThread || + interaction.channel?.type === ChannelType.AnnouncementThread; + const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined; + const threadParentId = + !isDirectMessage && isThreadChannel ? (interaction.channel.parentId ?? undefined) : undefined; const pluginReply = await executePluginCommandImpl({ command: pluginMatch.command, args: pluginMatch.args, @@ -933,6 +940,8 @@ async function dispatchDiscordCommandInteraction(params: { : `discord:channel:${channelId}`, to: `slash:${user.id}`, accountId, + messageThreadId, + threadParentId, }); if (!hasRenderableReplyPayload(pluginReply)) { await respond("Done."); diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 56e66e37d2c..da2ca7cd491 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -48,6 +48,7 @@ export const handlePluginCommand: CommandHandler = async ( typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined, + threadParentId: params.ctx.ThreadParentId?.trim() || undefined, }); return { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 7e523e6e9e2..b8064d18e4b 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -243,6 +243,24 @@ describe("registerPluginCommand", () => { }); }); + it("resolves Discord thread command bindings with parent channel context intact", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "discord", + from: "discord:channel:1480554272859881494", + accountId: "default", + messageThreadId: "thread-42", + threadParentId: "channel-parent-7", + }), + ).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "channel:1480554272859881494", + parentConversationId: "channel-parent-7", + threadId: "thread-42", + }); + }); + it("resolves Telegram topic command bindings without a Telegram registry entry", () => { expect( __testing.resolveBindingConversationFromCommand({ diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index abd18a181bd..4c25afccd44 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -148,6 +148,7 @@ function resolveBindingConversationFromCommand(params: { to?: string; accountId?: string; messageThreadId?: string | number; + threadParentId?: string; }): { channel: string; accountId: string; @@ -199,6 +200,8 @@ function resolveBindingConversationFromCommand(params: { "conversationId" in target ? target.conversationId : `${target.chatType === "direct" ? "user" : "channel"}:${target.to}`, + parentConversationId: params.threadParentId?.trim() || undefined, + threadId: params.messageThreadId, }; } return null; @@ -224,6 +227,7 @@ export async function executePluginCommand(params: { to?: PluginCommandContext["to"]; accountId?: PluginCommandContext["accountId"]; messageThreadId?: PluginCommandContext["messageThreadId"]; + threadParentId?: PluginCommandContext["threadParentId"]; }): Promise { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; @@ -244,6 +248,7 @@ export async function executePluginCommand(params: { to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId, + threadParentId: params.threadParentId, }); const ctx: PluginCommandContext = { @@ -259,6 +264,7 @@ export async function executePluginCommand(params: { to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId, + threadParentId: params.threadParentId, requestConversationBinding: async (bindingParams) => { if (!command.pluginRoot || !bindingConversation) { return { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 638fb564336..f130b07eba9 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1178,6 +1178,8 @@ export type PluginCommandContext = { accountId?: string; /** Thread/topic id if available */ messageThreadId?: string | number; + /** Parent conversation id for thread-capable channels */ + threadParentId?: string; requestConversationBinding: ( params?: PluginConversationBindingRequestParams, ) => Promise;