Work towards message buttons

This commit is contained in:
Lonami Exo 2023-10-28 20:05:17 +02:00
parent d15e84e595
commit 8fe89496d6
32 changed files with 917 additions and 140 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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
------

View File

@ -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]],

View File

@ -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")

View File

@ -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",
]

View File

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

View File

@ -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",

View File

@ -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",
]

View File

@ -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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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.

View File

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

View File

@ -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

View File

@ -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 <https://developer.mozilla.org/en-US/docs/Web/HTML/Element>`_.
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 <https://commonmark.org/>`_.
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

View File

@ -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 ""

View File

@ -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

View File

@ -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 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status>`_.
"""
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

View File

@ -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",

View File

@ -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",
]

View File

@ -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",
]