mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 15:51:22 +00:00
fix: unify control command handling
This commit is contained in:
26
src/auto-reply/command-detection.ts
Normal file
26
src/auto-reply/command-detection.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const CONTROL_COMMAND_RE =
|
||||
/(?:^|\s)\/(?:status|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
|
||||
|
||||
const CONTROL_COMMAND_EXACT = new Set([
|
||||
"status",
|
||||
"/status",
|
||||
"restart",
|
||||
"/restart",
|
||||
"activation",
|
||||
"/activation",
|
||||
"send",
|
||||
"/send",
|
||||
"reset",
|
||||
"/reset",
|
||||
"new",
|
||||
"/new",
|
||||
]);
|
||||
|
||||
export function hasControlCommand(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
|
||||
return CONTROL_COMMAND_RE.test(text);
|
||||
}
|
||||
@@ -107,6 +107,23 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports status when /status appears inline", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /status now",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Status");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows owner to set send policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { runReplyAgent } from "./reply/agent-runner.js";
|
||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||
import { applySessionHints } from "./reply/body.js";
|
||||
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import {
|
||||
handleDirectiveOnly,
|
||||
isDirectiveOnly,
|
||||
@@ -252,11 +253,22 @@ export async function getReplyFromConfig(
|
||||
triggerBodyNormalized,
|
||||
} = sessionState;
|
||||
|
||||
const directives = parseInlineDirectives(
|
||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
||||
);
|
||||
sessionCtx.Body = directives.cleaned;
|
||||
sessionCtx.BodyStripped = directives.cleaned;
|
||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const parsedDirectives = parseInlineDirectives(rawBody);
|
||||
const directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
...parsedDirectives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
sessionCtx.Body = parsedDirectives.cleaned;
|
||||
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
||||
|
||||
const surfaceKey =
|
||||
sessionCtx.Surface?.trim().toLowerCase() ??
|
||||
@@ -424,6 +436,7 @@ export async function getReplyFromConfig(
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
if (
|
||||
@@ -445,6 +458,7 @@ export async function getReplyFromConfig(
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -484,6 +498,14 @@ export async function getReplyFromConfig(
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
if (
|
||||
!commandAuthorized &&
|
||||
!baseBodyTrimmedRaw &&
|
||||
hasControlCommand(rawBody)
|
||||
) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
const isBareSessionReset =
|
||||
isNewSession &&
|
||||
baseBodyTrimmedRaw.length === 0 &&
|
||||
|
||||
@@ -21,12 +21,13 @@ import type { ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
isWhatsAppSurface: boolean;
|
||||
ownerList: string[];
|
||||
isOwnerSender: boolean;
|
||||
isAuthorizedSender: boolean;
|
||||
senderE164?: string;
|
||||
abortKey?: string;
|
||||
rawBodyNormalized: string;
|
||||
@@ -41,8 +42,16 @@ export function buildCommandContext(params: {
|
||||
sessionKey?: string;
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
} = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
@@ -80,14 +89,13 @@ export function buildCommandContext(params: {
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const isOwnerSender =
|
||||
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
|
||||
const isAuthorizedSender = commandAuthorized;
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
isOwnerSender,
|
||||
isAuthorizedSender,
|
||||
senderE164: senderE164 || undefined,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
@@ -101,6 +109,7 @@ export async function handleCommands(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -122,6 +131,7 @@ export async function handleCommands(params: {
|
||||
const {
|
||||
cfg,
|
||||
command,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -151,9 +161,9 @@ export async function handleCommands(params: {
|
||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||
};
|
||||
}
|
||||
if (!command.isOwnerSender) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -179,9 +189,9 @@ export async function handleCommands(params: {
|
||||
}
|
||||
|
||||
if (sendPolicyCommand.hasCommand) {
|
||||
if (!command.isOwnerSender) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -220,9 +230,9 @@ export async function handleCommands(params: {
|
||||
command.commandBodyNormalized === "restart" ||
|
||||
command.commandBodyNormalized.startsWith("/restart ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -235,14 +245,15 @@ export async function handleCommands(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
const statusRequested =
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status" ||
|
||||
command.commandBodyNormalized === "status" ||
|
||||
command.commandBodyNormalized.startsWith("/status ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
command.commandBodyNormalized.startsWith("/status ");
|
||||
if (statusRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
type ElevatedLevel,
|
||||
extractElevatedDirective,
|
||||
extractStatusDirective,
|
||||
extractThinkDirective,
|
||||
extractVerboseDirective,
|
||||
type ThinkLevel,
|
||||
@@ -49,6 +50,7 @@ export type InlineDirectives = {
|
||||
hasElevatedDirective: boolean;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
rawElevatedLevel?: string;
|
||||
hasStatusDirective: boolean;
|
||||
hasModelDirective: boolean;
|
||||
rawModelDirective?: string;
|
||||
hasQueueDirective: boolean;
|
||||
@@ -83,11 +85,15 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
rawLevel: rawElevatedLevel,
|
||||
hasDirective: hasElevatedDirective,
|
||||
} = extractElevatedDirective(verboseCleaned);
|
||||
const {
|
||||
cleaned: statusCleaned,
|
||||
hasDirective: hasStatusDirective,
|
||||
} = extractStatusDirective(elevatedCleaned);
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
hasDirective: hasModelDirective,
|
||||
} = extractModelDirective(elevatedCleaned);
|
||||
} = extractModelDirective(statusCleaned);
|
||||
const {
|
||||
cleaned: queueCleaned,
|
||||
queueMode,
|
||||
@@ -114,6 +120,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
hasElevatedDirective,
|
||||
elevatedLevel,
|
||||
rawElevatedLevel,
|
||||
hasStatusDirective,
|
||||
hasModelDirective,
|
||||
rawModelDirective: rawModel,
|
||||
hasQueueDirective,
|
||||
|
||||
@@ -74,4 +74,19 @@ export function extractElevatedDirective(body?: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export function extractStatusDirective(body?: string): {
|
||||
cleaned: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ThinkLevel, VerboseLevel };
|
||||
|
||||
@@ -17,11 +17,13 @@ export type MsgContext = {
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
SenderName?: string;
|
||||
SenderId?: string;
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
Surface?: string;
|
||||
WasMentioned?: boolean;
|
||||
CommandAuthorized?: boolean;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
Reference in New Issue
Block a user