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(""), StringSchema(""),
description="Optional: list of file paths to attach (images, audio, documents)", 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"], required=["content"],
) )
) )
@ -81,14 +85,20 @@ class MessageTool(Tool):
chat_id: str | None = None, chat_id: str | None = None,
message_id: str | None = None, message_id: str | None = None,
media: list[str] | None = None, media: list[str] | None = None,
buttons: list[list[str]] | None = None,
**kwargs: Any **kwargs: Any
) -> str: ) -> str:
from nanobot.utils.helpers import strip_think from nanobot.utils.helpers import strip_think
content = strip_think(content) 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_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get() default_chat_id = self._default_chat_id.get()
channel = channel or default_channel channel = channel or default_channel
chat_id = chat_id or default_chat_id chat_id = chat_id or default_chat_id
# Only inherit default message_id when targeting the same channel+chat. # Only inherit default message_id when targeting the same channel+chat.
@ -112,6 +122,7 @@ class MessageTool(Tool):
chat_id=chat_id, chat_id=chat_id,
content=content, content=content,
media=media or [], media=media or [],
buttons=buttons or [],
metadata={ metadata={
"message_id": message_id, "message_id": message_id,
} if message_id else {}, } if message_id else {},
@ -122,6 +133,7 @@ class MessageTool(Tool):
if channel == default_channel and chat_id == default_chat_id: if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else "" 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: except Exception as e:
return f"Error sending message: {str(e)}" return f"Error sending message: {str(e)}"

View File

@ -34,5 +34,5 @@ class OutboundMessage:
reply_to: str | None = None reply_to: str | None = None
media: list[str] = field(default_factory=list) media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) 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 loguru import logger
from pydantic import Field 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.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 telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -230,6 +230,8 @@ class TelegramConfig(Base):
connection_pool_size: int = 32 connection_pool_size: int = 32
pool_timeout: float = 5.0 pool_timeout: float = 5.0
streaming: bool = True 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) 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)...") logger.info("Starting Telegram bot (polling mode)...")
# Initialize and start polling # Initialize and start polling
@ -384,7 +394,7 @@ class TelegramChannel(BaseChannel):
# Start polling (this runs until stopped) # Start polling (this runs until stopped)
await self._app.updater.start_polling( await self._app.updater.start_polling(
allowed_updates=["message"], allowed_updates=allowed_updates,
drop_pending_updates=False, # Process pending messages on startup drop_pending_updates=False, # Process pending messages on startup
error_callback=self._on_polling_error, error_callback=self._on_polling_error,
) )
@ -510,10 +520,14 @@ class TelegramChannel(BaseChannel):
# Send text content # Send text content
if msg.content and msg.content != "[empty message]": if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(msg.metadata.get("_tool_hint")) 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( await self._send_text(
chat_id, chunk, reply_params, thread_kwargs, chat_id, chunk, reply_params, thread_kwargs,
render_as_blockquote=render_as_blockquote, render_as_blockquote=render_as_blockquote,
reply_markup=reply_markup if is_last else None,
) )
async def _call_with_retry(self, fn, *args, **kwargs): async def _call_with_retry(self, fn, *args, **kwargs):
@ -549,6 +563,7 @@ class TelegramChannel(BaseChannel):
reply_params=None, reply_params=None,
thread_kwargs: dict | None = None, thread_kwargs: dict | None = None,
render_as_blockquote: bool = False, render_as_blockquote: bool = False,
reply_markup=None,
) -> None: ) -> None:
"""Send a plain text message with HTML fallback.""" """Send a plain text message with HTML fallback."""
try: try:
@ -557,12 +572,10 @@ class TelegramChannel(BaseChannel):
self._app.bot.send_message, self._app.bot.send_message,
chat_id=chat_id, text=html, parse_mode="HTML", chat_id=chat_id, text=html, parse_mode="HTML",
reply_parameters=reply_params, reply_parameters=reply_params,
reply_markup=reply_markup,
**(thread_kwargs or {}), **(thread_kwargs or {}),
) )
except BadRequest as e: 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) logger.warning("HTML parse failed, falling back to plain text: {}", e)
try: try:
await self._call_with_retry( await self._call_with_retry(
@ -570,6 +583,7 @@ class TelegramChannel(BaseChannel):
chat_id=chat_id, chat_id=chat_id,
text=text, text=text,
reply_parameters=reply_params, reply_parameters=reply_params,
reply_markup=reply_markup,
**(thread_kwargs or {}), **(thread_kwargs or {}),
) )
except Exception as e2: except Exception as e2:
@ -1180,3 +1194,47 @@ class TelegramChannel(BaseChannel):
return "".join(Path(filename).suffixes) return "".join(Path(filename).suffixes)
return "" 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,
},
)