From 16327f21dadc12c4793d9a7bc8e033dacac09764 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Feb 2026 22:48:47 +0530 Subject: [PATCH] feat(telegram): support inline button styles (#18241) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 239cb3552e4eaf2597b8e1f4af82ab2ffd1d446c Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/agents/system-prompt.e2e.test.ts | 14 ++++ src/agents/system-prompt.ts | 2 +- src/agents/tools/message-tool.e2e.test.ts | 7 ++ src/agents/tools/message-tool.ts | 1 + src/agents/tools/telegram-actions.e2e.test.ts | 71 +++++++++++++++++++ src/agents/tools/telegram-actions.ts | 24 +++++-- src/channels/plugins/outbound/telegram.ts | 3 +- src/telegram/bot-message-dispatch.ts | 5 +- src/telegram/bot/delivery.ts | 3 +- src/telegram/button-types.ts | 9 +++ src/telegram/send.test.ts | 23 ++++++ src/telegram/send.ts | 6 +- 13 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 src/telegram/button-types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 537401a26df..b1f667dbf9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. +- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. ### Fixes diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index 35db6055e56..18fc269e039 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -371,6 +371,20 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain(`respond with ONLY: ${SILENT_REPLY_TOKEN}`); }); + it("includes inline button style guidance when runtime supports inline buttons", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["message"], + runtimeInfo: { + channel: "telegram", + capabilities: ["inlineButtons"], + }, + }); + + expect(prompt).toContain("buttons=[[{text,callback_data,style?}]]"); + expect(prompt).toContain("`style` can be `primary`, `success`, or `danger`"); + }); + it("includes runtime provider capabilities when present", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 99f797e1749..a2f1409669c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -123,7 +123,7 @@ function buildMessagingSection(params: { `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, params.inlineButtonsEnabled - ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." + ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data,style?}]]`; `style` can be `primary`, `success`, or `danger`." : params.runtimeChannel ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` : "", diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index 4fd8741292d..c8d4937913a 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -155,6 +155,13 @@ describe("message tool schema scoping", () => { expect(properties.components).toBeUndefined(); expect(properties.buttons).toBeDefined(); + const buttonItemProps = + ( + properties.buttons as { + items?: { items?: { properties?: Record } }; + } + )?.items?.items?.properties ?? {}; + expect(buttonItemProps.style).toBeDefined(); expect(actionEnum).toContain("send"); expect(actionEnum).toContain("react"); expect(actionEnum).not.toContain("poll"); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 6511fe30a0e..be0d4f10f3a 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -187,6 +187,7 @@ function buildSendSchema(options: { Type.Object({ text: Type.String(), callback_data: Type.String(), + style: Type.Optional(stringEnum(["danger", "success", "primary"])), }), ), { diff --git a/src/agents/tools/telegram-actions.e2e.test.ts b/src/agents/tools/telegram-actions.e2e.test.ts index d6f189eec6c..f9b9ffcc877 100644 --- a/src/agents/tools/telegram-actions.e2e.test.ts +++ b/src/agents/tools/telegram-actions.e2e.test.ts @@ -508,6 +508,46 @@ describe("handleTelegramAction", () => { }), ); }); + + it("forwards optional button style", async () => { + const cfg = { + channels: { + telegram: { botToken: "tok", capabilities: { inlineButtons: "all" } }, + }, + } as OpenClawConfig; + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Choose", + buttons: [ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: "primary", + }, + ], + ], + }, + cfg, + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "@testchannel", + "Choose", + expect.objectContaining({ + buttons: [ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: "primary", + }, + ], + ], + }), + ); + }); }); describe("readTelegramButtons", () => { @@ -517,4 +557,35 @@ describe("readTelegramButtons", () => { }); expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]); }); + + it("normalizes optional style", () => { + const result = readTelegramButtons({ + buttons: [ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: " PRIMARY ", + }, + ], + ], + }); + expect(result).toEqual([ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: "primary", + }, + ], + ]); + }); + + it("rejects unsupported button style", () => { + expect(() => + readTelegramButtons({ + buttons: [[{ text: "Option A", callback_data: "cmd:a", style: "secondary" }]], + }), + ).toThrow(/style must be one of danger, success, primary/i); + }); }); diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 091055f0278..6fa2e97d41d 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, @@ -23,14 +24,11 @@ import { readStringParam, } from "./common.js"; -type TelegramButton = { - text: string; - callback_data: string; -}; +const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"]; export function readTelegramButtons( params: Record, -): TelegramButton[][] | undefined { +): TelegramInlineButtons | undefined { const raw = params.buttons; if (raw == null) { return undefined; @@ -62,7 +60,21 @@ export function readTelegramButtons( `buttons[${rowIndex}][${buttonIndex}] callback_data too long (max 64 chars)`, ); } - return { text, callback_data: callbackData }; + const styleRaw = (button as { style?: unknown }).style; + const style = typeof styleRaw === "string" ? styleRaw.trim().toLowerCase() : undefined; + if (styleRaw !== undefined && !style) { + throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`); + } + if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) { + throw new Error( + `buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`, + ); + } + return { + text, + callback_data: callbackData, + ...(style ? { style: style as TelegramButtonStyle } : {}), + }; }); }); const filtered = rows.filter((row) => row.length > 0); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 49865e3ca61..51ba9d47503 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,3 +1,4 @@ +import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { @@ -53,7 +54,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const messageThreadId = parseTelegramThreadId(threadId); const telegramData = payload.channelData?.telegram as - | { buttons?: Array>; quoteText?: string } + | { buttons?: TelegramInlineButtons; quoteText?: string } | undefined; const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 2741ec393ca..32cc019e51d 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import type { TelegramBotOptions } from "./bot.js"; import type { TelegramStreamMode } from "./bot/types.js"; +import type { TelegramInlineButtons } from "./button-types.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; import { findModelInCatalog, @@ -300,9 +301,7 @@ export const dispatchTelegramMessage = async ({ const finalText = payload.text; const currentPreviewText = streamMode === "block" ? draftText : lastPartialText; const previewButtons = ( - payload.channelData?.telegram as - | { buttons?: Array> } - | undefined + payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined )?.buttons; let draftStoppedForPreviewEdit = false; // Skip preview edit for error payloads to avoid overwriting previous content diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 0379e205abf..76a21acc118 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -3,6 +3,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; import type { RuntimeEnv } from "../../runtime.js"; +import type { TelegramInlineButtons } from "../button-types.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; import { danger, logVerbose } from "../../globals.js"; @@ -108,7 +109,7 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; const telegramData = reply.channelData?.telegram as - | { buttons?: Array> } + | { buttons?: TelegramInlineButtons } | undefined; const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { diff --git a/src/telegram/button-types.ts b/src/telegram/button-types.ts new file mode 100644 index 00000000000..09c687b3320 --- /dev/null +++ b/src/telegram/button-types.ts @@ -0,0 +1,9 @@ +export type TelegramButtonStyle = "danger" | "success" | "primary"; + +export type TelegramInlineButton = { + text: string; + callback_data: string; + style?: TelegramButtonStyle; +}; + +export type TelegramInlineButtons = TelegramInlineButton[][]; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 7cd8b337c0e..e1df7eb0012 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -76,6 +76,29 @@ describe("buildInlineKeyboard", () => { }); }); + it("passes through button style", () => { + const result = buildInlineKeyboard([ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: "primary", + }, + ], + ]); + expect(result).toEqual({ + inline_keyboard: [ + [ + { + text: "Option A", + callback_data: "cmd:a", + style: "primary", + }, + ], + ], + }); + }); + it("filters invalid buttons and empty rows", () => { const result = buildInlineKeyboard([ [ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 72cf2eb5037..5687591cec3 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -6,6 +6,7 @@ import type { } from "@grammyjs/types"; import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy"; import type { RetryConfig } from "../infra/retry.js"; +import type { TelegramInlineButtons } from "./button-types.js"; import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { logVerbose } from "../globals.js"; @@ -55,7 +56,7 @@ type TelegramSendOpts = { /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ - buttons?: Array>; + buttons?: TelegramInlineButtons; }; type TelegramSendResult = { @@ -404,6 +405,7 @@ export function buildInlineKeyboard( (button): InlineKeyboardButton => ({ text: button.text, callback_data: button.callback_data, + ...(button.style ? { style: button.style } : {}), }), ), ) @@ -778,7 +780,7 @@ type TelegramEditOpts = { /** Controls whether link previews are shown in the edited message. */ linkPreview?: boolean; /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ - buttons?: Array>; + buttons?: TelegramInlineButtons; /** Optional config injection to avoid global loadConfig() (improves testability). */ cfg?: ReturnType; };