mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-28 12:31:15 +00:00
feat(telegram): add inline keyboard buttons
This commit is contained in:
parent
e3bca929fb
commit
8d33c1cb37
@ -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)}"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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,16 +520,20 @@ 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):
|
||||||
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
||||||
from telegram.error import RetryAfter
|
from telegram.error import RetryAfter
|
||||||
|
|
||||||
for attempt in range(1, _SEND_MAX_RETRIES + 1):
|
for attempt in range(1, _SEND_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
return await fn(*args, **kwargs)
|
return await 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:
|
||||||
@ -796,13 +810,13 @@ class TelegramChannel(BaseChannel):
|
|||||||
text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
|
text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
|
||||||
if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN:
|
if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN:
|
||||||
text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..."
|
text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..."
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
bot_id, _ = await self._ensure_bot_identity()
|
bot_id, _ = await self._ensure_bot_identity()
|
||||||
reply_user = getattr(reply, "from_user", None)
|
reply_user = getattr(reply, "from_user", None)
|
||||||
|
|
||||||
if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id:
|
if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id:
|
||||||
return f"[Reply to bot: {text}]"
|
return f"[Reply to bot: {text}]"
|
||||||
elif reply_user and getattr(reply_user, "username", None):
|
elif reply_user and getattr(reply_user, "username", None):
|
||||||
@ -947,7 +961,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
self._remember_thread_context(message)
|
self._remember_thread_context(message)
|
||||||
|
|
||||||
# Strip @bot_username suffix if present
|
# Strip @bot_username suffix if present
|
||||||
content = message.text or ""
|
content = message.text or ""
|
||||||
if content.startswith("/") and "@" in content:
|
if content.startswith("/") and "@" in content:
|
||||||
@ -955,7 +969,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
cmd_part = cmd_part.split("@")[0]
|
cmd_part = cmd_part.split("@")[0]
|
||||||
content = f"{cmd_part} {rest[0]}" if rest else cmd_part
|
content = f"{cmd_part} {rest[0]}" if rest else cmd_part
|
||||||
content = self._normalize_telegram_command(content)
|
content = self._normalize_telegram_command(content)
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=self._sender_id(user),
|
sender_id=self._sender_id(user),
|
||||||
chat_id=str(message.chat_id),
|
chat_id=str(message.chat_id),
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user