Continue implementation

This commit is contained in:
Lonami Exo 2023-11-08 14:07:33 +01:00
parent 4cc6ecc39b
commit f9435aa1f6
14 changed files with 267 additions and 124 deletions

View File

@ -5,6 +5,8 @@ build:
os: ubuntu-22.04
tools:
python: "3.11"
apt_packages:
- graphviz
sphinx:
configuration: client/doc/conf.py

View File

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

View File

@ -79,7 +79,7 @@ Note that `CommonMark's markdown <https://commonmark.org/>`_ is not fully compat
```
HTML is also not fully compatible with :term:`HTTP Bot API`'s
`MarkdownV2 style <https://core.telegram.org/bots/api#markdownv2-style>`_,
`HTML style <https://core.telegram.org/bots/api#html-style>`_,
and instead favours more standard `HTML elements <https://developer.mozilla.org/en-US/docs/Web/HTML/Element>`_:
* ``strong`` and ``b`` for **bold**.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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