Files
openclaw/src/auto-reply/reply/commands-core.ts
Bob 6a705a37f2 ACP: add persistent Discord channel and Telegram topic bindings (#34873)
* docs: add ACP persistent binding experiment plan

* docs: align ACP persistent binding spec to channel-local config

* docs: scope Telegram ACP bindings to forum topics only

* docs: lock bound /new and /reset behavior to in-place ACP reset

* ACP: add persistent discord/telegram conversation bindings

* ACP: fix persistent binding reuse and discord thread parent context

* docs: document channel-specific persistent ACP bindings

* ACP: split persistent bindings and share conversation id helpers

* ACP: defer configured binding init until preflight passes

* ACP: fix discord thread parent fallback and explicit disable inheritance

* ACP: keep bound /new and /reset in-place

* ACP: honor configured bindings in native command flows

* ACP: avoid configured fallback after runtime bind failure

* docs: refine ACP bindings experiment config examples

* acp: cut over to typed top-level persistent bindings

* ACP bindings: harden reset recovery and native command auth

* Docs: add ACP bound command auth proposal

* Tests: normalize i18n registry zh-CN assertion encoding

* ACP bindings: address review findings for reset and fallback routing

* ACP reset: gate hooks on success and preserve /new arguments

* ACP bindings: fix auth and binding-priority review findings

* Telegram ACP: gate ensure on auth and accepted messages

* ACP bindings: fix session-key precedence and unavailable handling

* ACP reset/native commands: honor fallback targets and abort on bootstrap failure

* Config schema: validate ACP binding channel and Telegram topic IDs

* Discord ACP: apply configured DM bindings to native commands

* ACP reset tails: dispatch through ACP after command handling

* ACP tails/native reset auth: fix target dispatch and restore full auth

* ACP reset detection: fallback to active ACP keys for DM contexts

* Tests: type runTurn mock input in ACP dispatch test

* ACP: dedup binding route bootstrap and reset target resolution

* reply: align ACP reset hooks with bound session key

* docs: replace personal discord ids with placeholders

* fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-05 09:38:12 +01:00

319 lines
11 KiB
TypeScript

import fs from "node:fs/promises";
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAcpCommand } from "./commands-acp.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import {
handleCommandsListCommand,
handleContextCommand,
handleExportSessionCommand,
handleHelpCommand,
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import {
handleAbortTrigger,
handleActivationCommand,
handleRestartCommand,
handleSessionCommand,
handleSendPolicyCommand,
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleTtsCommands } from "./commands-tts.js";
import type {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} from "./commands-types.js";
import { routeReply } from "./route-reply.js";
let HANDLERS: CommandHandler[] | null = null;
export type ResetCommandAction = "new" | "reset";
export async function emitResetCommandHooks(params: {
action: ResetCommandAction;
ctx: HandleCommandsParams["ctx"];
cfg: HandleCommandsParams["cfg"];
command: Pick<
HandleCommandsParams["command"],
"surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered"
>;
sessionKey?: string;
sessionEntry?: HandleCommandsParams["sessionEntry"];
previousSessionEntry?: HandleCommandsParams["previousSessionEntry"];
workspaceDir: string;
}): Promise<void> {
const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", {
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
cfg: params.cfg, // Pass config for LLM slug generation
});
await triggerInternalHook(hookEvent);
params.command.resetHookTriggered = true;
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
// oxlint-disable-next-line typescript/no-explicit-any
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const hookReply = { text: hookEvent.messages.join("\n\n") };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
// Fire before_reset plugin hook — extract memories before session history is lost
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
const sessionFile = prevEntry?.sessionFile;
// Fire-and-forget: read old session messages and run hook
void (async () => {
try {
const messages: unknown[] = [];
if (sessionFile) {
const content = await fs.readFile(sessionFile, "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// skip malformed lines
}
}
} else {
logVerbose("before_reset: no session file available, firing hook with empty messages");
}
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: params.action },
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
const mutableCtx = ctx as Record<string, unknown>;
mutableCtx.Body = resetTail;
mutableCtx.RawBody = resetTail;
mutableCtx.CommandBody = resetTail;
mutableCtx.BodyForCommands = resetTail;
mutableCtx.BodyForAgent = resetTail;
mutableCtx.BodyStripped = resetTail;
mutableCtx.AcpDispatchTailAfterReset = true;
}
function resolveSessionEntryForHookSessionKey(
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
sessionKey: string,
): HandleCommandsParams["sessionEntry"] | undefined {
if (!sessionStore) {
return undefined;
}
const directEntry = sessionStore[sessionKey];
if (directEntry) {
return directEntry;
}
const normalizedTarget = sessionKey.trim().toLowerCase();
if (!normalizedTarget) {
return undefined;
}
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
return candidateEntry;
}
}
return undefined;
}
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) {
HANDLERS = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
handleUsageCommand,
handleSessionCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,
handleExportSessionCommand,
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,
handleStopCommand,
handleCompactCommand,
handleAbortTrigger,
];
}
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
const resetRequested = Boolean(resetMatch);
if (resetRequested && !params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
const resetTail =
resetMatch != null
? params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart()
: "";
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
const boundAcpKey =
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetAcpSessionInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,
});
if (!resetResult.ok && !resetResult.skipped) {
logVerbose(
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
);
}
if (resetResult.ok) {
const hookSessionEntry =
boundAcpKey === params.sessionKey
? params.sessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
const hookPreviousSessionEntry =
boundAcpKey === params.sessionKey
? params.previousSessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: boundAcpKey,
sessionEntry: hookSessionEntry,
previousSessionEntry: hookPreviousSessionEntry,
workspaceDir: params.workspaceDir,
});
if (resetTail) {
applyAcpResetTailContext(params.ctx, resetTail);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyAcpResetTailContext(params.rootCtx, resetTail);
}
return {
shouldContinue: false,
};
}
return {
shouldContinue: false,
reply: { text: "✅ ACP session reset in place." },
};
}
if (resetResult.skipped) {
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
},
};
}
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset failed. Check /acp status and try again.",
},
};
}
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
workspaceDir: params.workspaceDir,
});
}
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: params.command.surface,
commandSource: params.ctx.CommandSource,
});
for (const handler of HANDLERS) {
const result = await handler(params, allowTextCommands);
if (result) {
return result;
}
}
const sendPolicy = resolveSendPolicy({
cfg: params.cfg,
entry: params.sessionEntry,
sessionKey: params.sessionKey,
channel: params.sessionEntry?.channel ?? params.command.channel,
chatType: params.sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
logVerbose(`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`);
return { shouldContinue: false };
}
return { shouldContinue: true };
}