mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 09:00:21 +00:00
fix: Discord acp inline actions + bound-thread filter (#33136) (thanks @thewilloftheshadow) (#33136)
This commit is contained in:
@@ -322,6 +322,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
name: "action",
|
||||
description: "Action to run",
|
||||
type: "string",
|
||||
preferAutocomplete: true,
|
||||
choices: [
|
||||
"spawn",
|
||||
"cancel",
|
||||
|
||||
@@ -31,6 +31,7 @@ export type CommandArgDefinition = {
|
||||
type: CommandArgType;
|
||||
required?: boolean;
|
||||
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
|
||||
preferAutocomplete?: boolean;
|
||||
captureRemaining?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
src/discord/monitor/native-command.options.test.ts
Normal file
69
src/discord/monitor/native-command.options.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user