diff --git a/CHANGELOG.md b/CHANGELOG.md index caf869d7a80..834adc17273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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/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. - 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. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index bdefb3ba16c..19c1a7d3746 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -322,6 +322,7 @@ function buildChatCommands(): ChatCommandDefinition[] { name: "action", description: "Action to run", type: "string", + preferAutocomplete: true, choices: [ "spawn", "cancel", diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index a14c7105074..a479f3414c6 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -31,6 +31,7 @@ export type CommandArgDefinition = { type: CommandArgType; required?: boolean; choices?: CommandArgChoice[] | CommandArgChoicesProvider; + preferAutocomplete?: boolean; captureRemaining?: boolean; }; diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 197b9509692..ec67c208a7e 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -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["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["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"; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index a7d8fde623f..e7415e72f71 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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, diff --git a/src/discord/monitor/native-command.options.test.ts b/src/discord/monitor/native-command.options.test.ts new file mode 100644 index 00000000000..808f9cf001b --- /dev/null +++ b/src/discord/monitor/native-command.options.test.ts @@ -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 { + const command = listNativeCommandSpecs({ provider: "discord" }).find( + (entry) => entry.name === name, + ); + if (!command) { + throw new Error(`missing native command: ${name}`); + } + const cfg = {} as ReturnType; + const discordConfig = {} as NonNullable["discord"]; + return createDiscordNativeCommand({ + command, + cfg, + discordConfig, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +type CommandOption = NonNullable["options"]>[number]; + +function findOption( + command: ReturnType, + 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); + }); +}); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index d9f319ff2be..8960f616453 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -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();