feat: gate slash commands and add compact
This commit is contained in:
@@ -6,30 +6,44 @@ import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
isEmbeddedPiRunActive,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { triggerClawdbotRestart } from "../../infra/restart.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
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 {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
} from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { buildHelpMessage, buildStatusMessage } from "../status.js";
|
||||
import {
|
||||
buildHelpMessage,
|
||||
buildStatusMessage,
|
||||
formatContextUsageShort,
|
||||
formatTokenCount,
|
||||
} from "../status.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
@@ -74,6 +88,30 @@ function resolveModelAuthLabel(
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function extractCompactInstructions(params: {
|
||||
rawBody?: string;
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
isGroup: boolean;
|
||||
}): string | undefined {
|
||||
const raw = stripStructuralPrefixes(params.rawBody ?? "");
|
||||
const stripped = params.isGroup
|
||||
? stripMentions(raw, params.ctx, params.cfg)
|
||||
: raw;
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const prefix = lowered.startsWith("/compact")
|
||||
? "/compact"
|
||||
: lowered.startsWith("compact")
|
||||
? "compact"
|
||||
: null;
|
||||
if (!prefix) return undefined;
|
||||
let rest = trimmed.slice(prefix.length).trimStart();
|
||||
if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
|
||||
return rest.length ? rest : undefined;
|
||||
}
|
||||
|
||||
export function buildCommandContext(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -82,66 +120,31 @@ export function buildCommandContext(params: {
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
} = params;
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const allowAll =
|
||||
!isWhatsAppSurface ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||
const abortKey =
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates =
|
||||
isWhatsAppSurface && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const isOwner =
|
||||
!isWhatsAppSurface ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
isAuthorizedSender,
|
||||
senderE164: senderE164 || undefined,
|
||||
isWhatsAppSurface: auth.isWhatsAppSurface,
|
||||
ownerList: auth.ownerList,
|
||||
isAuthorizedSender: auth.isAuthorizedSender,
|
||||
senderE164: auth.senderE164,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
commandBodyNormalized,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
from: auth.from,
|
||||
to: auth.to,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,6 +367,78 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply: { text: statusText } };
|
||||
}
|
||||
|
||||
const compactRequested =
|
||||
command.commandBodyNormalized === "/compact" ||
|
||||
command.commandBodyNormalized === "compact" ||
|
||||
command.commandBodyNormalized.startsWith("/compact ") ||
|
||||
command.commandBodyNormalized.startsWith("compact ");
|
||||
if (compactRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /compact from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!sessionEntry?.sessionId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Compaction unavailable (missing session id)." },
|
||||
};
|
||||
}
|
||||
const sessionId = sessionEntry.sessionId;
|
||||
if (isEmbeddedPiRunActive(sessionId)) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
||||
}
|
||||
const customInstructions = extractCompactInstructions({
|
||||
rawBody: ctx.Body,
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup,
|
||||
});
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface: command.surface,
|
||||
sessionFile: resolveSessionTranscriptPath(sessionId),
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot: sessionEntry.skillsSnapshot,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
customInstructions,
|
||||
ownerNumbers:
|
||||
command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
});
|
||||
|
||||
const totalTokens =
|
||||
sessionEntry.totalTokens ??
|
||||
(sessionEntry.inputTokens ?? 0) + (sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
contextTokens ?? sessionEntry.contextTokens ?? null,
|
||||
);
|
||||
const compactLabel = result.ok
|
||||
? result.compacted
|
||||
? result.result?.tokensBefore
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||
: "Compacted"
|
||||
: "Compaction skipped"
|
||||
: "Compaction failed";
|
||||
const reason = result.reason?.trim();
|
||||
const line = reason
|
||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||
: `${compactLabel} • ${contextSummary}`;
|
||||
enqueueSystemEvent(line);
|
||||
return { shouldContinue: false, reply: { text: `⚙️ ${line}` } };
|
||||
}
|
||||
|
||||
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
||||
if (abortRequested) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
@@ -37,8 +38,9 @@ export type SessionInitResult = {
|
||||
export async function initSessionState(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
commandAuthorized: boolean;
|
||||
}): Promise<SessionInitResult> {
|
||||
const { ctx, cfg } = params;
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
@@ -76,6 +78,11 @@ export async function initSessionState(params: {
|
||||
|
||||
const rawBody = ctx.Body ?? "";
|
||||
const trimmedBody = rawBody.trim();
|
||||
const resetAuthorized = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
}).isAuthorizedSender;
|
||||
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
|
||||
// web inbox before we get here. They prevented reset triggers like "/new"
|
||||
// from matching, so strip structural wrappers when checking for resets.
|
||||
@@ -84,6 +91,7 @@ export async function initSessionState(params: {
|
||||
: triggerBodyNormalized;
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (!resetAuthorized) break;
|
||||
if (trimmedBody === trigger || strippedForReset === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
|
||||
Reference in New Issue
Block a user