From 8d33c1cb378f4667f6f54fbeec8fdd5d8e7108ce Mon Sep 17 00:00:00 2001 From: Gunnar Thielebein Date: Wed, 22 Apr 2026 23:17:48 +0000 Subject: [PATCH] feat(telegram): add inline keyboard buttons --- nanobot/agent/tools/message.py | 16 ++++++- nanobot/bus/events.py | 2 +- nanobot/channels/telegram.py | 84 ++++++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index ee81effbd..ea0598a1a 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -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)}" diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 018c25b3d..44fba8485 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -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) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 6925658de..d55c1a029 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -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,16 +520,20 @@ 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): """Call an async Telegram API function with retry on pool/network timeout and RetryAfter.""" from telegram.error import RetryAfter - + for attempt in range(1, _SEND_MAX_RETRIES + 1): try: return await 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: @@ -796,13 +810,13 @@ class TelegramChannel(BaseChannel): text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." - + if not text: return None - + bot_id, _ = await self._ensure_bot_identity() reply_user = getattr(reply, "from_user", None) - + if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id: return f"[Reply to bot: {text}]" elif reply_user and getattr(reply_user, "username", None): @@ -947,7 +961,7 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) - + # Strip @bot_username suffix if present content = message.text or "" if content.startswith("/") and "@" in content: @@ -955,7 +969,7 @@ class TelegramChannel(BaseChannel): cmd_part = cmd_part.split("@")[0] content = f"{cmd_part} {rest[0]}" if rest else cmd_part content = self._normalize_telegram_command(content) - + await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), @@ -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, + }, + )