From 852f947b44e5c51cf7cc4a5979175e4a59ec5979 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 01:31:36 +0100 Subject: [PATCH] fix: unify control command handling --- CHANGELOG.md | 1 + src/auto-reply/command-detection.ts | 26 ++ src/auto-reply/reply.triggers.test.ts | 17 + src/auto-reply/reply.ts | 32 +- src/auto-reply/reply/commands.ts | 43 ++- src/auto-reply/reply/directive-handling.ts | 9 +- src/auto-reply/reply/directives.ts | 15 + src/auto-reply/templating.ts | 2 + src/discord/monitor.ts | 375 ++++----------------- src/imessage/monitor.ts | 28 +- src/signal/monitor.ts | 5 +- src/slack/monitor.ts | 24 +- src/telegram/bot.ts | 32 +- src/web/auto-reply.ts | 39 +-- 14 files changed, 273 insertions(+), 375 deletions(-) create mode 100644 src/auto-reply/command-detection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2eca491..d9a16692b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. +- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. ## 2026.1.5 diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts new file mode 100644 index 000000000..88f5643b6 --- /dev/null +++ b/src/auto-reply/command-detection.ts @@ -0,0 +1,26 @@ +const CONTROL_COMMAND_RE = + /(?:^|\s)\/(?:status|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i; + +const CONTROL_COMMAND_EXACT = new Set([ + "status", + "/status", + "restart", + "/restart", + "activation", + "/activation", + "send", + "/send", + "reset", + "/reset", + "new", + "/new", +]); + +export function hasControlCommand(text?: string): boolean { + if (!text) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + const lowered = trimmed.toLowerCase(); + if (CONTROL_COMMAND_EXACT.has(lowered)) return true; + return CONTROL_COMMAND_RE.test(text); +} diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index dc632df58..fe25af009 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -107,6 +107,23 @@ describe("trigger handling", () => { }); }); + it("reports status when /status appears inline", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "please /status now", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Status"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("allows owner to set send policy", async () => { await withTempHome(async (home) => { const cfg = { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5fb7a318c..78a5070e3 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -25,6 +25,7 @@ import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { applySessionHints } from "./reply/body.js"; import { buildCommandContext, handleCommands } from "./reply/commands.js"; +import { hasControlCommand } from "./command-detection.js"; import { handleDirectiveOnly, isDirectiveOnly, @@ -252,11 +253,22 @@ export async function getReplyFromConfig( triggerBodyNormalized, } = sessionState; - const directives = parseInlineDirectives( - sessionCtx.BodyStripped ?? sessionCtx.Body ?? "", - ); - sessionCtx.Body = directives.cleaned; - sessionCtx.BodyStripped = directives.cleaned; + const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; + const commandAuthorized = ctx.CommandAuthorized ?? true; + const parsedDirectives = parseInlineDirectives(rawBody); + const directives = commandAuthorized + ? parsedDirectives + : { + ...parsedDirectives, + hasThinkDirective: false, + hasVerboseDirective: false, + hasStatusDirective: false, + hasModelDirective: false, + hasQueueDirective: false, + queueReset: false, + }; + sessionCtx.Body = parsedDirectives.cleaned; + sessionCtx.BodyStripped = parsedDirectives.cleaned; const surfaceKey = sessionCtx.Surface?.trim().toLowerCase() ?? @@ -424,6 +436,7 @@ export async function getReplyFromConfig( sessionKey, isGroup, triggerBodyNormalized, + commandAuthorized, }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( @@ -445,6 +458,7 @@ export async function getReplyFromConfig( ctx, cfg, command, + directives, sessionEntry, sessionStore, sessionKey, @@ -484,6 +498,14 @@ export async function getReplyFromConfig( const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); + if ( + !commandAuthorized && + !baseBodyTrimmedRaw && + hasControlCommand(rawBody) + ) { + typing.cleanup(); + return undefined; + } const isBareSessionReset = isNewSession && baseBodyTrimmedRaw.length === 0 && diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index ecfbe32d4..68475ce9a 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -21,12 +21,13 @@ import type { ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import { stripMentions } from "./mentions.js"; +import type { InlineDirectives } from "./directive-handling.js"; export type CommandContext = { surface: string; isWhatsAppSurface: boolean; ownerList: string[]; - isOwnerSender: boolean; + isAuthorizedSender: boolean; senderE164?: string; abortKey?: string; rawBodyNormalized: string; @@ -41,8 +42,16 @@ export function buildCommandContext(params: { sessionKey?: string; isGroup: boolean; triggerBodyNormalized: string; + commandAuthorized: boolean; }): CommandContext { - const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params; + const { + ctx, + cfg, + sessionKey, + isGroup, + triggerBodyNormalized, + commandAuthorized, + } = params; const surface = (ctx.Surface ?? "").trim().toLowerCase(); const isWhatsAppSurface = surface === "whatsapp" || @@ -80,14 +89,13 @@ export function buildCommandContext(params: { const ownerList = ownerCandidates .map((entry) => normalizeE164(entry)) .filter((entry): entry is string => Boolean(entry)); - const isOwnerSender = - Boolean(senderE164) && ownerList.includes(senderE164 ?? ""); + const isAuthorizedSender = commandAuthorized; return { surface, isWhatsAppSurface, ownerList, - isOwnerSender, + isAuthorizedSender, senderE164: senderE164 || undefined, abortKey, rawBodyNormalized, @@ -101,6 +109,7 @@ export async function handleCommands(params: { ctx: MsgContext; cfg: ClawdbotConfig; command: CommandContext; + directives: InlineDirectives; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -122,6 +131,7 @@ export async function handleCommands(params: { const { cfg, command, + directives, sessionEntry, sessionStore, sessionKey, @@ -151,9 +161,9 @@ export async function handleCommands(params: { reply: { text: "⚙️ Group activation only applies to group chats." }, }; } - if (!command.isOwnerSender) { + if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /activation from non-owner in group: ${command.senderE164 || ""}`, + `Ignoring /activation from unauthorized sender in group: ${command.senderE164 || ""}`, ); return { shouldContinue: false }; } @@ -179,9 +189,9 @@ export async function handleCommands(params: { } if (sendPolicyCommand.hasCommand) { - if (!command.isOwnerSender) { + if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /send from non-owner: ${command.senderE164 || ""}`, + `Ignoring /send from unauthorized sender: ${command.senderE164 || ""}`, ); return { shouldContinue: false }; } @@ -220,9 +230,9 @@ export async function handleCommands(params: { command.commandBodyNormalized === "restart" || command.commandBodyNormalized.startsWith("/restart ") ) { - if (isGroup && !command.isOwnerSender) { + if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /restart from non-owner in group: ${command.senderE164 || ""}`, + `Ignoring /restart from unauthorized sender: ${command.senderE164 || ""}`, ); return { shouldContinue: false }; } @@ -235,14 +245,15 @@ export async function handleCommands(params: { }; } - if ( + const statusRequested = + directives.hasStatusDirective || command.commandBodyNormalized === "/status" || command.commandBodyNormalized === "status" || - command.commandBodyNormalized.startsWith("/status ") - ) { - if (isGroup && !command.isOwnerSender) { + command.commandBodyNormalized.startsWith("/status "); + if (statusRequested) { + if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /status from non-owner in group: ${command.senderE164 || ""}`, + `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, ); return { shouldContinue: false }; } diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index a7b6c96bb..aa87de762 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -20,6 +20,7 @@ import type { ReplyPayload } from "../types.js"; import { type ElevatedLevel, extractElevatedDirective, + extractStatusDirective, extractThinkDirective, extractVerboseDirective, type ThinkLevel, @@ -49,6 +50,7 @@ export type InlineDirectives = { hasElevatedDirective: boolean; elevatedLevel?: ElevatedLevel; rawElevatedLevel?: string; + hasStatusDirective: boolean; hasModelDirective: boolean; rawModelDirective?: string; hasQueueDirective: boolean; @@ -83,11 +85,15 @@ export function parseInlineDirectives(body: string): InlineDirectives { rawLevel: rawElevatedLevel, hasDirective: hasElevatedDirective, } = extractElevatedDirective(verboseCleaned); + const { + cleaned: statusCleaned, + hasDirective: hasStatusDirective, + } = extractStatusDirective(elevatedCleaned); const { cleaned: modelCleaned, rawModel, hasDirective: hasModelDirective, - } = extractModelDirective(elevatedCleaned); + } = extractModelDirective(statusCleaned); const { cleaned: queueCleaned, queueMode, @@ -114,6 +120,7 @@ export function parseInlineDirectives(body: string): InlineDirectives { hasElevatedDirective, elevatedLevel, rawElevatedLevel, + hasStatusDirective, hasModelDirective, rawModelDirective: rawModel, hasQueueDirective, diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index f7056343b..bf3fee257 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -74,4 +74,19 @@ export function extractElevatedDirective(body?: string): { }; } +export function extractStatusDirective(body?: string): { + cleaned: string; + hasDirective: boolean; +} { + if (!body) return { cleaned: "", hasDirective: false }; + const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { + cleaned, + hasDirective: !!match, + }; +} + export type { ElevatedLevel, ThinkLevel, VerboseLevel }; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 669b753f8..be5df6196 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -17,11 +17,13 @@ export type MsgContext = { GroupSpace?: string; GroupMembers?: string; SenderName?: string; + SenderId?: string; SenderUsername?: string; SenderTag?: string; SenderE164?: string; Surface?: string; WasMentioned?: boolean; + CommandAuthorized?: boolean; }; export type TemplateContext = MsgContext & { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 95b07049f..dda78e9ea 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,10 +1,7 @@ import { - ApplicationCommandOptionType, type Attachment, ChannelType, - type ChatInputCommandInteraction, Client, - type CommandInteractionOption, Events, GatewayIntentBits, type Guild, @@ -19,22 +16,19 @@ import { type User, } from "discord.js"; +import { hasControlCommand } from "../auto-reply/command-detection.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; -import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { - DiscordSlashCommandConfig, - ReplyToMode, -} from "../config/config.js"; +import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveSessionKey, resolveStorePath, updateLastRoute, } from "../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../globals.js"; +import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; @@ -47,7 +41,6 @@ export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - slashCommand?: DiscordSlashCommandConfig; mediaMaxMb?: number; historyLimit?: number; replyToMode?: ReplyToMode; @@ -140,9 +133,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; const allowFrom = dmConfig?.allowFrom; - const slashCommand = resolveSlashCommandConfig( - opts.slashCommand ?? cfg.discord?.slashCommand, - ); const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); @@ -183,9 +173,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { client.once(Events.ClientReady, () => { runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); - if (slashCommand.enabled) { - void ensureSlashCommand(client, slashCommand, runtime); - } }); client.on(Events.Error, (err) => { @@ -299,8 +286,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const resolvedRequireMention = channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + const hasAnyMention = Boolean( + !isDirectMessage && + (message.mentions?.everyone || + (message.mentions?.users?.size ?? 0) > 0 || + (message.mentions?.roles?.size ?? 0) > 0), + ); + const commandAuthorized = resolveDiscordCommandAuthorized({ + isDirectMessage, + allowFrom, + guildInfo, + author: message.author, + }); + const shouldBypassMention = + isGuildMessage && + resolvedRequireMention && + !wasMentioned && + !hasAnyMention && + commandAuthorized && + hasControlCommand(baseText); if (isGuildMessage && resolvedRequireMention) { - if (botId && !wasMentioned) { + if (botId && !wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, ); @@ -480,11 +486,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { : `channel:${message.channelId}`, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, + SenderId: message.author.id, SenderUsername: message.author.username, SenderTag: message.author.tag, GroupSubject: groupSubject, GroupRoom: groupRoom, - GroupSpace: isGuildMessage ? guildSlug || undefined : undefined, + GroupSpace: isGuildMessage + ? guildInfo?.id ?? guildSlug || undefined + : undefined, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -492,6 +501,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, + CommandAuthorized: commandAuthorized, }; const replyTarget = ctxPayload.To ?? undefined; if (!replyTarget) { @@ -695,179 +705,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { await handleReactionEvent(reaction, user, "removed"); }); - client.on(Events.InteractionCreate, async (interaction) => { - try { - if (!slashCommand.enabled) return; - if (!interaction.isChatInputCommand()) return; - if (interaction.commandName !== slashCommand.name) return; - if (interaction.user?.bot) return; - - const channelType = interaction.channel?.type as ChannelType | undefined; - const isGroupDm = channelType === ChannelType.GroupDM; - const isDirectMessage = - !interaction.inGuild() && channelType === ChannelType.DM; - const isGuildMessage = interaction.inGuild(); - - if (isGroupDm && !groupDmEnabled) { - logVerbose("discord: drop slash (group dms disabled)"); - return; - } - if (isDirectMessage && !dmEnabled) { - logVerbose("discord: drop slash (dms disabled)"); - return; - } - if (shouldLogVerbose()) { - logVerbose( - `discord: slash inbound guild=${interaction.guildId ?? "dm"} channel=${interaction.channelId} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"}`, - ); - } - - if (isGuildMessage) { - const guildInfo = resolveDiscordGuildEntry({ - guild: interaction.guild ?? null, - guildEntries, - }); - if ( - guildEntries && - Object.keys(guildEntries).length > 0 && - !guildInfo - ) { - logVerbose( - `Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`, - ); - return; - } - const channelName = - interaction.channel && - "name" in interaction.channel && - typeof interaction.channel.name === "string" - ? interaction.channel.name - : undefined; - const channelSlug = channelName - ? normalizeDiscordSlug(channelName) - : ""; - const channelConfig = resolveDiscordChannelConfig({ - guildInfo, - channelId: interaction.channelId, - channelName, - channelSlug, - }); - if (channelConfig?.allowed === false) { - logVerbose( - `Blocked discord channel ${interaction.channelId} not in guild channel allowlist`, - ); - return; - } - const userAllow = guildInfo?.users; - if (Array.isArray(userAllow) && userAllow.length > 0) { - const users = normalizeDiscordAllowList(userAllow, [ - "discord:", - "user:", - ]); - const userOk = - !users || - allowListMatches(users, { - id: interaction.user.id, - name: interaction.user.username, - tag: interaction.user.tag, - }); - if (!userOk) { - logVerbose( - `Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`, - ); - return; - } - } - } else if (isGroupDm) { - const channelName = - interaction.channel && - "name" in interaction.channel && - typeof interaction.channel.name === "string" - ? interaction.channel.name - : undefined; - const channelSlug = channelName - ? normalizeDiscordSlug(channelName) - : ""; - const groupDmAllowed = resolveGroupDmAllow({ - channels: groupDmChannels, - channelId: interaction.channelId, - channelName, - channelSlug, - }); - if (!groupDmAllowed) return; - } else if (isDirectMessage) { - if (Array.isArray(allowFrom) && allowFrom.length > 0) { - const allowList = normalizeDiscordAllowList(allowFrom, [ - "discord:", - "user:", - ]); - const permitted = - allowList && - allowListMatches(allowList, { - id: interaction.user.id, - name: interaction.user.username, - tag: interaction.user.tag, - }); - if (!permitted) { - logVerbose( - `Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`, - ); - return; - } - } - } - - const prompt = resolveSlashPrompt(interaction.options.data); - if (!prompt) { - await interaction.reply({ - content: "Message required.", - ephemeral: true, - }); - return; - } - - await interaction.deferReply({ ephemeral: slashCommand.ephemeral }); - - const userId = interaction.user.id; - const ctxPayload = { - Body: prompt, - From: `discord:${userId}`, - To: `slash:${userId}`, - ChatType: "direct", - SenderName: interaction.user.username, - Surface: "discord" as const, - WasMentioned: true, - MessageSid: interaction.id, - Timestamp: interaction.createdTimestamp, - SessionKey: `${slashCommand.sessionPrefix}:${userId}`, - }; - - const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - - await deliverSlashReplies({ - replies, - interaction, - ephemeral: slashCommand.ephemeral, - textLimit, - }); - } catch (err) { - runtime.error?.(danger(`slash handler failed: ${String(err)}`)); - if (interaction.isRepliable()) { - const content = "Sorry, something went wrong handling that command."; - if (interaction.deferred || interaction.replied) { - await interaction.followUp({ content, ephemeral: true }); - } else { - await interaction.reply({ content, ephemeral: true }); - } - } - } - }); - await client.login(token); await new Promise((resolve, reject) => { @@ -1164,6 +1001,37 @@ export function allowListMatches( return false; } +function resolveDiscordCommandAuthorized(params: { + isDirectMessage: boolean; + allowFrom?: Array; + guildInfo?: DiscordGuildEntryResolved | null; + author: User; +}): boolean { + const { isDirectMessage, allowFrom, guildInfo, author } = params; + if (isDirectMessage) { + if (!Array.isArray(allowFrom) || allowFrom.length === 0) return true; + const allowList = normalizeDiscordAllowList(allowFrom, [ + "discord:", + "user:", + ]); + if (!allowList) return true; + return allowListMatches(allowList, { + id: author.id, + name: author.username, + tag: author.tag, + }); + } + const users = guildInfo?.users; + if (!Array.isArray(users) || users.length === 0) return true; + const allowList = normalizeDiscordAllowList(users, ["discord:", "user:"]); + if (!allowList) return true; + return allowListMatches(allowList, { + id: author.id, + name: author.username, + tag: author.tag, + }); +} + export function shouldEmitDiscordReactionNotification(params: { mode: "off" | "own" | "all" | "allowlist" | undefined; botId?: string | null; @@ -1297,86 +1165,6 @@ export function resolveGroupDmAllow(params: { }); } -async function ensureSlashCommand( - client: Client, - slashCommand: Required, - runtime: RuntimeEnv, -) { - try { - const appCommands = client.application?.commands; - if (!appCommands) { - runtime.error?.(danger("discord slash commands unavailable")); - return; - } - const existing = await appCommands.fetch(); - const hasCommand = Array.from(existing.values()).some( - (entry) => entry.name === slashCommand.name, - ); - if (hasCommand) return; - await appCommands.create({ - name: slashCommand.name, - description: "Ask Clawdbot a question", - options: [ - { - name: "prompt", - description: "What should Clawdbot help with?", - type: ApplicationCommandOptionType.String, - required: true, - }, - ], - }); - runtime.log?.(`registered discord slash command /${slashCommand.name}`); - } catch (err) { - const status = (err as { status?: number | string })?.status; - const code = (err as { code?: number | string })?.code; - const message = String(err); - const isRateLimit = - status === 429 || code === 429 || /rate ?limit/i.test(message); - const text = `discord slash command setup failed: ${message}`; - if (isRateLimit) { - logVerbose(text); - runtime.error?.(warn(text)); - } else { - runtime.error?.(danger(text)); - } - } -} - -function resolveSlashCommandConfig( - raw: DiscordSlashCommandConfig | undefined, -): Required { - return { - enabled: raw ? raw.enabled !== false : false, - name: raw?.name?.trim() || "clawd", - sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash", - ephemeral: raw?.ephemeral !== false, - }; -} - -function resolveSlashPrompt( - options: readonly CommandInteractionOption[], -): string | undefined { - const direct = findFirstStringOption(options); - if (direct) return direct; - return undefined; -} - -function findFirstStringOption( - options: readonly CommandInteractionOption[], -): string | undefined { - for (const option of options) { - if (typeof option.value === "string") { - const trimmed = option.value.trim(); - if (trimmed) return trimmed; - } - if (option.options && option.options.length > 0) { - const nested = findFirstStringOption(option.options); - if (nested) return nested; - } - } - return undefined; -} - async function sendTyping(message: Message) { try { const channel = message.channel; @@ -1449,48 +1237,3 @@ async function deliverReplies({ runtime.log?.(`delivered reply to ${target}`); } } - -async function deliverSlashReplies({ - replies, - interaction, - ephemeral, - textLimit, -}: { - replies: ReplyPayload[]; - interaction: ChatInputCommandInteraction; - ephemeral: boolean; - textLimit: number; -}) { - const messages: string[] = []; - const chunkLimit = Math.min(textLimit, 2000); - for (const payload of replies) { - const textRaw = payload.text?.trim() ?? ""; - const text = - textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined; - const mediaList = - payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const combined = [ - text ?? "", - ...mediaList.map((url) => url.trim()).filter(Boolean), - ] - .filter(Boolean) - .join("\n"); - if (!combined) continue; - for (const chunk of chunkText(combined, chunkLimit)) { - messages.push(chunk); - } - } - - if (messages.length === 0) { - await interaction.editReply({ - content: "No response was generated for that command.", - }); - return; - } - - const [first, ...rest] = messages; - await interaction.editReply({ content: first }); - for (const message of rest) { - await interaction.followUp({ content: message, ephemeral }); - } -} diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 241642663..674ad7f72 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,3 +1,4 @@ +import { hasControlCommand } from "../auto-reply/command-detection.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; @@ -168,15 +169,14 @@ export async function monitorIMessageProvider( const isGroup = Boolean(message.is_group); if (isGroup && !chatId) return; - if ( - !isAllowedIMessageSender({ - allowFrom, - sender, - chatId: chatId ?? undefined, - chatGuid, - chatIdentifier, - }) - ) { + const commandAuthorized = isAllowedIMessageSender({ + allowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }); + if (!commandAuthorized) { logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`); return; } @@ -184,7 +184,13 @@ export async function monitorIMessageProvider( const messageText = (message.text ?? "").trim(); const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true; const requireMention = resolveGroupRequireMention(cfg, opts, chatId); - if (isGroup && requireMention && !mentioned) { + const shouldBypassMention = + isGroup && + requireMention && + !mentioned && + commandAuthorized && + hasControlCommand(messageText); + if (isGroup && requireMention && !mentioned && !shouldBypassMention) { logVerbose(`imessage: skipping group message (no mention)`); return; } @@ -228,6 +234,7 @@ export async function monitorIMessageProvider( ? (message.participants ?? []).filter(Boolean).join(", ") : undefined, SenderName: sender, + SenderId: sender, Surface: "imessage", MessageSid: message.id ? String(message.id) : undefined, Timestamp: createdAt, @@ -235,6 +242,7 @@ export async function monitorIMessageProvider( MediaType: mediaType, MediaUrl: mediaPath, WasMentioned: mentioned, + CommandAuthorized: commandAuthorized, }; if (!isGroup) { diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index eb04f87f6..9513b05de 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -287,7 +287,8 @@ export async function monitorSignalProvider( if (account && normalizeE164(sender) === normalizeE164(account)) { return; } - if (!isAllowedSender(sender, allowFrom)) { + const commandAuthorized = isAllowedSender(sender, allowFrom); + if (!commandAuthorized) { logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`); return; } @@ -349,12 +350,14 @@ export async function monitorSignalProvider( ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (groupName ?? undefined) : undefined, SenderName: envelope.sourceName ?? sender, + SenderId: sender, Surface: "signal" as const, MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined, Timestamp: envelope.timestamp ?? undefined, MediaPath: mediaPath, MediaType: mediaType, MediaUrl: mediaPath, + CommandAuthorized: commandAuthorized, }; if (!isGroup) { diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index b34ee2851..711dfe0cf 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -4,6 +4,7 @@ import type { } from "@slack/bolt"; import bolt from "@slack/bolt"; +import { hasControlCommand } from "../auto-reply/command-detection.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; @@ -581,7 +582,25 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { opts.wasMentioned ?? (!isDirectMessage && Boolean(botUserId && message.text?.includes(`<@${botUserId}>`))); - if (isRoom && channelConfig?.requireMention && !wasMentioned) { + const sender = await resolveUserName(message.user); + const senderName = sender?.name ?? message.user; + const allowList = normalizeAllowListLower(allowFrom); + const commandAuthorized = + allowList.length === 0 || + allowListMatches({ + allowList, + id: message.user, + name: senderName, + }); + const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); + const shouldBypassMention = + isRoom && + channelConfig?.requireMention && + !wasMentioned && + !hasAnyMention && + commandAuthorized && + hasControlCommand(message.text ?? ""); + if (isRoom && channelConfig?.requireMention && !wasMentioned && !shouldBypassMention) { logger.info( { channel: message.channel, reason: "no-mention" }, "skipping room message", @@ -597,7 +616,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; - const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; @@ -642,6 +660,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, SenderName: senderName, + SenderId: message.user, Surface: "slack" as const, MessageSid: message.ts, ReplyToId: message.thread_ts ?? message.ts, @@ -650,6 +669,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, + CommandAuthorized: commandAuthorized, }; const replyTarget = ctxPayload.To ?? undefined; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index aa5d7aa10..7d3f71e58 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -5,6 +5,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions, Message } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy"; +import { hasControlCommand } from "../auto-reply/command-detection.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; @@ -114,10 +115,36 @@ export function createTelegramBot(opts: TelegramBotOptions) { } const botUsername = ctx.me?.username?.toLowerCase(); + const allowFromList = Array.isArray(allowFrom) + ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) + : []; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandAuthorized = + allowFromList.length === 0 || + allowFromList.includes("*") || + (senderId && allowFromList.includes(senderId)) || + (senderId && allowFromList.includes(`telegram:${senderId}`)) || + (senderUsername && + allowFromList.some( + (entry) => + entry.toLowerCase() === senderUsername.toLowerCase() || + entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, + )); const wasMentioned = Boolean(botUsername) && hasBotMention(msg, botUsername); + const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( + (ent) => ent.type === "mention", + ); + const shouldBypassMention = + isGroup && + resolveGroupRequireMention(chatId) && + !wasMentioned && + !hasAnyMention && + commandAuthorized && + hasControlCommand(msg.text ?? msg.caption ?? ""); if (isGroup && resolveGroupRequireMention(chatId) && botUsername) { - if (!wasMentioned) { + if (!wasMentioned && !shouldBypassMention) { logger.info( { chatId, reason: "no-mention" }, "skipping group message", @@ -161,6 +188,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, Surface: "telegram", MessageSid: String(msg.message_id), ReplyToId: replyTarget?.id, @@ -171,6 +200,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, + CommandAuthorized: commandAuthorized, }; if (replyTarget && shouldLogVerbose()) { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 816ce0b2c..a565b0d95 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,3 +1,4 @@ +import { hasControlCommand } from "../auto-reply/command-detection.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { @@ -848,35 +849,23 @@ export async function monitorWebProvider( ); }; - const resolveOwnerList = (selfE164?: string | null) => { + const resolveCommandAllowFrom = () => { const allowFrom = mentionConfig.allowFrom; const raw = - Array.isArray(allowFrom) && allowFrom.length > 0 - ? allowFrom - : selfE164 - ? [selfE164] - : []; + Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : []; return raw .filter((entry): entry is string => Boolean(entry && entry !== "*")) .map((entry) => normalizeE164(entry)) .filter((entry): entry is string => Boolean(entry)); }; - const isOwnerSender = (msg: WebInboundMsg) => { + const isCommandAuthorized = (msg: WebInboundMsg) => { + const allowFrom = resolveCommandAllowFrom(); + if (allowFrom.length === 0) return true; + if (mentionConfig.allowFrom?.includes("*")) return true; const sender = normalizeE164(msg.senderE164 ?? ""); if (!sender) return false; - const owners = resolveOwnerList(msg.selfE164 ?? undefined); - return owners.includes(sender); - }; - - const isStatusCommand = (body: string) => { - const trimmed = body.trim().toLowerCase(); - if (!trimmed) return false; - return ( - trimmed === "/status" || - trimmed === "status" || - trimmed.startsWith("/status ") - ); + return allowFrom.includes(sender); }; const stripMentionsForCommand = (text: string, selfE164?: string | null) => { @@ -1193,6 +1182,7 @@ export async function monitorWebProvider( SenderName: msg.senderName, SenderE164: msg.senderE164, WasMentioned: msg.wasMentioned, + CommandAuthorized: isCommandAuthorized(msg), Surface: "whatsapp", }, { @@ -1333,12 +1323,15 @@ export async function monitorWebProvider( noteGroupMember(conversationId, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody); - const isOwner = isOwnerSender(msg); - const statusCommand = isStatusCommand(commandBody); + const commandAuthorized = isCommandAuthorized(msg); + const statusCommand = hasControlCommand(commandBody); + const hasAnyMention = (msg.mentionedJids?.length ?? 0) > 0; const shouldBypassMention = - isOwner && (activationCommand.hasCommand || statusCommand); + commandAuthorized && + (activationCommand.hasCommand || statusCommand) && + !hasAnyMention; - if (activationCommand.hasCommand && !isOwner) { + if (activationCommand.hasCommand && !commandAuthorized) { logVerbose( `Ignoring /activation from non-owner in group ${conversationId}`, );