fix: unify control command handling

This commit is contained in:
Peter Steinberger
2026-01-05 01:31:36 +01:00
parent 54ad1ead80
commit 852f947b44
14 changed files with 273 additions and 375 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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 &&

View File

@@ -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<string, SessionEntry>;
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 || "<unknown>"}`,
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
);
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 || "<unknown>"}`,
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
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 || "<unknown>"}`,
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
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 || "<unknown>"}`,
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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 & {

View File

@@ -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<void>((resolve, reject) => {
@@ -1164,6 +1001,37 @@ export function allowListMatches(
return false;
}
function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: Array<string | number>;
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<DiscordSlashCommandConfig>,
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<DiscordSlashCommandConfig> {
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 });
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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}`,
);