feat(telegram): support inline button styles (#18241)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 239cb3552e
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Ayaan Zaidi
2026-02-16 22:48:47 +05:30
committed by GitHub
parent a177f7b9fe
commit 16327f21da
13 changed files with 155 additions and 14 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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").`
: "",

View File

@@ -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<string, unknown> } };
}
)?.items?.items?.properties ?? {};
expect(buttonItemProps.style).toBeDefined();
expect(actionEnum).toContain("send");
expect(actionEnum).toContain("react");
expect(actionEnum).not.toContain("poll");

View File

@@ -187,6 +187,7 @@ function buildSendSchema(options: {
Type.Object({
text: Type.String(),
callback_data: Type.String(),
style: Type.Optional(stringEnum(["danger", "success", "primary"])),
}),
),
{

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>,
): 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);

View File

@@ -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<Array<{ text: string; callback_data: string }>>; quoteText?: string }
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;

View File

@@ -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<Array<{ text: string; callback_data: string }>> }
| undefined
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
)?.buttons;
let draftStoppedForPreviewEdit = false;
// Skip preview edit for error payloads to avoid overwriting previous content

View File

@@ -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<Array<{ text: string; callback_data: string }>> }
| { buttons?: TelegramInlineButtons }
| undefined;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
if (mediaList.length === 0) {

View File

@@ -0,0 +1,9 @@
export type TelegramButtonStyle = "danger" | "success" | "primary";
export type TelegramInlineButton = {
text: string;
callback_data: string;
style?: TelegramButtonStyle;
};
export type TelegramInlineButtons = TelegramInlineButton[][];

View File

@@ -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([
[

View File

@@ -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<Array<{ text: string; callback_data: string }>>;
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<Array<{ text: string; callback_data: string }>>;
buttons?: TelegramInlineButtons;
/** Optional config injection to avoid global loadConfig() (improves testability). */
cfg?: ReturnType<typeof loadConfig>;
};