Merge branch 'LonamiWebs:v2' into v2

This commit is contained in:
apepenkov 2023-11-09 11:06:51 +03:00 committed by GitHub
commit 96dd15f796
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 868 additions and 293 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

@ -37,6 +37,7 @@ from ..events import Event
from ..events.filters import Filter
from ..types import (
AdminRight,
AlbumBuilder,
AsyncList,
Chat,
ChatLike,
@ -53,8 +54,8 @@ from ..types import (
PasswordToken,
RecentAction,
User,
buttons,
)
from ..types import buttons as btns
from .auth import (
bot_sign_in,
check_password,
@ -77,10 +78,12 @@ from .dialogs import delete_dialog, edit_draft, get_dialogs, get_drafts
from .files import (
download,
get_file_bytes,
prepare_album,
send_audio,
send_file,
send_photo,
send_video,
upload,
)
from .messages import (
MessageMap,
@ -115,6 +118,7 @@ from .updates import (
set_handler_filter,
)
from .users import (
get_chats,
get_contacts,
get_me,
input_to_peer,
@ -680,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.
@ -1085,6 +1123,34 @@ class Client:
"""
return await pin_message(self, chat, message_id)
def prepare_album(self) -> AlbumBuilder:
"""
Prepare an album upload to send.
Albums are a way to send multiple photos or videos as separate messages with the same grouped identifier.
:return: A new album builder instance, with no media added to it yet.
.. rubric:: Example
.. code-block:: python
# Prepare a new album
album = await client.prepare_album()
# Add a bunch of photos
for photo in ('a.jpg', 'b.png'):
await album.add_photo(photo)
# A video in-between
await album.add_video('c.mp4')
# And another photo
await album.add_photo('d.jpeg')
# Album is ready to be sent to as many chats as needed
await album.send(chat)
"""
return prepare_album(self)
async def read_message(
self, chat: ChatLike, message_id: Union[int, Literal["all"]]
) -> None:
@ -1169,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`.
@ -1310,6 +1354,7 @@ class Client:
self,
chat: ChatLike,
file: Union[str, Path, InFileLike, File],
mime_type: Optional[str] = None,
*,
size: Optional[int] = None,
name: Optional[str] = None,
@ -1320,6 +1365,8 @@ class Client:
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send an audio file.
@ -1333,6 +1380,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`.
:param voice: See :meth:`send_file`.
:param title: See :meth:`send_file`.
@ -1351,6 +1399,7 @@ class Client:
self,
chat,
file,
mime_type,
size=size,
name=name,
duration=duration,
@ -1360,6 +1409,8 @@ class Client:
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
async def send_file(
@ -1386,6 +1437,8 @@ class Client:
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send any type of file with any amount of attributes.
@ -1438,8 +1491,10 @@ class Client:
When given a string or path, its :attr:`~pathlib.PurePath.name` will be used by default only if this parameter is omitted.
This parameter **must** be specified when sending a previously-opened or in-memory files.
The library will not attempt to read any ``name`` attributes the object may have.
When given a :term:`file-like object`, if it has a ``.name`` :class:`str` property, it will be used.
This is the case for files opened via :func:`open`.
This parameter **must** be specified when sending any other previously-opened or in-memory files.
:param mime_type:
Override for the default mime-type.
@ -1536,6 +1591,8 @@ class Client:
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
async def send_message(
@ -1547,9 +1604,7 @@ 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,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a message.
@ -1594,12 +1649,15 @@ class Client:
*,
size: Optional[int] = None,
name: Optional[str] = None,
mime_type: Optional[str] = None,
compress: bool = True,
width: Optional[int] = None,
height: Optional[int] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a photo file.
@ -1617,6 +1675,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param compress: See :meth:`send_file`.
:param width: See :meth:`send_file`.
:param height: See :meth:`send_file`.
@ -1636,12 +1695,15 @@ class Client:
file,
size=size,
name=name,
mime_type=mime_type,
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
async def send_video(
@ -1651,14 +1713,18 @@ class Client:
*,
size: Optional[int] = None,
name: Optional[str] = None,
mime_type: Optional[str] = None,
duration: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
muted: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a video file.
@ -1672,6 +1738,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`.
:param width: See :meth:`send_file`.
:param height: See :meth:`send_file`.
@ -1693,14 +1760,18 @@ class Client:
file,
size=size,
name=name,
mime_type=mime_type,
duration=duration,
width=width,
height=height,
round=round,
supports_streaming=supports_streaming,
muted=muted,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
async def set_chat_default_restrictions(
@ -1961,6 +2032,11 @@ class Client:
def _input_to_peer(self, input: Optional[abcs.InputPeer]) -> Optional[abcs.Peer]:
return input_to_peer(self, input)
async def _upload(
self, fd: Union[str, Path, InFileLike], size: Optional[int], name: Optional[str]
) -> Tuple[abcs.InputFile, str]:
return await upload(self, fd, size, name)
async def __call__(self, request: Request[Return]) -> Return:
if not self._sender:
raise ConnectionError("not connected")

View File

@ -4,8 +4,15 @@ import time
from typing import TYPE_CHECKING, Optional
from ...tl import functions, types
from ..types import AsyncList, ChatLike, Dialog, Draft, build_chat_map, build_msg_map
from .messages import parse_message
from ..types import (
AsyncList,
ChatLike,
Dialog,
Draft,
build_chat_map,
build_msg_map,
parse_message,
)
if TYPE_CHECKING:
from .client import Client
@ -125,7 +132,6 @@ async def edit_draft(
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
assert isinstance(message, str)
result = await self(
functions.messages.save_draft(

View File

@ -2,13 +2,13 @@ from __future__ import annotations
import hashlib
import mimetypes
import urllib.parse
from inspect import isawaitable
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional, Union
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from ...tl import abcs, functions, types
from ..types import (
AlbumBuilder,
AsyncList,
ChatLike,
File,
@ -16,10 +16,14 @@ from ..types import (
Message,
OutFileLike,
OutWrapper,
)
from ..types import buttons as btns
from ..types import (
expand_stripped_size,
generate_random_id,
parse_message,
try_get_url_path,
)
from .messages import parse_message
if TYPE_CHECKING:
from .client import Client
@ -34,6 +38,10 @@ BIG_FILE_SIZE = 10 * 1024 * 1024
math_round = round
def prepare_album(self: Client) -> AlbumBuilder:
return AlbumBuilder._create(client=self)
async def send_photo(
self: Client,
chat: ChatLike,
@ -41,12 +49,15 @@ async def send_photo(
*,
size: Optional[int] = None,
name: Optional[str] = None,
mime_type: Optional[str] = None,
compress: bool = True,
width: Optional[int] = None,
height: Optional[int] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
@ -54,12 +65,17 @@ async def send_photo(
file,
size=size,
name=name,
mime_type="image/jpeg" # specific mime doesn't matter, only that it's image
if compress
else mime_type,
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
@ -67,6 +83,7 @@ async def send_audio(
self: Client,
chat: ChatLike,
file: Union[str, Path, InFileLike, File],
mime_type: Optional[str] = None,
*,
size: Optional[int] = None,
name: Optional[str] = None,
@ -77,6 +94,8 @@ async def send_audio(
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
@ -84,6 +103,7 @@ async def send_audio(
file,
size=size,
name=name,
mime_type=mime_type,
duration=duration,
voice=voice,
title=title,
@ -91,6 +111,8 @@ async def send_audio(
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
@ -101,41 +123,40 @@ async def send_video(
*,
size: Optional[int] = None,
name: Optional[str] = None,
mime_type: Optional[str] = None,
duration: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
muted: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
chat,
file,
size=size,
mime_type=mime_type,
name=name,
duration=duration,
width=width,
height=height,
round=round,
supports_streaming=supports_streaming,
muted=muted,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
)
def try_get_url_path(maybe_url: Union[str, Path, InFileLike]) -> Optional[str]:
if not isinstance(maybe_url, str):
return None
lowercase = maybe_url.lower()
if lowercase.startswith("http://") or lowercase.startswith("https://"):
return urllib.parse.urlparse(maybe_url).path
return None
async def send_file(
self: Client,
chat: ChatLike,
@ -160,15 +181,18 @@ async def send_file(
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
reply_to: Optional[int] = None,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
message, entities = parse_message(
text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
)
assert isinstance(message, str)
# Re-send existing file.
if isinstance(file, File):
return await do_send_file(self, chat, file._input_media, message, entities)
return await do_send_file(
self, chat, file._input_media, message, entities, reply_to, buttons
)
# URLs are handled early as they can't use any other attributes either.
input_media: abcs.InputMedia
@ -190,23 +214,11 @@ async def send_file(
input_media = types.InputMediaDocumentExternal(
spoiler=False, url=file, ttl_seconds=None
)
return await do_send_file(self, chat, input_media, message, entities)
return await do_send_file(
self, chat, input_media, message, entities, reply_to, buttons
)
# Paths are opened and closed by us. Anything else is *only* read, not closed.
if isinstance(file, (str, Path)):
path = Path(file) if isinstance(file, str) else file
if size is None:
size = path.stat().st_size
if name is None:
name = path.name
with path.open("rb") as fd:
input_file = await upload(self, fd, size, name)
else:
if size is None:
raise ValueError("size must be set when sending file-like objects")
if name is None:
raise ValueError("name must be set when sending file-like objects")
input_file = await upload(self, file, size, name)
input_file, name = await upload(self, file, size, name)
# Mime is mandatory for documents, but we also use it to determine whether to send as photo.
if mime_type is None:
@ -249,7 +261,7 @@ async def send_file(
round_message=round,
supports_streaming=supports_streaming,
nosound=muted,
duration=int(math_round(duration)),
duration=duration,
w=width,
h=height,
preload_prefix_size=None,
@ -268,7 +280,9 @@ async def send_file(
ttl_seconds=None,
)
return await do_send_file(self, chat, input_media, message, entities)
return await do_send_file(
self, chat, input_media, message, entities, reply_to, buttons
)
async def do_send_file(
@ -277,6 +291,8 @@ async def do_send_file(
input_media: abcs.InputMedia,
message: str,
entities: Optional[List[abcs.MessageEntity]],
reply_to: Optional[int],
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]],
) -> Message:
peer = (await client._resolve_to_packed(chat))._to_input_peer()
random_id = generate_random_id()
@ -289,11 +305,15 @@ async def do_send_file(
noforwards=False,
update_stickersets_order=False,
peer=peer,
reply_to=None,
reply_to=types.InputReplyToMessage(
reply_to_msg_id=reply_to, top_msg_id=None
)
if reply_to
else None,
media=input_media,
message=message,
random_id=random_id,
reply_markup=None,
reply_markup=btns.build_keyboard(buttons),
entities=entities,
schedule_date=None,
send_as=None,
@ -304,6 +324,31 @@ async def do_send_file(
async def upload(
client: Client,
file: Union[str, Path, InFileLike],
size: Optional[int],
name: Optional[str],
) -> Tuple[abcs.InputFile, str]:
# Paths are opened and closed by us. Anything else is *only* read, not closed.
if isinstance(file, (str, Path)):
path = Path(file) if isinstance(file, str) else file
if size is None:
size = path.stat().st_size
if name is None:
name = path.name
with path.open("rb") as fd:
return await do_upload(client, fd, size, name), name
else:
if size is None:
raise ValueError("size must be set when sending file-like objects")
if name is None:
name = getattr(file, "name", None)
if not isinstance(name, str):
raise ValueError("name must be set when sending file-like objects")
return await do_upload(client, file, size, name), name
async def do_upload(
client: Client,
fd: InFileLike,
size: int,

View File

@ -2,85 +2,18 @@ from __future__ import annotations
import datetime
import sys
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Self, Tuple, Union
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Self, 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,
build_chat_map,
buttons,
generate_random_id,
peer_id,
)
from ..types import AsyncList, Chat, ChatLike, Message, build_chat_map
from ..types import buttons as btns
from ..types import generate_random_id, parse_message, peer_id
if TYPE_CHECKING:
from .client import Client
def parse_message(
*,
text: Optional[Union[str, Message]],
markdown: Optional[str],
html: Optional[str],
allow_empty: bool,
) -> Tuple[Union[str, Message], Optional[List[abcs.MessageEntity]]]:
cnt = sum((text is not None, markdown is not None, html is not None))
if cnt != 1:
if cnt == 0 and allow_empty:
return "", None
raise ValueError("must specify exactly one of text, markdown or html")
if text is not None:
parsed, entities = text, None
elif markdown is not None:
parsed, entities = parse_markdown_message(markdown)
elif html is not None:
parsed, entities = parse_html_message(html)
else:
raise RuntimeError("unexpected case")
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,
@ -90,16 +23,39 @@ 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,
buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
packed = await self._resolve_to_packed(chat)
peer = packed._to_input_peer()
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
random_id = generate_random_id()
result = await self(
functions.messages.send_message(
if isinstance(text, Message):
message = text.text or ""
request = functions.messages.send_message(
no_webpage=not text.link_preview,
silent=text.silent,
background=False,
clear_draft=False,
noforwards=not text.can_forward,
update_stickersets_order=False,
peer=peer,
reply_to=types.InputReplyToMessage(
reply_to_msg_id=text.replied_message_id, top_msg_id=None
)
if text.replied_message_id
else None,
message=message,
random_id=random_id,
reply_markup=getattr(text._raw, "reply_markup", None),
entities=getattr(text._raw, "entities", None) or None,
schedule_date=None,
send_as=None,
)
else:
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
request = functions.messages.send_message(
no_webpage=not link_preview,
silent=False,
background=False,
@ -114,33 +70,13 @@ async def send_message(
else None,
message=message,
random_id=random_id,
reply_markup=build_keyboard(buttons),
reply_markup=btns.build_keyboard(buttons),
entities=entities,
schedule_date=None,
send_as=None,
)
if isinstance(message, str)
else functions.messages.send_message(
no_webpage=not message.link_preview,
silent=message.silent,
background=False,
clear_draft=False,
noforwards=not message.can_forward,
update_stickersets_order=False,
peer=peer,
reply_to=types.InputReplyToMessage(
reply_to_msg_id=message.replied_message_id, top_msg_id=None
)
if message.replied_message_id
else None,
message=message.text or "",
random_id=random_id,
reply_markup=getattr(message._raw, "reply_markup", None),
entities=getattr(message._raw, "entities", None) or None,
schedule_date=None,
send_as=None,
)
)
result = await self(request)
if isinstance(result, types.UpdateShortSentMessage):
return Message._from_defaults(
self,
@ -161,7 +97,7 @@ async def send_message(
if reply_to
else None,
date=result.date,
message=message if isinstance(message, str) else (message.text or ""),
message=message,
media=result.media,
entities=result.entities,
ttl_period=result.ttl_period,
@ -184,7 +120,6 @@ async def edit_message(
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
assert isinstance(message, str)
return self._build_message_map(
await self(
functions.messages.edit_message(

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

@ -1,4 +1,5 @@
from .admin_right import AdminRight
from .album_builder import AlbumBuilder
from .async_list import AsyncList
from .callback_answer import CallbackAnswer
from .chat import (
@ -14,10 +15,23 @@ from .chat import (
from .chat_restriction import ChatRestriction
from .dialog import Dialog
from .draft import Draft
from .file import File, InFileLike, OutFileLike, OutWrapper, expand_stripped_size
from .file import (
File,
InFileLike,
OutFileLike,
OutWrapper,
expand_stripped_size,
try_get_url_path,
)
from .inline_result import InlineResult
from .login_token import LoginToken
from .message import Message, adapt_date, build_msg_map, generate_random_id
from .message import (
Message,
adapt_date,
build_msg_map,
generate_random_id,
parse_message,
)
from .meta import NoPublicConstructor
from .participant import Participant
from .password_token import PasswordToken
@ -25,6 +39,7 @@ from .recent_action import RecentAction
__all__ = [
"AdminRight",
"AlbumBuilder",
"AsyncList",
"ChatRestriction",
"CallbackAnswer",
@ -43,12 +58,14 @@ __all__ = [
"OutFileLike",
"OutWrapper",
"expand_stripped_size",
"try_get_url_path",
"InlineResult",
"LoginToken",
"Message",
"adapt_date",
"build_msg_map",
"generate_random_id",
"parse_message",
"NoPublicConstructor",
"Participant",
"PasswordToken",

View File

@ -0,0 +1,239 @@
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional, Union
from ...tl import abcs, functions, types
from .chat import ChatLike
from .file import InFileLike, try_get_url_path
from .message import Message, generate_random_id, parse_message
from .meta import NoPublicConstructor
if TYPE_CHECKING:
from ..client.client import Client
class AlbumBuilder(metaclass=NoPublicConstructor):
"""
Album builder to prepare albums with multiple files before sending it all at once.
This class is constructed by calling :meth:`telethon.Client.prepare_album`.
"""
def __init__(self, *, client: Client):
self._client = client
self._medias: List[types.InputSingleMedia] = []
async def add_photo(
self,
file: Union[str, Path, InFileLike],
*,
size: Optional[int] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> None:
"""
Add a photo to the album.
:param file:
The photo to attach to the album.
This behaves the same way as the file parameter in :meth:`telethon.Client.send_file`,
*except* that it cannot be previously-sent media.
:param size: See :meth:`telethon.Client.send_file`.
:param caption: See :ref:`formatting`.
:param caption_markdown: See :ref:`formatting`.
:param caption_html: See :ref:`formatting`.
"""
input_media: abcs.InputMedia
if try_get_url_path(file) is not None:
assert isinstance(file, str)
input_media = types.InputMediaPhotoExternal(
spoiler=False, url=file, ttl_seconds=None
)
else:
input_file, _ = await self._client._upload(file, size, "a.jpg")
input_media = types.InputMediaUploadedPhoto(
spoiler=False, file=input_file, stickers=None, ttl_seconds=None
)
media = await self._client(
functions.messages.upload_media(
peer=types.InputPeerSelf(), media=input_media
)
)
assert isinstance(media, types.MessageMediaPhoto)
assert isinstance(media.photo, types.Photo)
input_media = types.InputMediaPhoto(
spoiler=media.spoiler,
id=types.InputPhoto(
id=media.photo.id,
access_hash=media.photo.access_hash,
file_reference=media.photo.file_reference,
),
ttl_seconds=media.ttl_seconds,
)
message, entities = parse_message(
text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
)
self._medias.append(
types.InputSingleMedia(
media=input_media,
random_id=generate_random_id(),
message=message,
entities=entities,
)
)
async def add_video(
self,
file: Union[str, Path, InFileLike],
*,
size: Optional[int] = None,
name: Optional[str] = None,
mime_type: Optional[str] = None,
duration: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
muted: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> None:
"""
Add a video to the album.
:param file:
The video to attach to the album.
This behaves the same way as the file parameter in :meth:`telethon.Client.send_file`,
*except* that it cannot be previously-sent media.
:param size: See :meth:`telethon.Client.send_file`.
:param name: See :meth:`telethon.Client.send_file`.
:param mime_type: See :meth:`telethon.Client.send_file`.
:param duration: See :meth:`telethon.Client.send_file`.
:param width: See :meth:`telethon.Client.send_file`.
:param height: See :meth:`telethon.Client.send_file`.
:param round: See :meth:`telethon.Client.send_file`.
:param supports_streaming: See :meth:`telethon.Client.send_file`.
:param muted: See :meth:`telethon.Client.send_file`.
:param caption: See :ref:`formatting`.
:param caption_markdown: See :ref:`formatting`.
:param caption_html: See :ref:`formatting`.
"""
input_media: abcs.InputMedia
if try_get_url_path(file) is not None:
assert isinstance(file, str)
input_media = types.InputMediaDocumentExternal(
spoiler=False, url=file, ttl_seconds=None
)
else:
input_file, name = await self._client._upload(file, size, name)
if mime_type is None:
mime_type, _ = mimetypes.guess_type(name, strict=False)
if mime_type is None:
mime_type = "application/octet-stream"
attributes: List[abcs.DocumentAttribute] = []
attributes.append(types.DocumentAttributeFilename(file_name=name))
if duration is not None and width is not None and height is not None:
attributes.append(
types.DocumentAttributeVideo(
round_message=round,
supports_streaming=supports_streaming,
nosound=muted,
duration=duration,
w=width,
h=height,
preload_prefix_size=None,
)
)
input_media = types.InputMediaUploadedDocument(
nosound_video=muted,
force_file=False,
spoiler=False,
file=input_file,
thumb=None,
mime_type=mime_type,
attributes=attributes,
stickers=None,
ttl_seconds=None,
)
media = await self._client(
functions.messages.upload_media(
peer=types.InputPeerEmpty(), media=input_media
)
)
assert isinstance(media, types.MessageMediaDocument)
assert isinstance(media.document, types.Document)
input_media = types.InputMediaDocument(
spoiler=media.spoiler,
id=types.InputDocument(
id=media.document.id,
access_hash=media.document.access_hash,
file_reference=media.document.file_reference,
),
ttl_seconds=media.ttl_seconds,
query=None,
)
message, entities = parse_message(
text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
)
self._medias.append(
types.InputSingleMedia(
media=input_media,
random_id=generate_random_id(),
message=message,
entities=entities,
)
)
async def send(
self, chat: ChatLike, *, reply_to: Optional[int] = None
) -> List[Message]:
"""
Send the album.
:return: All sent messages that are part of the album.
.. rubric:: Example
.. code-block:: python
album = await client.prepare_album()
for photo in ('a.jpg', 'b.png'):
await album.add_photo(photo)
messages = await album.send(chat)
"""
peer = (await self._client._resolve_to_packed(chat))._to_input_peer()
msg_map = self._client._build_message_map(
await self._client(
functions.messages.send_multi_media(
silent=False,
background=False,
clear_draft=False,
noforwards=False,
update_stickersets_order=False,
peer=peer,
reply_to=types.InputReplyToMessage(
reply_to_msg_id=reply_to, top_msg_id=None
)
if reply_to
else None,
multi_media=self._medias,
schedule_date=None,
send_as=None,
)
),
peer,
)
return [msg_map.with_random_id(media.random_id) for media in self._medias]

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import weakref
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List, Optional, Union
from ....tl import abcs, types
from .button import Button
@ -23,6 +23,40 @@ def as_concrete_row(row: abcs.KeyboardButtonRow) -> types.KeyboardButtonRow:
return row
def build_keyboard(
btns: Optional[Union[List[Button], List[List[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], InlineButton):
return types.ReplyInlineMarkup(rows=rows)
else:
return types.ReplyKeyboardMarkup(
resize=False,
single_use=False,
selective=False,
persistent=False,
rows=rows,
placeholder=None,
)
def create_button(message: Message, raw: abcs.KeyboardButton) -> Button:
"""
Create a custom button from a Telegram button.

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

@ -1,6 +1,7 @@
from __future__ import annotations
import mimetypes
import urllib.parse
from inspect import isawaitable
from io import BufferedWriter
from pathlib import Path
@ -68,6 +69,15 @@ def photo_size_dimensions(
raise RuntimeError("unexpected case")
def try_get_url_path(maybe_url: Union[str, Path, InFileLike]) -> Optional[str]:
if not isinstance(maybe_url, str):
return None
lowercase = maybe_url.lower()
if lowercase.startswith("http://") or lowercase.startswith("https://"):
return urllib.parse.urlparse(maybe_url).path
return None
class InFileLike(Protocol):
"""
A :term:`file-like object` used for input only.

View File

@ -2,10 +2,15 @@ from __future__ import annotations
import datetime
import time
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Self, Union
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Self, Tuple, Union
from ...tl import abcs, types
from ..parsers import generate_html_message, generate_markdown_message
from ..parsers import (
generate_html_message,
generate_markdown_message,
parse_html_message,
parse_markdown_message,
)
from .buttons import Button, as_concrete_row, create_button
from .chat import Chat, ChatLike, expand_peer, peer_id
from .file import File
@ -276,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`.
@ -465,3 +490,28 @@ def build_msg_map(
msg.id: msg
for msg in (Message._from_raw(client, m, chat_map) for m in messages)
}
def parse_message(
*,
text: Optional[str],
markdown: Optional[str],
html: Optional[str],
allow_empty: bool,
) -> Tuple[str, Optional[List[abcs.MessageEntity]]]:
cnt = sum((text is not None, markdown is not None, html is not None))
if cnt != 1:
if cnt == 0 and allow_empty:
return "", None
raise ValueError("must specify exactly one of text, markdown or html")
if text is not None:
parsed, entities = text, None
elif markdown is not None:
parsed, entities = parse_markdown_message(markdown)
elif html is not None:
parsed, entities = parse_html_message(html)
else:
raise RuntimeError("unexpected case")
return parsed, entities or None

View File

@ -45,6 +45,11 @@ from ...tl.types import (
UpdateShortSentMessage,
UpdatesTooLong,
)
from ...tl.types.messages import (
AffectedFoundMessages,
AffectedHistory,
AffectedMessages,
)
from ..utils import (
CONTAINER_MAX_LENGTH,
CONTAINER_MAX_SIZE,
@ -69,6 +74,9 @@ UPDATE_IDS = {
UpdateShortMessage.constructor_id(),
UpdateShortSentMessage.constructor_id(),
UpdatesTooLong.constructor_id(),
AffectedFoundMessages.constructor_id(),
AffectedHistory.constructor_id(),
AffectedMessages.constructor_id(),
}
HEADER_LEN = 8 + 8 # salt, client_id

View File

@ -5,7 +5,7 @@ import time
from abc import ABC
from asyncio import FIRST_COMPLETED, Event, Future
from dataclasses import dataclass
from typing import Generic, List, Optional, Protocol, Self, Tuple, TypeVar
from typing import Generic, List, Optional, Protocol, Self, Tuple, Type, TypeVar
from ..crypto import AuthKey
from ..mtproto import (
@ -21,7 +21,10 @@ from ..mtproto import (
)
from ..tl import Request as RemoteCall
from ..tl.abcs import Updates
from ..tl.core import Serializable
from ..tl.mtproto.functions import ping_delay_disconnect
from ..tl.types import UpdateDeleteMessages, UpdateShort
from ..tl.types.messages import AffectedFoundMessages, AffectedHistory, AffectedMessages
MAXIMUM_DATA = (1024 * 1024) + (8 * 1024)
@ -315,12 +318,41 @@ class Sender:
try:
u = Updates.from_bytes(update)
except ValueError:
self._logger.warning(
"failed to deserialize incoming update; make sure the session is not in use elsewhere: %s",
update.hex(),
cid = struct.unpack_from("I", update)[0]
alt_classes: Tuple[Type[Serializable], ...] = (
AffectedFoundMessages,
AffectedHistory,
AffectedMessages,
)
else:
updates.append(u)
for cls in alt_classes:
if cid == cls.constructor_id():
affected = cls.from_bytes(update)
# mypy struggles with the types here quite a bit
assert isinstance(
affected,
(
AffectedFoundMessages,
AffectedHistory,
AffectedMessages,
),
)
u = UpdateShort(
update=UpdateDeleteMessages(
messages=[],
pts=affected.pts,
pts_count=affected.pts_count,
),
date=0,
)
break
else:
self._logger.warning(
"failed to deserialize incoming update; make sure the session is not in use elsewhere: %s",
update.hex(),
)
continue
updates.append(u)
for msg_id, ret in result.rpc_results:
for i, req in enumerate(self._requests):

View File

@ -61,6 +61,7 @@ class Gap(ValueError):
return "Gap()"
NO_DATE = 0 # used on adapted messages.affected* from lower layers
NO_SEQ = 0
NO_PTS = 0

View File

@ -13,6 +13,7 @@ from .defs import (
ENTRY_ACCOUNT,
ENTRY_SECRET,
LOG_LEVEL_TRACE,
NO_DATE,
NO_PTS,
NO_SEQ,
NO_UPDATES_TIMEOUT,
@ -294,9 +295,10 @@ class MessageBox:
if any_pts_applied:
if __debug__:
self._trace("updating seq as local pts was updated too")
self.date = datetime.datetime.fromtimestamp(
combined.date, tz=datetime.timezone.utc
)
if combined.date != NO_DATE:
self.date = datetime.datetime.fromtimestamp(
combined.date, tz=datetime.timezone.utc
)
if combined.seq != NO_SEQ:
self.seq = combined.seq

View File

@ -82,9 +82,8 @@ class Reader:
assert self._pos <= self._len
cid = struct.unpack("<I", self._view[self._pos - 4 : self._pos])[0]
ty = self._get_ty(cid)
if ty is None:
raise ValueError(f"No type found for constructor ID: {cid:x}")
assert issubclass(ty, cls)
if ty is None or not issubclass(ty, cls):
raise ValueError(f"No type found for constructor ID of {cls}: {cid:x}")
return ty._read_from(self)

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

View File

@ -3,6 +3,7 @@ Classes for the various objects the library returns.
"""
from .._impl.client.types import (
AdminRight,
AlbumBuilder,
AsyncList,
CallbackAnswer,
Channel,
@ -25,6 +26,7 @@ from .._impl.session import PackedChat, PackedType
__all__ = [
"AdminRight",
"AlbumBuilder",
"AsyncList",
"CallbackAnswer",
"Channel",

View File

@ -94,7 +94,7 @@ def generate(fs: FakeFs, tl: ParsedTl) -> None:
if type_path not in fs:
writer.write("import struct")
writer.write("from typing import List, Optional, Self")
writer.write("from typing import List, Optional, Self, Sequence")
writer.write("from .. import abcs")
writer.write("from ..core import Reader, Serializable, serialize_bytes_to")
@ -118,7 +118,8 @@ def generate(fs: FakeFs, tl: ParsedTl) -> None:
# def __init__()
if property_params:
params = "".join(
f", {p.name}: {param_type_fmt(p.ty)}" for p in property_params
f", {p.name}: {param_type_fmt(p.ty, immutable=False)}"
for p in property_params
)
writer.write(f" def __init__(_s, *{params}) -> None:")
for p in property_params:
@ -158,15 +159,20 @@ def generate(fs: FakeFs, tl: ParsedTl) -> None:
if function_path not in fs:
writer.write("import struct")
writer.write("from typing import List, Optional, Self")
writer.write("from typing import List, Optional, Self, Sequence")
writer.write("from .. import abcs")
writer.write("from ..core import Request, serialize_bytes_to")
# def name(params, ...)
required_params = [p for p in functiondef.params if not is_computed(p.ty)]
params = "".join(f", {p.name}: {param_type_fmt(p.ty)}" for p in required_params)
params = "".join(
f", {p.name}: {param_type_fmt(p.ty, immutable=True)}"
for p in required_params
)
star = "*" if params else ""
return_ty = param_type_fmt(NormalParameter(ty=functiondef.ty, flag=None))
return_ty = param_type_fmt(
NormalParameter(ty=functiondef.ty, flag=None), immutable=False
)
writer.write(
f"def {to_method_name(functiondef.name)}({star}{params}) -> Request[{return_ty}]:"
)

View File

@ -86,7 +86,7 @@ def inner_type_fmt(ty: Type) -> str:
return f"abcs.{ns}{to_class_name(ty.name)}"
def param_type_fmt(ty: BaseParameter) -> str:
def param_type_fmt(ty: BaseParameter, *, immutable: bool) -> str:
if isinstance(ty, FlagsParameter):
return "int"
elif not isinstance(ty, NormalParameter):
@ -104,7 +104,10 @@ def param_type_fmt(ty: BaseParameter) -> str:
res = "bytes" if inner_ty.name == "Object" else inner_type_fmt(inner_ty)
if ty.ty.generic_arg:
res = f"List[{res}]"
if immutable:
res = f"Sequence[{res}]"
else:
res = f"List[{res}]"
if ty.flag and ty.ty.name != "true":
res = f"Optional[{res}]"