2026-02-15 10:24:53 -06:00
import {
Button ,
Row ,
Separator ,
TextDisplay ,
serializePayload ,
type ButtonInteraction ,
type ComponentData ,
type MessagePayloadObject ,
type TopLevelComponents ,
} from "@buape/carbon" ;
2026-01-24 12:56:40 -08:00
import { ButtonStyle , Routes } from "discord-api-types/v10" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../../config/config.js" ;
2026-02-17 13:36:48 +09:00
import { loadSessionStore , resolveStorePath } from "../../config/sessions.js" ;
2026-02-01 10:03:47 +09:00
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js" ;
2026-02-17 13:36:48 +09:00
import { buildGatewayConnectionDetails } from "../../gateway/call.js" ;
import { GatewayClient } from "../../gateway/client.js" ;
2026-01-24 12:56:40 -08:00
import type { EventFrame } from "../../gateway/protocol/index.js" ;
2026-02-14 15:09:30 +00:00
import type {
ExecApprovalDecision ,
ExecApprovalRequest ,
ExecApprovalResolved ,
} from "../../infra/exec-approvals.js" ;
2026-02-01 10:03:47 +09:00
import { logDebug , logError } from "../../logger.js" ;
2026-02-15 10:24:53 -06:00
import { normalizeAccountId , resolveAgentIdFromSessionKey } from "../../routing/session-key.js" ;
2026-02-17 13:36:48 +09:00
import type { RuntimeEnv } from "../../runtime.js" ;
2026-02-15 10:24:53 -06:00
import {
GATEWAY_CLIENT_MODES ,
GATEWAY_CLIENT_NAMES ,
normalizeMessageChannel ,
} from "../../utils/message-channel.js" ;
import { createDiscordClient , stripUndefinedFields } from "../send.shared.js" ;
import { DiscordUiContainer } from "../ui.js" ;
2026-01-24 12:56:40 -08:00
const EXEC_APPROVAL_KEY = "execapproval" ;
2026-02-14 15:09:30 +00:00
export type { ExecApprovalRequest , ExecApprovalResolved } ;
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
/** Extract Discord channel ID from a session key like "agent:main:discord:channel:123456789" */
export function extractDiscordChannelId ( sessionKey? : string | null ) : string | null {
if ( ! sessionKey ) {
return null ;
}
// Session key format: agent:<id>:discord:channel:<channelId> or agent:<id>:discord:group:<channelId>
const match = sessionKey . match ( /discord:(?:channel|group):(\d+)/ ) ;
return match ? match [ 1 ] : null ;
}
2026-01-24 12:56:40 -08:00
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 {
2026-01-31 16:19:20 +09:00
if ( ! data || typeof data !== "object" ) {
return null ;
}
2026-01-24 12:56:40 -08:00
const coerce = ( value : unknown ) = >
typeof value === "string" || typeof value === "number" ? String ( value ) : "" ;
const rawId = coerce ( data . id ) ;
const rawAction = coerce ( data . action ) ;
2026-01-31 16:19:20 +09:00
if ( ! rawId || ! rawAction ) {
return null ;
}
2026-01-24 12:56:40 -08:00
const action = rawAction as ExecApprovalDecision ;
if ( action !== "allow-once" && action !== "allow-always" && action !== "deny" ) {
return null ;
}
return {
approvalId : decodeCustomIdValue ( rawId ) ,
action ,
} ;
}
2026-02-15 10:24:53 -06:00
type ExecApprovalContainerParams = {
cfg : OpenClawConfig ;
accountId : string ;
title : string ;
description? : string ;
commandPreview : string ;
metadataLines? : string [ ] ;
actionRow? : Row < Button > ;
footer? : string ;
accentColor? : string ;
} ;
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
class ExecApprovalContainer extends DiscordUiContainer {
constructor ( params : ExecApprovalContainerParams ) {
const components : Array < TextDisplay | Separator | Row < Button > > = [
new TextDisplay ( ` ## ${ params . title } ` ) ,
] ;
if ( params . description ) {
components . push ( new TextDisplay ( params . description ) ) ;
}
components . push ( new Separator ( { divider : true , spacing : "small" } ) ) ;
components . push ( new TextDisplay ( ` ### Command \ n \` \` \` \ n ${ params . commandPreview } \ n \` \` \` ` ) ) ;
if ( params . metadataLines ? . length ) {
components . push ( new TextDisplay ( params . metadataLines . join ( "\n" ) ) ) ;
}
if ( params . actionRow ) {
components . push ( params . actionRow ) ;
}
if ( params . footer ) {
components . push ( new Separator ( { divider : false , spacing : "small" } ) ) ;
components . push ( new TextDisplay ( ` -# ${ params . footer } ` ) ) ;
}
super ( {
cfg : params.cfg ,
accountId : params.accountId ,
components ,
accentColor : params.accentColor ,
2026-01-24 12:56:40 -08:00
} ) ;
}
2026-02-15 10:24:53 -06:00
}
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
class ExecApprovalActionButton extends Button {
customId : string ;
label : string ;
style : ButtonStyle ;
constructor ( params : {
approvalId : string ;
action : ExecApprovalDecision ;
label : string ;
style : ButtonStyle ;
} ) {
super ( ) ;
this . customId = buildExecApprovalCustomId ( params . approvalId , params . action ) ;
this . label = params . label ;
this . style = params . style ;
2026-01-24 12:56:40 -08:00
}
2026-02-15 10:24:53 -06:00
}
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
class ExecApprovalActionRow extends Row < Button > {
constructor ( approvalId : string ) {
super ( [
new ExecApprovalActionButton ( {
approvalId ,
action : "allow-once" ,
label : "Allow once" ,
style : ButtonStyle.Success ,
} ) ,
new ExecApprovalActionButton ( {
approvalId ,
action : "allow-always" ,
label : "Always allow" ,
style : ButtonStyle.Primary ,
} ) ,
new ExecApprovalActionButton ( {
approvalId ,
action : "deny" ,
label : "Deny" ,
style : ButtonStyle.Danger ,
} ) ,
] ) ;
}
}
function resolveExecApprovalAccountId ( params : {
cfg : OpenClawConfig ;
request : ExecApprovalRequest ;
} ) : string | null {
const sessionKey = params . request . request . sessionKey ? . trim ( ) ;
if ( ! sessionKey ) {
return null ;
}
try {
const agentId = resolveAgentIdFromSessionKey ( sessionKey ) ;
const storePath = resolveStorePath ( params . cfg . session ? . store , { agentId } ) ;
const store = loadSessionStore ( storePath ) ;
const entry = store [ sessionKey ] ;
const channel = normalizeMessageChannel ( entry ? . origin ? . provider ? ? entry ? . lastChannel ) ;
if ( channel && channel !== "discord" ) {
return null ;
}
const accountId = entry ? . origin ? . accountId ? ? entry ? . lastAccountId ;
return accountId ? . trim ( ) || null ;
} catch {
return null ;
}
}
function buildExecApprovalMetadataLines ( request : ExecApprovalRequest ) : string [ ] {
const lines : string [ ] = [ ] ;
if ( request . request . cwd ) {
lines . push ( ` - Working Directory: ${ request . request . cwd } ` ) ;
}
if ( request . request . host ) {
lines . push ( ` - Host: ${ request . request . host } ` ) ;
}
2026-01-24 12:56:40 -08:00
if ( request . request . agentId ) {
2026-02-15 10:24:53 -06:00
lines . push ( ` - Agent: ${ request . request . agentId } ` ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-15 10:24:53 -06:00
return lines ;
}
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
function buildExecApprovalPayload ( container : DiscordUiContainer ) : MessagePayloadObject {
const components : TopLevelComponents [ ] = [ container ] ;
return { components } ;
}
function createExecApprovalRequestContainer ( params : {
request : ExecApprovalRequest ;
cfg : OpenClawConfig ;
accountId : string ;
actionRow? : Row < Button > ;
} ) : ExecApprovalContainer {
const commandText = params . request . request . command ;
const commandPreview =
commandText . length > 1000 ? ` ${ commandText . slice ( 0 , 1000 ) } ... ` : commandText ;
const expiresAtSeconds = Math . max ( 0 , Math . floor ( params . request . expiresAtMs / 1000 ) ) ;
return new ExecApprovalContainer ( {
cfg : params.cfg ,
accountId : params.accountId ,
2026-01-24 12:56:40 -08:00
title : "Exec Approval Required" ,
description : "A command needs your approval." ,
2026-02-15 10:24:53 -06:00
commandPreview ,
metadataLines : buildExecApprovalMetadataLines ( params . request ) ,
actionRow : params.actionRow ,
footer : ` Expires <t: ${ expiresAtSeconds } :R> · ID: ${ params . request . id } ` ,
accentColor : "#FFA500" ,
} ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-15 10:24:53 -06:00
function createResolvedContainer ( params : {
request : ExecApprovalRequest ;
decision : ExecApprovalDecision ;
resolvedBy? : string | null ;
cfg : OpenClawConfig ;
accountId : string ;
} ) : ExecApprovalContainer {
const commandText = params . request . request . command ;
2026-01-24 12:56:40 -08:00
const commandPreview = commandText . length > 500 ? ` ${ commandText . slice ( 0 , 500 ) } ... ` : commandText ;
const decisionLabel =
2026-02-15 10:24:53 -06:00
params . decision === "allow-once"
2026-01-24 12:56:40 -08:00
? "Allowed (once)"
2026-02-15 10:24:53 -06:00
: params . decision === "allow-always"
2026-01-24 12:56:40 -08:00
? "Allowed (always)"
: "Denied" ;
2026-02-15 10:24:53 -06:00
const accentColor =
params . decision === "deny"
? "#ED4245"
: params . decision === "allow-always"
? "#5865F2"
: "#57F287" ;
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
return new ExecApprovalContainer ( {
cfg : params.cfg ,
accountId : params.accountId ,
2026-01-24 12:56:40 -08:00
title : ` Exec Approval: ${ decisionLabel } ` ,
2026-02-15 10:24:53 -06:00
description : params.resolvedBy ? ` Resolved by ${ params . resolvedBy } ` : "Resolved" ,
commandPreview ,
footer : ` ID: ${ params . request . id } ` ,
accentColor ,
} ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-15 10:24:53 -06:00
function createExpiredContainer ( params : {
request : ExecApprovalRequest ;
cfg : OpenClawConfig ;
accountId : string ;
} ) : ExecApprovalContainer {
const commandText = params . request . request . command ;
2026-01-24 12:56:40 -08:00
const commandPreview = commandText . length > 500 ? ` ${ commandText . slice ( 0 , 500 ) } ... ` : commandText ;
2026-02-15 10:24:53 -06:00
return new ExecApprovalContainer ( {
cfg : params.cfg ,
accountId : params.accountId ,
2026-01-24 12:56:40 -08:00
title : "Exec Approval: Expired" ,
description : "This approval request has expired." ,
2026-02-15 10:24:53 -06:00
commandPreview ,
footer : ` ID: ${ params . request . id } ` ,
accentColor : "#99AAB5" ,
} ) ;
2026-01-24 12:56:40 -08:00
}
export type DiscordExecApprovalHandlerOpts = {
token : string ;
accountId : string ;
config : DiscordExecApprovalConfig ;
gatewayUrl? : string ;
2026-01-30 03:15:10 +01:00
cfg : OpenClawConfig ;
2026-01-24 12:56:40 -08:00
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 ;
2026-01-31 16:19:20 +09:00
if ( ! config . enabled ) {
return false ;
}
if ( ! config . approvers || config . approvers . length === 0 ) {
return false ;
}
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
const requestAccountId = resolveExecApprovalAccountId ( {
cfg : this.opts.cfg ,
request ,
} ) ;
if ( requestAccountId ) {
const handlerAccountId = normalizeAccountId ( this . opts . accountId ) ;
if ( normalizeAccountId ( requestAccountId ) !== handlerAccountId ) {
return false ;
}
}
2026-01-24 12:56:40 -08:00
// Check agent filter
if ( config . agentFilter ? . length ) {
2026-01-31 16:19:20 +09:00
if ( ! request . request . agentId ) {
return false ;
}
if ( ! config . agentFilter . includes ( request . request . agentId ) ) {
return false ;
}
2026-01-24 12:56:40 -08:00
}
// Check session filter (substring match)
if ( config . sessionFilter ? . length ) {
const session = request . request . sessionKey ;
2026-01-31 16:19:20 +09:00
if ( ! session ) {
return false ;
}
2026-01-24 12:56:40 -08:00
const matches = config . sessionFilter . some ( ( p ) = > {
try {
return session . includes ( p ) || new RegExp ( p ) . test ( session ) ;
} catch {
return session . includes ( p ) ;
}
} ) ;
2026-01-31 16:19:20 +09:00
if ( ! matches ) {
return false ;
}
2026-01-24 12:56:40 -08:00
}
return true ;
}
async start ( ) : Promise < void > {
2026-01-31 16:19:20 +09:00
if ( this . started ) {
return ;
}
2026-01-24 12:56:40 -08:00
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" ) ;
2026-02-14 13:53:38 +00:00
const { url : gatewayUrl } = buildGatewayConnectionDetails ( {
config : this.opts.cfg ,
url : this.opts.gatewayUrl ,
} ) ;
2026-01-24 12:56:40 -08:00
this . gatewayClient = new GatewayClient ( {
2026-02-14 13:53:38 +00:00
url : gatewayUrl ,
2026-01-24 12:56:40 -08:00
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 > {
2026-01-31 16:19:20 +09:00
if ( ! this . started ) {
return ;
}
2026-01-24 12:56:40 -08:00
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 > {
2026-01-31 16:19:20 +09:00
if ( ! this . shouldHandle ( request ) ) {
return ;
}
2026-01-24 12:56:40 -08:00
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 ,
) ;
2026-02-15 10:24:53 -06:00
const actionRow = new ExecApprovalActionRow ( request . id ) ;
const container = createExecApprovalRequestContainer ( {
request ,
cfg : this.opts.cfg ,
accountId : this.opts.accountId ,
actionRow ,
} ) ;
const payload = buildExecApprovalPayload ( container ) ;
const body = stripUndefinedFields ( serializePayload ( payload ) ) ;
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
const target = this . opts . config . target ? ? "dm" ;
const sendToDm = target === "dm" || target === "both" ;
const sendToChannel = target === "channel" || target === "both" ;
let fallbackToDm = false ;
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
// Send to originating channel if configured
if ( sendToChannel ) {
const channelId = extractDiscordChannelId ( request . request . sessionKey ) ;
if ( channelId ) {
try {
const message = ( await discordRequest (
( ) = >
rest . post ( Routes . channelMessages ( channelId ) , {
2026-02-15 10:24:53 -06:00
body ,
2026-02-14 12:04:01 -06:00
} ) as Promise < { id : string ; channel_id : string } > ,
"send-approval-channel" ,
) ) as { id : string ; channel_id : string } ;
if ( message ? . id ) {
const timeoutMs = Math . max ( 0 , request . expiresAtMs - Date . now ( ) ) ;
const timeoutId = setTimeout ( ( ) = > {
void this . handleApprovalTimeout ( request . id , "channel" ) ;
} , timeoutMs ) ;
this . pending . set ( ` ${ request . id } :channel ` , {
discordMessageId : message.id ,
discordChannelId : channelId ,
timeoutId ,
} ) ;
logDebug ( ` discord exec approvals: sent approval ${ request . id } to channel ${ channelId } ` ) ;
}
} catch ( err ) {
logError ( ` discord exec approvals: failed to send to channel: ${ String ( err ) } ` ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-14 12:04:01 -06:00
} else {
if ( ! sendToDm ) {
logError (
` discord exec approvals: target is "channel" but could not extract channel id from session key " ${ request . request . sessionKey ? ? "(none)" } " — falling back to DM delivery for approval ${ request . id } ` ,
) ;
fallbackToDm = true ;
} else {
2026-02-15 10:24:53 -06:00
logDebug ( "discord exec approvals: could not extract channel id from session key" ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-14 12:04:01 -06:00
}
}
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
// Send to approver DMs if configured (or as fallback when channel extraction fails)
if ( sendToDm || fallbackToDm ) {
const approvers = this . opts . config . approvers ? ? [ ] ;
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
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 ;
}
2026-02-15 10:24:53 -06:00
// Send message with components v2 + buttons
2026-02-14 12:04:01 -06:00
const message = ( await discordRequest (
( ) = >
rest . post ( Routes . channelMessages ( dmChannel . id ) , {
2026-02-15 10:24:53 -06:00
body ,
2026-02-14 12:04:01 -06:00
} ) 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 ;
}
// Clear any existing pending DM entry to avoid timeout leaks
const existingDm = this . pending . get ( ` ${ request . id } :dm ` ) ;
if ( existingDm ) {
clearTimeout ( existingDm . timeoutId ) ;
}
// Set up timeout
const timeoutMs = Math . max ( 0 , request . expiresAtMs - Date . now ( ) ) ;
const timeoutId = setTimeout ( ( ) = > {
void this . handleApprovalTimeout ( request . id , "dm" ) ;
} , timeoutMs ) ;
this . pending . set ( ` ${ request . id } :dm ` , {
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 ) } ` ) ;
}
2026-01-24 12:56:40 -08:00
}
}
}
private async handleApprovalResolved ( resolved : ExecApprovalResolved ) : Promise < void > {
2026-02-14 12:04:01 -06:00
// Clean up all pending entries for this approval (channel + dm)
2026-01-24 12:56:40 -08:00
const request = this . requestCache . get ( resolved . id ) ;
this . requestCache . delete ( resolved . id ) ;
2026-01-31 16:19:20 +09:00
if ( ! request ) {
return ;
}
2026-01-24 12:56:40 -08:00
logDebug ( ` discord exec approvals: resolved ${ resolved . id } with ${ resolved . decision } ` ) ;
2026-02-15 10:24:53 -06:00
const container = createResolvedContainer ( {
request ,
decision : resolved.decision ,
resolvedBy : resolved.resolvedBy ,
cfg : this.opts.cfg ,
accountId : this.opts.accountId ,
} ) ;
2026-02-14 12:04:01 -06:00
for ( const suffix of [ ":channel" , ":dm" , "" ] ) {
const key = ` ${ resolved . id } ${ suffix } ` ;
const pending = this . pending . get ( key ) ;
if ( ! pending ) {
continue ;
}
clearTimeout ( pending . timeoutId ) ;
this . pending . delete ( key ) ;
2026-02-15 10:24:53 -06:00
await this . finalizeMessage ( pending . discordChannelId , pending . discordMessageId , container ) ;
2026-02-14 12:04:01 -06:00
}
2026-01-24 12:56:40 -08:00
}
2026-02-14 12:04:01 -06:00
private async handleApprovalTimeout (
approvalId : string ,
source ? : "channel" | "dm" ,
) : Promise < void > {
const key = source ? ` ${ approvalId } : ${ source } ` : approvalId ;
const pending = this . pending . get ( key ) ;
2026-01-31 16:19:20 +09:00
if ( ! pending ) {
return ;
}
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
this . pending . delete ( key ) ;
2026-01-24 12:56:40 -08:00
const request = this . requestCache . get ( approvalId ) ;
2026-02-14 12:04:01 -06:00
// Only clean up requestCache if no other pending entries exist for this approval
const hasOtherPending =
this . pending . has ( ` ${ approvalId } :channel ` ) ||
this . pending . has ( ` ${ approvalId } :dm ` ) ||
this . pending . has ( approvalId ) ;
if ( ! hasOtherPending ) {
this . requestCache . delete ( approvalId ) ;
}
2026-01-24 12:56:40 -08:00
2026-01-31 16:19:20 +09:00
if ( ! request ) {
return ;
}
2026-01-24 12:56:40 -08:00
2026-02-14 12:04:01 -06:00
logDebug ( ` discord exec approvals: timeout for ${ approvalId } ( ${ source ? ? "default" } ) ` ) ;
2026-01-24 12:56:40 -08:00
2026-02-15 10:24:53 -06:00
const container = createExpiredContainer ( {
request ,
cfg : this.opts.cfg ,
accountId : this.opts.accountId ,
} ) ;
await this . finalizeMessage ( pending . discordChannelId , pending . discordMessageId , container ) ;
2026-01-24 12:56:40 -08:00
}
2026-02-10 00:39:42 -06:00
private async finalizeMessage (
channelId : string ,
messageId : string ,
2026-02-15 10:24:53 -06:00
container : DiscordUiContainer ,
2026-02-10 00:39:42 -06:00
) : Promise < void > {
if ( ! this . opts . config . cleanupAfterResolve ) {
2026-02-15 10:24:53 -06:00
await this . updateMessage ( channelId , messageId , container ) ;
2026-02-10 00:39:42 -06:00
return ;
}
try {
const { rest , request : discordRequest } = createDiscordClient (
{ token : this.opts.token , accountId : this.opts.accountId } ,
this . opts . cfg ,
) ;
await discordRequest (
( ) = > rest . delete ( Routes . channelMessage ( channelId , messageId ) ) as Promise < void > ,
"delete-approval" ,
) ;
} catch ( err ) {
logError ( ` discord exec approvals: failed to delete message: ${ String ( err ) } ` ) ;
2026-02-15 10:24:53 -06:00
await this . updateMessage ( channelId , messageId , container ) ;
2026-02-10 00:39:42 -06:00
}
}
2026-01-24 12:56:40 -08:00
private async updateMessage (
channelId : string ,
messageId : string ,
2026-02-15 10:24:53 -06:00
container : DiscordUiContainer ,
2026-01-24 12:56:40 -08:00
) : Promise < void > {
try {
const { rest , request : discordRequest } = createDiscordClient (
{ token : this.opts.token , accountId : this.opts.accountId } ,
this . opts . cfg ,
) ;
2026-02-15 10:24:53 -06:00
const payload = buildExecApprovalPayload ( container ) ;
2026-01-24 12:56:40 -08:00
await discordRequest (
( ) = >
rest . patch ( Routes . channelMessage ( channelId , messageId ) , {
2026-02-15 10:24:53 -06:00
body : stripUndefinedFields ( serializePayload ( payload ) ) ,
2026-01-24 12:56:40 -08:00
} ) ,
"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 ;
}
}
2026-02-14 12:04:01 -06:00
/** Return the list of configured approver IDs. */
2026-02-16 12:22:58 -06:00
getApprovers ( ) : string [ ] {
2026-02-14 12:04:01 -06:00
return this . opts . config . approvers ? ? [ ] ;
}
2026-01-24 12:56:40 -08:00
}
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 ;
}
2026-02-14 12:04:01 -06:00
// Verify the user is an authorized approver
const approvers = this . ctx . handler . getApprovers ( ) ;
const userId = interaction . userId ;
if ( ! approvers . some ( ( id ) = > String ( id ) === userId ) ) {
try {
await interaction . reply ( {
content : "⛔ You are not authorized to approve exec requests." ,
ephemeral : true ,
} ) ;
} catch {
// Interaction may have expired
}
return ;
}
2026-01-24 12:56:40 -08:00
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 ) ;
}