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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
52
src/auto-reply/commands-registry.test.ts
Normal file
52
src/auto-reply/commands-registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
src/auto-reply/commands-registry.ts
Normal file
178
src/auto-reply/commands-registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user