feat(telegram): support inline buttons and scoped commands#8521
feat(telegram): support inline buttons and scoped commands#8521FlanChanXwO wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the Telegram adapter in AstrBot by adding support for Telegram-specific send options (such as Markdown/HTML parsing, link previews, and Inline Keyboards), callback query handling for button interactions, and new configuration options for proxies, command registration scopes, and plugin filtering. Feedback on the changes highlights that the unsupported style parameter should be removed from TelegramInlineButton and its tests to prevent runtime crashes. Additionally, calling delete_my_commands before set_my_commands is redundant and should be removed, parse_mode inputs should be normalized to be case-insensitive, and the inline keyboard markup should only be attached to the last item in a message chain to avoid duplicate keyboards.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| class TelegramInlineButton(BaseMessageComponent): | ||
| """Telegram inline keyboard button component.""" | ||
|
|
||
| type: str = "telegram_inline_button" | ||
| text: str | ||
| url: str | None = None | ||
| callback_data: str | None = None | ||
| login_url: Any = None | ||
| web_app: Any = None | ||
| switch_inline_query: str | None = None | ||
| switch_inline_query_current_chat: str | None = None | ||
| switch_inline_query_chosen_chat: Any = None | ||
| copy_text: Any = None | ||
| callback_game: Any = None | ||
| pay: bool | None = None | ||
| style: str | None = None | ||
| icon_custom_emoji_id: str | None = None | ||
|
|
||
| def __init__( | ||
| self, | ||
| text: str, | ||
| *, | ||
| url: str | None = None, | ||
| callback_data: str | None = None, | ||
| login_url: LoginUrl | str | None = None, | ||
| web_app: WebAppInfo | str | None = None, | ||
| switch_inline_query: str | None = None, | ||
| switch_inline_query_current_chat: str | None = None, | ||
| switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | ||
| | dict | ||
| | None = None, | ||
| copy_text: CopyTextButton | str | None = None, | ||
| callback_game: CallbackGame | None = None, | ||
| pay: bool | None = None, | ||
| style: str | None = None, | ||
| icon_custom_emoji_id: str | None = None, | ||
| ) -> None: | ||
| super().__init__( | ||
| text=text, | ||
| url=url, | ||
| callback_data=callback_data, | ||
| login_url=login_url, | ||
| web_app=web_app, | ||
| switch_inline_query=switch_inline_query, | ||
| switch_inline_query_current_chat=switch_inline_query_current_chat, | ||
| switch_inline_query_chosen_chat=switch_inline_query_chosen_chat, | ||
| copy_text=copy_text, | ||
| callback_game=callback_game, | ||
| pay=pay, | ||
| style=style, | ||
| icon_custom_emoji_id=icon_custom_emoji_id, | ||
| ) | ||
| self._validate_action() |
There was a problem hiding this comment.
The style parameter is not supported by Telegram's InlineKeyboardButton in the python-telegram-bot library (or the Telegram Bot API itself). Passing style to InlineKeyboardButton will raise a TypeError at runtime. Since Telegram inline buttons do not support styles (unlike Discord), this parameter should be removed entirely to prevent runtime crashes.
class TelegramInlineButton(BaseMessageComponent):
"""Telegram inline keyboard button component."""
type: str = "telegram_inline_button"
text: str
url: str | None = None
callback_data: str | None = None
login_url: Any = None
web_app: Any = None
switch_inline_query: str | None = None
switch_inline_query_current_chat: str | None = None
switch_inline_query_chosen_chat: Any = None
copy_text: Any = None
callback_game: Any = None
pay: bool | None = None
icon_custom_emoji_id: str | None = None
def __init__(
self,
text: str,
*,
url: str | None = None,
callback_data: str | None = None,
login_url: LoginUrl | str | None = None,
web_app: WebAppInfo | str | None = None,
switch_inline_query: str | None = None,
switch_inline_query_current_chat: str | None = None,
switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat
| dict
| None = None,
copy_text: CopyTextButton | str | None = None,
callback_game: CallbackGame | None = None,
pay: bool | None = None,
icon_custom_emoji_id: str | None = None,
) -> None:
super().__init__(
text=text,
url=url,
callback_data=callback_data,
login_url=login_url,
web_app=web_app,
switch_inline_query=switch_inline_query,
switch_inline_query_current_chat=switch_inline_query_current_chat,
switch_inline_query_chosen_chat=switch_inline_query_chosen_chat,
copy_text=copy_text,
callback_game=callback_game,
pay=pay,
icon_custom_emoji_id=icon_custom_emoji_id,
)
self._validate_action()| button_payload = { | ||
| key: value for key, value in payload.items() if value is not None | ||
| } | ||
| if self.style is not None: | ||
| button_payload["style"] = self.style | ||
| if self.icon_custom_emoji_id is not None: | ||
| button_payload["icon_custom_emoji_id"] = self.icon_custom_emoji_id | ||
|
|
||
| return InlineKeyboardButton( | ||
| text=self.text, | ||
| **button_payload, | ||
| ) |
There was a problem hiding this comment.
Remove the unsupported style parameter from the payload passed to InlineKeyboardButton to avoid raising a TypeError at runtime.
button_payload = {
key: value for key, value in payload.items() if value is not None
}
if self.icon_custom_emoji_id is not None:
button_payload["icon_custom_emoji_id"] = self.icon_custom_emoji_id
return InlineKeyboardButton(
text=self.text,
**button_payload,
)| styled_button = components.TelegramInlineButton( | ||
| "Styled", | ||
| callback_data="styled", | ||
| style="primary", | ||
| icon_custom_emoji_id="5368324170671202286", | ||
| ).to_telegram_button() | ||
| assert styled_button.style == "primary" | ||
| assert styled_button.icon_custom_emoji_id == "5368324170671202286" |
There was a problem hiding this comment.
Update the test to remove the assertion for the unsupported style parameter.
| styled_button = components.TelegramInlineButton( | |
| "Styled", | |
| callback_data="styled", | |
| style="primary", | |
| icon_custom_emoji_id="5368324170671202286", | |
| ).to_telegram_button() | |
| assert styled_button.style == "primary" | |
| assert styled_button.icon_custom_emoji_id == "5368324170671202286" | |
| styled_button = components.TelegramInlineButton( | |
| "Styled", | |
| callback_data="styled", | |
| icon_custom_emoji_id="5368324170671202286", | |
| ).to_telegram_button() | |
| assert styled_button.icon_custom_emoji_id == "5368324170671202286" |
| await self.client.delete_my_commands( | ||
| scope=scope, | ||
| language_code=language_code, | ||
| ) | ||
| await self.client.set_my_commands( | ||
| commands, | ||
| scope=scope, | ||
| language_code=language_code, | ||
| ) |
There was a problem hiding this comment.
Calling delete_my_commands right before set_my_commands is redundant because set_my_commands completely overwrites the existing list of commands for the given scope and language code anyway. Removing this redundant call halves the number of API requests, significantly reducing the risk of hitting Telegram's rate limits.
await self.client.set_my_commands(
commands,
scope=scope,
language_code=language_code,
)| def _normalize_parse_mode(parse_mode: str | None) -> str | None: | ||
| if parse_mode is None: | ||
| return None | ||
| normalized = parse_mode.strip() | ||
| if not normalized or normalized.lower() in {"plain", "plaintext", "none"}: | ||
| return None | ||
| if normalized not in {"MarkdownV2", "Markdown", "HTML"}: | ||
| raise ValueError( | ||
| "Telegram parse_mode must be one of MarkdownV2, Markdown, HTML, or plaintext.", | ||
| ) | ||
| return normalized |
There was a problem hiding this comment.
Telegram's parse_mode is case-sensitive (expecting exactly "MarkdownV2", "Markdown", or "HTML"). To make the API more robust and user-friendly, we should normalize case-insensitive inputs (e.g., "markdown", "html") to their correct casing instead of raising a ValueError.
| def _normalize_parse_mode(parse_mode: str | None) -> str | None: | |
| if parse_mode is None: | |
| return None | |
| normalized = parse_mode.strip() | |
| if not normalized or normalized.lower() in {"plain", "plaintext", "none"}: | |
| return None | |
| if normalized not in {"MarkdownV2", "Markdown", "HTML"}: | |
| raise ValueError( | |
| "Telegram parse_mode must be one of MarkdownV2, Markdown, HTML, or plaintext.", | |
| ) | |
| return normalized | |
| @staticmethod | |
| def _normalize_parse_mode(parse_mode: str | None) -> str | None: | |
| if parse_mode is None: | |
| return None | |
| normalized = parse_mode.strip() | |
| if not normalized or normalized.lower() in {"plain", "plaintext", "none"}: | |
| return None | |
| normalized_lower = normalized.lower() | |
| if normalized_lower == "markdownv2": | |
| return "MarkdownV2" | |
| if normalized_lower == "markdown": | |
| return "Markdown" | |
| if normalized_lower == "html": | |
| return "HTML" | |
| raise ValueError( | |
| "Telegram parse_mode must be one of MarkdownV2, Markdown, HTML, or plaintext.", | |
| ) |
| extra_payload = cls._build_text_payload({}, options, keyboard) | ||
| for i in chain: | ||
| payload = { | ||
| "chat_id": user_name, | ||
| } | ||
| if has_reply: | ||
| payload["reply_to_message_id"] = str(reply_message_id) | ||
| if message_thread_id: | ||
| payload["message_thread_id"] = message_thread_id | ||
| media_payload = payload | { | ||
| key: value | ||
| for key, value in extra_payload.items() | ||
| if key != "link_preview_options" | ||
| } | ||
|
|
||
| if isinstance(i, Plain): | ||
| if at_user_id and not at_flag: | ||
| i.text = f"@{at_user_id} {i.text}" | ||
| at_flag = True | ||
| await cls._send_text_chunks(client, i.text, payload) | ||
| await cls._send_text_chunks( | ||
| client, | ||
| i.text, | ||
| payload | extra_payload, | ||
| use_markdown=message.use_markdown_, | ||
| options=options, | ||
| ) |
There was a problem hiding this comment.
Similarly, if there are multiple items in the message chain (e.g., text followed by an image), both items will be sent with the reply_markup (inline keyboard). We should only attach the reply_markup to the last item of the chain to prevent duplicate keyboards from being sent. Since this logic of stripping reply_markup from non-last items is identical to the logic used in _send_text_chunks, please refactor this into a shared helper function to avoid code duplication.
extra_payload = cls._build_text_payload({}, options, keyboard)
for idx, i in enumerate(chain):
is_last_item = (idx == len(chain) - 1)
payload = {
"chat_id": user_name,
}
if has_reply:
payload["reply_to_message_id"] = str(reply_message_id)
if message_thread_id:
payload["message_thread_id"] = message_thread_id
item_extra_payload = cls._get_item_payload(extra_payload, is_last_item)
media_payload = payload | {
key: value
for key, value in item_extra_payload.items()
if key != "link_preview_options"
}
if isinstance(i, Plain):
if at_user_id and not at_flag:
i.text = f"@{at_user_id} {i.text}"
at_flag = True
await cls._send_text_chunks(
client,
i.text,
payload | item_extra_payload,
use_markdown=message.use_markdown_,
options=options,
)References
- When implementing similar functionality for different cases, refactor the logic into a shared helper function to avoid code duplication.
Summary
send_by_sessioncalls and expose callback-query helpers for plugin interactionsValidation
uv run pytest tests/test_telegram_adapter.py -quv run ruff check astrbot tests/test_telegram_adapter.py tests/fixtures/mocks/telegram.py tests/fixtures/helpers.pyuv run ruff format --check astrbot tests/test_telegram_adapter.py tests/fixtures/mocks/telegram.py tests/fixtures/helpers.pyDuplicate check
Checked open upstream PRs for Telegram inline buttons, callback handling, Markdown/link preview sends, adapter proxy config, and scoped bot commands. Existing Telegram PRs found were about connection pool exhaustion and nickname handling, so no same-feature PR was found.
Summary by Sourcery
Add richer Telegram adapter capabilities for inline keyboards, text/link preview options, callback interactions, and configurable command registration scopes and proxies.
New Features:
Enhancements:
Documentation:
Tests:
Chores: