diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 275b5782..04016f8c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,8 @@ build: os: ubuntu-22.04 tools: python: "3.11" + apt_packages: + - graphviz sphinx: configuration: client/doc/conf.py diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst index cff0b700..7bd9cdfb 100644 --- a/client/doc/concepts/botapi-vs-mtproto.rst +++ b/client/doc/concepts/botapi-vs-mtproto.rst @@ -318,7 +318,7 @@ In Telethon: .. code-block:: python from telethon import Client, events - from telethon.events.filters import Any, Command, TextOnly + from telethon.events.filters import Any, Command, Media bot = Client('bot', api_id, api_hash) # Handle '/start' and '/help' @@ -329,8 +329,8 @@ In Telethon: I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\ """) - # Handle all other messages with only 'text' - @bot.on(events.NewMessage, TextOnly()) + # Handle all other messages without media (negating the filter using ~) + @bot.on(events.NewMessage, ~Media()) async def echo_message(message: NewMessage): await message.reply(message.text) diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst index bdfaf5f1..a3c23ffc 100644 --- a/client/doc/concepts/messages.rst +++ b/client/doc/concepts/messages.rst @@ -79,7 +79,7 @@ Note that `CommonMark's markdown `_ is not fully compat ``` HTML is also not fully compatible with :term:`HTTP Bot API`'s -`MarkdownV2 style `_, +`HTML style `_, and instead favours more standard `HTML elements `_: * ``strong`` and ``b`` for **bold**. diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 7de3154c..a4cf6acd 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -118,6 +118,7 @@ from .updates import ( set_handler_filter, ) from .users import ( + get_chats, get_contacts, get_me, input_to_peer, @@ -683,6 +684,40 @@ class Client: """ return get_admin_log(self, chat) + async def get_chats(self, chats: Sequence[ChatLike]) -> List[Chat]: + """ + Get the latest basic information about the given chats. + + This method is most commonly used to turn one or more :class:`~types.PackedChat` into the original :class:`~types.Chat`. + This includes users, groups and broadcast channels. + + :param chats: + The users, groups or channels to fetch. + + :return: The fetched chats. + + .. rubric:: Example + + .. code-block:: python + + # Retrieve a PackedChat from somewhere + packed_user = my_database.get_packed_winner() + + # Fetch it + users = await client.get_chats([packed_user]) + user = users[0] # user will be a User if our packed_user was a user + + # Notify the user they won, using their current full name in the message + await client.send_message(packed_user, f'Congratulations {user.name}, you won!') + + .. caution:: + + This method supports being called with anything that looks like a chat, like every other method. + However, calling it with usernames or phone numbers will fetch the chats twice. + If that's the case, consider using :meth:`resolve_username` or :meth:`get_contacts` instead. + """ + return await get_chats(self, chats) + def get_contacts(self) -> AsyncList[User]: """ Get the users in your contact list. @@ -1200,28 +1235,6 @@ class Client: """ return await request_login_code(self, phone) - async def resolve_to_packed(self, chat: ChatLike) -> PackedChat: - """ - Resolve a :term:`chat` and return a compact, reusable reference to it. - - :param chat: - The :term:`chat` to resolve. - - :return: An efficient, reusable version of the input. - - .. rubric:: Example - - .. code-block:: python - - friend = await client.resolve_to_packed('@cat') - # Now you can use `friend` to get or send messages, files... - - .. seealso:: - - In-depth explanation for :doc:`/concepts/chats`. - """ - return await resolve_to_packed(self, chat) - async def resolve_username(self, username: str) -> Chat: """ Resolve a username into a :term:`chat`. diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index d0d8bb6d..486bdc92 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -154,5 +154,5 @@ async def dispatch_next(client: Client) -> None: for handler, filter in handlers: if not filter or filter(event): ret = await handler(event) - if ret is Continue or client._shortcircuit_handlers: + if ret is not Continue or client._shortcircuit_handlers: return diff --git a/client/src/telethon/_impl/client/client/users.py b/client/src/telethon/_impl/client/client/users.py index 2345e405..d7b1739a 100644 --- a/client/src/telethon/_impl/client/client/users.py +++ b/client/src/telethon/_impl/client/client/users.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional, Sequence from ...mtproto import RpcError from ...session import PackedChat, PackedType @@ -13,6 +13,7 @@ from ..types import ( Group, User, build_chat_map, + expand_peer, peer_id, ) @@ -73,12 +74,51 @@ async def resolve_username(self: Client, username: str) -> Chat: ) -async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: +async def get_chats(self: Client, chats: Sequence[ChatLike]) -> List[Chat]: + packed_chats: List[PackedChat] = [] + input_users: List[types.InputUser] = [] + input_chats: List[int] = [] + input_channels: List[types.InputChannel] = [] + + for chat in chats: + packed = await resolve_to_packed(self, chat) + if packed.is_user(): + input_users.append(packed._to_input_user()) + elif packed.is_chat(): + input_chats.append(packed.id) + else: + input_channels.append(packed._to_input_channel()) + + users = ( + (await self(functions.users.get_users(id=input_users))) if input_users else [] + ) + groups = ( + (await self(functions.messages.get_chats(id=input_chats))) + if input_chats + else [] + ) + assert isinstance(groups, types.messages.Chats) + channels = ( + (await self(functions.channels.get_channels(id=input_channels))) + if input_channels + else [] + ) + assert isinstance(channels, types.messages.Chats) + + chat_map = build_chat_map(self, users, groups.chats + channels.chats) + return [ + chat_map.get(chat.id) + or expand_peer(self, chat._to_peer(), broadcast=chat.ty == PackedType.BROADCAST) + for chat in packed_chats + ] + + +async def resolve_to_packed(client: Client, chat: ChatLike) -> PackedChat: if isinstance(chat, PackedChat): return chat if isinstance(chat, (User, Group, Channel)): - packed = chat.pack() or self._chat_hashes.get(chat.id) + packed = chat.pack() or client._chat_hashes.get(chat.id) if packed is not None: return packed @@ -96,11 +136,11 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: if isinstance(chat, types.InputPeerEmpty): raise ValueError("Cannot resolve chat") elif isinstance(chat, types.InputPeerSelf): - if not self._session.user: + if not client._session.user: raise ValueError("Cannot resolve chat") return PackedChat( - ty=PackedType.BOT if self._session.user.bot else PackedType.USER, - id=self._chat_hashes.self_id, + ty=PackedType.BOT if client._session.user.bot else PackedType.USER, + id=client._chat_hashes.self_id, access_hash=0, ) elif isinstance(chat, types.InputPeerChat): @@ -130,9 +170,9 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: if isinstance(chat, str): if chat.startswith("+"): - resolved = await resolve_phone(self, chat) + resolved = await resolve_phone(client, chat) elif chat == "me": - if me := self._session.user: + if me := client._session.user: return PackedChat( ty=PackedType.BOT if me.bot else PackedType.USER, id=me.id, @@ -141,13 +181,13 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: else: resolved = None else: - resolved = await resolve_username(self, username=chat) + resolved = await resolve_username(client, username=chat) if resolved and (packed := resolved.pack()) is not None: return packed if isinstance(chat, int): - packed = self._chat_hashes.get(chat) + packed = client._chat_hashes.get(chat) if packed is None: raise ValueError("Cannot resolve chat") return packed diff --git a/client/src/telethon/_impl/client/events/filters/__init__.py b/client/src/telethon/_impl/client/events/filters/__init__.py index d3a1c97a..740f1754 100644 --- a/client/src/telethon/_impl/client/events/filters/__init__.py +++ b/client/src/telethon/_impl/client/events/filters/__init__.py @@ -1,6 +1,6 @@ from .combinators import All, Any, Filter, Not -from .common import Chats, Senders -from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text, TextOnly +from .common import Chats, ChatType, Senders +from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text __all__ = [ "All", @@ -8,6 +8,7 @@ __all__ = [ "Filter", "Not", "Chats", + "ChatType", "Senders", "Command", "Forward", @@ -16,5 +17,4 @@ __all__ = [ "Outgoing", "Reply", "Text", - "TextOnly", ] diff --git a/client/src/telethon/_impl/client/events/filters/combinators.py b/client/src/telethon/_impl/client/events/filters/combinators.py index b8214eb6..a98870e1 100644 --- a/client/src/telethon/_impl/client/events/filters/combinators.py +++ b/client/src/telethon/_impl/client/events/filters/combinators.py @@ -1,10 +1,10 @@ import abc import typing -from typing import Callable, Tuple +from typing import Callable, Tuple, TypeAlias from ..event import Event -Filter = Callable[[Event], bool] +Filter: TypeAlias = Callable[[Event], bool] class Combinable(abc.ABC): @@ -48,11 +48,12 @@ class Any(Combinable): """ Combine multiple filters, returning :data:`True` if any of the filters pass. - When either filter is *combinable*, you can use the ``|`` operator instead. + When either filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`, + you can use the ``|`` operator instead. .. code-block:: python - from telethon.filters import Any, Command + from telethon.events.filters import Any, Command @bot.on(events.NewMessage, Any(Command('/start'), Command('/help'))) async def handler(event): ... @@ -87,11 +88,12 @@ class All(Combinable): """ Combine multiple filters, returning :data:`True` if all of the filters pass. - When either filter is *combinable*, you can use the ``&`` operator instead. + When either filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`, + you can use the ``&`` operator instead. .. code-block:: python - from telethon.filters import All, Command, Text + from telethon.events.filters import All, Command, Text @bot.on(events.NewMessage, All(Command('/start'), Text(r'\bdata:\w+'))) async def handler(event): ... @@ -126,11 +128,12 @@ class Not(Combinable): """ Negate the output of a single filter, returning :data:`True` if the nested filter does *not* pass. - When the filter is *combinable*, you can use the ``~`` operator instead. + When the filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`, + you can use the ``~`` operator instead. .. code-block:: python - from telethon.filters import All, Command + from telethon.events.filters import All, Command @bot.on(events.NewMessage, Not(Command('/start')) async def handler(event): ... diff --git a/client/src/telethon/_impl/client/events/filters/common.py b/client/src/telethon/_impl/client/events/filters/common.py index 8e4b4ca5..3e9d0944 100644 --- a/client/src/telethon/_impl/client/events/filters/common.py +++ b/client/src/telethon/_impl/client/events/filters/common.py @@ -1,4 +1,4 @@ -from typing import Literal, Sequence, Tuple, Type, Union +from typing import Sequence, Set, Type, Union from ...types import Channel, Group, User from ..event import Event @@ -8,20 +8,21 @@ from .combinators import Combinable class Chats(Combinable): """ Filter by ``event.chat.id``, if the event has a chat. + + :param chat_ids: The chat identifiers to filter on. """ __slots__ = ("_chats",) - def __init__(self, chat_id: Union[int, Sequence[int]], *chat_ids: int) -> None: - self._chats = {chat_id} if isinstance(chat_id, int) else set(chat_id) - self._chats.update(chat_ids) + def __init__(self, chat_ids: Sequence[int]) -> None: + self._chats = set(chat_ids) @property - def chat_ids(self) -> Tuple[int, ...]: + def chat_ids(self) -> Set[int]: """ - The chat identifiers this filter is filtering on. + A copy of the set of chat identifiers this filter is filtering on. """ - return tuple(self._chats) + return set(self._chats) def __call__(self, event: Event) -> bool: chat = getattr(event, "chat", None) @@ -32,20 +33,21 @@ class Chats(Combinable): class Senders(Combinable): """ Filter by ``event.sender.id``, if the event has a sender. + + :param sender_ids: The sender identifiers to filter on. """ __slots__ = ("_senders",) - def __init__(self, sender_id: Union[int, Sequence[int]], *sender_ids: int) -> None: - self._senders = {sender_id} if isinstance(sender_id, int) else set(sender_id) - self._senders.update(sender_ids) + def __init__(self, sender_ids: Sequence[int]) -> None: + self._senders = set(sender_ids) @property - def sender_ids(self) -> Tuple[int, ...]: + def sender_ids(self) -> Set[int]: """ - The sender identifiers this filter is filtering on. + A copy of the set of sender identifiers this filter is filtering on. """ - return tuple(self._senders) + return set(self._senders) def __call__(self, event: Event) -> bool: sender = getattr(event, "sender", None) @@ -55,37 +57,38 @@ class Senders(Combinable): class ChatType(Combinable): """ - Filter by chat type, either ``'user'``, ``'group'`` or ``'broadcast'``. + Filter by chat type using :func:`isinstance`. + + :param type: The chat type to filter on. + + .. rubric:: Example + + .. code-block:: python + + from telethon import events + from telethon.events import filters + from telethon.types import Channel + + # Handle only messages from broadcast channels + @client.on(events.NewMessage, filters.ChatType(Channel)) + async def handler(event): + print(event.text) """ __slots__ = ("_type",) def __init__( self, - type: Union[Literal["user"], Literal["group"], Literal["broadcast"]], + type: Type[Union[User, Group, Channel]], ) -> None: - if type == "user": - self._type: Union[Type[User], Type[Group], Type[Channel]] = User - elif type == "group": - self._type = Group - elif type == "broadcast": - self._type = Channel - else: - raise TypeError(f"unrecognised chat type: {type}") + self._type = type @property - def type(self) -> Union[Literal["user"], Literal["group"], Literal["broadcast"]]: + def type(self) -> Type[Union[User, Group, Channel]]: """ The chat type this filter is filtering on. """ - if self._type == User: - return "user" - elif self._type == Group: - return "group" - elif self._type == Channel: - return "broadcast" - else: - raise RuntimeError("unexpected case") + return self._type def __call__(self, event: Event) -> bool: sender = getattr(event, "chat", None) diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index 9045e190..70844f36 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -21,7 +21,9 @@ class Text(Combinable): you need to manually perform the check inside the handler instead. Note that the caption text in messages with media is also searched. - If you want to filter based on media, use :class:`TextOnly` or :class:`Media`. + If you want to filter based on media, use :class:`Media`. + + :param regexp: The regular expression to :func:`re.search` with on the text. """ __slots__ = ("_pattern",) @@ -43,6 +45,8 @@ class Command(Combinable): filter ``Command('/help')`` will match both ``"/help"`` and ``"/help@bot"``, but not ``"/list"`` or ``"/help@other"``. + :param command: The command to match on. + .. note:: The leading forward-slash is not automatically added! @@ -58,7 +62,7 @@ class Command(Combinable): __slots__ = ("_cmd", "_username") def __init__(self, command: str) -> None: - if re.match(r"\s", command): + if re.search(r"\s", command): raise ValueError(f"command cannot contain spaces: {command}") self._cmd = command @@ -87,11 +91,7 @@ class Command(Combinable): class Incoming(Combinable): """ - Filter by ``event.incoming``, that is, messages sent from others to the - logged-in account. - - This is not a reliable way to check that the update was not produced by - the logged-in account in broadcast channels. + Filter by ``event.incoming``, that is, messages sent from others to the logged-in account. """ __slots__ = () @@ -102,11 +102,9 @@ class Incoming(Combinable): class Outgoing(Combinable): """ - Filter by ``event.outgoing``, that is, messages sent from others to the - logged-in account. + Filter by ``event.outgoing``, that is, messages sent from the logged-in account. - This is not a reliable way to check that the update was not produced by - the logged-in account in broadcast channels. + This is not a reliable way to check that the update was not produced by the logged-in account in broadcast channels. """ __slots__ = () @@ -117,32 +115,24 @@ class Outgoing(Combinable): class Forward(Combinable): """ - Filter by ``event.forward``. + Filter by ``event.forward_info``, that is, messages that have been forwarded from elsewhere. """ __slots__ = () def __call__(self, event: Event) -> bool: - return getattr(event, "forward", None) is not None + return getattr(event, "forward_info", None) is not None class Reply(Combinable): """ - Filter by ``event.reply``. + Filter by ``event.replied_message_id``, that is, messages which are a reply to another message. """ __slots__ = () def __call__(self, event: Event) -> bool: - return getattr(event, "reply", None) is not None - - -class TextOnly(Combinable): - """ - Filter by messages with some text and no media. - - Note that link previews are only considered media if they have a photo or document. - """ + return getattr(event, "replied_message_id", None) is not None class Media(Combinable): @@ -156,6 +146,10 @@ class Media(Combinable): When you specify one or more media types, *only* those types will be considered. You can use literal strings or the constants defined by the filter. + + :param types: + The media types to filter on. + This is all of them if none are specified. """ PHOTO = "photo" diff --git a/client/src/telethon/_impl/client/events/messages.py b/client/src/telethon/_impl/client/events/messages.py index f5139209..07218d0f 100644 --- a/client/src/telethon/_impl/client/events/messages.py +++ b/client/src/telethon/_impl/client/events/messages.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Self +from typing import TYPE_CHECKING, Dict, List, Optional, Self, Union from ...tl import abcs, types -from ..types import Chat, Message +from ..types import Chat, Message, expand_peer, peer_id from .event import Event if TYPE_CHECKING: @@ -14,6 +14,8 @@ class NewMessage(Event, Message): """ Occurs when a new message is sent or received. + This event can be treated as the :class:`~telethon.types.Message` itself. + .. caution:: Messages sent with the :class:`~telethon.Client` are also caught, @@ -39,6 +41,8 @@ class NewMessage(Event, Message): class MessageEdited(Event, Message): """ Occurs when a new message is sent or received. + + This event can be treated as the :class:`~telethon.types.Message` itself. """ @classmethod @@ -80,32 +84,96 @@ class MessageDeleted(Event): else: return None + @property + def message_ids(self) -> List[int]: + """ + The message identifiers of the messages that were deleted. + """ + return self._msg_ids + + @property + def channel_id(self) -> Optional[int]: + """ + The channel identifier of the supergroup or broadcast channel where the messages were deleted. + + This will be :data:`None` if the messages were deleted anywhere else. + """ + return self._channel_id + class MessageRead(Event): """ Occurs both when your messages are read by others, and when you read messages. """ - def __init__(self, peer: abcs.Peer, max_id: int, out: bool) -> None: - self._peer = peer - self._max_id = max_id - self._out = out + def __init__( + self, + client: Client, + update: Union[ + types.UpdateReadHistoryInbox, + types.UpdateReadHistoryOutbox, + types.UpdateReadChannelInbox, + types.UpdateReadChannelOutbox, + ], + chat_map: Dict[int, Chat], + ) -> None: + 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.UpdateReadHistoryInbox): - return cls._create(update.peer, update.max_id, False) - elif isinstance(update, types.UpdateReadHistoryOutbox): - return cls._create(update.peer, update.max_id, True) - elif isinstance(update, types.UpdateReadChannelInbox): - return cls._create( - types.PeerChannel(channel_id=update.channel_id), update.max_id, False - ) - elif isinstance(update, types.UpdateReadChannelOutbox): - return cls._create( - types.PeerChannel(channel_id=update.channel_id), update.max_id, True - ) + if isinstance( + update, + ( + types.UpdateReadHistoryInbox, + types.UpdateReadHistoryOutbox, + types.UpdateReadChannelInbox, + types.UpdateReadChannelOutbox, + ), + ): + return cls._create(client, update, chat_map) else: return None + + def _peer(self) -> abcs.Peer: + if isinstance( + self._raw, (types.UpdateReadHistoryInbox, types.UpdateReadHistoryOutbox) + ): + return self._raw.peer + else: + return types.PeerChannel(channel_id=self._raw.channel_id) + + @property + def chat(self) -> Chat: + """ + The :term:`chat` when the messages were read. + """ + peer = self._peer() + pid = peer_id(peer) + if pid not in self._chat_map: + self._chat_map[pid] = expand_peer( + self._client, peer, broadcast=getattr(self._raw, "post", None) + ) + return self._chat_map[pid] + + @property + def max_message_id_read(self) -> int: + """ + The highest message identifier of the messages that have been marked as read. + + In other words, messages with an identifier below or equal (``<=``) to this value are considered read. + Messages with an identifier higher (``>``) to this value are considered unread. + + .. rubric:: Example + + .. code-block:: python + + if message.id <= event.max_message_id_read: + print('message is marked as read') + else: + print('message is not yet marked as read') + """ + return self._raw.max_id diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py index d985f654..4568c613 100644 --- a/client/src/telethon/_impl/client/types/draft.py +++ b/client/src/telethon/_impl/client/types/draft.py @@ -163,7 +163,7 @@ class Draft(metaclass=NoPublicConstructor): if chat := self._chat_map.get(peer_id(self._peer)): packed = chat.pack() if packed is None: - packed = await self._client.resolve_to_packed(peer_id(self._peer)) + packed = await self._client._resolve_to_packed(peer_id(self._peer)) return packed async def send(self) -> Message: diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index 2bc95173..4e304509 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -281,6 +281,26 @@ class Message(metaclass=NoPublicConstructor): return None + @property + def incoming(self) -> bool: + """ + :data:`True` if the message is incoming. + This would mean another user sent it, and the currently logged-in user received it. + + This is usually the opposite of :attr:`outgoing`, although some messages can be neither. + """ + return getattr(self._raw, "out", None) is False + + @property + def outgoing(self) -> bool: + """ + :data:`True` if the message is outgoing. + This would mean the currently logged-in user sent it. + + This is usually the opposite of :attr:`incoming`, although some messages can be neither. + """ + return getattr(self._raw, "out", None) is True + async def get_replied_message(self) -> Optional[Message]: """ Alias for :meth:`telethon.Client.get_messages_with_ids`. diff --git a/client/src/telethon/events/filters.py b/client/src/telethon/events/filters.py index a4726d9a..93944358 100644 --- a/client/src/telethon/events/filters.py +++ b/client/src/telethon/events/filters.py @@ -11,6 +11,7 @@ from .._impl.client.events.filters import ( All, Any, Chats, + ChatType, Command, Filter, Forward, @@ -21,13 +22,13 @@ from .._impl.client.events.filters import ( Reply, Senders, Text, - TextOnly, ) __all__ = [ "All", "Any", "Chats", + "ChatType", "Command", "Filter", "Forward", @@ -38,5 +39,4 @@ __all__ = [ "Reply", "Senders", "Text", - "TextOnly", ]