@@ -361,6 +361,24 @@ describe("/approve command", () => {
361361 } as OpenClawConfig ;
362362 }
363363
364+ function createDiscordApproveCfg (
365+ execApprovals : {
366+ enabled : boolean ;
367+ approvers : string [ ] ;
368+ target : "dm" | "channel" | "both" ;
369+ } | null = { enabled : true , approvers : [ "123" ] , target : "channel" } ,
370+ ) : OpenClawConfig {
371+ return {
372+ commands : { text : true } ,
373+ channels : {
374+ discord : {
375+ allowFrom : [ "*" ] ,
376+ ...( execApprovals ? { execApprovals } : { } ) ,
377+ } ,
378+ } ,
379+ } as OpenClawConfig ;
380+ }
381+
364382 it ( "rejects invalid usage" , async ( ) => {
365383 const cfg = {
366384 commands : { text : true } ,
@@ -414,6 +432,139 @@ describe("/approve command", () => {
414432 ) ;
415433 } ) ;
416434
435+ it ( "requires configured Discord approvers for exec approvals" , async ( ) => {
436+ for ( const testCase of [
437+ {
438+ name : "discord approvals disabled" ,
439+ cfg : createDiscordApproveCfg ( null ) ,
440+ senderId : "123" ,
441+ expectedText : "Discord exec approvals are not enabled" ,
442+ setup : ( ) =>
443+ callGatewayMock . mockRejectedValue (
444+ gatewayError ( "unknown or expired approval id" , "APPROVAL_NOT_FOUND" ) ,
445+ ) ,
446+ expectedGatewayCalls : 1 ,
447+ } ,
448+ {
449+ name : "discord non approver" ,
450+ cfg : createDiscordApproveCfg ( { enabled : true , approvers : [ "999" ] , target : "channel" } ) ,
451+ senderId : "123" ,
452+ expectedText : "not authorized to approve" ,
453+ setup : ( ) =>
454+ callGatewayMock . mockRejectedValue (
455+ gatewayError ( "unknown or expired approval id" , "APPROVAL_NOT_FOUND" ) ,
456+ ) ,
457+ expectedGatewayCalls : 1 ,
458+ } ,
459+ {
460+ name : "discord approver" ,
461+ cfg : createDiscordApproveCfg ( { enabled : true , approvers : [ "123" ] , target : "channel" } ) ,
462+ senderId : "123" ,
463+ expectedText : "Approval allow-once submitted" ,
464+ setup : ( ) => callGatewayMock . mockResolvedValue ( { ok : true } ) ,
465+ expectedGatewayCalls : 1 ,
466+ } ,
467+ ] as const ) {
468+ callGatewayMock . mockReset ( ) ;
469+ testCase . setup ( ) ;
470+ const params = buildParams ( "/approve abc12345 allow-once" , testCase . cfg , {
471+ Provider : "discord" ,
472+ Surface : "discord" ,
473+ SenderId : testCase . senderId ,
474+ } ) ;
475+
476+ const result = await handleCommands ( params ) ;
477+ expect ( result . shouldContinue , testCase . name ) . toBe ( false ) ;
478+ expect ( result . reply ?. text , testCase . name ) . toContain ( testCase . expectedText ) ;
479+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledTimes ( testCase . expectedGatewayCalls ) ;
480+ if ( testCase . expectedGatewayCalls > 0 ) {
481+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledWith (
482+ expect . objectContaining ( {
483+ method :
484+ testCase . name === "discord approver"
485+ ? "exec.approval.resolve"
486+ : "plugin.approval.resolve" ,
487+ params : { id : "abc12345" , decision : "allow-once" } ,
488+ } ) ,
489+ ) ;
490+ }
491+ }
492+ } ) ;
493+
494+ it ( "preserves legacy unprefixed plugin approval fallback on Discord" , async ( ) => {
495+ for ( const testCase of [
496+ {
497+ name : "discord legacy plugin approval with exec approvals disabled" ,
498+ cfg : createDiscordApproveCfg ( null ) ,
499+ senderId : "123" ,
500+ } ,
501+ {
502+ name : "discord legacy plugin approval for non approver" ,
503+ cfg : createDiscordApproveCfg ( { enabled : true , approvers : [ "999" ] , target : "channel" } ) ,
504+ senderId : "123" ,
505+ } ,
506+ ] as const ) {
507+ callGatewayMock . mockReset ( ) ;
508+ callGatewayMock . mockResolvedValue ( { ok : true } ) ;
509+ const params = buildParams ( "/approve legacy-plugin-123 allow-once" , testCase . cfg , {
510+ Provider : "discord" ,
511+ Surface : "discord" ,
512+ SenderId : testCase . senderId ,
513+ } ) ;
514+
515+ const result = await handleCommands ( params ) ;
516+ expect ( result . shouldContinue , testCase . name ) . toBe ( false ) ;
517+ expect ( result . reply ?. text , testCase . name ) . toContain ( "Approval allow-once submitted" ) ;
518+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledTimes ( 1 ) ;
519+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledWith (
520+ expect . objectContaining ( {
521+ method : "plugin.approval.resolve" ,
522+ params : { id : "legacy-plugin-123" , decision : "allow-once" } ,
523+ } ) ,
524+ ) ;
525+ }
526+ } ) ;
527+
528+ it ( "requires configured Discord approvers for plugin approvals" , async ( ) => {
529+ for ( const testCase of [
530+ {
531+ name : "discord plugin non approver" ,
532+ cfg : createDiscordApproveCfg ( { enabled : false , approvers : [ "999" ] , target : "channel" } ) ,
533+ senderId : "123" ,
534+ expectedText : "not authorized to approve plugin requests" ,
535+ expectedGatewayCalls : 0 ,
536+ } ,
537+ {
538+ name : "discord plugin approver" ,
539+ cfg : createDiscordApproveCfg ( { enabled : false , approvers : [ "123" ] , target : "channel" } ) ,
540+ senderId : "123" ,
541+ expectedText : "Approval allow-once submitted" ,
542+ expectedGatewayCalls : 1 ,
543+ } ,
544+ ] as const ) {
545+ callGatewayMock . mockReset ( ) ;
546+ callGatewayMock . mockResolvedValue ( { ok : true } ) ;
547+ const params = buildParams ( "/approve plugin:abc123 allow-once" , testCase . cfg , {
548+ Provider : "discord" ,
549+ Surface : "discord" ,
550+ SenderId : testCase . senderId ,
551+ } ) ;
552+
553+ const result = await handleCommands ( params ) ;
554+ expect ( result . shouldContinue , testCase . name ) . toBe ( false ) ;
555+ expect ( result . reply ?. text , testCase . name ) . toContain ( testCase . expectedText ) ;
556+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledTimes ( testCase . expectedGatewayCalls ) ;
557+ if ( testCase . expectedGatewayCalls > 0 ) {
558+ expect ( callGatewayMock , testCase . name ) . toHaveBeenCalledWith (
559+ expect . objectContaining ( {
560+ method : "plugin.approval.resolve" ,
561+ params : { id : "plugin:abc123" , decision : "allow-once" } ,
562+ } ) ,
563+ ) ;
564+ }
565+ }
566+ } ) ;
567+
417568 it ( "rejects unauthorized or invalid Telegram /approve variants" , async ( ) => {
418569 for ( const testCase of [
419570 {
0 commit comments