@@ -6,6 +6,27 @@ import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sd
66import { CodexAppServerClient } from "./client.js" ;
77import { CodexPluginController } from "./controller.js" ;
88
9+ const discordSdkState = vi . hoisted ( ( ) => ( {
10+ buildDiscordComponentMessage : vi . fn ( ( params : { spec : { text ?: string ; blocks ?: unknown [ ] } } ) => ( {
11+ components : [ params . spec . text ?? "" , ...( params . spec . blocks ?? [ ] ) ] ,
12+ entries : [ { id : "entry-1" , kind : "button" , label : "Tap" } ] ,
13+ modals : [ ] ,
14+ } ) ) ,
15+ editDiscordComponentMessage : vi . fn ( async ( ) => ( {
16+ messageId : "message-1" ,
17+ channelId : "channel:chan-1" ,
18+ } ) ) ,
19+ registerBuiltDiscordComponentMessage : vi . fn ( ) ,
20+ resolveDiscordAccount : vi . fn ( ( ) => ( { accountId : "default" } ) ) ,
21+ } ) ) ;
22+
23+ vi . mock ( "openclaw/plugin-sdk/discord" , ( ) => ( {
24+ buildDiscordComponentMessage : discordSdkState . buildDiscordComponentMessage ,
25+ editDiscordComponentMessage : discordSdkState . editDiscordComponentMessage ,
26+ registerBuiltDiscordComponentMessage : discordSdkState . registerBuiltDiscordComponentMessage ,
27+ resolveDiscordAccount : discordSdkState . resolveDiscordAccount ,
28+ } ) ) ;
29+
930function makeStateDir ( ) : string {
1031 return fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-app-server-test-" ) ) ;
1132}
@@ -217,6 +238,10 @@ afterEach(() => {
217238} ) ;
218239
219240beforeEach ( ( ) => {
241+ discordSdkState . buildDiscordComponentMessage . mockClear ( ) ;
242+ discordSdkState . editDiscordComponentMessage . mockClear ( ) ;
243+ discordSdkState . registerBuiltDiscordComponentMessage . mockClear ( ) ;
244+ discordSdkState . resolveDiscordAccount . mockClear ( ) ;
220245 vi . spyOn ( CodexAppServerClient . prototype , "logStartupProbe" ) . mockResolvedValue ( ) ;
221246 vi . stubGlobal (
222247 "fetch" ,
@@ -340,7 +365,7 @@ describe("Discord controller flows", () => {
340365 ) ;
341366 } ) ;
342367
343- it ( "refreshes Discord pickers by clearing the old components and sending a new picker " , async ( ) => {
368+ it ( "refreshes Discord pickers by editing the original interaction message " , async ( ) => {
344369 const { controller, sendComponentMessage } = await createControllerHarness ( ) ;
345370 const callback = await ( controller as any ) . store . putCallback ( {
346371 kind : "picker-view" ,
@@ -355,7 +380,7 @@ describe("Discord controller flows", () => {
355380 page : 0 ,
356381 } ,
357382 } ) ;
358- const clearComponents = vi . fn ( async ( ) => { } ) ;
383+ const editMessage = vi . fn ( async ( ) => { } ) ;
359384
360385 await controller . handleDiscordInteractive ( {
361386 channel : "discord" ,
@@ -376,26 +401,28 @@ describe("Discord controller flows", () => {
376401 acknowledge : vi . fn ( async ( ) => { } ) ,
377402 reply : vi . fn ( async ( ) => { } ) ,
378403 followUp : vi . fn ( async ( ) => { } ) ,
379- editMessage : vi . fn ( async ( ) => { } ) ,
380- clearComponents,
404+ editMessage,
405+ clearComponents : vi . fn ( async ( ) => { } ) ,
381406 } ,
382407 } as any ) ;
383408
384- expect ( clearComponents ) . toHaveBeenCalledWith (
409+ expect ( editMessage ) . toHaveBeenCalledWith (
385410 expect . objectContaining ( {
386- text : expect . stringContaining ( "Showing recent Codex sessions" ) ,
411+ components : expect . any ( Array ) ,
387412 } ) ,
388413 ) ;
389- expect ( sendComponentMessage ) . toHaveBeenCalledWith (
390- "channel:chan-1" ,
391- expect . objectContaining ( {
392- text : expect . stringContaining ( "Showing recent Codex sessions" ) ,
414+ expect ( discordSdkState . registerBuiltDiscordComponentMessage ) . toHaveBeenCalledWith ( {
415+ buildResult : expect . objectContaining ( {
416+ components : expect . any ( Array ) ,
417+ entries : expect . any ( Array ) ,
393418 } ) ,
394- expect . objectContaining ( { accountId : "default" } ) ,
395- ) ;
419+ messageId : "message-1" ,
420+ } ) ;
421+ expect ( discordSdkState . editDiscordComponentMessage ) . not . toHaveBeenCalled ( ) ;
422+ expect ( sendComponentMessage ) . not . toHaveBeenCalled ( ) ;
396423 } ) ;
397424
398- it ( "refreshes the Discord project picker without using interactive editMessage components " , async ( ) => {
425+ it ( "refreshes the Discord project picker by editing the interaction message " , async ( ) => {
399426 const { controller, sendComponentMessage } = await createControllerHarness ( ) ;
400427 const callback = await ( controller as any ) . store . putCallback ( {
401428 kind : "picker-view" ,
@@ -436,14 +463,78 @@ describe("Discord controller flows", () => {
436463 } ,
437464 } as any ) ;
438465
439- expect ( editMessage ) . not . toHaveBeenCalled ( ) ;
440- expect ( sendComponentMessage ) . toHaveBeenCalledWith (
466+ expect ( editMessage ) . toHaveBeenCalledWith (
467+ expect . objectContaining ( {
468+ components : expect . any ( Array ) ,
469+ } ) ,
470+ ) ;
471+ expect ( discordSdkState . registerBuiltDiscordComponentMessage ) . toHaveBeenCalledWith ( {
472+ buildResult : expect . objectContaining ( {
473+ components : expect . any ( Array ) ,
474+ entries : expect . any ( Array ) ,
475+ } ) ,
476+ messageId : "message-1" ,
477+ } ) ;
478+ expect ( discordSdkState . editDiscordComponentMessage ) . not . toHaveBeenCalled ( ) ;
479+ expect ( sendComponentMessage ) . not . toHaveBeenCalled ( ) ;
480+ } ) ;
481+
482+ it ( "falls back to direct Discord message edit when the interaction was already acknowledged" , async ( ) => {
483+ const { controller, sendComponentMessage } = await createControllerHarness ( ) ;
484+ const callback = await ( controller as any ) . store . putCallback ( {
485+ kind : "picker-view" ,
486+ conversation : {
487+ channel : "discord" ,
488+ accountId : "default" ,
489+ conversationId : "channel:chan-1" ,
490+ } ,
491+ view : {
492+ mode : "projects" ,
493+ includeAll : true ,
494+ page : 0 ,
495+ } ,
496+ } ) ;
497+ const acknowledge = vi . fn ( async ( ) => { } ) ;
498+ const editMessage = vi . fn ( async ( ) => {
499+ throw new Error ( "Interaction has already been acknowledged." ) ;
500+ } ) ;
501+
502+ await controller . handleDiscordInteractive ( {
503+ channel : "discord" ,
504+ accountId : "default" ,
505+ interactionId : "interaction-1" ,
506+ conversationId : "channel:chan-1" ,
507+ auth : { isAuthorizedSender : true } ,
508+ interaction : {
509+ kind : "button" ,
510+ data : `codexapp:${ callback . token } ` ,
511+ namespace : "codexapp" ,
512+ payload : callback . token ,
513+ messageId : "message-1" ,
514+ } ,
515+ senderId : "user-1" ,
516+ senderUsername : "Ada" ,
517+ respond : {
518+ acknowledge,
519+ reply : vi . fn ( async ( ) => { } ) ,
520+ followUp : vi . fn ( async ( ) => { } ) ,
521+ editMessage,
522+ clearComponents : vi . fn ( async ( ) => { } ) ,
523+ } ,
524+ } as any ) ;
525+
526+ expect ( editMessage ) . toHaveBeenCalled ( ) ;
527+ expect ( acknowledge ) . not . toHaveBeenCalled ( ) ;
528+ expect ( discordSdkState . registerBuiltDiscordComponentMessage ) . not . toHaveBeenCalled ( ) ;
529+ expect ( discordSdkState . editDiscordComponentMessage ) . toHaveBeenCalledWith (
441530 "channel:chan-1" ,
531+ "message-1" ,
442532 expect . objectContaining ( {
443533 text : expect . stringContaining ( "Choose a project to filter recent Codex sessions" ) ,
444534 } ) ,
445535 expect . objectContaining ( { accountId : "default" } ) ,
446536 ) ;
537+ expect ( sendComponentMessage ) . not . toHaveBeenCalled ( ) ;
447538 } ) ;
448539
449540 it ( "normalizes raw Discord callback conversation ids for guild interactions" , async ( ) => {
@@ -489,13 +580,7 @@ describe("Discord controller flows", () => {
489580 } ,
490581 } as any ) ;
491582
492- expect ( sendComponentMessage ) . toHaveBeenCalledWith (
493- "channel:chan-1" ,
494- expect . objectContaining ( {
495- text : expect . stringContaining ( "Choose a project to filter recent Codex sessions" ) ,
496- } ) ,
497- expect . objectContaining ( { accountId : "default" } ) ,
498- ) ;
583+ expect ( sendComponentMessage ) . not . toHaveBeenCalled ( ) ;
499584 } ) ;
500585
501586 it ( "hydrates a pending approved binding when status is requested after core approval" , async ( ) => {
@@ -892,6 +977,116 @@ describe("Discord controller flows", () => {
892977 ) ;
893978 } ) ;
894979
980+ it ( "retries an incomplete codex_resume bind before falling back to the picker" , async ( ) => {
981+ const { controller } = await createControllerHarness ( ) ;
982+ await ( controller as any ) . store . upsertPendingBind ( {
983+ conversation : {
984+ channel : "telegram" ,
985+ accountId : "default" ,
986+ conversationId : "123:topic:456" ,
987+ parentConversationId : "123" ,
988+ } ,
989+ threadId : "thread-1" ,
990+ workspaceDir : "/repo/openclaw" ,
991+ threadTitle : "Discord Thread" ,
992+ syncTopic : true ,
993+ notifyBound : true ,
994+ updatedAt : Date . now ( ) ,
995+ } ) ;
996+ const requestConversationBinding = vi . fn ( async ( ) => ( {
997+ status : "pending" as const ,
998+ reply : { text : "Plugin bind approval required" } ,
999+ } ) ) ;
1000+
1001+ const reply = await controller . handleCommand (
1002+ "codex_resume" ,
1003+ buildTelegramCommandContext ( {
1004+ args : "--sync" ,
1005+ commandBody : "/codex_resume --sync" ,
1006+ messageThreadId : 456 ,
1007+ getCurrentConversationBinding : vi . fn ( async ( ) => null ) ,
1008+ requestConversationBinding,
1009+ } ) ,
1010+ ) ;
1011+
1012+ expect ( reply ) . toEqual ( { text : "Plugin bind approval required" } ) ;
1013+ expect ( requestConversationBinding ) . toHaveBeenCalledWith (
1014+ expect . objectContaining ( {
1015+ summary : "Bind this conversation to Codex thread Discord Thread." ,
1016+ } ) ,
1017+ ) ;
1018+ } ) ;
1019+
1020+ it ( "rebinds an incomplete codex_resume bind when the retry is approved immediately" , async ( ) => {
1021+ const { controller, renameTopic, sendMessageTelegram } = await createControllerHarness ( ) ;
1022+ ( controller as any ) . client . readThreadContext = vi . fn ( async ( ) => ( {
1023+ lastUserMessage : "What were we doing here?" ,
1024+ lastAssistantMessage : "We were working on the app-server lifetime refactor." ,
1025+ } ) ) ;
1026+
1027+ await ( controller as any ) . store . upsertPendingBind ( {
1028+ conversation : {
1029+ channel : "telegram" ,
1030+ accountId : "default" ,
1031+ conversationId : "123:topic:456" ,
1032+ parentConversationId : "123" ,
1033+ } ,
1034+ threadId : "thread-1" ,
1035+ workspaceDir : "/repo/openclaw" ,
1036+ threadTitle : "Discord Thread" ,
1037+ syncTopic : true ,
1038+ notifyBound : true ,
1039+ updatedAt : Date . now ( ) ,
1040+ } ) ;
1041+
1042+ const reply = await controller . handleCommand (
1043+ "codex_resume" ,
1044+ buildTelegramCommandContext ( {
1045+ args : "--sync" ,
1046+ commandBody : "/codex_resume --sync" ,
1047+ messageThreadId : 456 ,
1048+ getCurrentConversationBinding : vi . fn ( async ( ) => null ) ,
1049+ requestConversationBinding : vi . fn ( async ( ) => ( { status : "bound" as const } ) ) ,
1050+ } ) ,
1051+ ) ;
1052+
1053+ await flushAsyncWork ( ) ;
1054+
1055+ expect ( reply ) . toEqual ( { } ) ;
1056+ expect ( renameTopic ) . toHaveBeenCalledWith (
1057+ "123" ,
1058+ 456 ,
1059+ "Discord Thread (openclaw)" ,
1060+ expect . objectContaining ( { accountId : "default" } ) ,
1061+ ) ;
1062+ expect ( sendMessageTelegram ) . toHaveBeenCalledWith (
1063+ "123" ,
1064+ expect . stringContaining ( "Thread Name: Discord Thread" ) ,
1065+ expect . objectContaining ( { accountId : "default" , messageThreadId : 456 } ) ,
1066+ ) ;
1067+ expect (
1068+ ( controller as any ) . store . getBinding ( {
1069+ channel : "telegram" ,
1070+ accountId : "default" ,
1071+ conversationId : "123:topic:456" ,
1072+ parentConversationId : "123" ,
1073+ } ) ,
1074+ ) . toEqual (
1075+ expect . objectContaining ( {
1076+ threadId : "thread-1" ,
1077+ workspaceDir : "/repo/openclaw" ,
1078+ } ) ,
1079+ ) ;
1080+ expect (
1081+ ( controller as any ) . store . getPendingBind ( {
1082+ channel : "telegram" ,
1083+ accountId : "default" ,
1084+ conversationId : "123:topic:456" ,
1085+ parentConversationId : "123" ,
1086+ } ) ,
1087+ ) . toBeNull ( ) ;
1088+ } ) ;
1089+
8951090 it ( "applies pending bind effects immediately when core reports the bind was approved" , async ( ) => {
8961091 const { controller, renameTopic, sendMessageTelegram } = await createControllerHarness ( ) ;
8971092 ( controller as any ) . client . readThreadContext = vi . fn ( async ( ) => ( {
@@ -1275,7 +1470,20 @@ describe("Discord controller flows", () => {
12751470 threadId : "thread-1" ,
12761471 workspaceDir : "/repo/openclaw" ,
12771472 } ) ;
1473+ const acknowledge = vi . fn ( async ( ) => { } ) ;
1474+ const clearComponents = vi . fn ( async ( ) => { } ) ;
12781475 const reply = vi . fn ( async ( ) => { } ) ;
1476+ const requestConversationBinding = vi . fn ( async ( ) => ( {
1477+ status : "pending" as const ,
1478+ reply : {
1479+ text : "Plugin bind approval required" ,
1480+ channelData : {
1481+ telegram : {
1482+ buttons : [ [ { text : "Allow once" , callback_data : "pluginbind:approval:o" } ] ] ,
1483+ } ,
1484+ } ,
1485+ } ,
1486+ } ) ) ;
12791487
12801488 await controller . handleDiscordInteractive ( {
12811489 channel : "discord" ,
@@ -1292,23 +1500,13 @@ describe("Discord controller flows", () => {
12921500 } ,
12931501 senderId : "user-1" ,
12941502 senderUsername : "Ada" ,
1295- requestConversationBinding : vi . fn ( async ( ) => ( {
1296- status : "pending" as const ,
1297- reply : {
1298- text : "Plugin bind approval required" ,
1299- channelData : {
1300- telegram : {
1301- buttons : [ [ { text : "Allow once" , callback_data : "pluginbind:approval:o" } ] ] ,
1302- } ,
1303- } ,
1304- } ,
1305- } ) ) ,
1503+ requestConversationBinding,
13061504 respond : {
1307- acknowledge : vi . fn ( async ( ) => { } ) ,
1505+ acknowledge,
13081506 reply,
13091507 followUp : vi . fn ( async ( ) => { } ) ,
13101508 editMessage : vi . fn ( async ( ) => { } ) ,
1311- clearComponents : vi . fn ( async ( ) => { } ) ,
1509+ clearComponents,
13121510 } ,
13131511 } as any ) ;
13141512
@@ -1320,6 +1518,19 @@ describe("Discord controller flows", () => {
13201518 } ) ,
13211519 expect . objectContaining ( { accountId : "default" } ) ,
13221520 ) ;
1521+ expect ( discordSdkState . editDiscordComponentMessage ) . toHaveBeenCalledWith (
1522+ "channel:chan-1" ,
1523+ "message-1" ,
1524+ {
1525+ text : "Binding approval requested below." ,
1526+ } ,
1527+ expect . objectContaining ( { accountId : "default" } ) ,
1528+ ) ;
1529+ expect ( acknowledge ) . toHaveBeenCalledTimes ( 1 ) ;
1530+ expect ( acknowledge . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
1531+ requestConversationBinding . mock . invocationCallOrder [ 0 ] ?? Number . POSITIVE_INFINITY ,
1532+ ) ;
1533+ expect ( clearComponents ) . not . toHaveBeenCalled ( ) ;
13231534 expect ( reply ) . not . toHaveBeenCalled ( ) ;
13241535 } ) ;
13251536
0 commit comments