Skip to content

Commit 0c63ae8

Browse files
committed
Plugins: share rich reply projection across channels
1 parent dba535f commit 0c63ae8

13 files changed

Lines changed: 669 additions & 141 deletions

docs/.generated/plugin-sdk-api-baseline.json

Lines changed: 138 additions & 57 deletions
Large diffs are not rendered by default.

docs/.generated/plugin-sdk-api-baseline.jsonl

Lines changed: 66 additions & 57 deletions
Large diffs are not rendered by default.

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,37 @@ describe("buildDiscordInteractiveComponents", () => {
101101
],
102102
});
103103
});
104+
105+
it("falls back to text guidance when widgets are unavailable", () => {
106+
expect(
107+
buildDiscordInteractiveComponents(
108+
{
109+
blocks: [
110+
{
111+
type: "buttons",
112+
buttons: [
113+
{
114+
label: "Retry",
115+
value: "retry",
116+
fallback: { command: "/job retry", text: "Retry the job" },
117+
},
118+
],
119+
},
120+
],
121+
fallbackText: "Use the fallback path.",
122+
},
123+
{
124+
richReplies: {
125+
buttons: false,
126+
selects: false,
127+
commandFallback: true,
128+
},
129+
},
130+
),
131+
).toEqual({
132+
blocks: [
133+
{ type: "text", text: "Use the fallback path.\n\nRetry: Retry the job (/job retry)" },
134+
],
135+
});
136+
});
104137
});

