openclaw/src/infra/exec-approval-reply.ts

193 lines
6.0 KiB
TypeScript

import type { ReplyPayload } from "../auto-reply/types.js";
import type { ExecHost } from "./exec-approvals.js";
export type ExecApprovalReplyDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalUnavailableReason =
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
export type ExecApprovalReplyMetadata = {
approvalId: string;
approvalSlug: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
};
export type ExecApprovalPendingReplyParams = {
warningText?: string;
approvalId: string;
approvalSlug: string;
approvalCommandId?: string;
command: string;
cwd?: string;
host: ExecHost;
nodeId?: string;
expiresAtMs?: number;
nowMs?: number;
};
export type ExecApprovalUnavailableReplyParams = {
warningText?: string;
channelLabel?: string;
reason: ExecApprovalUnavailableReason;
sentApproverDms?: boolean;
};
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent approval DMs to the approvers for this account.";
}
export function formatExecApprovalExpiresIn(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(0, Math.round((expiresAtMs - nowMs) / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (hours === 0 && minutes < 5 && seconds > 0) {
parts.push(`${seconds}s`);
}
return parts.join(" ");
}
function buildFence(text: string, language?: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
const languagePrefix = language ? language : "";
return `${fence}${languagePrefix}\n${text}\n${fence}`;
}
export function getExecApprovalReplyMetadata(
payload: ReplyPayload,
): ExecApprovalReplyMetadata | null {
const channelData = payload.channelData;
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
return null;
}
const execApproval = channelData.execApproval;
if (!execApproval || typeof execApproval !== "object" || Array.isArray(execApproval)) {
return null;
}
const record = execApproval as Record<string, unknown>;
const approvalId = typeof record.approvalId === "string" ? record.approvalId.trim() : "";
const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : "";
if (!approvalId || !approvalSlug) {
return null;
}
const allowedDecisions = Array.isArray(record.allowedDecisions)
? record.allowedDecisions.filter(
(value): value is ExecApprovalReplyDecision =>
value === "allow-once" || value === "allow-always" || value === "deny",
)
: undefined;
return {
approvalId,
approvalSlug,
allowedDecisions,
};
}
export function buildExecApprovalPendingReplyPayload(
params: ExecApprovalPendingReplyParams,
): ReplyPayload {
const approvalCommandId = params.approvalCommandId?.trim() || params.approvalSlug;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText);
}
lines.push("Approval required.");
lines.push("Run:");
lines.push(buildFence(`/approve ${approvalCommandId} allow-once`, "txt"));
lines.push("Pending command:");
lines.push(buildFence(params.command, "sh"));
lines.push("Other options:");
lines.push(
buildFence(
`/approve ${approvalCommandId} allow-always\n/approve ${approvalCommandId} deny`,
"txt",
),
);
const info: string[] = [];
info.push(`Host: ${params.host}`);
if (params.nodeId) {
info.push(`Node: ${params.nodeId}`);
}
if (params.cwd) {
info.push(`CWD: ${params.cwd}`);
}
if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) {
info.push(
`Expires in: ${formatExecApprovalExpiresIn(params.expiresAtMs, params.nowMs ?? Date.now())}`,
);
}
info.push(`Full id: \`${params.approvalId}\``);
lines.push(info.join("\n"));
return {
text: lines.join("\n\n"),
channelData: {
execApproval: {
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
};
}
export function buildExecApprovalUnavailableReplyPayload(
params: ExecApprovalUnavailableReplyParams,
): ReplyPayload {
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText);
}
if (params.sentApproverDms) {
lines.push(getExecApprovalApproverDmNoticeText());
return {
text: lines.join("\n\n"),
};
}
if (params.reason === "initiating-platform-disabled") {
lines.push(
`Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or enable Discord or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.",
);
} else if (params.reason === "initiating-platform-unsupported") {
lines.push(
`Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or enable Discord or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.",
);
} else {
lines.push(
"Exec approval is required, but no interactive approval client is currently available.",
);
lines.push(
"Open the Web UI or terminal UI, or enable Discord or Telegram exec approvals, then retry the command. If those accounts already know your owner ID via allowFrom, you can usually leave execApprovals.approvers unset.",
);
}
return {
text: lines.join("\n\n"),
};
}