feat(discord): add exec approval forwarding to DMs (#1621)
* 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>
This commit is contained in:
199
src/discord/monitor/exec-approvals.test.ts
Normal file
199
src/discord/monitor/exec-approvals.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecApprovalCustomId,
|
||||
parseExecApprovalData,
|
||||
type ExecApprovalRequest,
|
||||
DiscordExecApprovalHandler,
|
||||
} from "./exec-approvals.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||
|
||||
describe("buildExecApprovalCustomId", () => {
|
||||
it("encodes approval id and action", () => {
|
||||
const customId = buildExecApprovalCustomId("abc-123", "allow-once");
|
||||
expect(customId).toBe("execapproval:id=abc-123;action=allow-once");
|
||||
});
|
||||
|
||||
it("encodes special characters in approval id", () => {
|
||||
const customId = buildExecApprovalCustomId("abc=123;test", "deny");
|
||||
expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseExecApprovalData", () => {
|
||||
it("parses valid data", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" });
|
||||
expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
|
||||
});
|
||||
|
||||
it("parses encoded data", () => {
|
||||
const result = parseExecApprovalData({
|
||||
id: "abc%3D123%3Btest",
|
||||
action: "allow-always",
|
||||
});
|
||||
expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" });
|
||||
});
|
||||
|
||||
it("rejects invalid action", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123", action: "invalid" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
const result = parseExecApprovalData({ action: "deny" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing action", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects null/undefined input", () => {
|
||||
expect(parseExecApprovalData(null as any)).toBeNull();
|
||||
expect(parseExecApprovalData(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts all valid actions", () => {
|
||||
expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once");
|
||||
expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always");
|
||||
expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundtrip encoding", () => {
|
||||
it("encodes and decodes correctly", () => {
|
||||
const approvalId = "test-approval-with=special;chars&more";
|
||||
const action = "allow-always" as const;
|
||||
const customId = buildExecApprovalCustomId(approvalId, action);
|
||||
|
||||
// Parse the key=value pairs from the custom ID
|
||||
const parts = customId.split(";");
|
||||
const data: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/);
|
||||
if (match) {
|
||||
data[match[2]] = match[3];
|
||||
}
|
||||
}
|
||||
|
||||
const result = parseExecApprovalData(data);
|
||||
expect(result).toEqual({ approvalId, action });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordExecApprovalHandler.shouldHandle", () => {
|
||||
function createHandler(config: DiscordExecApprovalConfig) {
|
||||
return new DiscordExecApprovalHandler({
|
||||
token: "test-token",
|
||||
accountId: "default",
|
||||
config,
|
||||
cfg: {},
|
||||
});
|
||||
}
|
||||
|
||||
function createRequest(
|
||||
overrides: Partial<ExecApprovalRequest["request"]> = {},
|
||||
): ExecApprovalRequest {
|
||||
return {
|
||||
id: "test-id",
|
||||
request: {
|
||||
command: "echo hello",
|
||||
cwd: "/home/user",
|
||||
host: "gateway",
|
||||
agentId: "test-agent",
|
||||
sessionKey: "agent:test-agent:discord:123",
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60000,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns false when disabled", () => {
|
||||
const handler = createHandler({ enabled: false, approvers: ["123"] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no approvers", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: [] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true with minimal config", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by agent ID", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
agentFilter: ["allowed-agent"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
|
||||
expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
|
||||
expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by session key substring", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
sessionFilter: ["discord"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by session key regex", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
sessionFilter: ["^agent:.*:discord:"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("combines agent and session filters", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
agentFilter: ["my-agent"],
|
||||
sessionFilter: ["discord"],
|
||||
});
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "my-agent",
|
||||
sessionKey: "agent:my-agent:discord:123",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "other-agent",
|
||||
sessionKey: "agent:other:discord:123",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "my-agent",
|
||||
sessionKey: "agent:my-agent:telegram:123",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
549
src/discord/monitor/exec-approvals.ts
Normal file
549
src/discord/monitor/exec-approvals.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
|
||||
import { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { GatewayClient } from "../../gateway/client.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import type { EventFrame } from "../../gateway/protocol/index.js";
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import { createDiscordClient } from "../send.shared.js";
|
||||
import { logDebug, logError } from "../../logger.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||
import type { RuntimeEnv } from "../../runtime.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: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
|
||||
};
|
||||
|
||||
export class DiscordExecApprovalHandler {
|
||||
private gatewayClient: GatewayClient | null = null;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
private requestCache = new Map<string, ExecApprovalRequest>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof formatExpiredEmbed>,
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
createDiscordCommandArgFallbackButton,
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -406,6 +407,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
cfg,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const components = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
@@ -418,14 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
{
|
||||
commands,
|
||||
listeners: [],
|
||||
components: [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
],
|
||||
components,
|
||||
},
|
||||
[
|
||||
new GatewayPlugin({
|
||||
@@ -510,6 +529,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
@@ -575,6 +599,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user