Skip to content

Commit 8601315

Browse files
authored
Discord: keep interaction buttons live during bind flows (#26)
* Discord: keep refreshed picker buttons live * Discord: acknowledge pending bind clicks * Discord: ack resume clicks before binding * Discord: point resume button at bind approval reply
1 parent 9e50c1d commit 8601315

File tree

3 files changed

+406
-56
lines changed

3 files changed

+406
-56
lines changed

src/controller.test.ts

Lines changed: 246 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sd
66
import { CodexAppServerClient } from "./client.js";
77
import { 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+
930
function makeStateDir(): string {
1031
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-"));
1132
}
@@ -217,6 +238,10 @@ afterEach(() => {
217238
});
218239

219240
beforeEach(() => {
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

Comments
 (0)