feat(telegram): inline keyboard buttons (#491)

Co-authored-by: Azade <azade@hey.com>
This commit is contained in:
Peter Steinberger
2026-01-09 20:46:11 +01:00
parent 46f0a08878
commit 6d378ee608
19 changed files with 894 additions and 98 deletions

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
import {
type MessagePollResult,
type MessageSendResult,
@@ -9,9 +10,13 @@ import {
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
import type { AnyAgentTool } from "./common.js";
import {
createActionGate,
jsonResult,
readNumberParam,
readStringArrayParam,
@@ -62,6 +67,19 @@ const MessageToolSchema = Type.Object({
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
buttons: Type.Optional(
Type.Array(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
}),
),
{
description: "Telegram inline keyboard buttons (array of button rows)",
},
),
),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
@@ -118,6 +136,164 @@ type MessageToolOptions = {
config?: ClawdbotConfig;
};
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
const caps = new Set<string>();
for (const entry of cfg.telegram?.capabilities ?? []) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
const accounts = cfg.telegram?.accounts;
if (accounts && typeof accounts === "object") {
for (const account of Object.values(accounts)) {
const accountCaps = (account as { capabilities?: unknown })?.capabilities;
if (!Array.isArray(accountCaps)) continue;
for (const entry of accountCaps) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
}
}
return caps.has("inlinebuttons");
}
function buildMessageActionSchema(cfg: ClawdbotConfig) {
const actions = new Set<string>(["send"]);
const discordAccounts = listEnabledDiscordAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
const discordEnabled = discordAccounts.length > 0;
const discordGate = createActionGate(cfg.discord?.actions);
const slackAccounts = listEnabledSlackAccounts(cfg).filter(
(account) => account.botTokenSource !== "none",
);
const slackEnabled = slackAccounts.length > 0;
const isSlackActionEnabled = (key: string, defaultValue = true) => {
if (!slackEnabled) return false;
for (const account of slackAccounts) {
const gate = createActionGate(
(account.actions ?? cfg.slack?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate(key, defaultValue)) return true;
}
return false;
};
const telegramAccounts = listEnabledTelegramAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
const telegramEnabled = telegramAccounts.length > 0;
const telegramGate = createActionGate(cfg.telegram?.actions);
const whatsappGate = createActionGate(cfg.whatsapp?.actions);
const canDiscordReactions = discordEnabled && discordGate("reactions");
const canSlackReactions = isSlackActionEnabled("reactions");
const canTelegramReactions = telegramEnabled && telegramGate("reactions");
const canWhatsAppReactions = cfg.whatsapp ? whatsappGate("reactions") : false;
const canAnyReactions =
canDiscordReactions ||
canSlackReactions ||
canTelegramReactions ||
canWhatsAppReactions;
if (canAnyReactions) actions.add("react");
if (canDiscordReactions || canSlackReactions) actions.add("reactions");
const canDiscordMessages = discordEnabled && discordGate("messages");
const canSlackMessages = isSlackActionEnabled("messages");
if (canDiscordMessages || canSlackMessages) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
const canDiscordPins = discordEnabled && discordGate("pins");
const canSlackPins = isSlackActionEnabled("pins");
if (canDiscordPins || canSlackPins) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
const msteamsEnabled =
cfg.msteams?.enabled !== false &&
Boolean(cfg.msteams && resolveMSTeamsCredentials(cfg.msteams));
const canDiscordPolls = discordEnabled && discordGate("polls");
const canWhatsAppPolls = cfg.whatsapp ? whatsappGate("polls") : false;
if (canDiscordPolls || canWhatsAppPolls || msteamsEnabled)
actions.add("poll");
if (discordEnabled && discordGate("permissions")) actions.add("permissions");
if (discordEnabled && discordGate("threads")) {
actions.add("thread-create");
actions.add("thread-list");
actions.add("thread-reply");
}
if (discordEnabled && discordGate("search")) actions.add("search");
if (discordEnabled && discordGate("stickers")) actions.add("sticker");
if (
(discordEnabled && discordGate("memberInfo")) ||
isSlackActionEnabled("memberInfo")
) {
actions.add("member-info");
}
if (discordEnabled && discordGate("roleInfo")) actions.add("role-info");
if (
(discordEnabled && discordGate("reactions")) ||
isSlackActionEnabled("emojiList")
) {
actions.add("emoji-list");
}
if (discordEnabled && discordGate("emojiUploads"))
actions.add("emoji-upload");
if (discordEnabled && discordGate("stickerUploads"))
actions.add("sticker-upload");
const canDiscordRoles = discordEnabled && discordGate("roles", false);
if (canDiscordRoles) {
actions.add("role-add");
actions.add("role-remove");
}
if (discordEnabled && discordGate("channelInfo")) {
actions.add("channel-info");
actions.add("channel-list");
}
if (discordEnabled && discordGate("voiceStatus")) actions.add("voice-status");
if (discordEnabled && discordGate("events")) {
actions.add("event-list");
actions.add("event-create");
}
if (discordEnabled && discordGate("moderation", false)) {
actions.add("timeout");
actions.add("kick");
actions.add("ban");
}
return Type.Union(Array.from(actions).map((action) => Type.Literal(action)));
}
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const base = MessageToolSchema as unknown as Record<string, unknown>;
const baseProps = (base.properties ?? {}) as Record<string, unknown>;
const props: Record<string, unknown> = {
...baseProps,
action: buildMessageActionSchema(cfg),
};
const telegramEnabled = listEnabledTelegramAccounts(cfg).some(
(account) => account.tokenSource !== "none",
);
if (!telegramEnabled || !hasTelegramInlineButtons(cfg)) {
delete props.buttons;
}
return { ...base, properties: props };
}
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
@@ -126,12 +302,15 @@ function resolveAgentAccountId(value?: string): string | undefined {
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const schema = options?.config
? buildMessageToolSchema(options.config)
: MessageToolSchema;
return {
label: "Message",
name: "message",
description:
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
parameters: MessageToolSchema,
parameters: schema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
@@ -160,6 +339,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
@@ -216,6 +396,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
accountId: accountId ?? undefined,
buttons,
},
cfg,
);
@@ -344,6 +526,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);

View File

@@ -1,7 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { handleTelegramAction } from "./telegram-actions.js";
import {
handleTelegramAction,
readTelegramButtons,
} from "./telegram-actions.js";
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({
@@ -41,10 +44,12 @@ describe("handleTelegramAction", () => {
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", {
token: "tok",
remove: false,
});
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("removes reactions on empty emoji", async () => {
@@ -58,10 +63,12 @@ describe("handleTelegramAction", () => {
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "", {
token: "tok",
remove: false,
});
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("removes reactions when remove flag set", async () => {
@@ -76,10 +83,12 @@ describe("handleTelegramAction", () => {
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", {
token: "tok",
remove: true,
});
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: true }),
);
});
it("respects reaction gating", async () => {
@@ -112,7 +121,7 @@ describe("handleTelegramAction", () => {
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Hello, Telegram!",
{ token: "tok", mediaUrl: undefined },
expect.objectContaining({ token: "tok", mediaUrl: undefined }),
);
expect(result.content).toContainEqual({
type: "text",
@@ -134,7 +143,10 @@ describe("handleTelegramAction", () => {
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Check this image!",
{ token: "tok", mediaUrl: "https://example.com/image.jpg" },
expect.objectContaining({
token: "tok",
mediaUrl: "https://example.com/image.jpg",
}),
);
});
@@ -168,4 +180,50 @@ describe("handleTelegramAction", () => {
),
).rejects.toThrow(/Telegram bot token missing/);
});
it("requires inlineButtons capability when buttons are provided", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
cfg,
),
).rejects.toThrow(/inlineButtons/i);
});
it("sends messages with inline keyboard buttons when enabled", async () => {
const cfg = {
telegram: { botToken: "tok", capabilities: ["inlineButtons"] },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Choose",
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Choose",
expect.objectContaining({
buttons: [[{ text: "Option A", callback_data: "cmd:a" }]],
}),
);
});
});
describe("readTelegramButtons", () => {
it("returns trimmed button rows for valid input", () => {
const result = readTelegramButtons({
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
});
expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]);
});
});

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderCapabilities } from "../../config/provider-capabilities.js";
import {
reactMessageTelegram,
sendMessageTelegram,
@@ -15,11 +16,74 @@ import {
readStringParam,
} from "./common.js";
type TelegramButton = {
text: string;
callback_data: string;
};
function hasInlineButtonsCapability(params: {
cfg: ClawdbotConfig;
accountId?: string | undefined;
}): boolean {
const caps =
resolveProviderCapabilities({
cfg: params.cfg,
provider: "telegram",
accountId: params.accountId,
}) ?? [];
return caps.some((cap) => cap.toLowerCase() === "inlinebuttons");
}
export function readTelegramButtons(
params: Record<string, unknown>,
): TelegramButton[][] | undefined {
const raw = params.buttons;
if (raw == null) return undefined;
if (!Array.isArray(raw)) {
throw new Error("buttons must be an array of button rows");
}
const rows = raw.map((row, rowIndex) => {
if (!Array.isArray(row)) {
throw new Error(`buttons[${rowIndex}] must be an array`);
}
return row.map((button, buttonIndex) => {
if (!button || typeof button !== "object") {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] must be an object`,
);
}
const text =
typeof (button as { text?: unknown }).text === "string"
? (button as { text: string }).text.trim()
: "";
const callbackData =
typeof (button as { callback_data?: unknown }).callback_data ===
"string"
? (button as { callback_data: string }).callback_data.trim()
: "";
if (!text || !callbackData) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`,
);
}
if (callbackData.length > 64) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max 64 chars)`,
);
}
return { text, callback_data: callbackData };
});
});
const filtered = rows.filter((row) => row.length > 0);
return filtered.length > 0 ? filtered : undefined;
}
export async function handleTelegramAction(
params: Record<string, unknown>,
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const isActionEnabled = createActionGate(cfg.telegram?.actions);
if (action === "react") {
@@ -36,7 +100,7 @@ export async function handleTelegramAction(
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
});
const token = resolveTelegramToken(cfg).token;
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
@@ -45,6 +109,7 @@ export async function handleTelegramAction(
await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
token,
remove,
accountId: accountId ?? undefined,
});
if (!remove && !isEmpty) {
return jsonResult({ ok: true, added: emoji });
@@ -59,6 +124,15 @@ export async function handleTelegramAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const buttons = readTelegramButtons(params);
if (
buttons &&
!hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })
) {
throw new Error(
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to telegram.capabilities (or telegram.accounts.<id>.capabilities).',
);
}
// Optional threading parameters for forum topics and reply chains
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
integer: true,
@@ -66,7 +140,7 @@ export async function handleTelegramAction(
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const token = resolveTelegramToken(cfg).token;
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
@@ -74,7 +148,9 @@ export async function handleTelegramAction(
}
const result = await sendMessageTelegram(to, content, {
token,
accountId: accountId ?? undefined,
mediaUrl: mediaUrl || undefined,
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
});