diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a350b81dc1..913198b9d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. +- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. ## 2026.2.9 diff --git a/src/slack/monitor/commands.ts b/src/slack/monitor/commands.ts index f26be177d1d..a50b75704eb 100644 --- a/src/slack/monitor/commands.ts +++ b/src/slack/monitor/commands.ts @@ -1,5 +1,16 @@ import type { SlackSlashCommandConfig } from "../../config/config.js"; +/** + * Strip Slack mentions (<@U123>, <@U123|name>) so command detection works on + * normalized text. Use in both prepare and debounce gate for consistency. + */ +export function stripSlackMentionsForCommandDetection(text: string): string { + return (text ?? "") + .replace(/<@[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + export function normalizeSlackSlashCommandName(raw: string) { return raw.replace(/^\/+/, ""); } diff --git a/src/slack/monitor/message-handler.ts b/src/slack/monitor/message-handler.ts index f87c14ccc86..e974dbeebe3 100644 --- a/src/slack/monitor/message-handler.ts +++ b/src/slack/monitor/message-handler.ts @@ -6,6 +6,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; +import { stripSlackMentionsForCommandDetection } from "./commands.js"; import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js"; import { prepareSlackMessage } from "./message-handler/prepare.js"; import { createSlackThreadTsResolver } from "./thread-resolution.js"; @@ -50,7 +51,8 @@ export function createSlackMessageHandler(params: { if (entry.message.files && entry.message.files.length > 0) { return false; } - return !hasControlCommand(text, ctx.cfg); + const textForCommandDetection = stripSlackMentionsForCommandDetection(text); + return !hasControlCommand(textForCommandDetection, ctx.cfg); }, onFlush: async (entries) => { const last = entries.at(-1); diff --git a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts index 79983a7c81d..8f8c7a3386b 100644 --- a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts +++ b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts @@ -76,4 +76,79 @@ describe("prepareSlackMessage sender prefix", () => { const body = result?.ctxPayload.Body ?? ""; expect(body).toContain("Alice (U1): <@BOT> hello"); }); + + it("detects /new as control command when prefixed with Slack mention", async () => { + const ctx = { + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } } }, + }, + accountId: "default", + botToken: "xoxb", + app: { client: {} }, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "BOT", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + channelHistories: new Map(), + sessionScope: "per-sender", + mainKey: "agent:main:main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: ["U1"], + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: true, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "channel", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 2000, + ackReactionScope: "off", + mediaMaxBytes: 1000, + removeAckAfterReply: false, + logger: { info: vi.fn() }, + markMessageSeen: () => false, + shouldDropMismatchedSlackEvent: () => false, + resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", + isChannelAllowed: () => true, + resolveChannelName: async () => ({ name: "general", type: "channel" }), + resolveUserName: async () => ({ name: "Alice" }), + setSlackThreadStatus: async () => undefined, + } satisfies SlackMonitorContext; + + const result = await prepareSlackMessage({ + ctx, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> /new", + user: "U1", + ts: "1700000000.0002", + event_ts: "1700000000.0002", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + expect(result?.ctxPayload.CommandAuthorized).toBe(true); + }); }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 07584062a6f..900a0484c9b 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -42,6 +42,7 @@ import { resolveSlackThreadContext } from "../../threading.js"; import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js"; import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; +import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; @@ -249,7 +250,9 @@ export async function prepareSlackMessage(params: { cfg, surface: "slack", }); - const hasControlCommandInMessage = hasControlCommand(message.text ?? "", cfg); + // Strip Slack mentions (<@U123>) before command detection so "@Labrador /new" is recognized + const textForCommandDetection = stripSlackMentionsForCommandDetection(message.text ?? ""); + const hasControlCommandInMessage = hasControlCommand(textForCommandDetection, cfg); const ownerAuthorized = resolveSlackAllowListMatch({ allowList: allowFromLower,