Add client.prepare_album

This commit is contained in:
Lonami Exo 2023-11-07 19:34:10 +01:00
parent ae44426a78
commit 6047c689ca
10 changed files with 531 additions and 150 deletions

View File

@ -37,6 +37,7 @@ from ..events import Event
from ..events.filters import Filter from ..events.filters import Filter
from ..types import ( from ..types import (
AdminRight, AdminRight,
AlbumBuilder,
AsyncList, AsyncList,
Chat, Chat,
ChatLike, ChatLike,
@ -53,8 +54,8 @@ from ..types import (
PasswordToken, PasswordToken,
RecentAction, RecentAction,
User, User,
buttons,
) )
from ..types import buttons as btns
from .auth import ( from .auth import (
bot_sign_in, bot_sign_in,
check_password, check_password,
@ -77,10 +78,12 @@ from .dialogs import delete_dialog, edit_draft, get_dialogs, get_drafts
from .files import ( from .files import (
download, download,
get_file_bytes, get_file_bytes,
prepare_album,
send_audio, send_audio,
send_file, send_file,
send_photo, send_photo,
send_video, send_video,
upload,
) )
from .messages import ( from .messages import (
MessageMap, MessageMap,
@ -1085,6 +1088,34 @@ class Client:
""" """
return await pin_message(self, chat, message_id) 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( async def read_message(
self, chat: ChatLike, message_id: Union[int, Literal["all"]] self, chat: ChatLike, message_id: Union[int, Literal["all"]]
) -> None: ) -> None:
@ -1310,6 +1341,7 @@ class Client:
self, self,
chat: ChatLike, chat: ChatLike,
file: Union[str, Path, InFileLike, File], file: Union[str, Path, InFileLike, File],
mime_type: Optional[str] = None,
*, *,
size: Optional[int] = None, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
@ -1320,6 +1352,8 @@ class Client:
caption: Optional[str] = None, caption: Optional[str] = None,
caption_markdown: Optional[str] = None, caption_markdown: Optional[str] = None,
caption_html: 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:
""" """
Send an audio file. Send an audio file.
@ -1333,6 +1367,7 @@ class Client:
:param file: See :meth:`send_file`. :param file: See :meth:`send_file`.
:param size: See :meth:`send_file`. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`. :param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`. :param duration: See :meth:`send_file`.
:param voice: See :meth:`send_file`. :param voice: See :meth:`send_file`.
:param title: See :meth:`send_file`. :param title: See :meth:`send_file`.
@ -1351,6 +1386,7 @@ class Client:
self, self,
chat, chat,
file, file,
mime_type,
size=size, size=size,
name=name, name=name,
duration=duration, duration=duration,
@ -1360,6 +1396,8 @@ class Client:
caption=caption, caption=caption,
caption_markdown=caption_markdown, caption_markdown=caption_markdown,
caption_html=caption_html, caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
) )
async def send_file( async def send_file(
@ -1386,6 +1424,8 @@ class Client:
caption: Optional[str] = None, caption: Optional[str] = None,
caption_markdown: Optional[str] = None, caption_markdown: Optional[str] = None,
caption_html: 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:
""" """
Send any type of file with any amount of attributes. Send any type of file with any amount of attributes.
@ -1438,8 +1478,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. 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. When given a :term:`file-like object`, if it has a ``.name`` :class:`str` property, it will be used.
The library will not attempt to read any ``name`` attributes the object may have. 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: :param mime_type:
Override for the default mime-type. Override for the default mime-type.
@ -1536,6 +1578,8 @@ class Client:
caption=caption, caption=caption,
caption_markdown=caption_markdown, caption_markdown=caption_markdown,
caption_html=caption_html, caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
) )
async def send_message( async def send_message(
@ -1547,9 +1591,7 @@ class Client:
html: Optional[str] = None, html: Optional[str] = None,
link_preview: bool = False, link_preview: bool = False,
reply_to: Optional[int] = None, reply_to: Optional[int] = None,
buttons: Optional[ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
Union[List[buttons.Button], List[List[buttons.Button]]]
] = None,
) -> Message: ) -> Message:
""" """
Send a message. Send a message.
@ -1594,12 +1636,15 @@ class Client:
*, *,
size: Optional[int] = None, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
mime_type: Optional[str] = None,
compress: bool = True, compress: bool = True,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
caption: Optional[str] = None, caption: Optional[str] = None,
caption_markdown: Optional[str] = None, caption_markdown: Optional[str] = None,
caption_html: 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:
""" """
Send a photo file. Send a photo file.
@ -1617,6 +1662,7 @@ class Client:
:param file: See :meth:`send_file`. :param file: See :meth:`send_file`.
:param size: See :meth:`send_file`. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`. :param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param compress: See :meth:`send_file`. :param compress: See :meth:`send_file`.
:param width: See :meth:`send_file`. :param width: See :meth:`send_file`.
:param height: See :meth:`send_file`. :param height: See :meth:`send_file`.
@ -1636,12 +1682,15 @@ class Client:
file, file,
size=size, size=size,
name=name, name=name,
mime_type=mime_type,
compress=compress, compress=compress,
width=width, width=width,
height=height, height=height,
caption=caption, caption=caption,
caption_markdown=caption_markdown, caption_markdown=caption_markdown,
caption_html=caption_html, caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
) )
async def send_video( async def send_video(
@ -1651,14 +1700,18 @@ class Client:
*, *,
size: Optional[int] = None, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
mime_type: Optional[str] = None,
duration: Optional[float] = None, duration: Optional[float] = None,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
round: bool = False, round: bool = False,
supports_streaming: bool = False, supports_streaming: bool = False,
muted: bool = False,
caption: Optional[str] = None, caption: Optional[str] = None,
caption_markdown: Optional[str] = None, caption_markdown: Optional[str] = None,
caption_html: 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:
""" """
Send a video file. Send a video file.
@ -1672,6 +1725,7 @@ class Client:
:param file: See :meth:`send_file`. :param file: See :meth:`send_file`.
:param size: See :meth:`send_file`. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`. :param name: See :meth:`send_file`.
:param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`. :param duration: See :meth:`send_file`.
:param width: See :meth:`send_file`. :param width: See :meth:`send_file`.
:param height: See :meth:`send_file`. :param height: See :meth:`send_file`.
@ -1693,14 +1747,18 @@ class Client:
file, file,
size=size, size=size,
name=name, name=name,
mime_type=mime_type,
duration=duration, duration=duration,
width=width, width=width,
height=height, height=height,
round=round, round=round,
supports_streaming=supports_streaming, supports_streaming=supports_streaming,
muted=muted,
caption=caption, caption=caption,
caption_markdown=caption_markdown, caption_markdown=caption_markdown,
caption_html=caption_html, caption_html=caption_html,
reply_to=reply_to,
buttons=buttons,
) )
async def set_chat_default_restrictions( async def set_chat_default_restrictions(
@ -1961,6 +2019,11 @@ class Client:
def _input_to_peer(self, input: Optional[abcs.InputPeer]) -> Optional[abcs.Peer]: def _input_to_peer(self, input: Optional[abcs.InputPeer]) -> Optional[abcs.Peer]:
return input_to_peer(self, input) 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: async def __call__(self, request: Request[Return]) -> Return:
if not self._sender: if not self._sender:
raise ConnectionError("not connected") raise ConnectionError("not connected")

View File

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

View File

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

View File

@ -2,85 +2,18 @@ from __future__ import annotations
import datetime import datetime
import sys 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 ...session import PackedChat
from ...tl import abcs, functions, types 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
from ..types import ( from ..types import buttons as btns
AsyncList, from ..types import generate_random_id, parse_message, peer_id
Chat,
ChatLike,
Message,
build_chat_map,
buttons,
generate_random_id,
peer_id,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .client import Client 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( async def send_message(
self: Client, self: Client,
chat: ChatLike, chat: ChatLike,
@ -90,16 +23,39 @@ async def send_message(
html: Optional[str] = None, html: Optional[str] = None,
link_preview: bool = False, link_preview: bool = False,
reply_to: Optional[int] = None, 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: ) -> Message:
packed = await self._resolve_to_packed(chat) packed = await self._resolve_to_packed(chat)
peer = packed._to_input_peer() peer = packed._to_input_peer()
random_id = generate_random_id()
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( message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False text=text, markdown=markdown, html=html, allow_empty=False
) )
random_id = generate_random_id() request = functions.messages.send_message(
result = await self(
functions.messages.send_message(
no_webpage=not link_preview, no_webpage=not link_preview,
silent=False, silent=False,
background=False, background=False,
@ -114,33 +70,13 @@ async def send_message(
else None, else None,
message=message, message=message,
random_id=random_id, random_id=random_id,
reply_markup=build_keyboard(buttons), reply_markup=btns.build_keyboard(buttons),
entities=entities, entities=entities,
schedule_date=None, schedule_date=None,
send_as=None, send_as=None,
) )
if isinstance(message, str)
else functions.messages.send_message( result = await self(request)
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,
)
)
if isinstance(result, types.UpdateShortSentMessage): if isinstance(result, types.UpdateShortSentMessage):
return Message._from_defaults( return Message._from_defaults(
self, self,
@ -161,7 +97,7 @@ async def send_message(
if reply_to if reply_to
else None, else None,
date=result.date, date=result.date,
message=message if isinstance(message, str) else (message.text or ""), message=message,
media=result.media, media=result.media,
entities=result.entities, entities=result.entities,
ttl_period=result.ttl_period, ttl_period=result.ttl_period,
@ -184,7 +120,6 @@ async def edit_message(
message, entities = parse_message( message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False text=text, markdown=markdown, html=html, allow_empty=False
) )
assert isinstance(message, str)
return self._build_message_map( return self._build_message_map(
await self( await self(
functions.messages.edit_message( functions.messages.edit_message(

View File

@ -1,4 +1,5 @@
from .admin_right import AdminRight from .admin_right import AdminRight
from .album_builder import AlbumBuilder
from .async_list import AsyncList from .async_list import AsyncList
from .callback_answer import CallbackAnswer from .callback_answer import CallbackAnswer
from .chat import ( from .chat import (
@ -14,10 +15,23 @@ from .chat import (
from .chat_restriction import ChatRestriction from .chat_restriction import ChatRestriction
from .dialog import Dialog from .dialog import Dialog
from .draft import Draft 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 .inline_result import InlineResult
from .login_token import LoginToken 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 .meta import NoPublicConstructor
from .participant import Participant from .participant import Participant
from .password_token import PasswordToken from .password_token import PasswordToken
@ -25,6 +39,7 @@ from .recent_action import RecentAction
__all__ = [ __all__ = [
"AdminRight", "AdminRight",
"AlbumBuilder",
"AsyncList", "AsyncList",
"ChatRestriction", "ChatRestriction",
"CallbackAnswer", "CallbackAnswer",
@ -43,12 +58,14 @@ __all__ = [
"OutFileLike", "OutFileLike",
"OutWrapper", "OutWrapper",
"expand_stripped_size", "expand_stripped_size",
"try_get_url_path",
"InlineResult", "InlineResult",
"LoginToken", "LoginToken",
"Message", "Message",
"adapt_date", "adapt_date",
"build_msg_map", "build_msg_map",
"generate_random_id", "generate_random_id",
"parse_message",
"NoPublicConstructor", "NoPublicConstructor",
"Participant", "Participant",
"PasswordToken", "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 from __future__ import annotations
import weakref import weakref
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Optional, Union
from ....tl import abcs, types from ....tl import abcs, types
from .button import Button from .button import Button
@ -23,6 +23,40 @@ def as_concrete_row(row: abcs.KeyboardButtonRow) -> types.KeyboardButtonRow:
return row 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: def create_button(message: Message, raw: abcs.KeyboardButton) -> Button:
""" """
Create a custom button from a Telegram button. Create a custom button from a Telegram button.

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import mimetypes import mimetypes
import urllib.parse
from inspect import isawaitable from inspect import isawaitable
from io import BufferedWriter from io import BufferedWriter
from pathlib import Path from pathlib import Path
@ -68,6 +69,15 @@ def photo_size_dimensions(
raise RuntimeError("unexpected case") 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): class InFileLike(Protocol):
""" """
A :term:`file-like object` used for input only. A :term:`file-like object` used for input only.

View File

@ -2,10 +2,15 @@ from __future__ import annotations
import datetime import datetime
import time 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 ...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 .buttons import Button, as_concrete_row, create_button
from .chat import Chat, ChatLike, expand_peer, peer_id from .chat import Chat, ChatLike, expand_peer, peer_id
from .file import File from .file import File
@ -465,3 +470,28 @@ def build_msg_map(
msg.id: msg msg.id: msg
for msg in (Message._from_raw(client, m, chat_map) for m in messages) 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

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