diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f2cbab8abeb..b1fa1b6c487 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ec9fa02c3af9c210f7dbf6157d2f18e5c7171c29e6ae13b4f539aefcb25d178a plugin-sdk-api-baseline.json -2ee394b924edb9843987710a65f9d45523efadd7e7940e01c88ea39dcdcdad7c plugin-sdk-api-baseline.jsonl +df6c2799805dc3c57924dbb1632d11e7ed08ef4d7759f535998b170f1a10a638 plugin-sdk-api-baseline.json +e3526669b79e5eaa3b92e03bece552402209d3cf5b35343c33b62299f71b2efc plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/slack.md b/docs/channels/slack.md index a0aa739e896..2e77c6a152e 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1245,6 +1245,16 @@ back through the existing Slack interaction event path. Keep them for old prompts and Slack-specific escape hatches; use shared presentation for new portable controls. +The directive compiler APIs are also deprecated for new producer code: + +- `compileSlackInteractiveReplies(...)` +- `parseSlackOptionsLine(...)` +- `isSlackInteractiveRepliesEnabled(...)` +- `buildSlackInteractiveBlocks(...)` + +Use `presentation` payloads and `buildSlackPresentationBlocks(...)` for new +Slack-rendered controls. + Notes: - This is Slack-specific legacy UI. Other channels do not translate Slack Block diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index 96eada473d6..467a391c3ad 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -391,12 +391,33 @@ New code should accept or produce `MessagePresentation` directly. Existing `interactive` payloads are a deprecated subset of `presentation`; runtime support remains for older producers. -`presentationToInteractiveReply(...)` preserves visible presentation text by -mapping the title, text, context, buttons, and selects into the older -`InteractiveReply` shape. Component renderers that already draw title, text, -context, and divider blocks natively should use -`presentationToInteractiveControlsReply(...)` instead, then append only the -button and select controls. +The legacy `InteractiveReply*` types and conversion helpers are marked +`@deprecated` in the SDK: + +- `InteractiveReply`, `InteractiveReplyBlock`, `InteractiveReplyButton`, + `InteractiveReplyOption`, `InteractiveReplySelectBlock`, and + `InteractiveReplyTextBlock` +- `normalizeInteractiveReply(...)` +- `hasInteractiveReplyBlocks(...)` +- `interactiveReplyToPresentation(...)` +- `presentationToInteractiveReply(...)` +- `presentationToInteractiveControlsReply(...)` +- `resolveInteractiveTextFallback(...)` +- `reduceInteractiveReply(...)` + +`presentationToInteractiveReply(...)` and +`presentationToInteractiveControlsReply(...)` remain available as renderer +bridges for legacy channel implementations. New producer code should not call +them; send `presentation` and let core/channel adaptation handle rendering. + +Approval helpers also have presentation-first replacements: + +- use `buildApprovalPresentationFromActionDescriptors(...)` instead of + `buildApprovalInteractiveReplyFromActionDescriptors(...)` +- use `buildApprovalPresentation(...)` instead of + `buildApprovalInteractiveReply(...)` +- use `buildExecApprovalPresentation(...)` instead of + `buildExecApprovalInteractiveReply(...)` `renderMessagePresentationFallbackText(...)` returns an empty string for presentation blocks that have no text fallback, such as a divider-only diff --git a/extensions/slack/src/approval-handler.runtime.ts b/extensions/slack/src/approval-handler.runtime.ts index 58b2c64135c..f9d2f64c505 100644 --- a/extensions/slack/src/approval-handler.runtime.ts +++ b/extensions/slack/src/approval-handler.runtime.ts @@ -8,7 +8,7 @@ import type { } from "openclaw/plugin-sdk/approval-handler-runtime"; import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime"; -import { buildApprovalInteractiveReplyFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime"; +import { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime"; import type { ExecApprovalRequest } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logError } from "openclaw/plugin-sdk/logging-core"; @@ -142,7 +142,7 @@ function buildSlackPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBl const interactiveBlocks = resolveSlackReplyBlocks({ text: "", - interactive: buildApprovalInteractiveReplyFromActionDescriptors(view.actions), + presentation: buildApprovalPresentationFromActionDescriptors(view.actions), }) ?? []; return [ { diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index e63f3d88ddf..02479e09160 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -85,6 +85,9 @@ export function resolveSlackInteractiveBlockOffsets( return { buttonIndexOffset, selectIndexOffset }; } +/** + * @deprecated Use buildSlackPresentationBlocks with MessagePresentation. + */ export function buildSlackInteractiveBlocks( interactive?: InteractiveReply, options: SlackInteractiveBlockRenderOptions = {}, diff --git a/extensions/slack/src/interactive-replies.ts b/extensions/slack/src/interactive-replies.ts index 5a69c52aad3..864697d974f 100644 --- a/extensions/slack/src/interactive-replies.ts +++ b/extensions/slack/src/interactive-replies.ts @@ -162,6 +162,9 @@ function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boole return false; } +/** + * @deprecated Only needed for legacy Slack reply directives. New producers should emit presentation payloads. + */ export function isSlackInteractiveRepliesEnabled(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -173,6 +176,9 @@ export function isSlackInteractiveRepliesEnabled(params: { return resolveInteractiveRepliesFromCapabilities(account.config.capabilities); } +/** + * @deprecated Slack reply directives are legacy. New producers should emit presentation payloads. + */ export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload { const text = payload.text; if (!text) { @@ -230,6 +236,9 @@ export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayl }; } +/** + * @deprecated Legacy Slack directive fallback. New producers should emit presentation payloads. + */ export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload { const text = payload.text; if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) { diff --git a/extensions/slack/src/reply-blocks.ts b/extensions/slack/src/reply-blocks.ts index 562a0ea8841..6125b13eb9a 100644 --- a/extensions/slack/src/reply-blocks.ts +++ b/extensions/slack/src/reply-blocks.ts @@ -1,16 +1,28 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { parseSlackBlocksInput, SLACK_MAX_BLOCKS } from "./blocks-input.js"; -import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; +import { + buildSlackInteractiveBlocks, + buildSlackPresentationBlocks, + resolveSlackInteractiveBlockOffsets, + type SlackBlock, +} from "./blocks-render.js"; export function resolveSlackReplyBlocks(payload: ReplyPayload): SlackBlock[] | undefined { const slackData = payload.channelData?.slack; - const interactiveBlocks = buildSlackInteractiveBlocks(payload.interactive); let channelBlocks: SlackBlock[] = []; if (slackData && typeof slackData === "object" && !Array.isArray(slackData)) { channelBlocks = (parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as SlackBlock[]) ?? []; } - const blocks = [...channelBlocks, ...interactiveBlocks]; + const presentationBlocks = buildSlackPresentationBlocks( + payload.presentation, + resolveSlackInteractiveBlockOffsets(channelBlocks), + ); + const interactiveBlocks = buildSlackInteractiveBlocks( + payload.interactive, + resolveSlackInteractiveBlockOffsets([...channelBlocks, ...presentationBlocks]), + ); + const blocks = [...channelBlocks, ...presentationBlocks, ...interactiveBlocks]; if (blocks.length > SLACK_MAX_BLOCKS) { throw new Error( `Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`, diff --git a/extensions/slack/src/shared-interactive.test.ts b/extensions/slack/src/shared-interactive.test.ts index 1445317863d..04643cf4d2b 100644 --- a/extensions/slack/src/shared-interactive.test.ts +++ b/extensions/slack/src/shared-interactive.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildSlackInteractiveBlocks } from "./blocks-render.js"; +import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks } from "./blocks-render.js"; +import { resolveSlackReplyBlocks } from "./reply-blocks.js"; describe("buildSlackInteractiveBlocks", () => { it("renders shared interactive blocks in authored order", () => { @@ -306,3 +307,87 @@ describe("buildSlackInteractiveBlocks", () => { expect(buttonBlock.elements?.[3]).not.toHaveProperty("style"); }); }); + +describe("buildSlackPresentationBlocks", () => { + it("renders presentation controls without requiring legacy interactive payloads", () => { + const blocks = buildSlackPresentationBlocks({ + blocks: [ + { type: "text", text: "Pick" }, + { + type: "buttons", + buttons: [{ label: "Approve", value: "approve", style: "success" }], + }, + ], + }); + + expect(blocks).toEqual([ + { + type: "section", + text: { type: "mrkdwn", text: "Pick" }, + }, + { + type: "actions", + block_id: "openclaw_reply_buttons_1", + elements: [ + { + type: "button", + action_id: "openclaw:reply_button:1:1", + text: { + type: "plain_text", + text: "Approve", + emoji: true, + }, + value: "approve", + style: "primary", + }, + ], + }, + ]); + }); +}); + +describe("resolveSlackReplyBlocks", () => { + it("offsets legacy interactive blocks after channel and presentation controls", () => { + const blocks = resolveSlackReplyBlocks({ + channelData: { + slack: { + blocks: [ + { + type: "actions", + block_id: "openclaw_reply_buttons_1", + elements: [], + }, + ], + }, + }, + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Stage", value: "stage" }], + }, + ], + }, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Approve", value: "approve" }], + }, + ], + }, + }); + + const presentationButtonBlock = blocks?.[1] as + | { elements?: Array<{ action_id?: string }> } + | undefined; + const legacyButtonBlock = blocks?.[2] as + | { elements?: Array<{ action_id?: string }> } + | undefined; + expect(blocks?.[0]?.block_id).toBe("openclaw_reply_buttons_1"); + expect(blocks?.[1]?.block_id).toBe("openclaw_reply_buttons_2"); + expect(presentationButtonBlock?.elements?.[0]?.action_id).toBe("openclaw:reply_button:2:1"); + expect(blocks?.[2]?.block_id).toBe("openclaw_reply_buttons_3"); + expect(legacyButtonBlock?.elements?.[0]?.action_id).toBe("openclaw:reply_button:3:1"); + }); +}); diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 588fae68318..c26e745139c 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -13,7 +13,6 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeMessagePresentation, - presentationToInteractiveReply, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; @@ -138,7 +137,8 @@ function resolveTelegramButtonsFromParams( presentation = normalizeMessagePresentation(params.presentation), ) { return resolveTelegramInlineButtons({ - interactive: presentation ? presentationToInteractiveReply(presentation) : params.interactive, + presentation, + interactive: params.interactive, }); } diff --git a/extensions/telegram/src/approval-handler.runtime.ts b/extensions/telegram/src/approval-handler.runtime.ts index 69cc04246b4..e992c3c771d 100644 --- a/extensions/telegram/src/approval-handler.runtime.ts +++ b/extensions/telegram/src/approval-handler.runtime.ts @@ -6,7 +6,7 @@ import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/a import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime"; import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime"; import { - buildApprovalInteractiveReplyFromActionDescriptors, + buildApprovalPresentationFromActionDescriptors, buildExecApprovalPendingReplyPayload, } from "openclaw/plugin-sdk/approval-reply-runtime"; import type { ExecApprovalPendingReplyParams } from "openclaw/plugin-sdk/approval-reply-runtime"; @@ -92,7 +92,7 @@ function buildPendingPayload(params: { return { text: payload.text ?? "", buttons: resolveTelegramInlineButtons({ - interactive: buildApprovalInteractiveReplyFromActionDescriptors(params.view.actions), + presentation: buildApprovalPresentationFromActionDescriptors(params.view.actions), }), }; } diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 5aefbecd849..7e2341c9e1e 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -36,10 +36,7 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; -import { - normalizeMessagePresentation, - presentationToInteractiveReply, -} from "openclaw/plugin-sdk/interactive-runtime"; +import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import { createOutboundPayloadPlan, projectOutboundPayloadPlanForDelivery, @@ -145,12 +142,10 @@ function resolvePayloadTelegramInlineButtons( | { buttons?: TelegramInlineButtons } | undefined; const presentation = normalizeMessagePresentation(payload.presentation); - const interactive = - payload.interactive ?? - (presentation ? presentationToInteractiveReply(presentation) : undefined); return resolveTelegramInlineButtons({ buttons: telegramData?.buttons, - interactive, + presentation, + interactive: payload.interactive, }); } diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 4a8b0ac7c32..e2171b80bf8 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -543,6 +543,37 @@ describe("registerTelegramNativeCommands", () => { expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png"); }); + it("falls back to a normal reply when a progress result has presentation controls", async () => { + const presentation = { + blocks: [ + { + kind: "actions", + buttons: [{ label: "Approve", action: { type: "command", value: "/approve yes" } }], + }, + ], + }; + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Approval required", + presentation, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ + text: "Approval required", + presentation, + }); + }); + it("cleans up the progress placeholder before falling back after an edit failure", async () => { const { handler, sendMessage, deleteMessage } = registerPlugCommand({ args: "now", diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 4f452047b82..2c1ef8a1a4c 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -333,6 +333,7 @@ function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): b result.text.trim() && !result.mediaUrl && (!result.mediaUrls || result.mediaUrls.length === 0) && + !result.presentation && !result.interactive && !result.btw && telegramData?.pin !== true, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 69347ab3232..fd81df2563f 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -10,10 +10,7 @@ import { toPluginMessageSentEvent, } from "openclaw/plugin-sdk/hook-runtime"; import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime"; -import { - normalizeMessagePresentation, - presentationToInteractiveReply, -} from "openclaw/plugin-sdk/interactive-runtime"; +import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import { buildOutboundMediaLoadOptions, isGifMedia, @@ -773,9 +770,7 @@ export async function deliverReplies(params: { : []; const hasMedia = mediaList.length > 0; const presentation = normalizeMessagePresentation(reply?.presentation); - const interactive = - reply?.interactive ?? - (presentation ? presentationToInteractiveReply(presentation) : undefined); + const interactive = reply?.interactive; const resolvedReplyText = resolveTelegramInteractiveTextFallback({ text: reply?.text, @@ -850,6 +845,7 @@ export async function deliverReplies(params: { const replyMarkup = buildInlineKeyboard( resolveTelegramInlineButtons({ buttons: telegramData?.buttons, + presentation, interactive, }), ); diff --git a/extensions/telegram/src/button-types.test-helpers.ts b/extensions/telegram/src/button-types.test-helpers.ts index f8b0b6506fb..d80f4dbcc52 100644 --- a/extensions/telegram/src/button-types.test-helpers.ts +++ b/extensions/telegram/src/button-types.test-helpers.ts @@ -81,5 +81,28 @@ export function describeTelegramInteractiveButtonBehavior(): void { ], ]); }); + + it("prefers legacy interactive buttons over generic presentation buttons", () => { + expect( + resolveTelegramInlineButtons({ + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Generic", value: "generic" }], + }, + ], + }, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Legacy", value: "legacy" }], + }, + ], + }, + }), + ).toEqual([[{ text: "Legacy", callback_data: "legacy", style: undefined }]]); + }); }); } diff --git a/extensions/telegram/src/button-types.test.ts b/extensions/telegram/src/button-types.test.ts index 8fe96fe4abc..d7968cf4e5e 100644 --- a/extensions/telegram/src/button-types.test.ts +++ b/extensions/telegram/src/button-types.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildTelegramInteractiveButtons } from "./button-types.js"; +import { + buildTelegramInteractiveButtons, + buildTelegramPresentationButtons, +} from "./button-types.js"; import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js"; describeTelegramInteractiveButtonBehavior(); @@ -21,3 +24,27 @@ describe("buildTelegramInteractiveButtons callback limits", () => { ).toEqual([[{ text: "Keep", callback_data: "ok", style: undefined }]]); }); }); + +describe("buildTelegramPresentationButtons", () => { + it("builds inline buttons from presentation blocks", () => { + expect( + buildTelegramPresentationButtons({ + blocks: [ + { type: "text", text: "Choose" }, + { + type: "buttons", + buttons: [{ label: "Approve", value: "/approve req-1 allow-once", style: "success" }], + }, + ], + }), + ).toEqual([ + [ + { + text: "Approve", + callback_data: "/approve req-1 allow-once", + style: "success", + }, + ], + ]); + }); +}); diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index 4d409602a6b..59ba0f3bb23 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,8 +1,11 @@ import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { + isMessagePresentationInteractiveBlock, + normalizeMessagePresentation, normalizeInteractiveReply, type InteractiveReply, - type InteractiveReplyButton, + type MessagePresentation, + type MessagePresentationButton, } from "openclaw/plugin-sdk/interactive-runtime"; import { sanitizeTelegramCallbackData } from "./approval-callback-data.js"; @@ -21,12 +24,14 @@ export type TelegramInlineButtons = ReadonlyArray 0 ? rows : undefined; } +export function buildTelegramPresentationButtons( + presentation?: MessagePresentation, +): TelegramInlineButtons | undefined { + const rows: TelegramInlineButton[][] = []; + for (const block of presentation?.blocks ?? []) { + if (!isMessagePresentationInteractiveBlock(block)) { + continue; + } + if (block.type === "buttons") { + chunkInteractiveButtons(block.buttons, rows); + continue; + } + chunkInteractiveButtons( + block.options.map((option) => ({ + label: option.label, + value: option.value, + })), + rows, + ); + } + return rows.length > 0 ? rows : undefined; +} + export function resolveTelegramInlineButtons(params: { buttons?: TelegramInlineButtons; + presentation?: unknown; interactive?: unknown; }): TelegramInlineButtons | undefined { return ( - params.buttons ?? buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) + params.buttons ?? + buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ?? + buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation)) ); } diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index c63dafda687..3e0c3dbec71 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -216,6 +216,32 @@ describe("telegramOutbound", () => { ]); }); + it("preserves explicit Telegram buttons when rendering presentation payloads", async () => { + const rendered = await telegramOutbound.renderPresentation?.({ + payload: { + text: "Use native buttons:", + channelData: { + telegram: { + buttons: [[{ text: "Native", callback_data: "native" }]], + }, + }, + }, + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Generic", value: "generic" }], + }, + ], + }, + ctx: {} as never, + }); + + expect((rendered?.channelData?.telegram as { buttons?: unknown })?.buttons).toEqual([ + [{ text: "Native", callback_data: "native" }], + ]); + }); + it("lets allow-always approval callbacks reach Telegram's callback rewrite", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-approval", diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index a1fe41f0e70..76b3d24acbe 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -5,7 +5,6 @@ import { } from "openclaw/plugin-sdk/channel-send-result"; import { normalizeMessagePresentation, - presentationToInteractiveReply, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/outbound-runtime"; @@ -118,19 +117,17 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const presentation = normalizeMessagePresentation(params.payload.presentation); - const interactive = - params.payload.interactive ?? - (presentation ? presentationToInteractiveReply(presentation) : undefined); const text = resolveTelegramInteractiveTextFallback({ text: params.payload.text, - interactive, + interactive: params.payload.interactive, presentation, }) ?? ""; const mediaUrls = resolvePayloadMediaUrls(params.payload); const buttons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons, - interactive, + presentation, + interactive: params.payload.interactive, }); const payloadOpts = { ...params.baseOpts, @@ -213,11 +210,24 @@ export function createTelegramOutboundAdapter( batch: true, }, }, - renderPresentation: ({ payload, presentation }) => ({ - ...payload, - text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), - interactive: presentationToInteractiveReply(presentation), - }), + renderPresentation: ({ payload, presentation }) => { + const telegramData = payload.channelData?.telegram as Record | undefined; + const hasExplicitButtons = telegramData && "buttons" in telegramData; + const buttons = hasExplicitButtons + ? undefined + : resolveTelegramInlineButtons({ presentation }); + return { + ...payload, + text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), + channelData: { + ...payload.channelData, + telegram: { + ...telegramData, + ...(buttons ? { buttons } : {}), + }, + }, + }; + }, pinDeliveredMessage: async ({ cfg, target, messageId, pin }) => { const { pinMessageTelegram } = await loadSendModule(); await pinMessageTelegram(target.to, messageId, { diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 4b40fcfd76c..edcb136b6df 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -82,18 +82,31 @@ export type AgentRuntimeProviderHandle = { export type AgentRuntimeInteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; -export type AgentRuntimeInteractiveReplyButton = { +export type AgentRuntimeMessagePresentationButton = { label: string; value?: string; url?: string; style?: AgentRuntimeInteractiveButtonStyle; }; -export type AgentRuntimeInteractiveReplyOption = { +export type AgentRuntimeMessagePresentationOption = { label: string; value: string; }; +/** + * @deprecated Use AgentRuntimeMessagePresentationButton. + */ +export type AgentRuntimeInteractiveReplyButton = AgentRuntimeMessagePresentationButton; + +/** + * @deprecated Use AgentRuntimeMessagePresentationOption. + */ +export type AgentRuntimeInteractiveReplyOption = AgentRuntimeMessagePresentationOption; + +/** + * @deprecated Use AgentRuntimeMessagePresentationBlock. + */ export type AgentRuntimeInteractiveReplyBlock = | { type: "text"; @@ -109,6 +122,9 @@ export type AgentRuntimeInteractiveReplyBlock = options: AgentRuntimeInteractiveReplyOption[]; }; +/** + * @deprecated Use AgentRuntimeMessagePresentation. + */ export type AgentRuntimeInteractiveReply = { blocks: AgentRuntimeInteractiveReplyBlock[]; }; @@ -134,12 +150,12 @@ export type AgentRuntimeMessagePresentationBlock = } | { type: "buttons"; - buttons: AgentRuntimeInteractiveReplyButton[]; + buttons: AgentRuntimeMessagePresentationButton[]; } | { type: "select"; placeholder?: string; - options: AgentRuntimeInteractiveReplyOption[]; + options: AgentRuntimeMessagePresentationOption[]; }; export type AgentRuntimeMessagePresentation = { @@ -166,6 +182,9 @@ export type AgentRuntimeReplyPayload = { sensitiveMedia?: boolean; presentation?: AgentRuntimeMessagePresentation; delivery?: AgentRuntimeReplyPayloadDelivery; + /** + * @deprecated Use presentation. + */ interactive?: AgentRuntimeInteractiveReply; btw?: { question: string; diff --git a/src/channels/plugins/outbound/interactive.ts b/src/channels/plugins/outbound/interactive.ts index 4594af1650e..c89f9b02d04 100644 --- a/src/channels/plugins/outbound/interactive.ts +++ b/src/channels/plugins/outbound/interactive.ts @@ -5,6 +5,9 @@ export { presentationPageSize, } from "./presentation-limits.js"; +/** + * @deprecated Use MessagePresentation helpers for new rendering paths. + */ export function reduceInteractiveReply( interactive: InteractiveReply | undefined, initialState: TState, diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index dc4e63930ad..9e1d3268361 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -96,7 +96,7 @@ function buildTelegramExecApprovalPendingPayloadForTest(params: { }): ReplyPayload { return { text: `Telegram exec approval ${params.request.id}`, - interactive: { + presentation: { blocks: [ { type: "buttons", @@ -512,7 +512,7 @@ describe("exec approval forwarder", () => { expect(deliver).not.toHaveBeenCalled(); }); - it("attaches shared interactive approval buttons in forwarded fallback payloads", async () => { + it("attaches shared presentation approval buttons in forwarded fallback payloads", async () => { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: makeTargetsCfg([{ channel: "telegram", to: "123" }]), @@ -535,7 +535,7 @@ describe("exec approval forwarder", () => { expect(delivery.to).toBe("123"); const payload = requireFirstPayload(deliver); expect(payload.channelData?.execApproval).toEqual({ approvalId: "req-1" }); - expect(payload.interactive).toEqual({ + expect(payload.presentation).toEqual({ blocks: [ { type: "buttons", @@ -559,6 +559,7 @@ describe("exec approval forwarder", () => { }, ], }); + expect(payload.interactive).toBeUndefined(); }); it("stores exec metadata on generic forwarded fallback payloads", async () => { diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index c700c4cb809..136c10b3021 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -258,7 +258,7 @@ describe("exec approval reply helpers", () => { sessionKey: undefined, }, }); - expect(payload.interactive).toEqual({ + expect(payload.presentation).toEqual({ blocks: [ { type: "buttons", @@ -282,6 +282,7 @@ describe("exec approval reply helpers", () => { }, ], }); + expect(payload.interactive).toBeUndefined(); expect(payload.text).toContain("Heads up."); expect(payload.text).toContain("```txt\n/approve slug-1 allow-once\n```"); expect(payload.text).toContain("```sh\necho ok\n```"); @@ -324,7 +325,7 @@ describe("exec approval reply helpers", () => { expect(payload.text).toContain( "The effective approval policy requires approval every time, so Allow Always is unavailable.", ); - expect(payload.interactive).toEqual({ + expect(payload.presentation).toEqual({ blocks: [ { type: "buttons", @@ -343,6 +344,7 @@ describe("exec approval reply helpers", () => { }, ], }); + expect(payload.interactive).toBeUndefined(); }); it("stores agent and session metadata for downstream suppression checks", () => { diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 861d88627cb..99cf5b73286 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -1,5 +1,10 @@ import type { ReplyPayload } from "../auto-reply/types.js"; -import type { InteractiveReply, InteractiveReplyButton } from "../interactive/payload.js"; +import type { + InteractiveReply, + InteractiveReplyButton, + MessagePresentation, + MessagePresentationButton, +} from "../interactive/payload.js"; import { formatHumanList } from "../shared/human-list.js"; import { normalizeOptionalLowercaseString, @@ -35,7 +40,7 @@ export type ExecApprovalReplyMetadata = { export type ExecApprovalActionDescriptor = { decision: ExecApprovalReplyDecision; label: string; - style: NonNullable; + style: NonNullable; command: string; }; @@ -162,6 +167,52 @@ function buildApprovalInteractiveButtons( })); } +function buildApprovalPresentationButtons( + descriptors: readonly ExecApprovalActionDescriptor[], +): MessagePresentationButton[] { + return descriptors.map((descriptor) => ({ + label: descriptor.label, + value: descriptor.command, + style: descriptor.style, + })); +} + +export function buildApprovalPresentationFromActionDescriptors( + actions: readonly ExecApprovalActionDescriptor[], +): MessagePresentation | undefined { + const buttons = buildApprovalPresentationButtons(actions); + return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined; +} + +export function buildApprovalPresentation(params: { + approvalId: string; + ask?: string | null; + allowedDecisions?: readonly ExecApprovalReplyDecision[]; +}): MessagePresentation | undefined { + return buildApprovalPresentationFromActionDescriptors( + buildExecApprovalActionDescriptors({ + approvalCommandId: params.approvalId, + ask: params.ask, + allowedDecisions: params.allowedDecisions, + }), + ); +} + +export function buildExecApprovalPresentation(params: { + approvalCommandId: string; + ask?: string | null; + allowedDecisions?: readonly ExecApprovalReplyDecision[]; +}): MessagePresentation | undefined { + return buildApprovalPresentation({ + approvalId: params.approvalCommandId, + ask: params.ask, + allowedDecisions: params.allowedDecisions, + }); +} + +/** + * @deprecated Use buildApprovalPresentationFromActionDescriptors. + */ export function buildApprovalInteractiveReplyFromActionDescriptors( actions: readonly ExecApprovalActionDescriptor[], ): InteractiveReply | undefined { @@ -169,6 +220,9 @@ export function buildApprovalInteractiveReplyFromActionDescriptors( return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined; } +/** + * @deprecated Use buildApprovalPresentation. + */ export function buildApprovalInteractiveReply(params: { approvalId: string; ask?: string | null; @@ -183,6 +237,9 @@ export function buildApprovalInteractiveReply(params: { ); } +/** + * @deprecated Use buildExecApprovalPresentation. + */ export function buildExecApprovalInteractiveReply(params: { approvalCommandId: string; ask?: string | null; @@ -335,7 +392,7 @@ export function buildExecApprovalPendingReplyPayload( return { text: lines.join("\n\n"), - interactive: buildApprovalInteractiveReply({ + presentation: buildApprovalPresentation({ approvalId: params.approvalId, allowedDecisions, }), diff --git a/src/infra/plugin-approval-forwarder.test.ts b/src/infra/plugin-approval-forwarder.test.ts index f386d047353..44040a49706 100644 --- a/src/infra/plugin-approval-forwarder.test.ts +++ b/src/infra/plugin-approval-forwarder.test.ts @@ -69,7 +69,7 @@ async function flushPendingDelivery(): Promise { } type DeliveryArgs = { - payloads?: Array<{ text?: string; interactive?: unknown }>; + payloads?: Array<{ text?: string; presentation?: unknown; interactive?: unknown }>; }; function deliveryArgs(deliver: ReturnType): DeliveryArgs | undefined { @@ -137,7 +137,7 @@ describe("plugin approval forwarding", () => { expect(text).toContain("Sensitive tool call"); expect(text).toContain("plugin-req-1"); expect(text).toContain("/approve"); - expect(payload?.interactive).toEqual({ + expect(payload?.presentation).toEqual({ blocks: [ { type: "buttons", @@ -161,6 +161,7 @@ describe("plugin approval forwarding", () => { }, ], }); + expect(payload?.interactive).toBeUndefined(); }); it("renders only request-scoped plugin approval decisions", async () => { @@ -179,7 +180,7 @@ describe("plugin approval forwarding", () => { const payload = firstDeliveredPayload(deliver); expect(payload?.text).toContain("Reply with: /approve allow-once|deny"); expect(payload?.text).not.toContain("allow-always"); - expect(payload?.interactive).toEqual({ + expect(payload?.presentation).toEqual({ blocks: [ { type: "buttons", @@ -198,6 +199,7 @@ describe("plugin approval forwarding", () => { }, ], }); + expect(payload?.interactive).toBeUndefined(); }); it("includes severity icon for critical", async () => { diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 030b2088288..a8f0d28224c 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -5,7 +5,11 @@ import { export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; -export type InteractiveReplyButton = { +export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral"; + +export type MessagePresentationButtonStyle = InteractiveButtonStyle; + +export type MessagePresentationButton = { label: string; value?: string; url?: string; @@ -23,44 +27,61 @@ export type InteractiveReplyButton = { style?: InteractiveButtonStyle; }; -export type InteractiveReplyOption = { +export type MessagePresentationOption = { label: string; value: string; }; +/** + * @deprecated Use MessagePresentationButton. + */ +export type InteractiveReplyButton = MessagePresentationButton; + +/** + * @deprecated Use MessagePresentationOption. + */ +export type InteractiveReplyOption = MessagePresentationOption; + +/** + * @deprecated Use MessagePresentationTextBlock. + */ export type InteractiveReplyTextBlock = { type: "text"; text: string; }; +/** + * @deprecated Use MessagePresentationButtonsBlock. + */ type InteractiveReplyButtonsBlock = { type: "buttons"; buttons: InteractiveReplyButton[]; }; +/** + * @deprecated Use MessagePresentationSelectBlock. + */ export type InteractiveReplySelectBlock = { type: "select"; placeholder?: string; options: InteractiveReplyOption[]; }; +/** + * @deprecated Use MessagePresentationBlock. + */ export type InteractiveReplyBlock = | InteractiveReplyTextBlock | InteractiveReplyButtonsBlock | InteractiveReplySelectBlock; +/** + * @deprecated Use MessagePresentation. + */ export type InteractiveReply = { blocks: InteractiveReplyBlock[]; }; -export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral"; - -export type MessagePresentationButtonStyle = InteractiveButtonStyle; - -export type MessagePresentationButton = InteractiveReplyButton; - -export type MessagePresentationOption = InteractiveReplyOption; - export type MessagePresentationTextBlock = { type: "text"; text: string; @@ -215,6 +236,9 @@ function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefi return undefined; } +/** + * @deprecated Use normalizeMessagePresentation. + */ export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { const record = toRecord(raw); if (!record) { @@ -271,6 +295,9 @@ export function normalizeMessagePresentation(raw: unknown): MessagePresentation }; } +/** + * @deprecated Use hasMessagePresentationBlocks. + */ export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply { return Boolean(normalizeInteractiveReply(value)); } @@ -279,6 +306,9 @@ export function hasMessagePresentationBlocks(value: unknown): value is MessagePr return Boolean(normalizeMessagePresentation(value)); } +/** + * @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly. + */ export function presentationToInteractiveReply( presentation: MessagePresentation, ): InteractiveReply | undefined { @@ -339,6 +369,9 @@ export function isMessagePresentationInteractiveBlock( return block.type === "buttons" || block.type === "select"; } +/** + * @deprecated Avoid producing InteractiveReply payloads; send MessagePresentation directly. + */ export function presentationToInteractiveControlsReply( presentation: MessagePresentation, ): InteractiveReply | undefined { @@ -347,6 +380,9 @@ export function presentationToInteractiveControlsReply( }); } +/** + * @deprecated Legacy bridge for old InteractiveReply payloads. New producers should send MessagePresentation. + */ export function interactiveReplyToPresentation( interactive: InteractiveReply, ): MessagePresentation | undefined { @@ -466,6 +502,9 @@ export function hasReplyPayloadContent( }); } +/** + * @deprecated Use renderMessagePresentationFallbackText with MessagePresentation. + */ export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply; diff --git a/src/plugin-sdk/approval-renderers.test.ts b/src/plugin-sdk/approval-renderers.test.ts index 094323edeaa..cf5c63e5762 100644 --- a/src/plugin-sdk/approval-renderers.test.ts +++ b/src/plugin-sdk/approval-renderers.test.ts @@ -9,14 +9,14 @@ import { describe("plugin-sdk/approval-renderers", () => { it.each([ { - name: "builds shared approval payloads with generic interactive commands", + name: "builds shared approval payloads with generic presentation commands", payload: buildApprovalPendingReplyPayload({ approvalId: "plugin:approval-123", approvalSlug: "plugin:a", text: "Approval required @everyone", }), textExpected: (text: string) => expect(text).toContain("@everyone"), - interactiveExpected: { + presentationExpected: { blocks: [ { type: "buttons", @@ -63,7 +63,7 @@ describe("plugin-sdk/approval-renderers", () => { }, }), textExpected: (text: string) => expect(text).toContain("Plugin approval required"), - interactiveExpected: { + presentationExpected: { blocks: [ { type: "buttons", @@ -119,7 +119,7 @@ describe("plugin-sdk/approval-renderers", () => { }), textExpected: (text: string) => expect(text).toContain("Reply with: /approve allow-once|deny"), - interactiveExpected: { + presentationExpected: { blocks: [ { type: "buttons", @@ -158,7 +158,7 @@ describe("plugin-sdk/approval-renderers", () => { text: "resolved @everyone", }), textExpected: (text: string) => expect(text).toBe("resolved @everyone"), - interactiveExpected: undefined, + presentationExpected: undefined, channelDataExpected: { execApproval: { approvalId: "req-123", @@ -183,7 +183,7 @@ describe("plugin-sdk/approval-renderers", () => { }, }), textExpected: (text: string) => expect(text).toContain("Plugin approval allowed once"), - interactiveExpected: undefined, + presentationExpected: undefined, channelDataExpected: { execApproval: { approvalId: "plugin-approval-123", @@ -195,13 +195,14 @@ describe("plugin-sdk/approval-renderers", () => { }, }, }, - ])("$name", ({ payload, textExpected, interactiveExpected, channelDataExpected }) => { + ])("$name", ({ payload, textExpected, presentationExpected, channelDataExpected }) => { if (payload.text === undefined) { throw new Error("expected rendered approval text"); } textExpected(payload.text); - if (interactiveExpected) { - expect(payload.interactive).toEqual(interactiveExpected); + if (presentationExpected) { + expect(payload.presentation).toEqual(presentationExpected); + expect(payload.interactive).toBeUndefined(); } if (channelDataExpected) { expect(payload.channelData).toEqual(channelDataExpected); diff --git a/src/plugin-sdk/approval-renderers.ts b/src/plugin-sdk/approval-renderers.ts index a44b513003c..8e7b42da7cd 100644 --- a/src/plugin-sdk/approval-renderers.ts +++ b/src/plugin-sdk/approval-renderers.ts @@ -1,5 +1,5 @@ import { - buildApprovalInteractiveReply, + buildApprovalPresentation, type ExecApprovalReplyDecision, } from "../infra/exec-approval-reply.js"; import { @@ -27,7 +27,7 @@ export function buildApprovalPendingReplyPayload(params: { const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS; return { text: params.text, - interactive: buildApprovalInteractiveReply({ + presentation: buildApprovalPresentation({ approvalId: params.approvalId, allowedDecisions, }), diff --git a/src/plugin-sdk/approval-reply-runtime.ts b/src/plugin-sdk/approval-reply-runtime.ts index d3216df45de..3ca8d5eece7 100644 --- a/src/plugin-sdk/approval-reply-runtime.ts +++ b/src/plugin-sdk/approval-reply-runtime.ts @@ -1,5 +1,8 @@ export { buildApprovalInteractiveReplyFromActionDescriptors, + buildApprovalPresentation, + buildApprovalPresentationFromActionDescriptors, + buildExecApprovalPresentation, buildExecApprovalActionDescriptors, buildExecApprovalPendingReplyPayload, getExecApprovalApproverDmNoticeText,