Skip to content

feat(telegram): support inline buttons and scoped commands#8521

Draft
FlanChanXwO wants to merge 1 commit into
AstrBotDevs:masterfrom
FlanChanXwO:codex/telegram-buttons-md-proxy-commands
Draft

feat(telegram): support inline buttons and scoped commands#8521
FlanChanXwO wants to merge 1 commit into
AstrBotDevs:masterfrom
FlanChanXwO:codex/telegram-buttons-md-proxy-commands

Conversation

@FlanChanXwO
Copy link
Copy Markdown

@FlanChanXwO FlanChanXwO commented Jun 2, 2026

Summary

  • add Telegram message option components for inline keyboards, full link preview controls, and parse-mode aware sends
  • preserve Markdown/HTML options for proactive send_by_session calls and expose callback-query helpers for plugin interactions
  • add adapter-specific proxy settings plus plugin/scoped bot command registration

Validation

  • uv run pytest tests/test_telegram_adapter.py -q
  • uv run ruff check astrbot tests/test_telegram_adapter.py tests/fixtures/mocks/telegram.py tests/fixtures/helpers.py
  • uv run ruff format --check astrbot tests/test_telegram_adapter.py tests/fixtures/mocks/telegram.py tests/fixtures/helpers.py
  • JSON validation for dashboard locale config metadata files

Duplicate 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:

  • Introduce Telegram message components for inline keyboards, buttons, and per-message options including parse mode and link preview controls.
  • Support handling Telegram inline button callback queries as AstrBot events with helper APIs for inspecting and answering interactions.
  • Allow Telegram adapter bot commands to be registered per configurable scopes and filtered by an optional plugin allowlist.
  • Add Telegram-specific proxy configuration options for general Bot API traffic and getUpdates polling.

Enhancements:

  • Ensure proactive Telegram sends preserve message parse-mode settings and inline keyboards from message chains.
  • Improve Telegram text sending by validating parse modes, handling markdown conversion failures more gracefully, and sharing options across text and media payloads.

Documentation:

  • Document Telegram-specific send options, inline keyboard interactions, and callback handling in English and Chinese developer guides.
  • Extend Telegram platform docs to cover inline keyboard support, command registration scopes, and adapter-level proxy configuration.

Tests:

  • Add Telegram adapter tests for inline keyboard rendering, message options handling, markdown/plaintext behavior, callback query conversion, proxy usage, and command registration limits and scopes.

Chores:

  • Extend default Telegram configuration and dashboard metadata with command scope and proxy fields used by the adapter.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +19 to +71
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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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()

Comment on lines +120 to +131
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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,
        )

Comment on lines +517 to +524
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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the test to remove the assertion for the unsupported style parameter.

Suggested change
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"

Comment on lines +372 to 380
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,
                )

Comment on lines +109 to +119
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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.",
)

Comment on lines +398 to +423
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. When implementing similar functionality for different cases, refactor the logic into a shared helper function to avoid code duplication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant