feat(commands): unify chat commands (#275)

* Chat commands: registry, access groups, Carbon

* Chat commands: clear native commands on disable

* fix(commands): align command surface typing

* docs(changelog): note commands registry (PR #275)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Shadow
2026-01-06 14:17:56 -06:00
committed by GitHub
parent 1bf44bf30c
commit 9b22e1f6e9
40 changed files with 2357 additions and 1459 deletions

View File

@@ -32,4 +32,11 @@ describe("control command parsing", () => {
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
});
it("requires commands to be the full message", () => {
expect(hasControlCommand("hello /status")).toBe(false);
expect(hasControlCommand("/status please")).toBe(false);
expect(hasControlCommand("prefix /send on")).toBe(false);
expect(hasControlCommand("/send on")).toBe(true);
});
});

View File

@@ -1,22 +1,20 @@
const CONTROL_COMMAND_RE =
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i;
const CONTROL_COMMAND_EXACT = new Set([
"/help",
"/status",
"/restart",
"/activation",
"/send",
"/reset",
"/new",
"/compact",
]);
import { listChatCommands } from "./commands-registry.js";
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);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (lowered === normalized) return true;
if (command.acceptsArgs && lowered.startsWith(normalized)) {
const nextChar = trimmed.charAt(normalized.length);
if (nextChar && /\s/.test(nextChar)) return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildCommandText,
getCommandDetection,
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "./commands-registry.js";
describe("commands registry", () => {
it("builds command text with args", () => {
expect(buildCommandText("status")).toBe("/status");
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
});
it("exposes native specs", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});
it("respects text command gating", () => {
const cfg = { commands: { text: false } };
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "text",
}),
).toBe(false);
expect(
shouldHandleTextCommands({
cfg,
surface: "whatsapp",
commandSource: "text",
}),
).toBe(true);
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "native",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,178 @@
import type { ClawdbotConfig } from "../config/types.js";
export type ChatCommandDefinition = {
key: string;
nativeName: string;
description: string;
textAliases: string[];
acceptsArgs?: boolean;
};
export type NativeCommandSpec = {
name: string;
description: string;
acceptsArgs: boolean;
};
const CHAT_COMMANDS: ChatCommandDefinition[] = [
{
key: "help",
nativeName: "help",
description: "Show available commands.",
textAliases: ["/help"],
},
{
key: "status",
nativeName: "status",
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAliases: ["/restart"],
},
{
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAliases: ["/activation"],
acceptsArgs: true,
},
{
key: "send",
nativeName: "send",
description: "Set send policy.",
textAliases: ["/send"],
acceptsArgs: true,
},
{
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAliases: ["/reset"],
},
{
key: "new",
nativeName: "new",
description: "Start a new session.",
textAliases: ["/new"],
},
{
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAliases: ["/thinking", "/think", "/t"],
acceptsArgs: true,
},
{
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAliases: ["/verbose", "/v"],
acceptsArgs: true,
},
{
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAliases: ["/elevated", "/elev"],
acceptsArgs: true,
},
{
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model"],
acceptsArgs: true,
},
{
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAliases: ["/queue"],
acceptsArgs: true,
},
];
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
let cachedDetection:
| {
exact: Set<string>;
regex: RegExp;
}
| undefined;
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.map((command) => ({
name: command.nativeName,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) => command.nativeName.toLowerCase() === normalized,
);
}
export function buildCommandText(commandName: string, args?: string): string {
const trimmedArgs = args?.trim();
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
if (cachedDetection) return cachedDetection;
const exact = new Set<string>();
const patterns: string[] = [];
for (const command of CHAT_COMMANDS) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
exact.add(normalized);
const escaped = escapeRegExp(normalized);
if (!escaped) continue;
if (command.acceptsArgs) {
patterns.push(`${escaped}(?:\\s+.+)?`);
} else {
patterns.push(escaped);
}
}
}
const regex = patterns.length
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
: /$^/;
cachedDetection = { exact, regex };
return cachedDetection;
}
export function supportsNativeCommands(surface?: string): boolean {
if (!surface) return false;
return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase());
}
export function shouldHandleTextCommands(params: {
cfg: ClawdbotConfig;
surface?: string;
commandSource?: "text" | "native";
}): boolean {
const { cfg, surface, commandSource } = params;
const textEnabled = cfg.commands?.text !== false;
if (commandSource === "native") return true;
if (textEnabled) return true;
return !supportsNativeCommands(surface);
}

View File

@@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/activation\b(?:\s+([a-zA-Z]+))?/i);
const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
if (!match) return { hasCommand: false };
const mode = normalizeGroupActivation(match[1]);
return { hasCommand: true, mode };

View File

@@ -512,7 +512,7 @@ describe("directive parsing", () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const ctx = {
Body: "please do the thing /verbose on",
Body: "please do the thing",
From: "+1004",
To: "+2000",
};
@@ -546,6 +546,21 @@ describe("directive parsing", () => {
};
});
await getReplyFromConfig(
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
},
);
const res = await getReplyFromConfig(
ctx,
{},
@@ -827,7 +842,7 @@ describe("directive parsing", () => {
});
});
it("uses model override for inline /model", async () => {
it("ignores inline /model and uses the default model", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -867,8 +882,8 @@ describe("directive parsing", () => {
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.provider).toBe("openai");
expect(call?.model).toBe("gpt-4.1-mini");
expect(call?.provider).toBe("anthropic");
expect(call?.model).toBe("claude-opus-4-5");
});
});

View File

@@ -115,8 +115,15 @@ describe("trigger handling", () => {
});
});
it("reports status when /status appears inline", async () => {
it("ignores inline /status and runs the agent", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "please /status now",
@@ -127,8 +134,8 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(text).not.toContain("Status");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
});
});
@@ -265,8 +272,15 @@ describe("trigger handling", () => {
});
});
it("rejects elevated inline directive for unapproved sender", async () => {
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
@@ -293,8 +307,8 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(text).not.toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
});
});

View File

