import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; import type { OpenClawConfig } from "../../config/config.js"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; import type { RuntimeEnv } from "../../runtime.js"; import { GatewayClient } from "../../gateway/client.js"; import { logDebug, logError } from "../../logger.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { createDiscordClient } from "../send.shared.js"; const EXEC_APPROVAL_KEY = "execapproval"; export type ExecApprovalRequest = { id: string; request: { command: string; cwd?: string | null; host?: string | null; security?: string | null; ask?: string | null; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; }; createdAtMs: number; expiresAtMs: number; }; export type ExecApprovalResolved = { id: string; decision: ExecApprovalDecision; resolvedBy?: string | null; ts: number; }; type PendingApproval = { discordMessageId: string; discordChannelId: string; timeoutId: NodeJS.Timeout; }; function encodeCustomIdValue(value: string): string { return encodeURIComponent(value); } function decodeCustomIdValue(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } export function buildExecApprovalCustomId( approvalId: string, action: ExecApprovalDecision, ): string { return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join( ";", ); } export function parseExecApprovalData( data: ComponentData, ): { approvalId: string; action: ExecApprovalDecision } | null { if (!data || typeof data !== "object") { return null; } const coerce = (value: unknown) => typeof value === "string" || typeof value === "number" ? String(value) : ""; const rawId = coerce(data.id); const rawAction = coerce(data.action); if (!rawId || !rawAction) { return null; } const action = rawAction as ExecApprovalDecision; if (action !== "allow-once" && action !== "allow-always" && action !== "deny") { return null; } return { approvalId: decodeCustomIdValue(rawId), action, }; } function formatExecApprovalEmbed(request: ExecApprovalRequest) { const commandText = request.request.command; const commandPreview = commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText; const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); const fields: Array<{ name: string; value: string; inline: boolean }> = [ { name: "Command", value: `\`\`\`\n${commandPreview}\n\`\`\``, inline: false, }, ]; if (request.request.cwd) { fields.push({ name: "Working Directory", value: request.request.cwd, inline: true, }); } if (request.request.host) { fields.push({ name: "Host", value: request.request.host, inline: true, }); } if (request.request.agentId) { fields.push({ name: "Agent", value: request.request.agentId, inline: true, }); } return { title: "Exec Approval Required", description: "A command needs your approval.", color: 0xffa500, // Orange fields, footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` }, timestamp: new Date().toISOString(), }; } function formatResolvedEmbed( request: ExecApprovalRequest, decision: ExecApprovalDecision, resolvedBy?: string | null, ) { const commandText = request.request.command; const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; const decisionLabel = decision === "allow-once" ? "Allowed (once)" : decision === "allow-always" ? "Allowed (always)" : "Denied"; const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287; return { title: `Exec Approval: ${decisionLabel}`, description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved", color, fields: [ { name: "Command", value: `\`\`\`\n${commandPreview}\n\`\`\``, inline: false, }, ], footer: { text: `ID: ${request.id}` }, timestamp: new Date().toISOString(), }; } function formatExpiredEmbed(request: ExecApprovalRequest) { const commandText = request.request.command; const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; return { title: "Exec Approval: Expired", description: "This approval request has expired.", color: 0x99aab5, // Gray fields: [ { name: "Command", value: `\`\`\`\n${commandPreview}\n\`\`\``, inline: false, }, ], footer: { text: `ID: ${request.id}` }, timestamp: new Date().toISOString(), }; } export type DiscordExecApprovalHandlerOpts = { token: string; accountId: string; config: DiscordExecApprovalConfig; gatewayUrl?: string; cfg: OpenClawConfig; runtime?: RuntimeEnv; onResolve?: (id: string, decision: ExecApprovalDecision) => Promise; }; export class DiscordExecApprovalHandler { private gatewayClient: GatewayClient | null = null; private pending = new Map(); private requestCache = new Map(); private opts: DiscordExecApprovalHandlerOpts; private started = false; constructor(opts: DiscordExecApprovalHandlerOpts) { this.opts = opts; } shouldHandle(request: ExecApprovalRequest): boolean { const config = this.opts.config; if (!config.enabled) { return false; } if (!config.approvers || config.approvers.length === 0) { return false; } // Check agent filter if (config.agentFilter?.length) { if (!request.request.agentId) { return false; } if (!config.agentFilter.includes(request.request.agentId)) { return false; } } // Check session filter (substring match) if (config.sessionFilter?.length) { const session = request.request.sessionKey; if (!session) { return false; } const matches = config.sessionFilter.some((p) => { try { return session.includes(p) || new RegExp(p).test(session); } catch { return session.includes(p); } }); if (!matches) { return false; } } return true; } async start(): Promise { if (this.started) { return; } this.started = true; const config = this.opts.config; if (!config.enabled) { logDebug("discord exec approvals: disabled"); return; } if (!config.approvers || config.approvers.length === 0) { logDebug("discord exec approvals: no approvers configured"); return; } logDebug("discord exec approvals: starting handler"); this.gatewayClient = new GatewayClient({ url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789", clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "Discord Exec Approvals", mode: GATEWAY_CLIENT_MODES.BACKEND, scopes: ["operator.approvals"], onEvent: (evt) => this.handleGatewayEvent(evt), onHelloOk: () => { logDebug("discord exec approvals: connected to gateway"); }, onConnectError: (err) => { logError(`discord exec approvals: connect error: ${err.message}`); }, onClose: (code, reason) => { logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`); }, }); this.gatewayClient.start(); } async stop(): Promise { if (!this.started) { return; } this.started = false; // Clear all pending timeouts for (const pending of this.pending.values()) { clearTimeout(pending.timeoutId); } this.pending.clear(); this.requestCache.clear(); this.gatewayClient?.stop(); this.gatewayClient = null; logDebug("discord exec approvals: stopped"); } private handleGatewayEvent(evt: EventFrame): void { if (evt.event === "exec.approval.requested") { const request = evt.payload as ExecApprovalRequest; void this.handleApprovalRequested(request); } else if (evt.event === "exec.approval.resolved") { const resolved = evt.payload as ExecApprovalResolved; void this.handleApprovalResolved(resolved); } } private async handleApprovalRequested(request: ExecApprovalRequest): Promise { if (!this.shouldHandle(request)) { return; } logDebug(`discord exec approvals: received request ${request.id}`); this.requestCache.set(request.id, request); const { rest, request: discordRequest } = createDiscordClient( { token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg, ); const embed = formatExecApprovalEmbed(request); // Build action rows with buttons const components = [ { type: 1, // ACTION_ROW components: [ { type: 2, // BUTTON style: ButtonStyle.Success, label: "Allow once", custom_id: buildExecApprovalCustomId(request.id, "allow-once"), }, { type: 2, // BUTTON style: ButtonStyle.Primary, label: "Always allow", custom_id: buildExecApprovalCustomId(request.id, "allow-always"), }, { type: 2, // BUTTON style: ButtonStyle.Danger, label: "Deny", custom_id: buildExecApprovalCustomId(request.id, "deny"), }, ], }, ]; const approvers = this.opts.config.approvers ?? []; for (const approver of approvers) { const userId = String(approver); try { // Create DM channel const dmChannel = (await discordRequest( () => rest.post(Routes.userChannels(), { body: { recipient_id: userId }, }) as Promise<{ id: string }>, "dm-channel", )) as { id: string }; if (!dmChannel?.id) { logError(`discord exec approvals: failed to create DM for user ${userId}`); continue; } // Send message with embed and buttons const message = (await discordRequest( () => rest.post(Routes.channelMessages(dmChannel.id), { body: { embeds: [embed], components, }, }) as Promise<{ id: string; channel_id: string }>, "send-approval", )) as { id: string; channel_id: string }; if (!message?.id) { logError(`discord exec approvals: failed to send message to user ${userId}`); continue; } // Set up timeout const timeoutMs = Math.max(0, request.expiresAtMs - Date.now()); const timeoutId = setTimeout(() => { void this.handleApprovalTimeout(request.id); }, timeoutMs); this.pending.set(request.id, { discordMessageId: message.id, discordChannelId: dmChannel.id, timeoutId, }); logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`); } catch (err) { logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`); } } } private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise { const pending = this.pending.get(resolved.id); if (!pending) { return; } clearTimeout(pending.timeoutId); this.pending.delete(resolved.id); const request = this.requestCache.get(resolved.id); this.requestCache.delete(resolved.id); if (!request) { return; } logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`); await this.updateMessage( pending.discordChannelId, pending.discordMessageId, formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy), ); } private async handleApprovalTimeout(approvalId: string): Promise { const pending = this.pending.get(approvalId); if (!pending) { return; } this.pending.delete(approvalId); const request = this.requestCache.get(approvalId); this.requestCache.delete(approvalId); if (!request) { return; } logDebug(`discord exec approvals: timeout for ${approvalId}`); await this.updateMessage( pending.discordChannelId, pending.discordMessageId, formatExpiredEmbed(request), ); } private async updateMessage( channelId: string, messageId: string, embed: ReturnType, ): Promise { try { const { rest, request: discordRequest } = createDiscordClient( { token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg, ); await discordRequest( () => rest.patch(Routes.channelMessage(channelId, messageId), { body: { embeds: [embed], components: [], // Remove buttons }, }), "update-approval", ); } catch (err) { logError(`discord exec approvals: failed to update message: ${String(err)}`); } } async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise { if (!this.gatewayClient) { logError("discord exec approvals: gateway client not connected"); return false; } logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`); try { await this.gatewayClient.request("exec.approval.resolve", { id: approvalId, decision, }); logDebug(`discord exec approvals: resolved ${approvalId} successfully`); return true; } catch (err) { logError(`discord exec approvals: resolve failed: ${String(err)}`); return false; } } } export type ExecApprovalButtonContext = { handler: DiscordExecApprovalHandler; }; export class ExecApprovalButton extends Button { label = "execapproval"; customId = `${EXEC_APPROVAL_KEY}:seed=1`; style = ButtonStyle.Primary; private ctx: ExecApprovalButtonContext; constructor(ctx: ExecApprovalButtonContext) { super(); this.ctx = ctx; } async run(interaction: ButtonInteraction, data: ComponentData): Promise { const parsed = parseExecApprovalData(data); if (!parsed) { try { await interaction.update({ content: "This approval is no longer valid.", components: [], }); } catch { // Interaction may have expired } return; } const decisionLabel = parsed.action === "allow-once" ? "Allowed (once)" : parsed.action === "allow-always" ? "Allowed (always)" : "Denied"; // Update the message immediately to show the decision try { await interaction.update({ content: `Submitting decision: **${decisionLabel}**...`, components: [], // Remove buttons }); } catch { // Interaction may have expired, try to continue anyway } const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action); if (!ok) { try { await interaction.followUp({ content: "Failed to submit approval decision. The request may have expired or already been resolved.", ephemeral: true, }); } catch { // Interaction may have expired } } // On success, the handleApprovalResolved event will update the message with the final result } } export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button { return new ExecApprovalButton(ctx); }