feat(telegram): add inline keyboard buttons

This commit is contained in:
Gunnar Thielebein 2026-04-22 23:17:48 +00:00 committed by Xubin Ren
parent e3bca929fb
commit 8d33c1cb37
3 changed files with 86 additions and 16 deletions

View File

@ -17,6 +17,10 @@ from nanobot.bus.events import OutboundMessage
StringSchema(""),
description="Optional: list of file paths to attach (images, audio, documents)",
),
buttons=ArraySchema(
ArraySchema(StringSchema("Button label")),
description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
),
required=["content"],
)
)
@ -81,14 +85,20 @@ class MessageTool(Tool):
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
buttons: list[list[str]] | None = None,
**kwargs: Any
) -> str:
from nanobot.utils.helpers import strip_think
content = strip_think(content)
if buttons is not None:
if not isinstance(buttons, list) or any(
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
):
return "Error: buttons must be a list of list of strings"
default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get()
channel = channel or default_channel
chat_id = chat_id or default_chat_id
# Only inherit default message_id when targeting the same channel+chat.
@ -112,6 +122,7 @@ class MessageTool(Tool):
chat_id=chat_id,
content=content,
media=media or [],
buttons=buttons or [],
metadata={
"message_id": message_id,
} if message_id else {},
@ -122,6 +133,7 @@ class MessageTool(Tool):
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return f"Error sending message: {str(e)}"

View File

@ -34,5 +34,5 @@ class OutboundMessage:
reply_to: str | None = None
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)

View File

@ -11,9 +11,9 @@ from typing import Any, Literal
from loguru import logger
from pydantic import Field
from telegram import BotCommand, ReactionTypeEmoji, ReplyParameters, Update
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, ReactionTypeEmoji, ReplyParameters, Update
from telegram.error import BadRequest, NetworkError, TimedOut
from telegram.ext import Application, ContextTypes, MessageHandler, filters
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
@ -230,6 +230,8 @@ class TelegramConfig(Base):
connection_pool_size: int = 32
pool_timeout: float = 5.0
streaming: bool = True
# Enable inline keyboard buttons in Telegram messages.
inline_keyboards: bool = False
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
@ -364,6 +366,14 @@ class TelegramChannel(BaseChannel):
)
)
# Conditionally register inline keyboard callback handler
if self.config.inline_keyboards:
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
allowed_updates = ["message", "callback_query"]
logger.debug("Telegram inline keyboards enabled")
else:
allowed_updates = ["message"]
logger.info("Starting Telegram bot (polling mode)...")
# Initialize and start polling
@ -384,7 +394,7 @@ class TelegramChannel(BaseChannel):
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=["message"],
allowed_updates=allowed_updates,
drop_pending_updates=False, # Process pending messages on startup
error_callback=self._on_polling_error,
)
@ -510,10 +520,14 @@ class TelegramChannel(BaseChannel):
# Send text content
if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
chunks = split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN)
reply_markup = self._build_keyboard(msg.buttons) if getattr(msg, 'buttons', None) else None
for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1)
await self._send_text(
chat_id, chunk, reply_params, thread_kwargs,
render_as_blockquote=render_as_blockquote,
reply_markup=reply_markup if is_last else None,
)
async def _call_with_retry(self, fn, *args, **kwargs):
@ -549,6 +563,7 @@ class TelegramChannel(BaseChannel):
reply_params=None,
thread_kwargs: dict | None = None,
render_as_blockquote: bool = False,
reply_markup=None,
) -> None:
"""Send a plain text message with HTML fallback."""
try:
@ -557,12 +572,10 @@ class TelegramChannel(BaseChannel):
self._app.bot.send_message,
chat_id=chat_id, text=html, parse_mode="HTML",
reply_parameters=reply_params,
reply_markup=reply_markup,
**(thread_kwargs or {}),
)
except BadRequest as e:
# Only fall back to plain text on actual HTML parse/format errors.
# Network errors (TimedOut, NetworkError) should propagate immediately
# to avoid doubling connection demand during pool exhaustion.
logger.warning("HTML parse failed, falling back to plain text: {}", e)
try:
await self._call_with_retry(
@ -570,6 +583,7 @@ class TelegramChannel(BaseChannel):
chat_id=chat_id,
text=text,
reply_parameters=reply_params,
reply_markup=reply_markup,
**(thread_kwargs or {}),
)
except Exception as e2:
@ -1180,3 +1194,47 @@ class TelegramChannel(BaseChannel):
return "".join(Path(filename).suffixes)
return ""
def _build_keyboard(self, buttons: list) -> InlineKeyboardMarkup | None:
"""Build inline keyboard markup if inline_keyboards is enabled."""
if not buttons or not self.config.inline_keyboards:
return None
keyboard = [
[InlineKeyboardButton(label, callback_data=label) for label in row]
for row in buttons
]
return InlineKeyboardMarkup(keyboard)
async def _on_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle inline keyboard button clicks (callback queries)."""
if not update.callback_query or not update.effective_user:
return
query = update.callback_query
user = update.effective_user
chat_id = query.message.chat_id if query.message else None
sender_id = self._sender_id(user)
if not chat_id:
logger.warning("Callback query without chat_id")
return
button_label = query.data or ""
await query.answer()
if query.message:
try:
await query.message.edit_reply_markup(reply_markup=None)
except Exception:
pass
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
self._start_typing(str(chat_id))
await self._handle_message(
sender_id=sender_id,
chat_id=str(chat_id),
content=button_label,
metadata={
"callback_query_id": query.id,
"button_label": button_label,
"user_id": user.id,
"username": user.username,
"first_name": user.first_name,
"is_callback": True,
},
)