* feat(discord): add exec approval forwarding to DMs Add support for forwarding exec approval requests to Discord DMs, allowing users to approve/deny command execution via interactive buttons. Features: - New DiscordExecApprovalHandler that connects to gateway and listens for exec.approval.requested/resolved events - Sends DMs with embeds showing command details and 3 buttons: Allow once, Always allow, Deny - Configurable via channels.discord.execApprovals with: - enabled: boolean - approvers: Discord user IDs to notify - agentFilter: only forward for specific agents - sessionFilter: only forward for matching session patterns - Updates message embed when approval is resolved or expires Also fixes exec completion routing: when async exec completes after approval, the heartbeat now uses a specialized prompt to ensure the model relays the result to the user instead of responding HEARTBEAT_OK. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: generic exec approvals forwarding (#1621) (thanks @czekaj) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { callGateway } from "../../gateway/call.js";
|
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import type { CommandHandler } from "./commands-types.js";
|
|
|
|
const COMMAND = "/approve";
|
|
|
|
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
|
|
allow: "allow-once",
|
|
once: "allow-once",
|
|
"allow-once": "allow-once",
|
|
allowonce: "allow-once",
|
|
always: "allow-always",
|
|
"allow-always": "allow-always",
|
|
allowalways: "allow-always",
|
|
deny: "deny",
|
|
reject: "deny",
|
|
block: "deny",
|
|
};
|
|
|
|
type ParsedApproveCommand =
|
|
| { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" }
|
|
| { ok: false; error: string };
|
|
|
|
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
|
|
const rest = trimmed.slice(COMMAND.length).trim();
|
|
if (!rest) {
|
|
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
|
}
|
|
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
if (tokens.length < 2) {
|
|
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
|
}
|
|
|
|
const first = tokens[0].toLowerCase();
|
|
const second = tokens[1].toLowerCase();
|
|
|
|
if (DECISION_ALIASES[first]) {
|
|
return {
|
|
ok: true,
|
|
decision: DECISION_ALIASES[first],
|
|
id: tokens.slice(1).join(" ").trim(),
|
|
};
|
|
}
|
|
if (DECISION_ALIASES[second]) {
|
|
return {
|
|
ok: true,
|
|
decision: DECISION_ALIASES[second],
|
|
id: tokens[0],
|
|
};
|
|
}
|
|
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
|
}
|
|
|
|
function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
|
|
const channel = params.command.channel;
|
|
const sender = params.command.senderId ?? "unknown";
|
|
return `${channel}:${sender}`;
|
|
}
|
|
|
|
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
|
|
if (!allowTextCommands) return null;
|
|
const normalized = params.command.commandBodyNormalized;
|
|
const parsed = parseApproveCommand(normalized);
|
|
if (!parsed) return null;
|
|
if (!params.command.isAuthorizedSender) {
|
|
logVerbose(
|
|
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
|
);
|
|
return { shouldContinue: false };
|
|
}
|
|
|
|
if (!parsed.ok) {
|
|
return { shouldContinue: false, reply: { text: parsed.error } };
|
|
}
|
|
|
|
const resolvedBy = buildResolvedByLabel(params);
|
|
try {
|
|
await callGateway({
|
|
method: "exec.approval.resolve",
|
|
params: { id: parsed.id, decision: parsed.decision },
|
|
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
clientDisplayName: `Chat approval (${resolvedBy})`,
|
|
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
|
});
|
|
} catch (err) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: `❌ Failed to submit approval: ${String(err)}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
|
|
};
|
|
};
|