Skip to content

Commit c7d31ba

Browse files
vincentkocsteipete
authored andcommitted
Channels: centralize shared interactive rendering
1 parent 92bea97 commit c7d31ba

16 files changed

Lines changed: 346 additions & 146 deletions

extensions/discord/src/actions/handle-action.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "../../../../src/channels/plugins/actio
1010
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
1111
import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js";
1212
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
13-
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
13+
import { buildDiscordInteractiveComponents } from "../components.js";
1414
import { resolveDiscordChannelId } from "../targets.js";
1515
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
1616

extensions/discord/src/components.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
type TopLevelComponents,
2626
} from "@buape/carbon";
2727
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
28+
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
29+
import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js";
2830

2931
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
3032
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
@@ -212,6 +214,52 @@ export type DiscordComponentBuildResult = {
212214
modals: DiscordModalEntry[];
213215
};
214216

217+
function resolveDiscordInteractiveButtonStyle(
218+
style?: InteractiveButtonStyle,
219+
): DiscordComponentButtonStyle | undefined {
220+
return style ?? "secondary";
221+
}
222+
223+
export function buildDiscordInteractiveComponents(
224+
interactive?: InteractiveReply,
225+
): DiscordComponentMessageSpec | undefined {
226+
const blocks = reduceInteractiveReply(
227+
interactive,
228+
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
229+
(state, block) => {
230+
if (block.type === "buttons") {
231+
if (block.buttons.length === 0) {
232+
return state;
233+
}
234+
state.push({
235+
type: "actions",
236+
buttons: block.buttons.map((button) => ({
237+
label: button.label,
238+
style: resolveDiscordInteractiveButtonStyle(button.style),
239+
callbackData: button.value,
240+
})),
241+
});
242+
return state;
243+
}
244+
if (block.type === "select" && block.options.length > 0) {
245+
state.push({
246+
type: "actions",
247+
select: {
248+
type: "string",
249+
placeholder: block.placeholder,
250+
options: block.options.map((option) => ({
251+
label: option.label,
252+
value: option.value,
253+
})),
254+
},
255+
});
256+
}
257+
return state;
258+
},
259+
);
260+
return blocks.length > 0 ? { blocks } : undefined;
261+
}
262+
215263
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
216264
["row", "actions"],
217265
["action-row", "actions"],

