fix: require slash for control commands

This commit is contained in:
Peter Steinberger
2026-01-06 07:05:08 +01:00
parent 7d896b5f67
commit b5c604b7b7
6 changed files with 41 additions and 24 deletions

View File

@@ -11,6 +11,7 @@
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
### Fixes ### Fixes
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { hasControlCommand } from "./command-detection.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
describe("control command parsing", () => {
it("requires slash for send policy", () => {
expect(parseSendPolicyCommand("/send on")).toEqual({
hasCommand: true,
mode: "allow",
});
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
});
it("requires slash for activation", () => {
expect(parseActivationCommand("/activation mention")).toEqual({
hasCommand: true,
mode: "mention",
});
expect(parseActivationCommand("activation mention")).toEqual({
hasCommand: false,
});
});
it("treats bare commands as non-control", () => {
expect(hasControlCommand("/send")).toBe(true);
expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("/help")).toBe(true);
expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
});
});

View File

@@ -2,21 +2,13 @@ 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; /(?:^|\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([ const CONTROL_COMMAND_EXACT = new Set([
"help",
"/help", "/help",
"status",
"/status", "/status",
"restart",
"/restart", "/restart",
"activation",
"/activation", "/activation",
"send",
"/send", "/send",
"reset",
"/reset", "/reset",
"new",
"/new", "/new",
"compact",
"/compact", "/compact",
]); ]);

View File

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

View File

@@ -102,11 +102,7 @@ function extractCompactInstructions(params: {
const trimmed = stripped.trim(); const trimmed = stripped.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
const lowered = trimmed.toLowerCase(); const lowered = trimmed.toLowerCase();
const prefix = lowered.startsWith("/compact") const prefix = lowered.startsWith("/compact") ? "/compact" : null;
? "/compact"
: lowered.startsWith("compact")
? "compact"
: null;
if (!prefix) return undefined; if (!prefix) return undefined;
let rest = trimmed.slice(prefix.length).trimStart(); let rest = trimmed.slice(prefix.length).trimStart();
if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
@@ -197,9 +193,7 @@ export async function handleCommands(params: {
const resetRequested = const resetRequested =
command.commandBodyNormalized === "/reset" || command.commandBodyNormalized === "/reset" ||
command.commandBodyNormalized === "reset" || command.commandBodyNormalized === "/new";
command.commandBodyNormalized === "/new" ||
command.commandBodyNormalized === "new";
if (resetRequested && !command.isAuthorizedSender) { if (resetRequested && !command.isAuthorizedSender) {
logVerbose( logVerbose(
`Ignoring /reset from unauthorized sender: ${command.senderE164 || "<unknown>"}`, `Ignoring /reset from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
@@ -300,7 +294,6 @@ export async function handleCommands(params: {
if ( if (
command.commandBodyNormalized === "/restart" || command.commandBodyNormalized === "/restart" ||
command.commandBodyNormalized === "restart" ||
command.commandBodyNormalized.startsWith("/restart ") command.commandBodyNormalized.startsWith("/restart ")
) { ) {
if (!command.isAuthorizedSender) { if (!command.isAuthorizedSender) {
@@ -320,7 +313,6 @@ export async function handleCommands(params: {
const helpRequested = const helpRequested =
command.commandBodyNormalized === "/help" || command.commandBodyNormalized === "/help" ||
command.commandBodyNormalized === "help" ||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized); /(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
if (helpRequested) { if (helpRequested) {
if (!command.isAuthorizedSender) { if (!command.isAuthorizedSender) {
@@ -335,7 +327,6 @@ export async function handleCommands(params: {
const statusRequested = const statusRequested =
directives.hasStatusDirective || directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" || command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized === "status" ||
command.commandBodyNormalized.startsWith("/status "); command.commandBodyNormalized.startsWith("/status ");
if (statusRequested) { if (statusRequested) {
if (!command.isAuthorizedSender) { if (!command.isAuthorizedSender) {
@@ -383,9 +374,7 @@ export async function handleCommands(params: {
const compactRequested = const compactRequested =
command.commandBodyNormalized === "/compact" || command.commandBodyNormalized === "/compact" ||
command.commandBodyNormalized === "compact" || command.commandBodyNormalized.startsWith("/compact ");
command.commandBodyNormalized.startsWith("/compact ") ||
command.commandBodyNormalized.startsWith("compact ");
if (compactRequested) { if (compactRequested) {
if (!command.isAuthorizedSender) { if (!command.isAuthorizedSender) {
logVerbose( logVerbose(

View File

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