fix: unify control command handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
26
src/auto-reply/command-detection.ts
Normal file
26
src/auto-reply/command-detection.ts
Normal 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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user