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).
|
- 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.
|
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
|
||||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
- 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
|
## 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 () => {
|
it("allows owner to set send policy", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { runReplyAgent } from "./reply/agent-runner.js";
|
|||||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||||
import { applySessionHints } from "./reply/body.js";
|
import { applySessionHints } from "./reply/body.js";
|
||||||
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
||||||
|
import { hasControlCommand } from "./command-detection.js";
|
||||||
import {
|
import {
|
||||||
handleDirectiveOnly,
|
handleDirectiveOnly,
|
||||||
isDirectiveOnly,
|
isDirectiveOnly,
|
||||||
@@ -252,11 +253,22 @@ export async function getReplyFromConfig(
|
|||||||
triggerBodyNormalized,
|
triggerBodyNormalized,
|
||||||
} = sessionState;
|
} = sessionState;
|
||||||
|
|
||||||
const directives = parseInlineDirectives(
|
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||||
);
|
const parsedDirectives = parseInlineDirectives(rawBody);
|
||||||
sessionCtx.Body = directives.cleaned;
|
const directives = commandAuthorized
|
||||||
sessionCtx.BodyStripped = directives.cleaned;
|
? parsedDirectives
|
||||||
|
: {
|
||||||
|
...parsedDirectives,
|
||||||
|
hasThinkDirective: false,
|
||||||
|
hasVerboseDirective: false,
|
||||||
|
hasStatusDirective: false,
|
||||||
|
hasModelDirective: false,
|
||||||
|
hasQueueDirective: false,
|
||||||
|
queueReset: false,
|
||||||
|
};
|
||||||
|
sessionCtx.Body = parsedDirectives.cleaned;
|
||||||
|
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
||||||
|
|
||||||
const surfaceKey =
|
const surfaceKey =
|
||||||
sessionCtx.Surface?.trim().toLowerCase() ??
|
sessionCtx.Surface?.trim().toLowerCase() ??
|
||||||
@@ -424,6 +436,7 @@ export async function getReplyFromConfig(
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
isGroup,
|
isGroup,
|
||||||
triggerBodyNormalized,
|
triggerBodyNormalized,
|
||||||
|
commandAuthorized,
|
||||||
});
|
});
|
||||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||||
if (
|
if (
|
||||||
@@ -445,6 +458,7 @@ export async function getReplyFromConfig(
|
|||||||
ctx,
|
ctx,
|
||||||
cfg,
|
cfg,
|
||||||
command,
|
command,
|
||||||
|
directives,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -484,6 +498,14 @@ export async function getReplyFromConfig(
|
|||||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||||
const baseBodyTrimmedRaw = baseBody.trim();
|
const baseBodyTrimmedRaw = baseBody.trim();
|
||||||
|
if (
|
||||||
|
!commandAuthorized &&
|
||||||
|
!baseBodyTrimmedRaw &&
|
||||||
|
hasControlCommand(rawBody)
|
||||||
|
) {
|
||||||
|
typing.cleanup();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const isBareSessionReset =
|
const isBareSessionReset =
|
||||||
isNewSession &&
|
isNewSession &&
|
||||||
baseBodyTrimmedRaw.length === 0 &&
|
baseBodyTrimmedRaw.length === 0 &&
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ import type { ThinkLevel, VerboseLevel } from "../thinking.js";
|
|||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||||
import { stripMentions } from "./mentions.js";
|
import { stripMentions } from "./mentions.js";
|
||||||
|
import type { InlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
export type CommandContext = {
|
export type CommandContext = {
|
||||||
surface: string;
|
surface: string;
|
||||||
isWhatsAppSurface: boolean;
|
isWhatsAppSurface: boolean;
|
||||||
ownerList: string[];
|
ownerList: string[];
|
||||||
isOwnerSender: boolean;
|
isAuthorizedSender: boolean;
|
||||||
senderE164?: string;
|
senderE164?: string;
|
||||||
abortKey?: string;
|
abortKey?: string;
|
||||||
rawBodyNormalized: string;
|
rawBodyNormalized: string;
|
||||||
@@ -41,8 +42,16 @@ export function buildCommandContext(params: {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
triggerBodyNormalized: string;
|
triggerBodyNormalized: string;
|
||||||
|
commandAuthorized: boolean;
|
||||||
}): CommandContext {
|
}): CommandContext {
|
||||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
const {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
sessionKey,
|
||||||
|
isGroup,
|
||||||
|
triggerBodyNormalized,
|
||||||
|
commandAuthorized,
|
||||||
|
} = params;
|
||||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||||
const isWhatsAppSurface =
|
const isWhatsAppSurface =
|
||||||
surface === "whatsapp" ||
|
surface === "whatsapp" ||
|
||||||
@@ -80,14 +89,13 @@ export function buildCommandContext(params: {
|
|||||||
const ownerList = ownerCandidates
|
const ownerList = ownerCandidates
|
||||||
.map((entry) => normalizeE164(entry))
|
.map((entry) => normalizeE164(entry))
|
||||||
.filter((entry): entry is string => Boolean(entry));
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
const isOwnerSender =
|
const isAuthorizedSender = commandAuthorized;
|
||||||
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
surface,
|
surface,
|
||||||
isWhatsAppSurface,
|
isWhatsAppSurface,
|
||||||
ownerList,
|
ownerList,
|
||||||
isOwnerSender,
|
isAuthorizedSender,
|
||||||
senderE164: senderE164 || undefined,
|
senderE164: senderE164 || undefined,
|
||||||
abortKey,
|
abortKey,
|
||||||
rawBodyNormalized,
|
rawBodyNormalized,
|
||||||
@@ -101,6 +109,7 @@ export async function handleCommands(params: {
|
|||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
command: CommandContext;
|
command: CommandContext;
|
||||||
|
directives: InlineDirectives;
|
||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore?: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@@ -122,6 +131,7 @@ export async function handleCommands(params: {
|
|||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
command,
|
command,
|
||||||
|
directives,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -151,9 +161,9 @@ export async function handleCommands(params: {
|
|||||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!command.isOwnerSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
||||||
);
|
);
|
||||||
return { shouldContinue: false };
|
return { shouldContinue: false };
|
||||||
}
|
}
|
||||||
@@ -179,9 +189,9 @@ export async function handleCommands(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sendPolicyCommand.hasCommand) {
|
if (sendPolicyCommand.hasCommand) {
|
||||||
if (!command.isOwnerSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
);
|
);
|
||||||
return { shouldContinue: false };
|
return { shouldContinue: false };
|
||||||
}
|
}
|
||||||
@@ -220,9 +230,9 @@ export async function handleCommands(params: {
|
|||||||
command.commandBodyNormalized === "restart" ||
|
command.commandBodyNormalized === "restart" ||
|
||||||
command.commandBodyNormalized.startsWith("/restart ")
|
command.commandBodyNormalized.startsWith("/restart ")
|
||||||
) {
|
) {
|
||||||
if (isGroup && !command.isOwnerSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
);
|
);
|
||||||
return { shouldContinue: false };
|
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 === "status" ||
|
command.commandBodyNormalized === "status" ||
|
||||||
command.commandBodyNormalized.startsWith("/status ")
|
command.commandBodyNormalized.startsWith("/status ");
|
||||||
) {
|
if (statusRequested) {
|
||||||
if (isGroup && !command.isOwnerSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
);
|
);
|
||||||
return { shouldContinue: false };
|
return { shouldContinue: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { ReplyPayload } from "../types.js";
|
|||||||
import {
|
import {
|
||||||
type ElevatedLevel,
|
type ElevatedLevel,
|
||||||
extractElevatedDirective,
|
extractElevatedDirective,
|
||||||
|
extractStatusDirective,
|
||||||
extractThinkDirective,
|
extractThinkDirective,
|
||||||
extractVerboseDirective,
|
extractVerboseDirective,
|
||||||
type ThinkLevel,
|
type ThinkLevel,
|
||||||
@@ -49,6 +50,7 @@ export type InlineDirectives = {
|
|||||||
hasElevatedDirective: boolean;
|
hasElevatedDirective: boolean;
|
||||||
elevatedLevel?: ElevatedLevel;
|
elevatedLevel?: ElevatedLevel;
|
||||||
rawElevatedLevel?: string;
|
rawElevatedLevel?: string;
|
||||||
|
hasStatusDirective: boolean;
|
||||||
hasModelDirective: boolean;
|
hasModelDirective: boolean;
|
||||||
rawModelDirective?: string;
|
rawModelDirective?: string;
|
||||||
hasQueueDirective: boolean;
|
hasQueueDirective: boolean;
|
||||||
@@ -83,11 +85,15 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
|||||||
rawLevel: rawElevatedLevel,
|
rawLevel: rawElevatedLevel,
|
||||||
hasDirective: hasElevatedDirective,
|
hasDirective: hasElevatedDirective,
|
||||||
} = extractElevatedDirective(verboseCleaned);
|
} = extractElevatedDirective(verboseCleaned);
|
||||||
|
const {
|
||||||
|
cleaned: statusCleaned,
|
||||||
|
hasDirective: hasStatusDirective,
|
||||||
|
} = extractStatusDirective(elevatedCleaned);
|
||||||
const {
|
const {
|
||||||
cleaned: modelCleaned,
|
cleaned: modelCleaned,
|
||||||
rawModel,
|
rawModel,
|
||||||
hasDirective: hasModelDirective,
|
hasDirective: hasModelDirective,
|
||||||
} = extractModelDirective(elevatedCleaned);
|
} = extractModelDirective(statusCleaned);
|
||||||
const {
|
const {
|
||||||
cleaned: queueCleaned,
|
cleaned: queueCleaned,
|
||||||
queueMode,
|
queueMode,
|
||||||
@@ -114,6 +120,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
|||||||
hasElevatedDirective,
|
hasElevatedDirective,
|
||||||
elevatedLevel,
|
elevatedLevel,
|
||||||
rawElevatedLevel,
|
rawElevatedLevel,
|
||||||
|
hasStatusDirective,
|
||||||
hasModelDirective,
|
hasModelDirective,
|
||||||
rawModelDirective: rawModel,
|
rawModelDirective: rawModel,
|
||||||
hasQueueDirective,
|
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 };
|
export type { ElevatedLevel, ThinkLevel, VerboseLevel };
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export type MsgContext = {
|
|||||||
GroupSpace?: string;
|
GroupSpace?: string;
|
||||||
GroupMembers?: string;
|
GroupMembers?: string;
|
||||||
SenderName?: string;
|
SenderName?: string;
|
||||||
|
SenderId?: string;
|
||||||
SenderUsername?: string;
|
SenderUsername?: string;
|
||||||
SenderTag?: string;
|
SenderTag?: string;
|
||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
Surface?: string;
|
Surface?: string;
|
||||||
WasMentioned?: boolean;
|
WasMentioned?: boolean;
|
||||||
|
CommandAuthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ApplicationCommandOptionType,
|
|
||||||
type Attachment,
|
type Attachment,
|
||||||
ChannelType,
|
ChannelType,
|
||||||
type ChatInputCommandInteraction,
|
|
||||||
Client,
|
Client,
|
||||||
type CommandInteractionOption,
|
|
||||||
Events,
|
Events,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
type Guild,
|
type Guild,
|
||||||
@@ -19,22 +16,19 @@ import {
|
|||||||
type User,
|
type User,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.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 { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type {
|
import type { ReplyToMode } from "../config/config.js";
|
||||||
DiscordSlashCommandConfig,
|
|
||||||
ReplyToMode,
|
|
||||||
} from "../config/config.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
} from "../config/sessions.js";
|
} 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 { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
@@ -47,7 +41,6 @@ export type MonitorDiscordOpts = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
slashCommand?: DiscordSlashCommandConfig;
|
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
@@ -140,9 +133,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const dmConfig = cfg.discord?.dm;
|
const dmConfig = cfg.discord?.dm;
|
||||||
const guildEntries = cfg.discord?.guilds;
|
const guildEntries = cfg.discord?.guilds;
|
||||||
const allowFrom = dmConfig?.allowFrom;
|
const allowFrom = dmConfig?.allowFrom;
|
||||||
const slashCommand = resolveSlashCommandConfig(
|
|
||||||
opts.slashCommand ?? cfg.discord?.slashCommand,
|
|
||||||
);
|
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
||||||
@@ -183,9 +173,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
client.once(Events.ClientReady, () => {
|
client.once(Events.ClientReady, () => {
|
||||||
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
||||||
if (slashCommand.enabled) {
|
|
||||||
void ensureSlashCommand(client, slashCommand, runtime);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.Error, (err) => {
|
client.on(Events.Error, (err) => {
|
||||||
@@ -299,8 +286,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
const resolvedRequireMention =
|
const resolvedRequireMention =
|
||||||
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
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 (isGuildMessage && resolvedRequireMention) {
|
||||||
if (botId && !wasMentioned) {
|
if (botId && !wasMentioned && !shouldBypassMention) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: drop guild message (mention required, botId=${botId})`,
|
`discord: drop guild message (mention required, botId=${botId})`,
|
||||||
);
|
);
|
||||||
@@ -480,11 +486,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
: `channel:${message.channelId}`,
|
: `channel:${message.channelId}`,
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName: message.member?.displayName ?? message.author.tag,
|
SenderName: message.member?.displayName ?? message.author.tag,
|
||||||
|
SenderId: message.author.id,
|
||||||
SenderUsername: message.author.username,
|
SenderUsername: message.author.username,
|
||||||
SenderTag: message.author.tag,
|
SenderTag: message.author.tag,
|
||||||
GroupSubject: groupSubject,
|
GroupSubject: groupSubject,
|
||||||
GroupRoom: groupRoom,
|
GroupRoom: groupRoom,
|
||||||
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
|
GroupSpace: isGuildMessage
|
||||||
|
? guildInfo?.id ?? guildSlug || undefined
|
||||||
|
: undefined,
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
@@ -492,6 +501,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
const replyTarget = ctxPayload.To ?? undefined;
|
const replyTarget = ctxPayload.To ?? undefined;
|
||||||
if (!replyTarget) {
|
if (!replyTarget) {
|
||||||
@@ -695,179 +705,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
await handleReactionEvent(reaction, user, "removed");
|
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 client.login(token);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -1164,6 +1001,37 @@ export function allowListMatches(
|
|||||||
return false;
|
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: {
|
export function shouldEmitDiscordReactionNotification(params: {
|
||||||
mode: "off" | "own" | "all" | "allowlist" | undefined;
|
mode: "off" | "own" | "all" | "allowlist" | undefined;
|
||||||
botId?: string | null;
|
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) {
|
async function sendTyping(message: Message) {
|
||||||
try {
|
try {
|
||||||
const channel = message.channel;
|
const channel = message.channel;
|
||||||
@@ -1449,48 +1237,3 @@ async function deliverReplies({
|
|||||||
runtime.log?.(`delivered reply to ${target}`);
|
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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
@@ -168,15 +169,14 @@ export async function monitorIMessageProvider(
|
|||||||
const isGroup = Boolean(message.is_group);
|
const isGroup = Boolean(message.is_group);
|
||||||
if (isGroup && !chatId) return;
|
if (isGroup && !chatId) return;
|
||||||
|
|
||||||
if (
|
const commandAuthorized = isAllowedIMessageSender({
|
||||||
!isAllowedIMessageSender({
|
allowFrom,
|
||||||
allowFrom,
|
sender,
|
||||||
sender,
|
chatId: chatId ?? undefined,
|
||||||
chatId: chatId ?? undefined,
|
chatGuid,
|
||||||
chatGuid,
|
chatIdentifier,
|
||||||
chatIdentifier,
|
});
|
||||||
})
|
if (!commandAuthorized) {
|
||||||
) {
|
|
||||||
logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`);
|
logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,13 @@ export async function monitorIMessageProvider(
|
|||||||
const messageText = (message.text ?? "").trim();
|
const messageText = (message.text ?? "").trim();
|
||||||
const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true;
|
const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true;
|
||||||
const requireMention = resolveGroupRequireMention(cfg, opts, chatId);
|
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)`);
|
logVerbose(`imessage: skipping group message (no mention)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,6 +234,7 @@ export async function monitorIMessageProvider(
|
|||||||
? (message.participants ?? []).filter(Boolean).join(", ")
|
? (message.participants ?? []).filter(Boolean).join(", ")
|
||||||
: undefined,
|
: undefined,
|
||||||
SenderName: sender,
|
SenderName: sender,
|
||||||
|
SenderId: sender,
|
||||||
Surface: "imessage",
|
Surface: "imessage",
|
||||||
MessageSid: message.id ? String(message.id) : undefined,
|
MessageSid: message.id ? String(message.id) : undefined,
|
||||||
Timestamp: createdAt,
|
Timestamp: createdAt,
|
||||||
@@ -235,6 +242,7 @@ export async function monitorIMessageProvider(
|
|||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
WasMentioned: mentioned,
|
WasMentioned: mentioned,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ export async function monitorSignalProvider(
|
|||||||
if (account && normalizeE164(sender) === normalizeE164(account)) {
|
if (account && normalizeE164(sender) === normalizeE164(account)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAllowedSender(sender, allowFrom)) {
|
const commandAuthorized = isAllowedSender(sender, allowFrom);
|
||||||
|
if (!commandAuthorized) {
|
||||||
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
|
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -349,12 +350,14 @@ export async function monitorSignalProvider(
|
|||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
|
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
|
||||||
SenderName: envelope.sourceName ?? sender,
|
SenderName: envelope.sourceName ?? sender,
|
||||||
|
SenderId: sender,
|
||||||
Surface: "signal" as const,
|
Surface: "signal" as const,
|
||||||
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
|
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
|
||||||
Timestamp: envelope.timestamp ?? undefined,
|
Timestamp: envelope.timestamp ?? undefined,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from "@slack/bolt";
|
} from "@slack/bolt";
|
||||||
import bolt 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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
@@ -581,7 +582,25 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
opts.wasMentioned ??
|
opts.wasMentioned ??
|
||||||
(!isDirectMessage &&
|
(!isDirectMessage &&
|
||||||
Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)));
|
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(
|
logger.info(
|
||||||
{ channel: message.channel, reason: "no-mention" },
|
{ channel: message.channel, reason: "no-mention" },
|
||||||
"skipping room message",
|
"skipping room message",
|
||||||
@@ -597,7 +616,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
||||||
if (!rawBody) return;
|
if (!rawBody) return;
|
||||||
|
|
||||||
const sender = await resolveUserName(message.user);
|
|
||||||
const senderName = sender?.name ?? message.user;
|
const senderName = sender?.name ?? message.user;
|
||||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||||
|
|
||||||
@@ -642,6 +660,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
||||||
GroupSubject: isRoomish ? roomLabel : undefined,
|
GroupSubject: isRoomish ? roomLabel : undefined,
|
||||||
SenderName: senderName,
|
SenderName: senderName,
|
||||||
|
SenderId: message.user,
|
||||||
Surface: "slack" as const,
|
Surface: "slack" as const,
|
||||||
MessageSid: message.ts,
|
MessageSid: message.ts,
|
||||||
ReplyToId: message.thread_ts ?? message.ts,
|
ReplyToId: message.thread_ts ?? message.ts,
|
||||||
@@ -650,6 +669,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
const replyTarget = ctxPayload.To ?? undefined;
|
const replyTarget = ctxPayload.To ?? undefined;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler";
|
|||||||
import type { ApiClientOptions, Message } from "grammy";
|
import type { ApiClientOptions, Message } from "grammy";
|
||||||
import { Bot, InputFile, webhookCallback } 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 { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
@@ -114,10 +115,36 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const botUsername = ctx.me?.username?.toLowerCase();
|
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 =
|
const wasMentioned =
|
||||||
Boolean(botUsername) && hasBotMention(msg, botUsername);
|
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 (isGroup && resolveGroupRequireMention(chatId) && botUsername) {
|
||||||
if (!wasMentioned) {
|
if (!wasMentioned && !shouldBypassMention) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ chatId, reason: "no-mention" },
|
{ chatId, reason: "no-mention" },
|
||||||
"skipping group message",
|
"skipping group message",
|
||||||
@@ -161,6 +188,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||||
SenderName: buildSenderName(msg),
|
SenderName: buildSenderName(msg),
|
||||||
|
SenderId: senderId || undefined,
|
||||||
|
SenderUsername: senderUsername || undefined,
|
||||||
Surface: "telegram",
|
Surface: "telegram",
|
||||||
MessageSid: String(msg.message_id),
|
MessageSid: String(msg.message_id),
|
||||||
ReplyToId: replyTarget?.id,
|
ReplyToId: replyTarget?.id,
|
||||||
@@ -171,6 +200,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (replyTarget && shouldLogVerbose()) {
|
if (replyTarget && shouldLogVerbose()) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
@@ -848,35 +849,23 @@ export async function monitorWebProvider(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveOwnerList = (selfE164?: string | null) => {
|
const resolveCommandAllowFrom = () => {
|
||||||
const allowFrom = mentionConfig.allowFrom;
|
const allowFrom = mentionConfig.allowFrom;
|
||||||
const raw =
|
const raw =
|
||||||
Array.isArray(allowFrom) && allowFrom.length > 0
|
Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : [];
|
||||||
? allowFrom
|
|
||||||
: selfE164
|
|
||||||
? [selfE164]
|
|
||||||
: [];
|
|
||||||
return raw
|
return raw
|
||||||
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||||
.map((entry) => normalizeE164(entry))
|
.map((entry) => normalizeE164(entry))
|
||||||
.filter((entry): entry is string => Boolean(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 ?? "");
|
const sender = normalizeE164(msg.senderE164 ?? "");
|
||||||
if (!sender) return false;
|
if (!sender) return false;
|
||||||
const owners = resolveOwnerList(msg.selfE164 ?? undefined);
|
return allowFrom.includes(sender);
|
||||||
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 ")
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
|
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
|
||||||
@@ -1193,6 +1182,7 @@ export async function monitorWebProvider(
|
|||||||
SenderName: msg.senderName,
|
SenderName: msg.senderName,
|
||||||
SenderE164: msg.senderE164,
|
SenderE164: msg.senderE164,
|
||||||
WasMentioned: msg.wasMentioned,
|
WasMentioned: msg.wasMentioned,
|
||||||
|
CommandAuthorized: isCommandAuthorized(msg),
|
||||||
Surface: "whatsapp",
|
Surface: "whatsapp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1333,12 +1323,15 @@ export async function monitorWebProvider(
|
|||||||
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
|
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
|
||||||
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
|
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
|
||||||
const activationCommand = parseActivationCommand(commandBody);
|
const activationCommand = parseActivationCommand(commandBody);
|
||||||
const isOwner = isOwnerSender(msg);
|
const commandAuthorized = isCommandAuthorized(msg);
|
||||||
const statusCommand = isStatusCommand(commandBody);
|
const statusCommand = hasControlCommand(commandBody);
|
||||||
|
const hasAnyMention = (msg.mentionedJids?.length ?? 0) > 0;
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
isOwner && (activationCommand.hasCommand || statusCommand);
|
commandAuthorized &&
|
||||||
|
(activationCommand.hasCommand || statusCommand) &&
|
||||||
|
!hasAnyMention;
|
||||||
|
|
||||||
if (activationCommand.hasCommand && !isOwner) {
|
if (activationCommand.hasCommand && !commandAuthorized) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /activation from non-owner in group ${conversationId}`,
|
`Ignoring /activation from non-owner in group ${conversationId}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user