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:
Lucas Czekaj
2026-01-24 12:56:40 -08:00
committed by GitHub
parent fe7436a1f6
commit 483fba41b9
22 changed files with 1511 additions and 14 deletions

View File

@@ -0,0 +1,29 @@
export type ExecApprovalForwardingMode = "session" | "targets" | "both";
export type ExecApprovalForwardTarget = {
/** Channel id (e.g. "discord", "slack", or plugin channel id). */
channel: string;
/** Destination id (channel id, user id, etc. depending on channel). */
to: string;
/** Optional account id for multi-account channels. */
accountId?: string;
/** Optional thread id to reply inside a thread. */
threadId?: string | number;
};
export type ExecApprovalForwardingConfig = {
/** Enable forwarding exec approvals to chat channels. Default: false. */
enabled?: boolean;
/** Delivery mode (session=origin chat, targets=config targets, both=both). Default: session. */
mode?: ExecApprovalForwardingMode;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Explicit delivery targets (used when mode includes targets). */
targets?: ExecApprovalForwardTarget[];
};
export type ApprovalsConfig = {
exec?: ExecApprovalForwardingConfig;
};

View File

@@ -1,4 +1,5 @@
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { ApprovalsConfig } from "./types.approvals.js";
import type { AuthConfig } from "./types.auth.js";
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
@@ -84,6 +85,7 @@ export type ClawdbotConfig = {
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
approvals?: ApprovalsConfig;
session?: SessionConfig;
web?: WebConfig;
channels?: ChannelsConfig;

View File

@@ -72,6 +72,17 @@ export type DiscordActionConfig = {
channels?: boolean;
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Discord user IDs to receive approval prompts. Required if enabled. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
};
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -124,6 +135,8 @@ export type DiscordAccountConfig = {
guilds?: Record<string, DiscordGuildEntry>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding configuration. */
execApprovals?: DiscordExecApprovalConfig;
};
export type DiscordConfig = {

View File

@@ -2,6 +2,7 @@
export * from "./types.agent-defaults.js";
export * from "./types.agents.js";
export * from "./types.approvals.js";
export * from "./types.auth.js";
export * from "./types.base.js";
export * from "./types.browser.js";

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
const ExecApprovalForwardTargetSchema = z
.object({
channel: z.string().min(1),
to: z.string().min(1),
accountId: z.string().optional(),
threadId: z.union([z.string(), z.number()]).optional(),
})
.strict();
const ExecApprovalForwardingSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.union([z.literal("session"), z.literal("targets"), z.literal("both")]).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
targets: z.array(ExecApprovalForwardTargetSchema).optional(),
})
.strict()
.optional();
export const ApprovalsSchema = z
.object({
exec: ExecApprovalForwardingSchema,
})
.strict()
.optional();

View File

@@ -244,6 +244,15 @@ export const DiscordAccountSchema = z
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: z.array(z.union([z.string(), z.number()])).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
})
.strict()
.optional(),
})
.strict();

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
@@ -220,6 +221,7 @@ export const ClawdbotSchema = z
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
approvals: ApprovalsSchema,
session: SessionSchema,
cron: z
.object({