fix(regression): preserve discord thread bindings for plugin commands

This commit is contained in:
Tak Hoffman
2026-03-27 19:48:18 -05:00
parent b1eeca3b00
commit b598cdf968
7 changed files with 108 additions and 1 deletions

View File

@@ -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<void> }).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,

View File

@@ -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: {

View File

@@ -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.");

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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<PluginCommandResult> {
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 {

View File

@@ -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<PluginConversationBindingRequestResult>;