fix: Discord acp inline actions + bound-thread filter (#33136) (thanks @thewilloftheshadow) (#33136)

This commit is contained in:
Shadow
2026-03-03 09:30:21 -06:00
committed by GitHub
parent 8e2e4b2ed5
commit 4abf398a17
7 changed files with 284 additions and 3 deletions

View File

@@ -322,6 +322,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
name: "action",
description: "Action to run",
type: "string",
preferAutocomplete: true,
choices: [
"spawn",
"cancel",

View File

@@ -31,6 +31,7 @@ export type CommandArgDefinition = {
type: CommandArgType;
required?: boolean;
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
preferAutocomplete?: boolean;
captureRemaining?: boolean;
};

View File

@@ -83,6 +83,187 @@ describe("preflightDiscordMessage", () => {
transcribeFirstAudioMock.mockReset();
});
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
const threadBinding = createThreadBinding({
targetKind: "acp",
targetSessionKey: "agent:main:acp:discord-thread-1",
});
const threadId = "thread-system-1";
const parentId = "channel-parent-1";
const client = {
fetchChannel: async (channelId: string) => {
if (channelId === threadId) {
return {
id: threadId,
type: ChannelType.PublicThread,
name: "focus",
parentId,
ownerId: "owner-1",
};
}
if (channelId === parentId) {
return {
id: parentId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-system-1",
content:
"⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
timestamp: new Date().toISOString(),
channelId: threadId,
attachments: [],
mentionedUsers: [],
mentionedRoles: [],
mentionedEveryone: false,
author: {
id: "relay-bot-1",
bot: true,
username: "OpenClaw",
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: {
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
} as import("./thread-bindings.js").ThreadBindingManager,
data: {
channel_id: threadId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).toBeNull();
});
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
const threadBinding = createThreadBinding({
targetKind: "acp",
targetSessionKey: "agent:main:acp:discord-thread-1",
});
const threadId = "thread-bot-regular-1";
const parentId = "channel-parent-regular-1";
const client = {
fetchChannel: async (channelId: string) => {
if (channelId === threadId) {
return {
id: threadId,
type: ChannelType.PublicThread,
name: "focus",
parentId,
ownerId: "owner-1",
};
}
if (channelId === parentId) {
return {
id: parentId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-bot-regular-1",
content: "here is tool output chunk",
timestamp: new Date().toISOString(),
channelId: threadId,
attachments: [],
mentionedUsers: [],
mentionedRoles: [],
mentionedEveryone: false,
author: {
id: "relay-bot-1",
bot: true,
username: "Relay",
},
} as unknown as import("@buape/carbon").Message;
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
listBySession: () => [],
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
});
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: {
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
} as import("./thread-bindings.js").ThreadBindingManager,
data: {
channel_id: threadId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).not.toBeNull();
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
});
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
const threadBinding = createThreadBinding();
const threadId = "thread-bot-focus";

View File

@@ -66,6 +66,23 @@ export type {
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
function isBoundThreadBotSystemMessage(params: {
isBoundThreadSession: boolean;
isBotAuthor: boolean;
text?: string;
}): boolean {
if (!params.isBoundThreadSession || !params.isBotAuthor) {
return false;
}
const text = params.text?.trim();
if (!text) {
return false;
}
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
}
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
isBoundThreadSession: boolean;
@@ -324,6 +341,17 @@ export async function preflightDiscordMessage(
agentId: boundAgentId ?? route.agentId,
}
: route;
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
if (
isBoundThreadBotSystemMessage({
isBoundThreadSession,
isBotAuthor: Boolean(author.bot),
text: messageText,
})
) {
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
return null;
}
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
@@ -492,7 +520,6 @@ export async function preflightDiscordMessage(
channelConfig,
guildInfo,
});
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession,

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { listNativeCommandSpecs } from "../../auto-reply/commands-registry.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
function createNativeCommand(name: string): ReturnType<typeof createDiscordNativeCommand> {
const command = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === name,
);
if (!command) {
throw new Error(`missing native command: ${name}`);
}
const cfg = {} as ReturnType<typeof loadConfig>;
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
return createDiscordNativeCommand({
command,
cfg,
discordConfig,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
}
type CommandOption = NonNullable<ReturnType<typeof createDiscordNativeCommand>["options"]>[number];
function findOption(
command: ReturnType<typeof createDiscordNativeCommand>,
name: string,
): CommandOption | undefined {
return command.options?.find((entry) => entry.name === name);
}
function readAutocomplete(option: CommandOption | undefined): unknown {
if (!option || typeof option !== "object") {
return undefined;
}
return (option as { autocomplete?: unknown }).autocomplete;
}
function readChoices(option: CommandOption | undefined): unknown[] | undefined {
if (!option || typeof option !== "object") {
return undefined;
}
const value = (option as { choices?: unknown }).choices;
return Array.isArray(value) ? value : undefined;
}
describe("createDiscordNativeCommand option wiring", () => {
it("uses autocomplete for /acp action so inline action values are accepted", () => {
const command = createNativeCommand("acp");
const action = findOption(command, "action");
expect(action).toBeDefined();
expect(typeof readAutocomplete(action)).toBe("function");
expect(readChoices(action)).toBeUndefined();
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("voice");
const action = findOption(command, "action");
expect(action).toBeDefined();
expect(readAutocomplete(action)).toBeUndefined();
expect(readChoices(action)?.length).toBeGreaterThan(0);
});
});

View File

@@ -116,8 +116,9 @@ function buildDiscordCommandOptions(params: {
}
const resolvedChoices = resolveCommandArgChoices({ command, arg, cfg });
const shouldAutocomplete =
resolvedChoices.length > 0 &&
(typeof arg.choices === "function" || resolvedChoices.length > 25);
arg.preferAutocomplete === true ||
(resolvedChoices.length > 0 &&
(typeof arg.choices === "function" || resolvedChoices.length > 25));
const autocomplete = shouldAutocomplete
? async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();