extensions/discord/src/outbound-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
99
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
1010
import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js";
1111
import type { DiscordComponentMessageSpec } from "./components.js";
12+
import { buildDiscordInteractiveComponents } from "./components.js";
1213
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
1314
import { normalizeDiscordOutboundTarget } from "./normalize.js";
1415
import {
@@ -17,7 +18,6 @@ import {
1718
sendPollDiscord,
1819
sendWebhookMessageDiscord,
1920
} from "./send.js";
20-
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
2121

2222
function resolveDiscordOutboundTarget(params: {
2323
to: string;

extensions/discord/src/shared-interactive.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
2+
import { buildDiscordInteractiveComponents } from "./components.js";
33

44
describe("buildDiscordInteractiveComponents", () => {
55
it("maps shared buttons and selects into Discord component blocks", () => {

extensions/discord/src/shared-interactive.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,43 @@
11
import type { Block, KnownBlock } from "@slack/web-api";
2+
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
23
import type { InteractiveReply } from "../../../src/interactive/payload.js";
34
import { truncateSlackText } from "./truncate.js";
45

5-
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
6-
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
6+
export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
7+
export const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
78
const SLACK_SECTION_TEXT_MAX = 3000;
89
const SLACK_PLAIN_TEXT_MAX = 75;
9-
const SLACK_OPTION_VALUE_MAX = 75;
1010

1111
export type SlackBlock = Block | KnownBlock;
1212

13-
function buildSlackReplyChoiceToken(value: string, index: number): string {
14-
const slug = value
15-
.trim()
16-
.toLowerCase()
17-
.replace(/[^a-z0-9]+/g, "_")
18-
.replace(/^_+|_+$/g, "");
19-
return truncateSlackText(`reply_${index}_${slug || "choice"}`, SLACK_OPTION_VALUE_MAX);
20-
}
21-
2213
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
23-
const blocks: SlackBlock[] = [];
24-
let buttonIndex = 0;
25-
let selectIndex = 0;
26-
for (const block of interactive?.blocks ?? []) {
14+
const initialState = {
15+
blocks: [] as SlackBlock[],
16+
buttonIndex: 0,
17+
selectIndex: 0,
18+
};
19+
return reduceInteractiveReply(interactive, initialState, (state, block) => {
2720
if (block.type === "text") {
2821
const trimmed = block.text.trim();
2922
if (!trimmed) {
30-
continue;
23+
return state;
3124
}
32-
blocks.push({
25+
state.blocks.push({
3326
type: "section",
3427
text: {
3528
type: "mrkdwn",
3629
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
3730
},
3831
});
39-
continue;
32+
return state;
4033
}
4134
if (block.type === "buttons") {
4235
if (block.buttons.length === 0) {
43-
continue;
36+
return state;
4437
}
45-
blocks.push({
38+
state.blocks.push({
4639
type: "actions",
47-
block_id: `openclaw_reply_buttons_${++buttonIndex}`,
40+
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
4841
elements: block.buttons.map((button, choiceIndex) => ({
4942
type: "button",
5043
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
@@ -53,17 +46,17 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
5346
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
5447
emoji: true,
5548
},
56-
value: buildSlackReplyChoiceToken(button.value, choiceIndex + 1),
49+
value: button.value,
5750
})),
5851
});
59-
continue;
52+
return state;
6053
}
6154
if (block.options.length === 0) {
62-
continue;
55+
return state;
6356
}
64-
blocks.push({
57+
state.blocks.push({
6558
type: "actions",
66-
block_id: `openclaw_reply_select_${++selectIndex}`,
59+
block_id: `openclaw_reply_select_${++state.selectIndex}`,
6760
elements: [
6861
{
6962
type: "static_select",
@@ -82,11 +75,11 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
8275
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
8376
emoji: true,
8477
},
85-
value: buildSlackReplyChoiceToken(option.value, choiceIndex + 1),
78+
value: option.value,
8679
})),
8780
},
8881
],
8982
});
90-
}
91-
return blocks;
83+
return state;
84+
}).blocks;
9285
}

extensions/slack/src/monitor/events/interactions.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
77
handled: false,
88
duplicate: false,
99
}));
10+
const resolvePluginConversationBindingApprovalMock = vi.fn();
11+
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
1012

1113
vi.mock("../../../../../src/infra/system-events.js", () => ({
1214
enqueueSystemEvent: (...args: unknown[]) =>
@@ -18,6 +20,21 @@ vi.mock("../../../../../src/plugins/interactive.js", () => ({
1820
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
1921
}));
2022

23+
vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
24+
const actual = await vi.importActual<
25+
typeof import("../../../../../src/plugins/conversation-binding.js")
26+
>("../../../../../src/plugins/conversation-binding.js");
27+
return {
28+
...actual,
29+
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
30+
(resolvePluginConversationBindingApprovalMock as (...innerArgs: unknown[]) => unknown)(
31+
...args,
32+
),
33+
buildPluginBindingResolvedText: (...args: unknown[]) =>
34+
(buildPluginBindingResolvedTextMock as (...innerArgs: unknown[]) => unknown)(...args),
35+
};
36+
});
37+
2138
type RegisteredHandler = (args: {
2239
ack: () => Promise<void>;
2340
body: {
@@ -166,6 +183,10 @@ describe("registerSlackInteractionEvents", () => {
166183
beforeEach(() => {
167184
enqueueSystemEventMock.mockClear();
168185
dispatchPluginInteractiveHandlerMock.mockClear();
186+
resolvePluginConversationBindingApprovalMock.mockClear();
187+
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
188+
buildPluginBindingResolvedTextMock.mockClear();
189+
buildPluginBindingResolvedTextMock.mockReturnValue("Binding updated.");
169190
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
170191
matched: false,
171192
handled: false,
@@ -299,9 +320,11 @@ describe("registerSlackInteractionEvents", () => {
299320
expect.objectContaining({
300321
channel: "slack",
301322
data: "codex:approve:thread-1",
323+
interactionId: "U123:C1:100.200:codex:approve:thread-1",
302324
ctx: expect.objectContaining({
303325
accountId: ctx.accountId,
304326
conversationId: "C1",
327+
interactionId: "U123:C1:100.200:codex:approve:thread-1",
305328
threadId: "100.100",
306329
interaction: expect.objectContaining({
307330
actionId: "codex",
@@ -314,6 +337,74 @@ describe("registerSlackInteractionEvents", () => {
314337
expect(app.client.chat.update).not.toHaveBeenCalled();
315338
});
316339

340+
it("resolves plugin binding approvals from shared interactive Slack actions", async () => {
341+
resolvePluginConversationBindingApprovalMock.mockResolvedValueOnce({
342+
status: "approved",
343+
decision: "allow-once",
344+
request: {
345+
pluginId: "codex",
346+
pluginName: "Codex",
347+
summary: "for this thread",
348+
},
349+
});
350+
const { ctx, app, getHandler } = createContext();
351+
registerSlackInteractionEvents({ ctx: ctx as never });
352+
353+
const handler = getHandler();
354+
expect(handler).toBeTruthy();
355+
356+
const ack = vi.fn().mockResolvedValue(undefined);
357+
const respond = vi.fn().mockResolvedValue(undefined);
358+
await handler!({
359+
ack,
360+
respond,
361+
body: {
362+
user: { id: "U123" },
363+
channel: { id: "C1" },
364+
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
365+
message: {
366+
ts: "100.200",
367+
text: "Approve this bind?",
368+
blocks: [
369+
{
370+
type: "actions",
371+
block_id: "bind_actions",
372+
elements: [{ type: "button", action_id: "openclaw:reply_button" }],
373+
},
374+
],
375+
},
376+
},
377+
action: {
378+
type: "button",
379+
action_id: "openclaw:reply_button",
380+
block_id: "bind_actions",
381+
value: "pluginbind:approval-123:o",
382+
text: { type: "plain_text", text: "Allow once" },
383+
},
384+
});
385+
386+
expect(ack).toHaveBeenCalled();
387+
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledWith({
388+
approvalId: "approval-123",
389+
decision: "allow-once",
390+
senderId: "U123",
391+
});
392+
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
393+
expect(app.client.chat.update).toHaveBeenCalledWith(
394+
expect.objectContaining({
395+
channel: "C1",
396+
ts: "100.200",
397+
text: "Approve this bind?",
398+
blocks: [],
399+
}),
400+
);
401+
expect(respond).toHaveBeenCalledWith({
402+
text: "Binding updated.",
403+
response_type: "ephemeral",
404+
});
405+
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
406+
});
407+
317408
it("drops block actions when mismatch guard triggers", async () => {
318409
enqueueSystemEventMock.mockClear();
319410
const { ctx, app, getHandler } = createContext({

0 commit comments

Comments
 (0)