mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:34:44 +00:00
fix: Discord acp inline actions + bound-thread filter (#33136) (thanks @thewilloftheshadow) (#33136)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user