From 8fe89496d6a96960a3d440f89b90900733f90fc2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 28 Oct 2023 20:05:17 +0200 Subject: [PATCH] Work towards message buttons --- client/doc/concepts/sessions.rst | 2 +- client/doc/developing/migration-guide.rst | 2 +- client/doc/modules/client.rst | 13 +- client/doc/modules/types.rst | 17 ++ .../telethon/_impl/client/client/client.py | 151 +++++++++------ .../telethon/_impl/client/client/messages.py | 62 ++++++- .../telethon/_impl/client/events/__init__.py | 4 +- .../telethon/_impl/client/events/queries.py | 54 +++++- .../telethon/_impl/client/types/__init__.py | 2 + .../_impl/client/types/buttons/__init__.py | 89 +++++++++ .../_impl/client/types/buttons/button.py | 74 ++++++++ .../_impl/client/types/buttons/callback.py | 62 +++++++ .../client/types/buttons/inline_button.py | 34 ++++ .../types/buttons/request_geo_location.py | 14 ++ .../client/types/buttons/request_phone.py | 14 ++ .../client/types/buttons/request_poll.py | 14 ++ .../client/types/buttons/switch_inline.py | 32 ++++ .../_impl/client/types/buttons/text.py | 38 ++++ .../_impl/client/types/buttons/url.py | 30 +++ .../_impl/client/types/callback_answer.py | 28 +++ .../src/telethon/_impl/client/types/draft.py | 4 +- .../_impl/client/types/forward_info.py | 22 +++ .../_impl/client/types/inline_result.py | 14 +- .../_impl/client/types/link_preview.py | 22 +++ .../_impl/client/types/login_token.py | 6 + .../telethon/_impl/client/types/message.py | 172 +++++++++++------- .../_impl/client/types/password_token.py | 3 + .../_impl/client/types/recent_action.py | 5 + .../src/telethon/_impl/mtproto/mtp/types.py | 18 ++ client/src/telethon/events/__init__.py | 4 +- .../telethon/{types.py => types/__init__.py} | 11 +- client/src/telethon/types/buttons.py | 40 ++++ 32 files changed, 917 insertions(+), 140 deletions(-) create mode 100644 client/src/telethon/_impl/client/types/buttons/__init__.py create mode 100644 client/src/telethon/_impl/client/types/buttons/button.py create mode 100644 client/src/telethon/_impl/client/types/buttons/callback.py create mode 100644 client/src/telethon/_impl/client/types/buttons/inline_button.py create mode 100644 client/src/telethon/_impl/client/types/buttons/request_geo_location.py create mode 100644 client/src/telethon/_impl/client/types/buttons/request_phone.py create mode 100644 client/src/telethon/_impl/client/types/buttons/request_poll.py create mode 100644 client/src/telethon/_impl/client/types/buttons/switch_inline.py create mode 100644 client/src/telethon/_impl/client/types/buttons/text.py create mode 100644 client/src/telethon/_impl/client/types/buttons/url.py create mode 100644 client/src/telethon/_impl/client/types/callback_answer.py create mode 100644 client/src/telethon/_impl/client/types/forward_info.py create mode 100644 client/src/telethon/_impl/client/types/link_preview.py rename client/src/telethon/{types.py => types/__init__.py} (70%) create mode 100644 client/src/telethon/types/buttons.py diff --git a/client/doc/concepts/sessions.rst b/client/doc/concepts/sessions.rst index 20af0bf3..1ea37615 100644 --- a/client/doc/concepts/sessions.rst +++ b/client/doc/concepts/sessions.rst @@ -43,7 +43,7 @@ The :class:`session.Storage` abstract base class defines the required methods to Telethon comes with two built-in storages: * :class:`~session.SqliteSession`. This is used by default when a string or path is used. -* :class:`~session.MemorySession`. This is used by default when the path is ``None``. +* :class:`~session.MemorySession`. This is used by default when the path is :data:`None`. You can also use it directly when you have a :class:`~session.Session` instance. It's useful when you don't have file-system access. diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index 440f3608..f6bd6129 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -377,7 +377,7 @@ Now handlers are called in order until the filter for one returns :data:`True`. The default behaviour is that handlers after that one are not called. This behaviour can be changed with the ``check_all_handlers`` flag in :class:`Client` constructor. -:class:`events.CallbackQuery` no longer also handles "inline bot callback queries". +``events.CallbackQuery`` has been renamed to :class:`events.ButtonCallback` and no longer also handles "inline bot callback queries". This was a hacky workaround. :class:`events.MessageRead` no longer triggers when the *contents* of a message are read, such as voice notes being played. diff --git a/client/doc/modules/client.rst b/client/doc/modules/client.rst index 7df92f57..cf44d0cc 100644 --- a/client/doc/modules/client.rst +++ b/client/doc/modules/client.rst @@ -1 +1,12 @@ -.. autoclass:: telethon.Client +.. currentmodule:: telethon + +Client class +============ + +The :class:`Client` class is the "entry point" of the library. + +Most client methods have an alias in the respective types. +For example, :meth:`Client.forward_messages` can also be invoked from :meth:`types.Message.forward`. +With a few exceptions, "client.verb_object" methods also exist as "object.verb". + +.. autoclass:: Client diff --git a/client/doc/modules/types.rst b/client/doc/modules/types.rst index 8470e70e..caa97617 100644 --- a/client/doc/modules/types.rst +++ b/client/doc/modules/types.rst @@ -1,8 +1,25 @@ Types ===== +This section contains most custom types used by the library. +:doc:`events` and the :doc:`client` get their own section to prevent the page from growing out of control. + +Some of these are further divided into additional submodules. +This keeps them neatly grouped and avoids polluting a single module too much. + + +Core types +---------- + .. automodule:: telethon.types + +Keyboard buttons +---------------- + +.. automodule:: telethon.types.buttons + + Errors ------ diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 458916c3..0de0a55b 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -51,6 +51,7 @@ from ..types import ( PasswordToken, RecentAction, User, + buttons, ) from .auth import ( bot_sign_in, @@ -88,6 +89,7 @@ from .messages import ( get_messages, get_messages_with_ids, pin_message, + read_message, search_all_messages, search_messages, send_message, @@ -126,8 +128,6 @@ class Client: """ A client capable of connecting to Telegram and sending requests. - This is the "entry point" of the library. - This class can be used as an asynchronous context manager to automatically :meth:`connect` and :meth:`disconnect`: .. code-block:: python @@ -491,6 +491,58 @@ class Client: """ await download(self, media, file) + async def edit_draft( + self, + chat: ChatLike, + text: Optional[str] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + reply_to: Optional[int] = None, + ) -> Draft: + """ + Set a draft message in a chat. + + This can also be used to clear the draft by setting the text to an empty string ``""``. + + :param chat: + The :term:`chat` where the draft will be saved to. + + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :ref:`formatting`. + + :param reply_to: + The message identifier of the message to reply to. + + :return: The created draft. + + .. rubric:: Example + + .. code-block:: python + + # Edit message to have text without formatting + await client.edit_message(chat, msg_id, text='New text') + + # Remove the link preview without changing the text + await client.edit_message(chat, msg_id, link_preview=False) + + .. seealso:: + + :meth:`telethon.types.Message.edit` + """ + return await edit_draft( + self, + chat, + text, + markdown=markdown, + html=html, + link_preview=link_preview, + reply_to=reply_to, + ) + async def edit_message( self, chat: ChatLike, @@ -987,6 +1039,40 @@ class Client: """ return await pin_message(self, chat, message_id) + async def read_message( + self, chat: ChatLike, message_id: Union[int, Literal["all"]] + ) -> None: + """ + Mark messages as read. + + This will send a read acknowledgment to all messages with identifiers below and up-to the given message identifier. + + This is often represented as a blue double-check (✓✓). + + A single check (✓) in Telegram often indicates the message was sent and perhaps received, but not read. + + A clock (🕒) in Telegram often indicates the message was not yet sent at all. + This most commonly occurs when sending messages without a network connection. + + :param chat: + The chat where the messages to be marked as read are. + + :param message_id: + The identifier of the message to mark as read. + All messages older (sent before) this one will also be marked as read. + + The literal ``'all'`` may be used to mark all messages in a chat as read. + + .. rubric:: Example + + .. code-block:: python + + # Mark all messages as read + message = await client.read_message(chat, 'all') + await message.delete() + """ + await read_message(self, chat, message_id) + def remove_event_handler(self, handler: Callable[[Event], Awaitable[Any]]) -> None: """ Remove the handler as a function to be called when events occur. @@ -1417,6 +1503,9 @@ class Client: html: Optional[str] = None, link_preview: bool = False, reply_to: Optional[int] = None, + buttons: Optional[ + Union[List[buttons.Button], List[List[buttons.Button]]] + ] = None, ) -> Message: """ Send a message. @@ -1432,6 +1521,11 @@ class Client: :param reply_to: The message identifier of the message to reply to. + :param buttons: + The buttons to use for the message. + + Only bot accounts can send buttons. + .. rubric:: Example .. code-block:: python @@ -1446,6 +1540,7 @@ class Client: html=html, link_preview=link_preview, reply_to=reply_to, + buttons=buttons, ) async def send_photo( @@ -1573,58 +1668,6 @@ class Client: def set_default_rights(self, chat: ChatLike, user: ChatLike) -> None: set_default_rights(self, chat, user) - async def edit_draft( - self, - chat: ChatLike, - text: Optional[str] = None, - *, - markdown: Optional[str] = None, - html: Optional[str] = None, - link_preview: bool = False, - reply_to: Optional[int] = None, - ) -> Draft: - """ - Set a draft message in a chat. - - This can also be used to clear the draft by setting the text to an empty string ``""``. - - :param chat: - The :term:`chat` where the draft will be saved to. - - :param text: See :ref:`formatting`. - :param markdown: See :ref:`formatting`. - :param html: See :ref:`formatting`. - :param link_preview: See :ref:`formatting`. - - :param reply_to: - The message identifier of the message to reply to. - - :return: The created draft. - - .. rubric:: Example - - .. code-block:: python - - # Edit message to have text without formatting - await client.edit_message(chat, msg_id, text='New text') - - # Remove the link preview without changing the text - await client.edit_message(chat, msg_id, link_preview=False) - - .. seealso:: - - :meth:`telethon.types.Message.edit` - """ - return await edit_draft( - self, - chat, - text, - markdown=markdown, - html=html, - link_preview=link_preview, - reply_to=reply_to, - ) - def set_handler_filter( self, handler: Callable[[Event], Awaitable[Any]], diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index d63ec284..b27335ab 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union from ...session import PackedChat from ...tl import abcs, functions, types from ..parsers import parse_html_message, parse_markdown_message -from ..types import AsyncList, Chat, ChatLike, Message +from ..types import AsyncList, Chat, ChatLike, Message, buttons from ..utils import build_chat_map, generate_random_id, peer_id if TYPE_CHECKING: @@ -39,6 +39,40 @@ def parse_message( return parsed, entities or None +def build_keyboard( + btns: Optional[Union[List[buttons.Button], List[List[buttons.Button]]]] +) -> Optional[abcs.ReplyMarkup]: + # list[button] -> list[list[button]] + # This does allow for "invalid" inputs (mixing lists and non-lists), but that's acceptable. + buttons_lists_iter = ( + button if isinstance(button, list) else [button] for button in (btns or []) + ) + # Remove empty rows (also making it easy to check if all-empty). + buttons_lists = [bs for bs in buttons_lists_iter if bs] + + if not buttons_lists: + return None + + rows: List[abcs.KeyboardButtonRow] = [ + types.KeyboardButtonRow(buttons=[btn._raw for btn in btns]) + for btns in buttons_lists + ] + + # Guaranteed to have at least one, first one used to check if it's inline. + # If the user mixed inline with non-inline, Telegram will complain. + if isinstance(buttons_lists[0][0], buttons.InlineButton): + return types.ReplyInlineMarkup(rows=rows) + else: + return types.ReplyKeyboardMarkup( + resize=False, + single_use=False, + selective=False, + persistent=False, + rows=rows, + placeholder=None, + ) + + async def send_message( self: Client, chat: ChatLike, @@ -48,6 +82,7 @@ async def send_message( html: Optional[str] = None, link_preview: bool = False, reply_to: Optional[int] = None, + buttons: Optional[Union[List[buttons.Button], List[List[buttons.Button]]]] = None, ) -> Message: packed = await self._resolve_to_packed(chat) peer = packed._to_input_peer() @@ -71,14 +106,14 @@ async def send_message( else None, message=message, random_id=random_id, - reply_markup=None, + reply_markup=build_keyboard(buttons), entities=entities, schedule_date=None, send_as=None, ) if isinstance(message, str) else functions.messages.send_message( - no_webpage=not message.web_preview, + no_webpage=not message.link_preview, silent=message.silent, background=False, clear_draft=False, @@ -531,6 +566,27 @@ async def unpin_message( ) +async def read_message( + self: Client, chat: ChatLike, message_id: Union[int, Literal["all"]] +) -> None: + packed = await self._resolve_to_packed(chat) + if message_id == "all": + message_id = 0 + + if packed.is_channel(): + await self( + functions.channels.read_history( + channel=packed._to_input_channel(), max_id=message_id + ) + ) + else: + await self( + functions.messages.read_history( + peer=packed._to_input_peer(), max_id=message_id + ) + ) + + class MessageMap: __slots__ = ("_client", "_peer", "_random_id_to_id", "_id_to_message") diff --git a/client/src/telethon/_impl/client/events/__init__.py b/client/src/telethon/_impl/client/events/__init__.py index efb682d8..eda5c327 100644 --- a/client/src/telethon/_impl/client/events/__init__.py +++ b/client/src/telethon/_impl/client/events/__init__.py @@ -1,6 +1,6 @@ from .event import Event from .messages import MessageDeleted, MessageEdited, MessageRead, NewMessage -from .queries import CallbackQuery, InlineQuery +from .queries import ButtonCallback, InlineQuery __all__ = [ "Event", @@ -8,6 +8,6 @@ __all__ = [ "MessageEdited", "MessageRead", "NewMessage", - "CallbackQuery", + "ButtonCallback", "InlineQuery", ] diff --git a/client/src/telethon/_impl/client/events/queries.py b/client/src/telethon/_impl/client/events/queries.py index 3582a659..08b5ef7f 100644 --- a/client/src/telethon/_impl/client/events/queries.py +++ b/client/src/telethon/_impl/client/events/queries.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, Optional, Self -from ...tl import abcs, types +from ...tl import abcs, functions, types from ..types import Chat from .event import Event @@ -10,25 +10,61 @@ if TYPE_CHECKING: from ..client.client import Client -class CallbackQuery(Event): +class ButtonCallback(Event): """ - Occurs when an inline button was pressed. + Occurs when the user :meth:`~telethon.types.buttons.Callback.click`\ s a :class:`~telethon.types.buttons.Callback` button. - Only bot accounts can receive this event. + Only bot accounts can receive this event, because only bots can send :class:`~telethon.types.buttons.Callback` buttons. """ - def __init__(self, update: types.UpdateBotCallbackQuery): - self._update = update + def __init__( + self, + client: Client, + update: types.UpdateBotCallbackQuery, + chat_map: Dict[int, Chat], + ): + self._client = client + self._raw = update + self._chat_map = chat_map @classmethod def _try_from_update( cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] ) -> Optional[Self]: - if isinstance(update, types.UpdateBotCallbackQuery): - return cls._create(update) + if isinstance(update, types.UpdateBotCallbackQuery) and update.data is not None: + return cls._create(client, update, chat_map) else: return None + @property + def data(self) -> bytes: + assert self._raw.data is not None + return self._raw.data + + async def answer( + self, + text: Optional[str] = None, + ) -> None: + """ + Answer the callback query. + + .. important:: + + You must call this function for the loading circle to stop on the user's side. + + :param text: + The text of the message to display to the user, usually as a toast. + """ + await self._client( + functions.messages.set_bot_callback_answer( + alert=False, + query_id=self._raw.query_id, + message=text, + url=None, + cache_time=0, + ) + ) + class InlineQuery(Event): """ @@ -38,7 +74,7 @@ class InlineQuery(Event): """ def __init__(self, update: types.UpdateBotInlineQuery): - self._update = update + self._raw = update @classmethod def _try_from_update( diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py index d6b2a2c3..b284f9b0 100644 --- a/client/src/telethon/_impl/client/types/__init__.py +++ b/client/src/telethon/_impl/client/types/__init__.py @@ -1,4 +1,5 @@ from .async_list import AsyncList +from .callback_answer import CallbackAnswer from .chat import Channel, Chat, ChatLike, Group, User from .dialog import Dialog from .draft import Draft @@ -13,6 +14,7 @@ from .recent_action import RecentAction __all__ = [ "AsyncList", + "CallbackAnswer", "Channel", "Chat", "ChatLike", diff --git a/client/src/telethon/_impl/client/types/buttons/__init__.py b/client/src/telethon/_impl/client/types/buttons/__init__.py new file mode 100644 index 00000000..79918db0 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/__init__.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from ....tl import abcs, types +from .button import Button +from .callback import Callback +from .inline_button import InlineButton +from .request_geo_location import RequestGeoLocation +from .request_phone import RequestPhone +from .request_poll import RequestPoll +from .switch_inline import SwitchInline +from .text import Text +from .url import Url + +if TYPE_CHECKING: + from ..message import Message + + +def as_concrete_row(row: abcs.KeyboardButtonRow) -> types.KeyboardButtonRow: + assert isinstance(row, types.KeyboardButtonRow) + return row + + +def create_button(message: Message, raw: abcs.KeyboardButton) -> Button: + """ + Create a custom button from a Telegram button. + + Types with no friendly variant fallback to :class:`telethon.types.buttons.Button` or `telethon.types.buttons.Inline`. + """ + cls = Button + + if isinstance(raw, types.KeyboardButtonCallback): + cls = Callback + elif isinstance(raw, types.KeyboardButtonRequestGeoLocation): + cls = RequestGeoLocation + elif isinstance(raw, types.KeyboardButtonRequestPhone): + cls = RequestPhone + elif isinstance(raw, types.KeyboardButtonRequestPoll): + cls = RequestPoll + elif isinstance(raw, types.KeyboardButtonSwitchInline): + cls = SwitchInline + elif isinstance(raw, types.KeyboardButton): + cls = Text + elif isinstance(raw, types.KeyboardButtonUrl): + cls = Url + elif isinstance( + raw, + ( + types.KeyboardButtonBuy, + types.KeyboardButtonGame, + types.KeyboardButtonUrlAuth, + types.InputKeyboardButtonUrlAuth, + types.KeyboardButtonWebView, + ), + ): + cls = InlineButton + elif isinstance( + raw, + ( + types.InputKeyboardButtonUserProfile, + types.KeyboardButtonUserProfile, + types.KeyboardButtonSimpleWebView, + types.KeyboardButtonRequestPeer, + ), + ): + cls = Button + else: + raise RuntimeError("unexpected case") + + instance = cls.__new__(cls) + instance._msg = weakref.ref(message) + instance._raw = raw + return instance + + +__all__ = [ + "Button", + "Callback", + "InlineButton", + "RequestGeoLocation", + "RequestPhone", + "RequestPoll", + "SwitchInline", + "Text", + "Url", + "create", +] diff --git a/client/src/telethon/_impl/client/types/buttons/button.py b/client/src/telethon/_impl/client/types/buttons/button.py new file mode 100644 index 00000000..c23fe977 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/button.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Optional, Union + +from ....tl import types + +if TYPE_CHECKING: + from ..message import Message + + +ButtonTypes = Union[ + types.KeyboardButton, + types.KeyboardButtonUrl, + types.KeyboardButtonCallback, + types.KeyboardButtonRequestPhone, + types.KeyboardButtonRequestGeoLocation, + types.KeyboardButtonSwitchInline, + types.KeyboardButtonGame, + types.KeyboardButtonBuy, + types.KeyboardButtonUrlAuth, + types.InputKeyboardButtonUrlAuth, + types.KeyboardButtonRequestPoll, + types.InputKeyboardButtonUserProfile, + types.KeyboardButtonUserProfile, + types.KeyboardButtonWebView, + types.KeyboardButtonSimpleWebView, + types.KeyboardButtonRequestPeer, +] + + +class Button: + """ + The button base type. + + All other :mod:`~telethon.types.buttons` inherit this class. + + You can only click buttons that have been received from Telegram. + Attempting to click a button you created will fail with an error. + + Not all buttons can be clicked, and each button will do something different when clicked. + The reason for this is that Telethon cannot interact with any user to complete certain tasks. + Only straightforward actions can be performed automatically, such as sending a text message. + + To check if a button is clickable, use :func:`hasattr` on the ``'click'`` method. + + :param text: See below. + """ + + def __init__(self, text: str) -> None: + if self.__class__ == Button: + raise TypeError( + f"Can't instantiate abstract class {self.__class__.__name__}" + ) + + self._raw: ButtonTypes = types.KeyboardButton(text=text) + self._msg: Optional[weakref.ReferenceType[Message]] = None + + @property + def text(self) -> str: + """ + The button's text that is displayed to the user. + """ + return self._raw.text + + @text.setter + def text(self, value: str) -> None: + self._raw.text = value + + def _message(self) -> Message: + if self._msg and (message := self._msg()): + return message + else: + raise ValueError("Buttons created by yourself cannot be clicked") diff --git a/client/src/telethon/_impl/client/types/buttons/callback.py b/client/src/telethon/_impl/client/types/buttons/callback.py new file mode 100644 index 00000000..c38097d7 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/callback.py @@ -0,0 +1,62 @@ +from typing import Optional + +from ....tl import functions, types +from ..callback_answer import CallbackAnswer +from .inline_button import InlineButton + + +class Callback(InlineButton): + """ + Inline button that will trigger a :class:`telethon.events.ButtonCallback` with the button's data. + + :param text: See below. + :param data: See below. + """ + + def __init__(self, text: str, data: Optional[bytes] = None) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonCallback( + requires_password=False, + text=text, + data=data or text.encode("utf-8", errors="replace"), + ) + + @property + def data(self) -> bytes: + """ + The button's binary payload. + + This data will be received by :class:`telethon.events.ButtonCallback` when the button is pressed. + """ + assert isinstance(self._raw, types.KeyboardButtonCallback) + return self._raw.data + + @data.setter + def data(self, value: bytes) -> None: + assert isinstance(self._raw, types.KeyboardButtonCallback) + self._raw.data = value + + async def click(self) -> Optional[CallbackAnswer]: + """ + Click the button, sending the button's :attr:`data` to the bot. + + The bot will receive a :class:`~telethon.events.ButtonCallback` event + which they must quickly :meth:`~telethon.events.ButtonCallback.answer`. + + The bot's answer will be returned, or :data:`None` if they don't answer in time. + """ + message = self._message() + packed = message.chat.pack() + assert packed + + return CallbackAnswer._create( + await message._client( + functions.messages.get_bot_callback_answer( + game=False, + peer=packed._to_input_peer(), + msg_id=message.id, + data=self.data, + password=None, + ) + ) + ) diff --git a/client/src/telethon/_impl/client/types/buttons/inline_button.py b/client/src/telethon/_impl/client/types/buttons/inline_button.py new file mode 100644 index 00000000..aae5a1c2 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/inline_button.py @@ -0,0 +1,34 @@ +from .button import Button + + +class InlineButton(Button): + """ + Inline button base type. + + Inline buttons appear directly under a message (inline in the chat history). + + You cannot create a naked :class:`InlineButton` directly. + Instead, it can be used to check whether a button is inline or not. + + Buttons that behave as a "custom key" and replace the user's virtual keyboard + can be tested by checking that they are not inline. + + .. rubric:: Example + + .. code-block:: python + + from telethon.types import buttons + + is_inline_button = isinstance(button, buttons.Inline) + is_keyboard_button = not isinstance(button, buttons.Inline) + + :param text: See below. + """ + + def __init__(self, text: str) -> None: + if self.__class__ == InlineButton: + raise TypeError( + f"Can't instantiate abstract class {self.__class__.__name__}" + ) + else: + super().__init__(text) diff --git a/client/src/telethon/_impl/client/types/buttons/request_geo_location.py b/client/src/telethon/_impl/client/types/buttons/request_geo_location.py new file mode 100644 index 00000000..290756d2 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/request_geo_location.py @@ -0,0 +1,14 @@ +from ....tl import types +from .button import Button + + +class RequestGeoLocation(Button): + """ + Keyboard button that will prompt the user to share the geo point with their current location. + + :param text: See below. + """ + + def __init__(self, text: str) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonRequestGeoLocation(text=text) diff --git a/client/src/telethon/_impl/client/types/buttons/request_phone.py b/client/src/telethon/_impl/client/types/buttons/request_phone.py new file mode 100644 index 00000000..99d24a1a --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/request_phone.py @@ -0,0 +1,14 @@ +from ....tl import types +from .button import Button + + +class RequestPhone(Button): + """ + Keyboard button that will prompt the user to share the contact with their phone number. + + :param text: See below. + """ + + def __init__(self, text: str) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonRequestPhone(text=text) diff --git a/client/src/telethon/_impl/client/types/buttons/request_poll.py b/client/src/telethon/_impl/client/types/buttons/request_poll.py new file mode 100644 index 00000000..61660db2 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/request_poll.py @@ -0,0 +1,14 @@ +from ....tl import types +from .button import Button + + +class RequestPoll(Button): + """ + Keyboard button that will prompt the user to create a poll. + + :param text: See below. + """ + + def __init__(self, text: str, *, quiz: bool = False) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonRequestPoll(text=text, quiz=quiz) diff --git a/client/src/telethon/_impl/client/types/buttons/switch_inline.py b/client/src/telethon/_impl/client/types/buttons/switch_inline.py new file mode 100644 index 00000000..977b35c8 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/switch_inline.py @@ -0,0 +1,32 @@ +from typing import Optional + +from ....tl import types +from .inline_button import InlineButton + + +class SwitchInline(InlineButton): + """ + Inline button that will switch the user to inline mode to trigger :class:`telethon.events.InlineQuery`. + + :param text: See below. + :param query: See below. + """ + + def __init__(self, text: str, query: Optional[str] = None) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonSwitchInline( + same_peer=False, text=text, query=query or "", peer_types=None + ) + + @property + def query(self) -> str: + """ + The query string to set by default on the user's message input. + """ + assert isinstance(self._raw, types.KeyboardButtonSwitchInline) + return self._raw.query + + @query.setter + def query(self, value: str) -> None: + assert isinstance(self._raw, types.KeyboardButtonSwitchInline) + self._raw.query = value diff --git a/client/src/telethon/_impl/client/types/buttons/text.py b/client/src/telethon/_impl/client/types/buttons/text.py new file mode 100644 index 00000000..a9d2026d --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/text.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .button import Button + +if TYPE_CHECKING: + from ..message import Message + + +class Text(Button): + """ + This is the most basic keyboard button and only has :attr:`text`. + + Note that it is not possible to distinguish between a :meth:`click` to this button being and the user typing the text themselves. + + :param text: See below. + """ + + def __init__(self, text: str) -> None: + super().__init__(text) + + @property + def text(self) -> str: + """ + The button's text that is both displayed to the user and will be sent on :meth:`click`. + """ + return self._raw.text + + @text.setter + def text(self, value: str) -> None: + self._raw.text = value + + async def click(self) -> Message: + """ + Click the button, sending a message to the chat as-if the user typed and sent the text themselves. + """ + return await self._message().respond(self._raw.text) diff --git a/client/src/telethon/_impl/client/types/buttons/url.py b/client/src/telethon/_impl/client/types/buttons/url.py new file mode 100644 index 00000000..07d1cc96 --- /dev/null +++ b/client/src/telethon/_impl/client/types/buttons/url.py @@ -0,0 +1,30 @@ +from typing import Optional + +from ....tl import types +from .inline_button import InlineButton + + +class Url(InlineButton): + """ + Inline button that will prompt the user to open the specified URL when clicked. + + :param text: See below. + :param url: See below. + """ + + def __init__(self, text: str, url: Optional[str] = None) -> None: + super().__init__(text) + self._raw = types.KeyboardButtonUrl(text=text, url=url or text) + + @property + def url(self) -> str: + """ + The URL to open. + """ + assert isinstance(self._raw, types.KeyboardButtonUrl) + return self._raw.url + + @url.setter + def url(self, value: str) -> None: + assert isinstance(self._raw, types.KeyboardButtonUrl) + self._raw.url = value diff --git a/client/src/telethon/_impl/client/types/callback_answer.py b/client/src/telethon/_impl/client/types/callback_answer.py new file mode 100644 index 00000000..600f030e --- /dev/null +++ b/client/src/telethon/_impl/client/types/callback_answer.py @@ -0,0 +1,28 @@ +from typing import Optional + +from ...tl import abcs, types +from .meta import NoPublicConstructor + + +class CallbackAnswer(metaclass=NoPublicConstructor): + """ + A bot's :class:`~telethon.types.buttons.Callback` :meth:`~telethon.events.ButtonCallback.answer`. + """ + + def __init__(self, raw: abcs.messages.BotCallbackAnswer) -> None: + assert isinstance(raw, types.messages.BotCallbackAnswer) + self._raw = raw + + @property + def text(self) -> Optional[str]: + """ + The answer's text, usually displayed as a toast. + """ + return self._raw.message + + @property + def url(self) -> Optional[str]: + """ + The answer's URL. + """ + return self._raw.url diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py index c26cb871..866779fb 100644 --- a/client/src/telethon/_impl/client/types/draft.py +++ b/client/src/telethon/_impl/client/types/draft.py @@ -56,7 +56,9 @@ class Draft(metaclass=NoPublicConstructor): @property def chat(self) -> Chat: """ - The chat where the draft will be sent to. + The chat where the draft is saved. + + This is also the chat where the message will be sent to by :meth:`send`. """ from ..utils import expand_peer, peer_id diff --git a/client/src/telethon/_impl/client/types/forward_info.py b/client/src/telethon/_impl/client/types/forward_info.py new file mode 100644 index 00000000..b9976b1c --- /dev/null +++ b/client/src/telethon/_impl/client/types/forward_info.py @@ -0,0 +1,22 @@ +from typing import Self + +from ...tl import types +from .meta import NoPublicConstructor + + +class ForwardInfo(metaclass=NoPublicConstructor): + """ + Information about where a message was forwarded from. + + This is also known as the forward header, as it's often displayed at the top of messages. + """ + + __slots__ = ("_code", "_phone") + + def __init__(self, code: types.auth.SentCode, phone: str) -> None: + self._code = code + self._phone = phone + + @classmethod + def _new(cls, code: types.auth.SentCode, phone: str) -> Self: + return cls._create(code, phone) diff --git a/client/src/telethon/_impl/client/types/inline_result.py b/client/src/telethon/_impl/client/types/inline_result.py index 4bdd3c53..a4046b9f 100644 --- a/client/src/telethon/_impl/client/types/inline_result.py +++ b/client/src/telethon/_impl/client/types/inline_result.py @@ -12,6 +12,12 @@ if TYPE_CHECKING: class InlineResult(metaclass=NoPublicConstructor): + """ + A single inline result from an inline query made to a bot. + + This is returned when calling :meth:`telethon.Client.inline_query`. + """ + def __init__( self, client: Client, @@ -30,10 +36,16 @@ class InlineResult(metaclass=NoPublicConstructor): @property def title(self) -> str: + """ + The title of the result, or the empty string if there is none. + """ return self._raw.title or "" @property def description(self) -> Optional[str]: + """ + The description of the result, if available. + """ return self._raw.description async def send( @@ -41,7 +53,7 @@ class InlineResult(metaclass=NoPublicConstructor): chat: Optional[ChatLike] = None, ) -> Message: """ - Send the inline result to the desired chat. + Send the result to the desired chat. :param chat: The chat where the inline result should be sent to. diff --git a/client/src/telethon/_impl/client/types/link_preview.py b/client/src/telethon/_impl/client/types/link_preview.py new file mode 100644 index 00000000..83229d19 --- /dev/null +++ b/client/src/telethon/_impl/client/types/link_preview.py @@ -0,0 +1,22 @@ +from typing import Self + +from ...tl import types +from .meta import NoPublicConstructor + + +class LinkPreview(metaclass=NoPublicConstructor): + """ + Information about a link preview. + + This comes from media attached to a message, and is automatically generated by Telegram. + """ + + __slots__ = ("_code", "_phone") + + def __init__(self, code: types.auth.SentCode, phone: str) -> None: + self._code = code + self._phone = phone + + @classmethod + def _new(cls, code: types.auth.SentCode, phone: str) -> Self: + return cls._create(code, phone) diff --git a/client/src/telethon/_impl/client/types/login_token.py b/client/src/telethon/_impl/client/types/login_token.py index 3a74d7cc..afd33087 100644 --- a/client/src/telethon/_impl/client/types/login_token.py +++ b/client/src/telethon/_impl/client/types/login_token.py @@ -21,4 +21,10 @@ class LoginToken(metaclass=NoPublicConstructor): @property def timeout(self) -> Optional[int]: + """ + Number of seconds before this token expires. + + This property does not return different values as the current time advances. + To determine when the token expires, add the timeout to the current time as soon as the token is obtained. + """ return self._code.timeout diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index e21a52f8..3dc164ff 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -1,10 +1,11 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any, Dict, Optional, Self, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Self, Union from ...tl import abcs, types from ..parsers import generate_html_message, generate_markdown_message +from .buttons import Button, as_concrete_row, create_button from .chat import Chat, ChatLike from .file import File from .meta import NoPublicConstructor @@ -109,10 +110,18 @@ class Message(metaclass=NoPublicConstructor): @property def text(self) -> Optional[str]: + """ + The message text without any formatting. + """ return getattr(self._raw, "message", None) @property def text_html(self) -> Optional[str]: + """ + The message text formatted using standard `HTML elements `_. + + See :ref:`formatting` to learn the HTML elements used. + """ if text := getattr(self._raw, "message", None): return generate_html_message( text, getattr(self._raw, "entities", None) or [] @@ -122,6 +131,11 @@ class Message(metaclass=NoPublicConstructor): @property def text_markdown(self) -> Optional[str]: + """ + The message text formatted as `CommonMark's markdown `_. + + See :ref:`formatting` to learn the formatting characters used. + """ if text := getattr(self._raw, "message", None): return generate_markdown_message( text, getattr(self._raw, "entities", None) or [] @@ -131,22 +145,37 @@ class Message(metaclass=NoPublicConstructor): @property def date(self) -> Optional[datetime.datetime]: + """ + The date when the message was sent. + """ from ..utils import adapt_date return adapt_date(getattr(self._raw, "date", None)) @property def chat(self) -> Chat: + """ + The :term:`chat` when the message was sent. + """ from ..utils import expand_peer, peer_id peer = self._raw.peer_id or types.PeerUser(user_id=0) - broadcast = broadcast = getattr(self._raw, "post", None) - return self._chat_map.get(peer_id(peer)) or expand_peer( - peer, broadcast=broadcast - ) + pid = peer_id(peer) + if pid not in self._chat_map: + self._chat_map[pid] = expand_peer( + peer, broadcast=getattr(self._raw, "post", None) + ) + return self._chat_map[pid] @property def sender(self) -> Optional[Chat]: + """ + The :term:`chat` that sent the message. + + This will usually be a :class:`User`, but can also be a :class:`Channel`. + + If there is no sender, it means the message was sent by an anonymous user. + """ from ..utils import expand_peer, peer_id if (from_ := getattr(self._raw, "from_id", None)) is not None: @@ -165,11 +194,21 @@ class Message(metaclass=NoPublicConstructor): @property def photo(self) -> Optional[File]: + """ + The compressed photo media :attr:`file` in the message. + + This can also be used as a way to check that the message media is a photo. + """ photo = self._file() return photo if photo and photo._photo else None @property def audio(self) -> Optional[File]: + """ + The audio media :attr:`file` in the message. + + This can also be used as a way to check that the message media is an audio. + """ audio = self._file() return ( audio @@ -182,6 +221,11 @@ class Message(metaclass=NoPublicConstructor): @property def video(self) -> Optional[File]: + """ + The video media :attr:`file` in the message. + + This can also be used as a way to check that the message media is a video. + """ audio = self._file() return ( audio @@ -194,6 +238,16 @@ class Message(metaclass=NoPublicConstructor): @property def file(self) -> Optional[File]: + """ + The downloadable file in the message. + + This might also come from a link preview. + + Unlike :attr:`photo`, :attr:`audio` and :attr:`video`, + this property does not care about the media type, only whether it can be downloaded. + + This means the file will be :data:`None` for other media types, such as polls, venues or contacts. + """ return self._file() @property @@ -231,6 +285,7 @@ class Message(metaclass=NoPublicConstructor): markdown: Optional[str] = None, html: Optional[str] = None, link_preview: bool = False, + buttons: Optional[Union[List[Button], List[List[Button]]]] = None, ) -> Message: """ Alias for :meth:`telethon.Client.send_message`. @@ -241,7 +296,12 @@ class Message(metaclass=NoPublicConstructor): :param link_preview: See :meth:`~telethon.Client.send_message`. """ return await self._client.send_message( - self.chat, text, markdown=markdown, html=html, link_preview=link_preview + self.chat, + text, + markdown=markdown, + html=html, + link_preview=link_preview, + buttons=buttons, ) async def reply( @@ -251,6 +311,7 @@ class Message(metaclass=NoPublicConstructor): markdown: Optional[str] = None, html: Optional[str] = None, link_preview: bool = False, + buttons: Optional[Union[List[Button], List[List[Button]]]] = None, ) -> Message: """ Alias for :meth:`telethon.Client.send_message` with the ``reply_to`` parameter set to this message. @@ -267,6 +328,7 @@ class Message(metaclass=NoPublicConstructor): html=html, link_preview=link_preview, reply_to=self.id, + buttons=buttons, ) async def delete(self, *, revoke: bool = True) -> None: @@ -309,20 +371,23 @@ class Message(metaclass=NoPublicConstructor): """ return (await self._client.forward_messages(target, [self.id], self.chat))[0] - async def mark_read(self) -> None: - pass + async def read(self) -> None: + """ + Alias for :meth:`telethon.Client.read_message`. + """ + await self._client.read_message(self.chat, self.id) - async def pin(self) -> None: + async def pin(self) -> Message: """ Alias for :meth:`telethon.Client.pin_message`. """ - pass + return await self._client.pin_message(self.chat, self.id) async def unpin(self) -> None: """ Alias for :meth:`telethon.Client.unpin_message`. """ - pass + await self._client.unpin_message(self.chat, self.id) # --- @@ -331,63 +396,46 @@ class Message(metaclass=NoPublicConstructor): pass @property - def buttons(self) -> None: - pass + def buttons(self) -> Optional[List[List[Button]]]: + """ + The buttons attached to the message. + + These are displayed under the message if they are :class:`~telethon.types.InlineButton`, + and replace the user's virtual keyboard otherwise. + + The returned value is a list of rows, each row having a list of buttons, one per column. + The amount of columns in each row can vary. For example: + + .. code-block:: python + + buttons = [ + [col_0, col_1], # row 0 + [ col_0 ], # row 1 + [col_0, col_1, col_2], # row 2 + ] + + row = 2 + col = 1 + button = buttons[row][col] # the middle button on the bottom row + """ + markup = getattr(self._raw, "reply_markup", None) + if not isinstance(markup, (types.ReplyKeyboardMarkup, types.ReplyInlineMarkup)): + return None + + return [ + [create_button(self, button) for button in row.buttons] + for row in map(as_concrete_row, markup.rows) + ] @property - def web_preview(self) -> None: - pass - - @property - def voice(self) -> None: - pass - - @property - def video_note(self) -> None: - pass - - @property - def gif(self) -> None: - pass - - @property - def sticker(self) -> None: - pass - - @property - def contact(self) -> None: - pass - - @property - def game(self) -> None: - pass - - @property - def geo(self) -> None: - pass - - @property - def invoice(self) -> None: - pass - - @property - def poll(self) -> None: - pass - - @property - def venue(self) -> None: - pass - - @property - def dice(self) -> None: - pass - - @property - def via_bot(self) -> None: + def link_preview(self) -> None: pass @property def silent(self) -> bool: + """ + :data:`True` if the message is silent and should not cause a notification. + """ return getattr(self._raw, "silent", None) or False @property diff --git a/client/src/telethon/_impl/client/types/password_token.py b/client/src/telethon/_impl/client/types/password_token.py index e769fc17..cfcf4365 100644 --- a/client/src/telethon/_impl/client/types/password_token.py +++ b/client/src/telethon/_impl/client/types/password_token.py @@ -20,4 +20,7 @@ class PasswordToken(metaclass=NoPublicConstructor): @property def hint(self) -> str: + """ + The password hint, or the empty string if none is known. + """ return self._password.hint or "" diff --git a/client/src/telethon/_impl/client/types/recent_action.py b/client/src/telethon/_impl/client/types/recent_action.py index 19c178a8..14bb443b 100644 --- a/client/src/telethon/_impl/client/types/recent_action.py +++ b/client/src/telethon/_impl/client/types/recent_action.py @@ -27,4 +27,9 @@ class RecentAction(metaclass=NoPublicConstructor): @property def id(self) -> int: + """ + The identifier of this action. + + This identifier is *not* the same as the one in the message that was edited or deleted. + """ return self._raw.id diff --git a/client/src/telethon/_impl/mtproto/mtp/types.py b/client/src/telethon/_impl/mtproto/mtp/types.py index 0d557618..4aea6297 100644 --- a/client/src/telethon/_impl/mtproto/mtp/types.py +++ b/client/src/telethon/_impl/mtproto/mtp/types.py @@ -18,6 +18,11 @@ class RpcError(ValueError): This is the parent class of all :data:`telethon.errors` subtypes. + :param code: See below. + :param name: See below. + :param value: See below. + :param caused_by: Constructor identifier of the request that caused the error. + .. seealso:: :doc:`/concepts/errors` @@ -41,14 +46,27 @@ class RpcError(ValueError): @property def code(self) -> int: + """ + Integer code of the error. + + This usually reassembles an `HTTP status code `_. + """ return self._code @property def name(self) -> str: + """ + Name of the error, usually in ``SCREAMING_CASE``. + """ return self._name @property def value(self) -> Optional[int]: + """ + Integer value contained within the error. + + For example, if the :attr:`name` is ``'FLOOD_WAIT'``, this would be the number of seconds. + """ return self._value @classmethod diff --git a/client/src/telethon/events/__init__.py b/client/src/telethon/events/__init__.py index 93b11e34..a2ec3224 100644 --- a/client/src/telethon/events/__init__.py +++ b/client/src/telethon/events/__init__.py @@ -6,7 +6,7 @@ Classes related to the different event types that wrap incoming Telegram updates The :doc:`/concepts/updates` concept to learn how to listen to these events. """ from .._impl.client.events import ( - CallbackQuery, + ButtonCallback, Event, InlineQuery, MessageDeleted, @@ -16,7 +16,7 @@ from .._impl.client.events import ( ) __all__ = [ - "CallbackQuery", + "ButtonCallback", "Event", "InlineQuery", "MessageDeleted", diff --git a/client/src/telethon/types.py b/client/src/telethon/types/__init__.py similarity index 70% rename from client/src/telethon/types.py rename to client/src/telethon/types/__init__.py index 89aac915..29391b0c 100644 --- a/client/src/telethon/types.py +++ b/client/src/telethon/types/__init__.py @@ -1,8 +1,9 @@ """ Classes for the various objects the library returns. """ -from ._impl.client.types import ( +from .._impl.client.types import ( AsyncList, + CallbackAnswer, Channel, Chat, Dialog, @@ -17,23 +18,27 @@ from ._impl.client.types import ( RecentAction, User, ) -from ._impl.session import PackedChat, PackedType +from .._impl.client.types.buttons import Button, InlineButton +from .._impl.session import PackedChat, PackedType __all__ = [ - "InlineResult", "AsyncList", + "CallbackAnswer", "Channel", "Chat", "Dialog", "Draft", "File", "Group", + "InlineResult", "LoginToken", "Message", "Participant", "PasswordToken", "RecentAction", "User", + "Button", + "InlineButton", "PackedChat", "PackedType", ] diff --git a/client/src/telethon/types/buttons.py b/client/src/telethon/types/buttons.py new file mode 100644 index 00000000..3c857305 --- /dev/null +++ b/client/src/telethon/types/buttons.py @@ -0,0 +1,40 @@ +""" +Keyboard buttons. + +This includes both the buttons returned by :attr:`telethon.types.Message.buttons` +and those you can define when using :meth:`telethon.Client.send_message`: + +.. code-block:: python + + from telethon.types import buttons + + # As a user account, you can search for and click on buttons: + for row in message.buttons: + for button in row: + if isinstance(button, buttons.Callback) and button.data == b'data': + await button.click() + + # As a bot account, you can send them: + await bot.send_message(chat, text, buttons=[ + buttons.Callback('Demo', b'data') + ]) +""" +from .._impl.client.types.buttons import ( + Callback, + RequestGeoLocation, + RequestPhone, + RequestPoll, + SwitchInline, + Text, + Url, +) + +__all__ = [ + "Callback", + "RequestGeoLocation", + "RequestPhone", + "RequestPoll", + "SwitchInline", + "Text", + "Url", +]