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

@@ -123,6 +123,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @bmendonca3. - Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @bmendonca3.
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9. - Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode. - Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
- Discord/acp inline actions: prefer autocomplete for `/acp` action inline values and ignore bound-thread bot system messages to prevent ACP loops. (#33136) Thanks @thewilloftheshadow.
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun. - Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai. - Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3. - Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.

View File

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

View File

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

View File

@@ -83,6 +83,187 @@ describe("preflightDiscordMessage", () => {
transcribeFirstAudioMock.mockReset(); 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 () => { it("bypasses mention gating in bound threads for allowed bot senders", async () => {
const threadBinding = createThreadBinding(); const threadBinding = createThreadBinding();
const threadId = "thread-bot-focus"; const threadId = "thread-bot-focus";

View File

@@ -66,6 +66,23 @@ export type {
DiscordMessagePreflightParams, DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js"; } 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: { export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean; shouldRequireMention: boolean;
isBoundThreadSession: boolean; isBoundThreadSession: boolean;
@@ -324,6 +341,17 @@ export async function preflightDiscordMessage(
agentId: boundAgentId ?? route.agentId, agentId: boundAgentId ?? route.agentId,
} }
: route; : 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 mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
const explicitlyMentioned = Boolean( const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId), botId && message.mentionedUsers?.some((user: User) => user.id === botId),
@@ -492,7 +520,6 @@ export async function preflightDiscordMessage(
channelConfig, channelConfig,
guildInfo, guildInfo,
}); });
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
const shouldRequireMention = resolvePreflightMentionRequirement({ const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig, shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession, 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 resolvedChoices = resolveCommandArgChoices({ command, arg, cfg });
const shouldAutocomplete = const shouldAutocomplete =
resolvedChoices.length > 0 && arg.preferAutocomplete === true ||
(typeof arg.choices === "function" || resolvedChoices.length > 25); (resolvedChoices.length > 0 &&
(typeof arg.choices === "function" || resolvedChoices.length > 25));
const autocomplete = shouldAutocomplete const autocomplete = shouldAutocomplete
? async (interaction: AutocompleteInteraction) => { ? async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused(); const focused = interaction.options.getFocused();