extensions/discord/src/shared-interactive.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
1+
import type { ChannelCapabilities } from "openclaw/plugin-sdk/channel-contract";
2+
import {
3+
projectInteractiveReplyForCapabilities,
4+
reduceInteractiveReply,
5+
} from "openclaw/plugin-sdk/interactive-runtime";
26
import type {
37
InteractiveButtonStyle,
48
InteractiveReply,
@@ -15,9 +19,20 @@ const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
1519

1620
export function buildDiscordInteractiveComponents(
1721
interactive?: InteractiveReply,
22+
capabilities?: Pick<ChannelCapabilities, "richReplies"> | null,
1823
): DiscordComponentMessageSpec | undefined {
19-
const blocks = reduceInteractiveReply(
24+
const projected = projectInteractiveReplyForCapabilities({
2025
interactive,
26+
capabilities: capabilities ?? {
27+
richReplies: {
28+
buttons: true,
29+
selects: true,
30+
commandFallback: true,
31+
},
32+
},
33+
}).interactive;
34+
const blocks = reduceInteractiveReply(
35+
projected,
2136
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
2237
(state, block) => {
2338
if (block.type === "text") {

extensions/slack/src/blocks-render.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Block, KnownBlock } from "@slack/web-api";
2-
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
2+
import type { ChannelCapabilities } from "openclaw/plugin-sdk/channel-contract";
3+
import {
4+
projectInteractiveReplyForCapabilities,
5+
reduceInteractiveReply,
6+
} from "openclaw/plugin-sdk/interactive-runtime";
37
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
48
import { truncateSlackText } from "./truncate.js";
59

@@ -30,13 +34,26 @@ function resolveSlackButtonStyle(
3034
return undefined;
3135
}
3236

33-
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
37+
export function buildSlackInteractiveBlocks(
38+
interactive?: InteractiveReply,
39+
capabilities?: Pick<ChannelCapabilities, "richReplies"> | null,
40+
): SlackBlock[] {
41+
const projected = projectInteractiveReplyForCapabilities({
42+
interactive,
43+
capabilities: capabilities ?? {
44+
richReplies: {
45+
buttons: true,
46+
selects: true,
47+
commandFallback: true,
48+
},
49+
},
50+
}).interactive;
3451
const initialState = {
3552
blocks: [] as SlackBlock[],
3653
buttonIndex: 0,
3754
selectIndex: 0,
3855
};
39-
return reduceInteractiveReply(interactive, initialState, (state, block) => {
56+
return reduceInteractiveReply(projected, initialState, (state, block) => {
4057
if (block.type === "text") {
4158
const trimmed = block.text.trim();
4259
if (!trimmed) {

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,40 @@ describe("buildSlackInteractiveBlocks", () => {
107107
expect(buttonBlock.elements?.[2]?.style).toBe("primary");
108108
expect(buttonBlock.elements?.[3]).not.toHaveProperty("style");
109109
});
110+
111+
it("falls back to text guidance when the channel cannot render interactive widgets", () => {
112+
expect(
113+
buildSlackInteractiveBlocks(
114+
{
115+
blocks: [
116+
{
117+
type: "buttons",
118+
buttons: [
119+
{
120+
label: "Retry",
121+
value: "retry",
122+
fallback: { command: "/job retry", text: "Retry the job" },
123+
},
124+
],
125+
},
126+
],
127+
fallbackText: "Use the fallback path.",
128+
},
129+
{
130+
richReplies: {
131+
buttons: false,
132+
selects: false,
133+
commandFallback: true,
134+
},
135+
},
136+
),
137+
).toEqual([
138+
expect.objectContaining({
139+
type: "section",
140+
text: expect.objectContaining({
141+
text: "Use the fallback path.\n\nRetry: Retry the job (/job retry)",
142+
}),
143+
}),
144+
]);
145+
});
110146
});

extensions/telegram/src/button-types.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,27 @@ describe("resolveTelegramInlineButtons", () => {
8282
}),
8383
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
8484
});
85+
86+
it("converts selects into inline buttons when button rendering is the supported fallback", () => {
87+
expect(
88+
buildTelegramInteractiveButtons(
89+
{
90+
blocks: [
91+
{
92+
type: "select",
93+
placeholder: "Pick one",
94+
options: [{ label: "Alpha", value: "alpha", actionId: "choice.alpha" }],
95+
},
96+
],
97+
},
98+
{
99+
richReplies: {
100+
buttons: true,
101+
selects: false,
102+
commandFallback: true,
103+
},
104+
},
105+
),
106+
).toEqual([[{ text: "Alpha", callback_data: "alpha", style: undefined }]]);
107+
});
85108
});

extensions/telegram/src/button-types.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
1+
import type { ChannelCapabilities } from "openclaw/plugin-sdk/channel-contract";
2+
import {
3+
projectInteractiveReplyForCapabilities,
4+
reduceInteractiveReply,
5+
} from "openclaw/plugin-sdk/interactive-runtime";
26
import {
37
normalizeInteractiveReply,
48
type InteractiveReply,
@@ -50,27 +54,34 @@ function chunkInteractiveButtons(
5054

5155
export function buildTelegramInteractiveButtons(
5256
interactive?: InteractiveReply,
57+
capabilities?: Pick<ChannelCapabilities, "richReplies"> | null,
5358
): TelegramInlineButtons | undefined {
54-
const rows = reduceInteractiveReply(
59+
const projected = projectInteractiveReplyForCapabilities({
5560
interactive,
56-
[] as TelegramInlineButton[][],
57-
(state, block) => {
58-
if (block.type === "buttons") {
59-
chunkInteractiveButtons(block.buttons, state);
60-
return state;
61-
}
62-
if (block.type === "select") {
63-
chunkInteractiveButtons(
64-
block.options.map((option) => ({
65-
label: option.label,
66-
value: option.value,
67-
})),
68-
state,
69-
);
70-
}
71-
return state;
61+
capabilities: capabilities ?? {
62+
richReplies: {
63+
buttons: true,
64+
selects: true,
65+
commandFallback: true,
66+
},
7267
},
73-
);
68+
}).interactive;
69+
const rows = reduceInteractiveReply(projected, [] as TelegramInlineButton[][], (state, block) => {
70+
if (block.type === "buttons") {
71+
chunkInteractiveButtons(block.buttons, state);
72+
return state;
73+
}
74+
if (block.type === "select") {
75+
chunkInteractiveButtons(
76+
block.options.map((option) => ({
77+
label: option.label,
78+
value: option.value,
79+
})),
80+
state,
81+
);
82+
}
83+
return state;
84+
});
7485
return rows.length > 0 ? rows : undefined;
7586
}
7687

src/channels/plugins/contracts/plugins-core.contract.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,27 @@ const registryWithDemoLoaderNoOutbound = createTestRegistry([
424424
{ pluginId: "demo-loader", plugin: demoNoOutboundPlugin, source: "test-no-outbound" },
425425
]);
426426

427+
const demoRichRepliesPlugin = createChannelTestPluginBase({
428+
id: "demo-loader",
429+
label: "Demo Loader",
430+
capabilities: {
431+
chatTypes: ["direct", "thread"],
432+
richReplies: {
433+
buttons: true,
434+
selects: false,
435+
commandFallback: true,
436+
},
437+
interactionResponses: {
438+
acknowledge: true,
439+
editText: true,
440+
},
441+
},
442+
});
443+
444+
const registryWithRichRepliesPlugin = createTestRegistry([
445+
{ pluginId: "demo-loader", plugin: demoRichRepliesPlugin, source: "test-rich-replies" },
446+
]);
447+
427448
const demoOriginChannelId = "demo-origin";
428449
const demoTargetChannelId = "demo-target";
429450

@@ -527,6 +548,12 @@ describe("channel plugin loader", () => {
527548
kind: "missing-outbound" as const,
528549
registry: registryWithDemoLoaderNoOutbound,
529550
},
551+
{
552+
name: "preserves rich reply and interaction capability metadata on loaded plugins",
553+
kind: "rich-capabilities" as const,
554+
registry: registryWithRichRepliesPlugin,
555+
expectedPlugin: demoRichRepliesPlugin,
556+
},
530557
] as const)("$name", async (testCase) => {
531558
switch (testCase.kind) {
532559
case "plugin":
@@ -562,6 +589,23 @@ describe("channel plugin loader", () => {
562589
case "missing-outbound":
563590
await expectOutboundAdapterMissingCase(testCase.registry);
564591
return;
592+
case "rich-capabilities": {
593+
setActivePluginRegistry(testCase.registry);
594+
await expect(loadChannelPlugin("demo-loader")).resolves.toMatchObject({
595+
capabilities: {
596+
richReplies: {
597+
buttons: true,
598+
selects: false,
599+
commandFallback: true,
600+
},
601+
interactionResponses: {
602+
acknowledge: true,
603+
editText: true,
604+
},
605+
},
606+
});
607+
return;
608+
}
565609
}
566610
});
567611
});

0 commit comments

Comments
 (0)