@@ -31,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand } from "./command-detection.js";
import { shouldHandleTextCommands } from "./commands-registry.js";
import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
@@ -38,6 +39,7 @@ import { applySessionHints } from "./reply/body.js";
import { buildCommandContext, handleCommands } from "./reply/commands.js";
import {
handleDirectiveOnly,
type InlineDirectives,
isDirectiveOnly,
parseInlineDirectives,
persistInlineDirectives,
@@ -48,7 +50,7 @@ import {
defaultGroupActivation,
resolveGroupRequireMention,
} from "./reply/groups.js";
import { stripMentions } from "./reply/mentions.js";
import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js";
import {
createModelSelectionState,
resolveContextTokens,
@@ -83,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
const BARE_SESSION_RESET_PROMPT =
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
const CONTROL_COMMAND_PREFIX_RE =
/^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i;
function normalizeAllowToken(value?: string) {
if (!value) return "";
return value.trim().toLowerCase();
@@ -254,7 +253,7 @@ export async function getReplyFromConfig(
}
const commandAuthorized = ctx.CommandAuthorized ?? true;
const commandAuth = resolveCommandAuthorization({
resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized,
@@ -281,7 +280,47 @@ export async function getReplyFromConfig(
} = sessionState;
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const parsedDirectives = parseInlineDirectives(rawBody);
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
cleaned,
hasThinkDirective: false,
thinkLevel: undefined,
rawThinkLevel: undefined,
hasVerboseDirective: false,
verboseLevel: undefined,
rawVerboseLevel: undefined,
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
hasStatusDirective: false,
hasModelDirective: false,
rawModelDirective: undefined,
hasQueueDirective: false,
queueMode: undefined,
queueReset: false,
rawQueueMode: undefined,
debounceMs: undefined,
cap: undefined,
dropPolicy: undefined,
rawDebounce: undefined,
rawCap: undefined,
rawDrop: undefined,
hasQueueOptions: false,
});
let parsedDirectives = parseInlineDirectives(rawBody);
const hasDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
parsedDirectives.hasElevatedDirective ||
parsedDirectives.hasStatusDirective ||
parsedDirectives.hasModelDirective ||
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
}
const directives = commandAuthorized
? parsedDirectives
: {
@@ -468,6 +507,11 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
command.isWhatsAppProvider &&
@@ -538,20 +582,15 @@ export async function getReplyFromConfig(
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
const strippedCommandBody = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
: triggerBodyNormalized;
if (
!commandAuth.isAuthorizedSender &&
CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim())
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(rawBody)
) {
typing.cleanup();
return undefined;
}
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
typing.cleanup();
return undefined;
}
const isBareSessionReset =
isNewSession &&
baseBodyTrimmedRaw.length === 0 &&

View File

@@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import {
normalizeGroupActivation,
parseActivationCommand,
@@ -47,6 +48,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { incrementCompactionCount } from "./session-updates.js";
export type CommandContext = {
surface: string;
provider: string;
isWhatsAppProvider: boolean;
ownerList: string[];
@@ -123,7 +125,8 @@ export function buildCommandContext(params: {
cfg,
commandAuthorized: params.commandAuthorized,
});
const provider = (ctx.Provider ?? "").trim().toLowerCase();
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
const provider = (ctx.Provider ?? surface).trim().toLowerCase();
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
@@ -132,6 +135,7 @@ export function buildCommandContext(params: {
: rawBodyNormalized;
return {
surface,
provider,
isWhatsAppProvider: auth.isWhatsAppProvider,
ownerList: auth.ownerList,
@@ -207,8 +211,13 @@ export async function handleCommands(params: {
const sendPolicyCommand = parseSendPolicyCommand(
command.commandBodyNormalized,
);
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
if (activationCommand.hasCommand) {
if (allowTextCommands && activationCommand.hasCommand) {
if (!isGroup) {
return {
shouldContinue: false,
@@ -255,7 +264,7 @@ export async function handleCommands(params: {
};
}
if (sendPolicyCommand.hasCommand) {
if (allowTextCommands && sendPolicyCommand.hasCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -292,10 +301,7 @@ export async function handleCommands(params: {
};
}
if (
command.commandBodyNormalized === "/restart" ||
command.commandBodyNormalized.startsWith("/restart ")
) {
if (allowTextCommands && command.commandBodyNormalized === "/restart") {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -311,10 +317,8 @@ export async function handleCommands(params: {
};
}
const helpRequested =
command.commandBodyNormalized === "/help" ||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
if (helpRequested) {
const helpRequested = command.commandBodyNormalized === "/help";
if (allowTextCommands && helpRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -326,9 +330,8 @@ export async function handleCommands(params: {
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized.startsWith("/status ");
if (statusRequested) {
command.commandBodyNormalized === "/status";
if (allowTextCommands && statusRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -451,7 +454,7 @@ export async function handleCommands(params: {
}
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
if (abortRequested) {
if (allowTextCommands && abortRequested) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.abortedLastRun = true;
sessionEntry.updatedAt = Date.now();

View File

@@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/send\b(?:\s+([a-zA-Z]+))?/i);
const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
if (!match) return { hasCommand: false };
const token = match[1]?.trim().toLowerCase();
if (!token) return { hasCommand: true };

View File

@@ -28,8 +28,11 @@ export type MsgContext = {
SenderE164?: string;
/** Provider label (whatsapp|telegram|discord|imessage|...). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
};
export type TemplateContext = MsgContext & {

View File

@@ -32,6 +32,7 @@ const GROUP_LABELS: Record<string, string> = {
models: "Models",
routing: "Routing",
messages: "Messages",
commands: "Commands",
session: "Session",
cron: "Cron",
hooks: "Hooks",
@@ -58,6 +59,7 @@ const GROUP_ORDER: Record<string, number> = {
models: 50,
routing: 60,
messages: 70,
commands: 75,
session: 80,
cron: 90,
hooks: 100,
@@ -94,6 +96,9 @@ const FIELD_LABELS: Record<string, string> = {
"agent.model.fallbacks": "Model Fallbacks",
"agent.imageModel.primary": "Image Model",
"agent.imageModel.fallbacks": "Image Model Fallbacks",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
@@ -137,6 +142,11 @@ const FIELD_HELP: Record<string, string> = {
"Optional image model (provider/model) used when the primary model lacks image input.",
"agent.imageModel.fallbacks":
"Ordered fallback image models (provider/model).",
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
"commands.useAccessGroups":
"Enforce access-group allowlists/policies for commands.",
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).",
"messages.ackReaction":

View File

@@ -300,17 +300,6 @@ export type DiscordGuildEntry = {
channels?: Record<string, DiscordGuildChannelConfig>;
};
export type DiscordSlashCommandConfig = {
/** Enable handling for the configured slash command (default: false). */
enabled?: boolean;
/** Slash command name (default: "clawd"). */
name?: string;
/** Session key prefix for slash commands (default: "discord:slash"). */
sessionPrefix?: string;
/** Reply ephemerally (default: true). */
ephemeral?: boolean;
};
export type DiscordActionConfig = {
reactions?: boolean;
stickers?: boolean;
@@ -350,7 +339,6 @@ export type DiscordConfig = {
actions?: DiscordActionConfig;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
slashCommand?: DiscordSlashCommandConfig;
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;
@@ -577,6 +565,15 @@ export type MessagesConfig = {
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
};
export type CommandsConfig = {
/** Enable native command registration when supported (default: false). */
native?: boolean;
/** Enable text command parsing (default: true). */
text?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */
useAccessGroups?: boolean;
};
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
export type BridgeConfig = {
@@ -998,6 +995,7 @@ export type ClawdbotConfig = {
};
routing?: RoutingConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
session?: SessionConfig;
web?: WebConfig;
whatsapp?: WhatsAppConfig;

View File

@@ -165,6 +165,14 @@ const MessagesSchema = z
})
.optional();
const CommandsSchema = z
.object({
native: z.boolean().optional(),
text: z.boolean().optional(),
useAccessGroups: z.boolean().optional(),
})
.optional();
const HeartbeatSchema = z
.object({
every: z.string().optional(),
@@ -632,6 +640,7 @@ export const ClawdbotSchema = z.object({
.optional(),
routing: RoutingSchema,
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,
cron: z
.object({
@@ -786,14 +795,6 @@ export const ClawdbotSchema = z.object({
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
slashCommand: z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(),
sessionPrefix: z.string().optional(),
ephemeral: z.boolean().optional(),
})
.optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
actions: z

View File

@@ -1,3 +1,4 @@
import type { Guild } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import {
allowListMatches,
@@ -12,8 +13,7 @@ import {
shouldEmitDiscordReactionNotification,
} from "./monitor.js";
const fakeGuild = (id: string, name: string) =>
({ id, name }) as unknown as import("discord.js").Guild;
const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild;
const makeEntries = (
entries: Record<string, Partial<DiscordGuildEntryResolved>>,

View File

@@ -1,267 +1,170 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { monitorDiscordProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
const dispatchMock = vi.fn();
vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
}));
vi.mock("discord.js", () => {
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
class Client {
static lastClient: Client | null = null;
user = { id: "bot-id", tag: "bot#1" };
constructor() {
Client.lastClient = this;
}
on(event: string, handler: (...args: unknown[]) => void) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
}
once(event: string, handler: (...args: unknown[]) => void) {
this.on(event, handler);
}
off(event: string, handler: (...args: unknown[]) => void) {
handlers.get(event)?.delete(handler);
}
emit(event: string, ...args: unknown[]) {
for (const handler of handlers.get(event) ?? []) {
Promise.resolve(handler(...args)).catch(() => {});
}
}
login = vi.fn().mockResolvedValue(undefined);
destroy = vi.fn().mockImplementation(async () => {
handlers.clear();
Client.lastClient = null;
});
}
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
Client,
__getLastClient: () => Client.lastClient,
Events: {
ClientReady: "ready",
Error: "error",
MessageCreate: "messageCreate",
MessageReactionAdd: "reactionAdd",
MessageReactionRemove: "reactionRemove",
},
ChannelType: {
DM: "dm",
GroupDM: "group_dm",
GuildText: "guild_text",
},
MessageType: {
Default: "default",
ChatInputCommand: "chat_command",
ContextMenuCommand: "context_command",
},
GatewayIntentBits: {},
Partials: {},
...actual,
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),
};
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForClient() {
const discord = (await import("discord.js")) as unknown as {
__getLastClient: () => { emit: (...args: unknown[]) => void } | null;
};
for (let i = 0; i < 10; i += 1) {
const client = discord.__getLastClient();
if (client) return client;
await flush();
}
return null;
}
beforeEach(() => {
config = {
messages: { responsePrefix: "PFX" },
discord: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
vi.resetModules();
});
describe("monitorDiscordProvider tool results", () => {
it("sends tool summaries with responsePrefix", async () => {
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
describe("discord tool result dispatch", () => {
it("sends status replies with responsePrefix", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: { dm: { enabled: true, policy: "open" } },
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const controller = new AbortController();
const run = monitorDiscordProvider({
const runtimeError = vi.fn();
const handler = createDiscordMessageHandler({
cfg,
token: "token",
abortSignal: controller.signal,
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
client.emit(discord.Events.MessageCreate, {
id: "m1",
content: "hello",
author: { id: "u1", bot: false, username: "Ada" },
channelId: "c1",
channel: {
type: discord.ChannelType.DM,
isSendable: () => false,
runtime: {
log: vi.fn(),
error: runtimeError,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
guild: undefined,
mentions: { has: () => false },
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
await flush();
controller.abort();
await run;
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
}),
} as unknown as Client;
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
await handler(
{
message: {
id: "m1",
content: "/status",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
},
author: { id: "u1", bot: false, username: "Ada" },
guild_id: null,
},
client,
);
expect(runtimeError).not.toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000);
it("accepts guild messages when mentionPatterns match", async () => {
config = {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: true } },
},
routing: {
allowFrom: [],
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
};
replyMock.mockResolvedValue({ text: "hi" });
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const controller = new AbortController();
const run = monitorDiscordProvider({
const handler = createDiscordMessageHandler({
cfg,
token: "token",
abortSignal: controller.signal,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
client.emit(discord.Events.MessageCreate, {
id: "m2",
content: "clawd: hello",
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" },
channelId: "c1",
channel: {
type: discord.ChannelType.GuildText,
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
isSendable: () => false,
}),
} as unknown as Client;
await handler(
{
message: {
id: "m2",
content: "clawd: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
guild: { id: "g1", name: "Guild" },
mentions: {
has: () => false,
everyone: false,
users: { size: 0 },
roles: { size: 0 },
},
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
config = {
...config,
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
};
const controller = new AbortController();
const run = monitorDiscordProvider({
token: "token",
abortSignal: controller.signal,
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
const reply = vi.fn().mockResolvedValue(undefined);
client.emit(discord.Events.MessageCreate, {
id: "m3",
content: "hello",
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
channelId: "c1",
channel: {
type: discord.ChannelType.DM,
isSendable: () => false,
},
guild: undefined,
mentions: { has: () => false },
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
reply,
});
await flush();
controller.abort();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(reply).toHaveBeenCalledTimes(1);
expect(String(reply.mock.calls[0]?.[0] ?? "")).toContain(
"Pairing code: PAIRCODE",
client,
);
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
}, 10000);
});

File diff suppressed because it is too large Load Diff

View File

@@ -74,3 +74,27 @@ export async function probeDiscord(
};
}
}
export async function fetchDiscordApplicationId(
token: string,
timeoutMs: number,
fetcher: typeof fetch = fetch,
): Promise<string | undefined> {
const normalized = normalizeDiscordToken(token);
if (!normalized) return undefined;
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
timeoutMs,
fetcher,
{
Authorization: `Bot ${normalized}`,
},
);
if (!res.ok) return undefined;
const json = (await res.json()) as { id?: string };
return json.id ?? undefined;
} catch {
return undefined;
}
}

View File

@@ -1,4 +1,4 @@
import { PermissionsBitField, Routes } from "discord.js";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -53,7 +53,7 @@ const makeRest = () => {
get: getMock,
patch: patchMock,
delete: deleteMock,
} as unknown as import("discord.js").REST,
} as unknown as import("@buape/carbon").RequestClient,
postMock,
putMock,
getMock,
@@ -108,9 +108,7 @@ describe("sendMessageDiscord", () => {
it("adds missing permission hints on 50013", async () => {
const { rest, postMock, getMock } = makeRest();
const perms = new PermissionsBitField([
PermissionsBitField.Flags.ViewChannel,
]);
const perms = PermissionFlagsBits.ViewChannel;
const apiError = Object.assign(new Error("Missing Permissions"), {
code: 50013,
status: 403,
@@ -126,7 +124,7 @@ describe("sendMessageDiscord", () => {
.mockResolvedValueOnce({ id: "bot1" })
.mockResolvedValueOnce({
id: "guild1",
roles: [{ id: "guild1", permissions: perms.bitfield.toString() }],
roles: [{ id: "guild1", permissions: perms.toString() }],
})
.mockResolvedValueOnce({ roles: [] });
@@ -152,7 +150,9 @@ describe("sendMessageDiscord", () => {
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
files: [expect.objectContaining({ name: "photo.jpg" })],
body: expect.objectContaining({
files: [expect.objectContaining({ name: "photo.jpg" })],
}),
}),
);
});
@@ -268,10 +268,8 @@ describe("fetchChannelPermissionsDiscord", () => {
it("calculates permissions from guild roles", async () => {
const { rest, getMock } = makeRest();
const perms = new PermissionsBitField([
PermissionsBitField.Flags.ViewChannel,
PermissionsBitField.Flags.SendMessages,
]);
const perms =
PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages;
getMock
.mockResolvedValueOnce({
id: "chan1",
@@ -282,7 +280,7 @@ describe("fetchChannelPermissionsDiscord", () => {
.mockResolvedValueOnce({
id: "guild1",
roles: [
{ id: "guild1", permissions: perms.bitfield.toString() },
{ id: "guild1", permissions: perms.toString() },
{ id: "role2", permissions: "0" },
],
})
@@ -303,7 +301,7 @@ describe("readMessagesDiscord", () => {
vi.clearAllMocks();
});
it("passes query params as URLSearchParams", async () => {
it("passes query params as an object", async () => {
const { rest, getMock } = makeRest();
getMock.mockResolvedValue([]);
await readMessagesDiscord(
@@ -312,8 +310,8 @@ describe("readMessagesDiscord", () => {
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe("limit=5&before=10");
const options = call?.[1] as Record<string, unknown>;
expect(options).toEqual({ limit: 5, before: "10" });
});
});
@@ -376,8 +374,7 @@ describe("searchMessagesDiscord", () => {
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe("content=hello&limit=5");
expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5");
});
it("supports channel/author arrays and clamps limit", async () => {
@@ -394,9 +391,8 @@ describe("searchMessagesDiscord", () => {
{ rest, token: "t" },
);
const call = getMock.mock.calls[0];
const options = call?.[1] as { query?: URLSearchParams };
expect(options.query?.toString()).toBe(
"content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
expect(call?.[0]).toBe(
"/guilds/g1/messages/search?content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
);
});
});
@@ -546,13 +542,13 @@ describe("uploadStickerDiscord", () => {
name: "clawdbot_wave",
description: "Clawdbot waving",
tags: "👋",
files: [
expect.objectContaining({
name: "asset.png",
contentType: "image/png",
}),
],
},
files: [
expect.objectContaining({
name: "asset.png",
contentType: "image/png",
}),
],
}),
);
});

View File

@@ -1,4 +1,4 @@
import { ChannelType, PermissionsBitField, REST, Routes } from "discord.js";
import { RequestClient } from "@buape/carbon";
import { PollLayoutType } from "discord-api-types/payloads/v10";
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
import type {
@@ -11,6 +11,11 @@ import type {
APIVoiceState,
RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import {
ChannelType,
PermissionFlagsBits,
Routes,
} from "discord-api-types/v10";
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
@@ -47,6 +52,10 @@ export class DiscordSendError extends Error {
}
}
const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter(
([, value]) => typeof value === "bigint",
) as Array<[string, bigint]>;
type DiscordRecipient =
| {
kind: "user";
@@ -61,7 +70,7 @@ type DiscordSendOpts = {
token?: string;
mediaUrl?: string;
verbose?: boolean;
rest?: REST;
rest?: RequestClient;
replyTo?: string;
};
@@ -72,7 +81,7 @@ export type DiscordSendResult = {
export type DiscordReactOpts = {
token?: string;
rest?: REST;
rest?: RequestClient;
};
export type DiscordReactionUser = {
@@ -174,6 +183,10 @@ function resolveToken(explicit?: string) {
return token;
}
function resolveRest(token: string, rest?: RequestClient) {
return rest ?? new RequestClient(token);
}
function normalizeReactionEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
@@ -252,6 +265,22 @@ function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
};
}
function addPermissionBits(base: bigint, add?: string) {
if (!add) return base;
return base | BigInt(add);
}
function removePermissionBits(base: bigint, deny?: string) {
if (!deny) return base;
return base & ~BigInt(deny);
}
function bitfieldToPermissions(bitfield: bigint) {
return PERMISSION_ENTRIES.filter(([, value]) => (bitfield & value) === value)
.map(([name]) => name)
.sort();
}
function getDiscordErrorCode(err: unknown) {
if (!err || typeof err !== "object") return undefined;
const candidate =
@@ -279,7 +308,7 @@ async function buildDiscordSendError(
err: unknown,
ctx: {
channelId: string;
rest: REST;
rest: RequestClient;
token: string;
hasMedia: boolean;
},
@@ -327,7 +356,7 @@ async function buildDiscordSendError(
}
async function resolveChannelId(
rest: REST,
rest: RequestClient,
recipient: DiscordRecipient,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
@@ -343,7 +372,7 @@ async function resolveChannelId(
}
async function sendDiscordText(
rest: REST,
rest: RequestClient,
channelId: string,
text: string,
replyTo?: string,
@@ -379,7 +408,7 @@ async function sendDiscordText(
}
async function sendDiscordMedia(
rest: REST,
rest: RequestClient,
channelId: string,
text: string,
mediaUrl: string,
@@ -395,13 +424,13 @@ async function sendDiscordMedia(
body: {
content: caption || undefined,
message_reference: messageReference,
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
},
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
})) as { id: string; channel_id: string };
if (text.length > DISCORD_TEXT_LIMIT) {
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
@@ -429,7 +458,7 @@ function formatReactionEmoji(emoji: {
return buildReactionIdentifier(emoji);
}
async function fetchBotUserId(rest: REST) {
async function fetchBotUserId(rest: RequestClient) {
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
if (!me?.id) {
throw new Error("Failed to resolve bot user id");
@@ -443,7 +472,7 @@ export async function sendMessageDiscord(
opts: DiscordSendOpts = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
let result:
@@ -482,7 +511,7 @@ export async function sendStickerDiscord(
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
@@ -505,7 +534,7 @@ export async function sendPollDiscord(
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
@@ -529,7 +558,7 @@ export async function reactMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const encoded = normalizeReactionEmoji(emoji);
await rest.put(
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
@@ -543,7 +572,7 @@ export async function fetchReactionsDiscord(
opts: DiscordReactOpts & { limit?: number } = {},
): Promise<DiscordReactionSummary[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const message = (await rest.get(
Routes.channelMessage(channelId, messageId),
)) as {
@@ -566,7 +595,7 @@ export async function fetchReactionsDiscord(
const encoded = encodeURIComponent(identifier);
const users = (await rest.get(
Routes.channelMessageReaction(channelId, messageId, encoded),
{ query: new URLSearchParams({ limit: String(limit) }) },
{ limit },
)) as Array<{ id: string; username?: string; discriminator?: string }>;
summaries.push({
emoji: {
@@ -593,7 +622,7 @@ export async function fetchChannelPermissionsDiscord(
opts: DiscordReactOpts = {},
): Promise<DiscordPermissionsSummary> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
const channelType = "type" in channel ? channel.type : undefined;
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
@@ -616,47 +645,47 @@ export async function fetchChannelPermissionsDiscord(
const rolesById = new Map<string, APIRole>(
(guild.roles ?? []).map((role) => [role.id, role]),
);
const base = new PermissionsBitField();
const everyoneRole = rolesById.get(guildId);
let base = 0n;
if (everyoneRole?.permissions) {
base.add(BigInt(everyoneRole.permissions));
base = addPermissionBits(base, everyoneRole.permissions);
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
base.add(BigInt(role.permissions));
base = addPermissionBits(base, role.permissions);
}
}
const permissions = new PermissionsBitField(base);
let permissions = base;
const overwrites =
"permission_overwrites" in channel
? (channel.permission_overwrites ?? [])
: [];
for (const overwrite of overwrites) {
if (overwrite.id === guildId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (member.roles?.includes(overwrite.id)) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (overwrite.id === botId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
return {
channelId,
guildId,
permissions: permissions.toArray(),
raw: permissions.bitfield.toString(),
permissions: bitfieldToPermissions(permissions),
raw: permissions.toString(),
isDm: false,
channelType,
};
@@ -668,19 +697,20 @@ export async function readMessagesDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const limit =
typeof query.limit === "number" && Number.isFinite(query.limit)
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
: undefined;
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (query.before) params.set("before", query.before);
if (query.after) params.set("after", query.after);
if (query.around) params.set("around", query.around);
return (await rest.get(Routes.channelMessages(channelId), {
query: params,
})) as APIMessage[];
const params: Record<string, string | number> = {};
if (limit) params.limit = limit;
if (query.before) params.before = query.before;
if (query.after) params.after = query.after;
if (query.around) params.around = query.around;
return (await rest.get(
Routes.channelMessages(channelId),
params,
)) as APIMessage[];
}
export async function editMessageDiscord(
@@ -690,7 +720,7 @@ export async function editMessageDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
body: { content: payload.content },
})) as APIMessage;
@@ -702,7 +732,7 @@ export async function deleteMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelMessage(channelId, messageId));
return { ok: true };
}
@@ -713,7 +743,7 @@ export async function pinMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.put(Routes.channelPin(channelId, messageId));
return { ok: true };
}
@@ -724,7 +754,7 @@ export async function unpinMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelPin(channelId, messageId));
return { ok: true };
}
@@ -734,7 +764,7 @@ export async function listPinsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
}
@@ -744,7 +774,7 @@ export async function createThreadDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const body: Record<string, unknown> = { name: payload.name };
if (payload.autoArchiveMinutes) {
body.auto_archive_duration = payload.autoArchiveMinutes;
@@ -758,17 +788,18 @@ export async function listThreadsDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
if (payload.includeArchived) {
if (!payload.channelId) {
throw new Error("channelId required to list archived threads");
}
const params = new URLSearchParams();
if (payload.before) params.set("before", payload.before);
if (payload.limit) params.set("limit", String(payload.limit));
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
query: params,
});
const params: Record<string, string | number> = {};
if (payload.before) params.before = payload.before;
if (payload.limit) params.limit = payload.limit;
return await rest.get(
Routes.channelThreads(payload.channelId, "public"),
params,
);
}
return await rest.get(Routes.guildActiveThreads(payload.guildId));
}
@@ -778,7 +809,7 @@ export async function searchMessagesDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const params = new URLSearchParams();
params.set("content", query.content);
if (query.channelIds?.length) {
@@ -795,9 +826,9 @@ export async function searchMessagesDiscord(
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
params.set("limit", String(limit));
}
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
query: params,
});
return await rest.get(
`/guilds/${query.guildId}/messages/search?${params.toString()}`,
);
}
export async function listGuildEmojisDiscord(
@@ -805,7 +836,7 @@ export async function listGuildEmojisDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return await rest.get(Routes.guildEmojis(guildId));
}
@@ -814,7 +845,7 @@ export async function uploadEmojiDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw(
payload.mediaUrl,
DISCORD_MAX_EMOJI_BYTES,
@@ -844,7 +875,7 @@ export async function uploadStickerDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw(
payload.mediaUrl,
DISCORD_MAX_STICKER_BYTES,
@@ -866,14 +897,14 @@ export async function uploadStickerDiscord(
"Sticker description",
),
tags: normalizeEmojiName(payload.tags, "Sticker tags"),
files: [
{
data: media.buffer,
name: media.fileName ?? "sticker",
contentType,
},
],
},
files: [
{
data: media.buffer,
name: media.fileName ?? "sticker",
contentType,
},
],
});
}
@@ -883,7 +914,7 @@ export async function fetchMemberInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildMember(guildId, userId),
)) as APIGuildMember;
@@ -894,7 +925,7 @@ export async function fetchRoleInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIRole[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
}
@@ -903,7 +934,7 @@ export async function addRoleDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.put(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
@@ -915,7 +946,7 @@ export async function removeRoleDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
@@ -927,7 +958,7 @@ export async function fetchChannelInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channel(channelId))) as APIChannel;
}
@@ -936,7 +967,7 @@ export async function listGuildChannelsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIChannel[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
}
@@ -946,7 +977,7 @@ export async function fetchVoiceStatusDiscord(
opts: DiscordReactOpts = {},
): Promise<APIVoiceState> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildVoiceState(guildId, userId),
)) as APIVoiceState;
@@ -957,7 +988,7 @@ export async function listScheduledEventsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildScheduledEvents(guildId),
)) as APIGuildScheduledEvent[];
@@ -969,7 +1000,7 @@ export async function createScheduledEventDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.post(Routes.guildScheduledEvents(guildId), {
body: payload,
})) as APIGuildScheduledEvent;
@@ -980,7 +1011,7 @@ export async function timeoutMemberDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
let until = payload.until;
if (!until && payload.durationMinutes) {
const ms = payload.durationMinutes * 60 * 1000;
@@ -990,7 +1021,9 @@ export async function timeoutMemberDiscord(
Routes.guildMember(payload.guildId, payload.userId),
{
body: { communication_disabled_until: until ?? null },
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
},
)) as APIGuildMember;
}
@@ -1000,9 +1033,11 @@ export async function kickMemberDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
});
return { ok: true };
}
@@ -1012,7 +1047,7 @@ export async function banMemberDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const deleteMessageDays =
typeof payload.deleteMessageDays === "number" &&
Number.isFinite(payload.deleteMessageDays)
@@ -1023,7 +1058,9 @@ export async function banMemberDiscord(
deleteMessageDays !== undefined
? { delete_message_days: deleteMessageDays }
: undefined,
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
});
return { ok: true };
}

View File

@@ -473,7 +473,6 @@ export function createProviderManager(
token: discordToken.trim(),
runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal,
slashCommand: cfg.discord?.slashCommand,
mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit,
})

View File

@@ -248,7 +248,6 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
// Should merge into a single user message
expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("user");
expect(contents[0].parts).toHaveLength(2);
@@ -333,7 +332,6 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
// Should have 1 user + 1 merged model message
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
@@ -394,17 +392,16 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
// Tool result creates a user turn with functionResponse
// The next user message should be merged into it or there should be proper alternation
// Check that we don't have consecutive user messages
for (let i = 1; i < contents.length; i++) {
if (contents[i].role === "user" && contents[i - 1].role === "user") {
// If consecutive, they should have been merged
expect.fail("Consecutive user messages should be merged");
}
}
// The conversation should be valid for Gemini
expect(contents.length).toBeGreaterThan(0);
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user");
const toolResponsePart = contents[2].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolResponse = asRecord(toolResponsePart);
expect(toolResponse.functionResponse).toBeTruthy();
});
it("ensures function call comes after user turn, not after model turn", () => {
@@ -472,11 +469,14 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
// Consecutive model messages should be merged so function call is in same turn as text
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
// The model message should have both text and function call
expect(contents[1].parts?.length).toBe(2);
const toolCallPart = contents[1].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionCall" in part,
);
const toolCall = asRecord(toolCallPart);
expect(toolCall.functionCall).toBeTruthy();
});
});

View File

@@ -8,6 +8,11 @@ import {
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
@@ -389,6 +394,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const channelsConfig = cfg.slack?.channels;
const dmEnabled = dmConfig?.enabled ?? true;
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
const slashCommand = resolveSlackSlashCommandConfig(
@@ -672,7 +678,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
name: senderName,
});
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "slack",
});
const shouldBypassMention =
allowTextCommands &&
isRoom &&
channelConfig?.requireMention &&
!wasMentioned &&
@@ -1301,193 +1312,242 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
},
);
if (slashCommand.enabled) {
const handleSlashCommand = async (params: {
command: SlackCommandMiddlewareArgs["command"];
ack: SlackCommandMiddlewareArgs["ack"];
respond: SlackCommandMiddlewareArgs["respond"];
prompt: string;
}) => {
const { command, ack, respond, prompt } = params;
try {
if (!prompt.trim()) {
await ack({
text: "Message required.",
response_type: "ephemeral",
});
return;
}
await ack();
if (botUserId && command.user_id === botUserId) return;
const channelInfo = await resolveChannelName(command.channel_id);
const channelType =
channelInfo?.type ??
(command.channel_name === "directmessage" ? "im" : undefined);
const isDirectMessage = channelType === "im";
const isGroupDm = channelType === "mpim";
const isRoom = channelType === "channel" || channelType === "group";
if (isDirectMessage && !dmEnabled) {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (isGroupDm && !groupDmEnabled) {
await respond({
text: "Slack group DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (isGroupDm && groupDmChannels.length > 0) {
const allowList = normalizeAllowListLower(groupDmChannels);
const channelName = channelInfo?.name;
const candidates = [
command.channel_id,
channelName ? `#${channelName}` : undefined,
channelName,
channelName ? normalizeSlackSlug(channelName) : undefined,
]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase());
const permitted =
allowList.includes("*") ||
candidates.some((candidate) => allowList.includes(candidate));
if (!permitted) {
await respond({
text: "This group DM is not allowed.",
response_type: "ephemeral",
});
return;
}
}
const storeAllowFrom = await readProviderAllowFromStore("slack").catch(
() => [],
);
const effectiveAllowFrom = normalizeAllowList([
...allowFrom,
...storeAllowFrom,
]);
const effectiveAllowFromLower =
normalizeAllowListLower(effectiveAllowFrom);
let commandAuthorized = true;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (dmPolicy !== "open") {
const sender = await resolveUserName(command.user_id);
const senderName = sender?.name ?? undefined;
const permitted = allowListMatches({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({
provider: "slack",
id: command.user_id,
meta: { name: senderName },
});
await respond({
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
response_type: "ephemeral",
});
} else {
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
return;
}
commandAuthorized = true;
}
}
if (isRoom) {
const channelConfig = resolveSlackChannelConfig({
channelId: command.channel_id,
channelName: channelInfo?.name,
channels: channelsConfig,
});
if (
useAccessGroups &&
!isSlackRoomAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured:
Boolean(channelsConfig) &&
Object.keys(channelsConfig ?? {}).length > 0,
channelAllowed: channelConfig?.allowed !== false,
})
) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
if (useAccessGroups && channelConfig?.allowed === false) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
const sender = await resolveUserName(command.user_id);
const senderName = sender?.name ?? command.user_name ?? command.user_id;
const channelName = channelInfo?.name;
const roomLabel = channelName
? `#${channelName}`
: `#${command.channel_id}`;
const isRoomish = isRoom || isGroupDm;
const route = resolveAgentRoute({
cfg,
provider: "slack",
teamId: teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? command.user_id : command.channel_id,
},
});
const ctxPayload = {
Body: prompt,
From: isDirectMessage
? `slack:${command.user_id}`
: isRoom
? `slack:channel:${command.channel_id}`
: `slack:group:${command.channel_id}`,
To: `slash:${command.user_id}`,
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
GroupSubject: isRoomish ? roomLabel : undefined,
SenderName: senderName,
SenderId: command.user_id,
Provider: "slack" as const,
Surface: "slack" as const,
WasMentioned: true,
MessageSid: command.trigger_id,
Timestamp: Date.now(),
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
AccountId: route.accountId,
CommandSource: "native" as const,
CommandAuthorized: commandAuthorized,
};
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
await deliverSlackSlashReplies({
replies,
respond,
ephemeral: slashCommand.ephemeral,
textLimit,
});
} catch (err) {
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
await respond({
text: "Sorry, something went wrong handling that command.",
response_type: "ephemeral",
});
}
};
const nativeCommands =
cfg.commands?.native === true ? listNativeCommandSpecs() : [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
app.command(
`/${command.name}`,
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
const prompt = buildCommandText(command.name, cmd.text);
await handleSlashCommand({ command: cmd, ack, respond, prompt });
},
);
}
} else if (slashCommand.enabled) {
app.command(
slashCommand.name,
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
try {
const prompt = command.text?.trim();
if (!prompt) {
await ack({
text: "Message required.",
response_type: "ephemeral",
});
return;
}
await ack();
if (botUserId && command.user_id === botUserId) return;
const channelInfo = await resolveChannelName(command.channel_id);
const channelType =
channelInfo?.type ??
(command.channel_name === "directmessage" ? "im" : undefined);
const isDirectMessage = channelType === "im";
const isGroupDm = channelType === "mpim";
const isRoom = channelType === "channel" || channelType === "group";
if (isDirectMessage && !dmEnabled) {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (isGroupDm && !groupDmEnabled) {
await respond({
text: "Slack group DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (isGroupDm && groupDmChannels.length > 0) {
const allowList = normalizeAllowListLower(groupDmChannels);
const channelName = channelInfo?.name;
const candidates = [
command.channel_id,
channelName ? `#${channelName}` : undefined,
channelName,
channelName ? normalizeSlackSlug(channelName) : undefined,
]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase());
const permitted =
allowList.includes("*") ||
candidates.some((candidate) => allowList.includes(candidate));
if (!permitted) {
await respond({
text: "This group DM is not allowed.",
response_type: "ephemeral",
});
return;
}
}
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readProviderAllowFromStore(
"slack",
).catch(() => []);
const effectiveAllowFrom = normalizeAllowList([
...allowFrom,
...storeAllowFrom,
]);
const sender = await resolveUserName(command.user_id);
const permitted = allowListMatches({
allowList: normalizeAllowListLower(effectiveAllowFrom),
id: command.user_id,
name: sender?.name ?? undefined,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const senderName = sender?.name ?? undefined;
const { code } = await upsertProviderPairingRequest({
provider: "slack",
id: command.user_id,
meta: { name: senderName },
});
await respond({
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
response_type: "ephemeral",
});
} else {
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
return;
}
}
}
if (isRoom) {
const channelConfig = resolveSlackChannelConfig({
channelId: command.channel_id,
channelName: channelInfo?.name,
channels: channelsConfig,
});
if (channelConfig?.allowed === false) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
const sender = await resolveUserName(command.user_id);
const senderName =
sender?.name ?? command.user_name ?? command.user_id;
const channelName = channelInfo?.name;
const roomLabel = channelName
? `#${channelName}`
: `#${command.channel_id}`;
const isRoomish = isRoom || isGroupDm;
const route = resolveAgentRoute({
cfg,
provider: "slack",
teamId: teamId || undefined,
peer: { kind: "dm", id: command.user_id },
});
const ctxPayload = {
Body: prompt,
From: isDirectMessage
? `slack:${command.user_id}`
: isRoom
? `slack:channel:${command.channel_id}`
: `slack:group:${command.channel_id}`,
To: `slash:${command.user_id}`,
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
GroupSubject: isRoomish ? roomLabel : undefined,
SenderName: senderName,
Provider: "slack" as const,
WasMentioned: true,
MessageSid: command.trigger_id,
Timestamp: Date.now(),
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
AccountId: route.accountId,
};
const replyResult = await getReplyFromConfig(
ctxPayload,
undefined,
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
await deliverSlackSlashReplies({
replies,
respond,
ephemeral: slashCommand.ephemeral,
textLimit,
});
} catch (err) {
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
await respond({
text: "Sorry, something went wrong handling that command.",
response_type: "ephemeral",
});
}
await handleSlashCommand({
command,
ack,
respond,
prompt: command.text?.trim() ?? "",
});
},
);
}

View File

@@ -41,6 +41,7 @@ const onSpy = vi.fn();
const stopSpy = vi.fn();
const sendChatActionSpy = vi.fn();
const setMessageReactionSpy = vi.fn(async () => undefined);
const setMyCommandsSpy = vi.fn(async () => undefined);
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
@@ -48,6 +49,7 @@ type ApiStub = {
config: { use: (arg: unknown) => void };
sendChatAction: typeof sendChatActionSpy;
setMessageReaction: typeof setMessageReactionSpy;
setMyCommands: typeof setMyCommandsSpy;
sendMessage: typeof sendMessageSpy;
sendAnimation: typeof sendAnimationSpy;
sendPhoto: typeof sendPhotoSpy;
@@ -56,6 +58,7 @@ const apiStub: ApiStub = {
config: { use: useSpy },
sendChatAction: sendChatActionSpy,
setMessageReaction: setMessageReactionSpy,
setMyCommands: setMyCommandsSpy,
sendMessage: sendMessageSpy,
sendAnimation: sendAnimationSpy,
sendPhoto: sendPhotoSpy,
@@ -95,6 +98,7 @@ describe("createTelegramBot", () => {
sendAnimationSpy.mockReset();
sendPhotoSpy.mockReset();
setMessageReactionSpy.mockReset();
setMyCommandsSpy.mockReset();
});
it("installs grammY throttler", () => {
@@ -275,6 +279,16 @@ describe("createTelegramBot", () => {
]);
});
it("clears native commands when disabled", () => {
loadConfig.mockReturnValue({
commands: { native: false },
});
createTelegramBot({ token: "tok" });
expect(setMyCommandsSpy).toHaveBeenCalledWith([]);
});
it("skips group messages when requireMention is enabled and no mention matches", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<

View File

@@ -9,6 +9,10 @@ import {
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
} from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
@@ -160,6 +164,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
);
};
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const nativeEnabled = cfg.commands?.native === true;
const nativeDisabledExplicit = cfg.commands?.native === false;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
@@ -483,6 +490,139 @@ export function createTelegramBot(opts: TelegramBotOptions) {
if (!queuedFinal) return;
};
const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : [];
if (nativeCommands.length > 0) {
bot.api
.setMyCommands(
nativeCommands.map((command) => ({
command: command.name,
description: command.description,
})),
)
.catch((err) => {
runtime.error?.(
danger(`telegram setMyCommands failed: ${String(err)}`),
);
});
for (const command of nativeCommands) {
bot.command(command.name, async (ctx) => {
const msg = ctx.message;
if (!msg) return;
const chatId = msg.chat.id;
const isGroup =
msg.chat.type === "group" || msg.chat.type === "supergroup";
if (isGroup && useAccessGroups) {
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(
chatId,
"Telegram group commands are disabled.",
);
return;
}
if (groupPolicy === "allowlist") {
const senderId = msg.from?.id;
if (senderId == null) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: groupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return;
}
}
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()}`,
));
if (!commandAuthorized) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
const prompt = buildCommandText(command.name, ctx.match ?? "");
const ctxPayload = {
Body: prompt,
From: isGroup ? `group:${chatId}` : `telegram:${chatId}`,
To: `slash:${senderId || chatId}`,
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),
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
};
const replyResult = await getReplyFromConfig(
ctxPayload,
undefined,
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
await deliverReplies({
replies,
chatId: String(chatId),
token: opts.token,
runtime,
bot,
replyToMode,
textLimit,
});
});
}
} else if (nativeDisabledExplicit) {
bot.api.setMyCommands([]).catch((err) => {
runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`));
});
}
bot.on("message", async (ctx) => {
try {
const msg = ctx.message;