fix: unify control command handling

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

View File

@@ -0,0 +1,26 @@
const CONTROL_COMMAND_RE =
/(?:^|\s)\/(?:status|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
const CONTROL_COMMAND_EXACT = new Set([
"status",
"/status",
"restart",
"/restart",
"activation",
"/activation",
"send",
"/send",
"reset",
"/reset",
"new",
"/new",
]);
export function hasControlCommand(text?: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const lowered = trimmed.toLowerCase();
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
return CONTROL_COMMAND_RE.test(text);
}

View File

@@ -107,6 +107,23 @@ describe("trigger handling", () => {
});
});
it("reports status when /status appears inline", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "please /status now",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {

View File

@@ -25,6 +25,7 @@ import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
import { applySessionHints } from "./reply/body.js";
import { buildCommandContext, handleCommands } from "./reply/commands.js";
import { hasControlCommand } from "./command-detection.js";
import {
handleDirectiveOnly,
isDirectiveOnly,
@@ -252,11 +253,22 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
} = sessionState;
const directives = parseInlineDirectives(
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
);
sessionCtx.Body = directives.cleaned;
sessionCtx.BodyStripped = directives.cleaned;
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const commandAuthorized = ctx.CommandAuthorized ?? true;
const parsedDirectives = parseInlineDirectives(rawBody);
const directives = commandAuthorized
? parsedDirectives
: {
...parsedDirectives,
hasThinkDirective: false,
hasVerboseDirective: false,
hasStatusDirective: false,
hasModelDirective: false,
hasQueueDirective: false,
queueReset: false,
};
sessionCtx.Body = parsedDirectives.cleaned;
sessionCtx.BodyStripped = parsedDirectives.cleaned;
const surfaceKey =
sessionCtx.Surface?.trim().toLowerCase() ??
@@ -424,6 +436,7 @@ export async function getReplyFromConfig(
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
@@ -445,6 +458,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
directives,
sessionEntry,
sessionStore,
sessionKey,
@@ -484,6 +498,14 @@ export async function getReplyFromConfig(
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
if (
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(rawBody)
) {
typing.cleanup();
return undefined;
}
const isBareSessionReset =
isNewSession &&
baseBodyTrimmedRaw.length === 0 &&

View File

@@ -21,12 +21,13 @@ import type { ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { stripMentions } from "./mentions.js";
import type { InlineDirectives } from "./directive-handling.js";
export type CommandContext = {
surface: string;
isWhatsAppSurface: boolean;
ownerList: string[];
isOwnerSender: boolean;
isAuthorizedSender: boolean;
senderE164?: string;
abortKey?: string;
rawBodyNormalized: string;
@@ -41,8 +42,16 @@ export function buildCommandContext(params: {
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const {
ctx,
cfg,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
} = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface =
surface === "whatsapp" ||
@@ -80,14 +89,13 @@ export function buildCommandContext(params: {
const ownerList = ownerCandidates
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
const isOwnerSender =
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
const isAuthorizedSender = commandAuthorized;
return {
surface,
isWhatsAppSurface,
ownerList,
isOwnerSender,
isAuthorizedSender,
senderE164: senderE164 || undefined,
abortKey,
rawBodyNormalized,
@@ -101,6 +109,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -122,6 +131,7 @@ export async function handleCommands(params: {
const {
cfg,
command,
directives,
sessionEntry,
sessionStore,
sessionKey,
@@ -151,9 +161,9 @@ export async function handleCommands(params: {
reply: { text: "⚙️ Group activation only applies to group chats." },
};
}
if (!command.isOwnerSender) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -179,9 +189,9 @@ export async function handleCommands(params: {
}
if (sendPolicyCommand.hasCommand) {
if (!command.isOwnerSender) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -220,9 +230,9 @@ export async function handleCommands(params: {
command.commandBodyNormalized === "restart" ||
command.commandBodyNormalized.startsWith("/restart ")
) {
if (isGroup && !command.isOwnerSender) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
@@ -235,14 +245,15 @@ export async function handleCommands(params: {
};
}
if (
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized === "status" ||
command.commandBodyNormalized.startsWith("/status ")
) {
if (isGroup && !command.isOwnerSender) {
command.commandBodyNormalized.startsWith("/status ");
if (statusRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}

View File

@@ -20,6 +20,7 @@ import type { ReplyPayload } from "../types.js";
import {
type ElevatedLevel,
extractElevatedDirective,
extractStatusDirective,
extractThinkDirective,
extractVerboseDirective,
type ThinkLevel,
@@ -49,6 +50,7 @@ export type InlineDirectives = {
hasElevatedDirective: boolean;
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasStatusDirective: boolean;
hasModelDirective: boolean;
rawModelDirective?: string;
hasQueueDirective: boolean;
@@ -83,11 +85,15 @@ export function parseInlineDirectives(body: string): InlineDirectives {
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(verboseCleaned);
const {
cleaned: statusCleaned,
hasDirective: hasStatusDirective,
} = extractStatusDirective(elevatedCleaned);
const {
cleaned: modelCleaned,
rawModel,
hasDirective: hasModelDirective,
} = extractModelDirective(elevatedCleaned);
} = extractModelDirective(statusCleaned);
const {
cleaned: queueCleaned,
queueMode,
@@ -114,6 +120,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
hasElevatedDirective,
elevatedLevel,
rawElevatedLevel,
hasStatusDirective,
hasModelDirective,
rawModelDirective: rawModel,
hasQueueDirective,

View File

@@ -74,4 +74,19 @@ export function extractElevatedDirective(body?: string): {
};
}
export function extractStatusDirective(body?: string): {
cleaned: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
hasDirective: !!match,
};
}
export type { ElevatedLevel, ThinkLevel, VerboseLevel };

View File

@@ -17,11 +17,13 @@ export type MsgContext = {
GroupSpace?: string;
GroupMembers?: string;
SenderName?: string;
SenderId?: string;
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
};
export type TemplateContext = MsgContext & {