diff --git a/client/src/telethon/__init__.py b/client/src/telethon/__init__.py index eaf37e9d..fb1528e9 100644 --- a/client/src/telethon/__init__.py +++ b/client/src/telethon/__init__.py @@ -1,6 +1,6 @@ from ._impl import tl as _tl from ._impl.client import Client, Config -from ._impl.errors import errors +from ._impl.client.errors import errors from ._impl.mtproto import RpcError from ._impl.session import Session from .version import __version__ diff --git a/client/src/telethon/_impl/client/client/auth.py b/client/src/telethon/_impl/client/client/auth.py index 6a66c7ac..8c6ce071 100644 --- a/client/src/telethon/_impl/client/client/auth.py +++ b/client/src/telethon/_impl/client/client/auth.py @@ -4,8 +4,8 @@ import getpass import re from typing import TYPE_CHECKING, Optional, Union +from ...crypto import two_factor_auth from ...mtproto import RpcError -from ...session import Session from ...session import User as SessionUser from ...tl import abcs, functions, types from ..types import LoginToken, PasswordToken, User @@ -192,8 +192,46 @@ async def get_password_information(client: Client) -> PasswordToken: async def check_password( self: Client, token: PasswordToken, password: Union[str, bytes] ) -> User: - self, token, password - raise NotImplementedError + algo = token._password.current_algo + if not isinstance( + algo, types.PasswordKdfAlgoSha256Sha256Pbkdf2HmacshA512Iter100000Sha256ModPow + ): + raise RuntimeError("unrecognised 2FA algorithm") + + if not two_factor_auth.check_p_and_g(algo.p, algo.g): + token = await get_password_information(self) + if not isinstance( + algo, + types.PasswordKdfAlgoSha256Sha256Pbkdf2HmacshA512Iter100000Sha256ModPow, + ): + raise RuntimeError("unrecognised 2FA algorithm") + if not two_factor_auth.check_p_and_g(algo.p, algo.g): + raise RuntimeError("failed to get correct password information") + + assert token._password.srp_id is not None + assert token._password.srp_B is not None + + two_fa = two_factor_auth.calculate_2fa( + salt1=algo.salt1, + salt2=algo.salt2, + g=algo.g, + p=algo.p, + g_b=token._password.srp_B, + a=token._password.secure_random, + password=password.encode("utf-8") if isinstance(password, str) else password, + ) + + result = await self( + functions.auth.check_password( + password=types.InputCheckPasswordSrp( + srp_id=token._password.srp_id, + A=two_fa.g_a, + M1=two_fa.m1, + ) + ) + ) + + return await complete_login(self, result) async def sign_out(self: Client) -> None: diff --git a/client/src/telethon/_impl/client/client/chats.py b/client/src/telethon/_impl/client/client/chats.py index 3e8ca1f7..5186731b 100644 --- a/client/src/telethon/_impl/client/client/chats.py +++ b/client/src/telethon/_impl/client/client/chats.py @@ -1,11 +1,83 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional + +from ...tl import abcs, functions, types +from ..types import AsyncList, ChatLike, Participant +from ..utils import build_chat_map if TYPE_CHECKING: from .client import Client -def get_participants(self: Client) -> None: - self - raise NotImplementedError +class ParticipantList(AsyncList[Participant]): + def __init__( + self, + client: Client, + chat: ChatLike, + ): + super().__init__() + self._client = client + self._chat = chat + self._peer: Optional[abcs.InputPeer] = None + self._offset = 0 + + async def _fetch_next(self) -> None: + if self._peer is None: + self._peer = ( + await self._client._resolve_to_packed(self._chat) + )._to_input_peer() + + if isinstance(self._peer, types.InputPeerChannel): + result = await self._client( + functions.channels.get_participants( + channel=types.InputChannel( + channel_id=self._peer.channel_id, + access_hash=self._peer.access_hash, + ), + filter=types.ChannelParticipantsRecent(), + offset=self._offset, + limit=200, + hash=0, + ) + ) + assert isinstance(result, types.channels.ChannelParticipants) + + chat_map = build_chat_map(result.users, result.chats) + + self._buffer.extend( + Participant._from_raw_channel(p, chat_map) for p in result.participants + ) + self._total = result.count + + elif isinstance(self._peer, types.InputPeerChat): + result = await self._client( + functions.messages.get_full_chat(chat_id=self._peer.chat_id) # type: ignore [arg-type] + ) + assert isinstance(result, types.messages.ChatFull) + assert isinstance(result.full_chat, types.ChatFull) + + chat_map = build_chat_map(result.users, result.chats) + + participants = result.full_chat.participants + if isinstance(participants, types.ChatParticipantsForbidden): + if participants.self_participant: + self._buffer.append( + Participant._from_raw_chat( + participants.self_participant, chat_map + ) + ) + elif isinstance(participants, types.ChatParticipants): + self._buffer.extend( + Participant._from_raw_chat(p, chat_map) + for p in participants.participants + ) + + self._total = len(self._buffer) + self._done = True + else: + raise TypeError("can only get participants from channels and groups") + + +def get_participants(self: Client, chat: ChatLike) -> AsyncList[Participant]: + return ParticipantList(self, chat) diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 3db7ebb0..5cb7bc6f 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -35,12 +35,13 @@ from ..types import ( AsyncList, Chat, ChatLike, + Dialog, File, InFileLike, LoginToken, - MediaLike, Message, OutFileLike, + Participant, PasswordToken, User, ) @@ -58,7 +59,7 @@ from .chats import get_participants from .dialogs import delete_dialog, get_dialogs from .files import ( download, - iter_download, + get_file_bytes, send_audio, send_file, send_photo, @@ -184,7 +185,7 @@ class Client: self._chat_hashes = ChatHashCache(None) self._last_update_limit_warn: Optional[float] = None self._updates: asyncio.Queue[ - Tuple[abcs.Update, Dict[int, Union[abcs.User, abcs.Chat]]] + Tuple[abcs.Update, Dict[int, Chat]] ] = asyncio.Queue(maxsize=self._config.update_queue_limit or 0) self._dispatcher: Optional[asyncio.Task[None]] = None self._downloader_map = object() @@ -315,8 +316,29 @@ class Client: """ await connect(self) - async def delete_dialog(self) -> None: - await delete_dialog(self) + async def delete_dialog(self, chat: ChatLike) -> None: + """ + Delete a dialog. + + This lets you leave a group, unsubscribe from a channel, or delete a one-to-one private conversation. + + Note that the group or channel will not be deleted. + + Note that bot accounts do not have dialogs, so this method will fail. + + :param chat: + The :term:`chat` representing the dialog to delete. + + .. rubric:: Example + + .. code-block:: python + + async for dialog in client.iter_dialogs(): + if 'dog pictures' in dialog.chat.full_name: + # You've realized you're more of a cat person + await client.delete_dialog(dialog.chat) + """ + await delete_dialog(self, chat) async def delete_messages( self, chat: ChatLike, message_ids: List[int], *, revoke: bool = True @@ -374,12 +396,12 @@ class Client: """ await disconnect(self) - async def download(self, media: MediaLike, file: OutFileLike) -> None: + async def download(self, media: File, file: Union[str, Path, OutFileLike]) -> None: """ Download a file. :param media: - The media to download. + The media file to download. This will often come from :attr:`telethon.types.Message.file`. :param file: @@ -387,6 +409,10 @@ class Client: Note that the extension is not automatically added to the path. You can get the file extension with :attr:`telethon.types.File.ext`. + .. warning:: + + If the file already exists, it will be overwritten! + .. rubric:: Example .. code-block:: python @@ -400,7 +426,7 @@ class Client: .. seealso:: - :meth:`iter_download`, for fine-grained control over the download. + :meth:`get_file_bytes`, for more control over the download. """ await download(self, media, file) @@ -485,7 +511,7 @@ class Client: """ return await forward_messages(self, target, message_ids, source) - async def get_contacts(self) -> AsyncList[User]: + def get_contacts(self) -> AsyncList[User]: """ Get the users in your contact list. @@ -498,10 +524,29 @@ class Client: async for user in client.get_contacts(): print(user.full_name, user.id) """ - return await get_contacts(self) + return get_contacts(self) - def get_dialogs(self) -> None: - get_dialogs(self) + def get_dialogs(self) -> AsyncList[Dialog]: + """ + Get the dialogs you're part of. + + This list of includes the groups you've joined, channels you've subscribed to, and open one-to-one private conversations. + + Note that bot accounts do not have dialogs, so this method will fail. + + :return: Your dialogs. + + .. rubric:: Example + + .. code-block:: python + + async for dialog in client.get_dialogs(): + print( + dialog.chat.full_name, + dialog.last_message.text if dialog.last_message else '' + ) + """ + return get_dialogs(self) def get_handler_filter( self, handler: Callable[[Event], Awaitable[Any]] @@ -564,8 +609,6 @@ class Client: """ Get the message history from a :term:`chat`. - Edit a message. - :param chat: The :term:`chat` where the message to edit is. @@ -604,8 +647,24 @@ class Client: ) -> AsyncList[Message]: return get_messages_with_ids(self, chat, message_ids) - def get_participants(self) -> None: - get_participants(self) + def get_participants(self, chat: ChatLike) -> AsyncList[Participant]: + """ + Get the participants in a group or channel, along with their permissions. + + Note that Telegram is rather strict when it comes to fetching members. + It is very likely that you will not be able to fetch all the members. + There is no way to bypass this. + + :return: The participants. + + .. rubric:: Example + + .. code-block:: python + + async for participant in client.get_participants(chat): + print(participant.user.full_name) + """ + return get_participants(self, chat) async def inline_query( self, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None @@ -674,8 +733,31 @@ class Client: """ return await is_authorized(self) - async def iter_download(self) -> None: - await iter_download(self) + def get_file_bytes(self, media: File) -> AsyncList[bytes]: + """ + Get the contents of an uploaded media file as chunks of :class:`bytes`. + + This lets you iterate over the chunks of a file and print progress while the download occurs. + + If you just want to download a file to disk without printing progress, use :meth:`download` instead. + + :param media: + The media file to download. + This will often come from :attr:`telethon.types.Message.file`. + + .. rubric:: Example + + .. code-block:: python + + if file := message.file: + with open(f'media{file.ext}', 'wb') as fd: + downloaded = 0 + async for chunk in client.get_file_bytes(file): + downloaded += len(chunk) + fd.write(chunk) + print(f'Downloaded {downloaded // 1024}/{file.size // 1024} KiB') + """ + return get_file_bytes(self, media) def on( self, event_cls: Type[Event], filter: Optional[Filter] = None @@ -815,8 +897,8 @@ class Client: """ return await resolve_to_packed(self, chat) - async def resolve_username(self) -> Chat: - return await resolve_username(self) + async def resolve_username(self, username: str) -> Chat: + return await resolve_username(self, username) async def run_until_disconnected(self) -> None: await run_until_disconnected(self) diff --git a/client/src/telethon/_impl/client/client/dialogs.py b/client/src/telethon/_impl/client/client/dialogs.py index 97627d96..7ab8ee1a 100644 --- a/client/src/telethon/_impl/client/client/dialogs.py +++ b/client/src/telethon/_impl/client/client/dialogs.py @@ -2,15 +2,79 @@ from __future__ import annotations from typing import TYPE_CHECKING +from ...tl import abcs, functions, types +from ..types import AsyncList, ChatLike, Dialog, User +from ..utils import build_chat_map + if TYPE_CHECKING: from .client import Client -def get_dialogs(self: Client) -> None: - self - raise NotImplementedError +class DialogList(AsyncList[Dialog]): + def __init__(self, client: Client): + super().__init__() + self._client = client + self._offset = 0 + + async def _fetch_next(self) -> None: + result = await self._client( + functions.messages.get_dialogs( + exclude_pinned=False, + folder_id=None, + offset_date=0, + offset_id=0, + offset_peer=types.InputPeerEmpty(), + limit=0, + hash=0, + ) + ) + + if isinstance(result, types.messages.Dialogs): + self._total = len(result.dialogs) + self._done = True + elif isinstance(result, types.messages.DialogsSlice): + self._total = result.count + else: + raise RuntimeError("unexpected case") + + assert isinstance(result, (types.messages.Dialogs, types.messages.DialogsSlice)) + + chat_map = build_chat_map(result.users, result.chats) + + self._buffer.extend(Dialog._from_raw(d, chat_map) for d in result.dialogs) -async def delete_dialog(self: Client) -> None: - self - raise NotImplementedError +def get_dialogs(self: Client) -> AsyncList[Dialog]: + return DialogList(self) + + +async def delete_dialog(self: Client, chat: ChatLike) -> None: + peer = (await self._resolve_to_packed(chat))._to_input_peer() + if isinstance(peer, types.InputPeerChannel): + await self( + functions.channels.leave_channel( + channel=types.InputChannel( + channel_id=peer.channel_id, + access_hash=peer.access_hash, + ) + ) + ) + elif isinstance(peer, types.InputPeerChat): + await self( + functions.messages.delete_chat_user( + revoke_history=False, + chat_id=peer.chat_id, + user_id=types.InputUserSelf(), + ) + ) + elif isinstance(peer, types.InputPeerUser): + await self( + functions.messages.delete_history( + just_clear=False, + revoke=False, + peer=peer, + max_id=0, + min_date=None, + max_date=None, + ) + ) diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py index cd68245f..3f1c5fa0 100644 --- a/client/src/telethon/_impl/client/client/files.py +++ b/client/src/telethon/_impl/client/client/files.py @@ -1,11 +1,23 @@ from __future__ import annotations +import asyncio import hashlib +from functools import partial +from inspect import isawaitable +from io import BufferedWriter from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Any, Coroutine, Optional, Tuple, Union from ...tl import abcs, functions, types -from ..types import ChatLike, File, InFileLike, MediaLike, Message, OutFileLike +from ..types import ( + AsyncList, + ChatLike, + File, + InFileLike, + Message, + OutFileLike, + OutWrapper, +) from ..utils import generate_random_id from .messages import parse_message @@ -222,73 +234,102 @@ async def upload( hash_md5 = hashlib.md5() is_big = file._size > BIG_FILE_SIZE - while uploaded != file._size: - chunk = await file._read(MAX_CHUNK_SIZE - len(buffer)) - if not chunk: - raise ValueError("unexpected end-of-file") + fd = file._open() + try: + while uploaded != file._size: + chunk = await fd.read(MAX_CHUNK_SIZE - len(buffer)) + if not chunk: + raise ValueError("unexpected end-of-file") - if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == file._size: - to_store = chunk - else: - buffer += chunk - if len(buffer) == MAX_CHUNK_SIZE: - to_store = buffer + if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == file._size: + to_store = chunk else: - continue + buffer += chunk + if len(buffer) == MAX_CHUNK_SIZE: + to_store = buffer + else: + continue - if is_big: - await client( - functions.upload.save_big_file_part( - file_id=file_id, - file_part=part, - file_total_parts=part, - bytes=to_store, + if is_big: + await client( + functions.upload.save_big_file_part( + file_id=file_id, + file_part=part, + file_total_parts=part, + bytes=to_store, + ) ) - ) - else: - await client( - functions.upload.save_file_part( - file_id=file_id, file_part=total_parts, bytes=to_store + else: + await client( + functions.upload.save_file_part( + file_id=file_id, file_part=total_parts, bytes=to_store + ) ) - ) - hash_md5.update(to_store) + hash_md5.update(to_store) - buffer.clear() - part += 1 + buffer.clear() + part += 1 + finally: + fd.close() if file._size > BIG_FILE_SIZE: - types.InputFileBig( + return types.InputFileBig( id=file_id, parts=total_parts, name=file._name, ) else: - types.InputFile( + return types.InputFile( id=file_id, parts=total_parts, name=file._name, md5_checksum=hash_md5.hexdigest(), ) - raise NotImplementedError -async def iter_download(self: Client) -> None: - raise NotImplementedError - # result = self( - # functions.upload.get_file( - # precise=False, - # cdn_supported=False, - # location=types.InputFileLocation(), - # offset=0, - # limit=MAX_CHUNK_SIZE, - # ) - # ) - # assert isinstance(result, types.upload.File) - # if len(result.bytes) < MAX_CHUNK_SIZE: - # done - # else: - # offset += MAX_CHUNK_SIZE +class FileBytesList(AsyncList[bytes]): + def __init__( + self, + client: Client, + file: File, + ): + super().__init__() + self._client = client + self._loc = file._input_location() + self._offset = 0 + + async def _fetch_next(self) -> None: + result = await self._client( + functions.upload.get_file( + precise=False, + cdn_supported=False, + location=self._loc, + offset=self._offset, + limit=MAX_CHUNK_SIZE, + ) + ) + assert isinstance(result, types.upload.File) + + if result.bytes: + self._offset += MAX_CHUNK_SIZE + self._buffer.append(result.bytes) + + self._done = len(result.bytes) < MAX_CHUNK_SIZE -async def download(self: Client, media: MediaLike, file: OutFileLike) -> None: - raise NotImplementedError +def get_file_bytes(self: Client, media: File) -> AsyncList[bytes]: + return FileBytesList(self, media) + + +async def download( + self: Client, media: File, file: Union[str, Path, OutFileLike] +) -> None: + fd = OutWrapper(file) + try: + async for chunk in get_file_bytes(self, media): + ret = fd.write(chunk) + if isawaitable(ret): + await ret + + finally: + fd.close() diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index 2ee7d71f..91cedbc1 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -7,8 +7,8 @@ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union from ...session import PackedChat from ...tl import abcs, functions, types from ..parsers import parse_html_message, parse_markdown_message -from ..types import AsyncList, ChatLike, Message -from ..utils import generate_random_id +from ..types import AsyncList, Chat, ChatLike, Message +from ..utils import build_chat_map, generate_random_id, peer_id if TYPE_CHECKING: from .client import Client @@ -147,30 +147,47 @@ async def forward_messages( class MessageList(AsyncList[Message]): - def _extend_buffer(self, client: Client, messages: abcs.messages.Messages) -> None: + def _extend_buffer( + self, client: Client, messages: abcs.messages.Messages + ) -> Dict[int, Chat]: if isinstance(messages, types.messages.Messages): - self._buffer.extend(Message._from_raw(m) for m in messages.messages) + chat_map = build_chat_map(messages.users, messages.chats) + self._buffer.extend( + Message._from_raw(client, m, chat_map) for m in messages.messages + ) self._total = len(messages.messages) self._done = True + return chat_map elif isinstance(messages, types.messages.MessagesSlice): - self._buffer.extend(Message._from_raw(m) for m in messages.messages) + chat_map = build_chat_map(messages.users, messages.chats) + self._buffer.extend( + Message._from_raw(client, m, chat_map) for m in messages.messages + ) self._total = messages.count + return chat_map elif isinstance(messages, types.messages.ChannelMessages): - self._buffer.extend(Message._from_raw(m) for m in messages.messages) + chat_map = build_chat_map(messages.users, messages.chats) + self._buffer.extend( + Message._from_raw(client, m, chat_map) for m in messages.messages + ) self._total = messages.count + return chat_map elif isinstance(messages, types.messages.MessagesNotModified): self._total = messages.count + return {} else: raise RuntimeError("unexpected case") - def _last_non_empty_message(self) -> Message: + def _last_non_empty_message( + self, + ) -> Union[types.Message, types.MessageService, types.MessageEmpty]: return next( ( - m + m._raw for m in reversed(self._buffer) if not isinstance(m._raw, types.MessageEmpty) ), - Message._from_raw(types.MessageEmpty(id=0, peer_id=None)), + types.MessageEmpty(id=0, peer_id=None), ) @@ -192,6 +209,8 @@ class HistoryList(MessageList): self._offset_id = offset_id self._offset_date = offset_date + self._done = limit <= 0 + async def _fetch_next(self) -> None: if self._peer is None: self._peer = ( @@ -213,10 +232,11 @@ class HistoryList(MessageList): self._extend_buffer(self._client, result) self._limit -= len(self._buffer) + self._done = not self._limit if self._buffer: last = self._last_non_empty_message() self._offset_id = self._buffer[-1].id - if (date := getattr(last._raw, "date", None)) is not None: + if (date := getattr(last, "date", None)) is not None: self._offset_date = date @@ -331,7 +351,7 @@ class SearchList(MessageList): if self._buffer: last = self._last_non_empty_message() self._offset_id = self._buffer[-1].id - if (date := getattr(last._raw, "date", None)) is not None: + if (date := getattr(last, "date", None)) is not None: self._offset_date = date @@ -388,20 +408,21 @@ class GlobalSearchList(MessageList): ) ) - self._extend_buffer(self._client, result) + chat_map = self._extend_buffer(self._client, result) self._limit -= len(self._buffer) if self._buffer: last = self._last_non_empty_message() - last_packed = last.chat.pack() self._offset_id = self._buffer[-1].id - if (date := getattr(last._raw, "date", None)) is not None: + if (date := getattr(last, "date", None)) is not None: self._offset_date = date if isinstance(result, types.messages.MessagesSlice): self._offset_rate = result.next_rate or 0 - self._offset_peer = ( - last_packed._to_input_peer() if last_packed else types.InputPeerEmpty() - ) + + self._offset_peer = types.InputPeerEmpty() + if last.peer_id and (chat := chat_map.get(peer_id(last.peer_id))): + if packed := chat.pack(): + self._offset_peer = packed._to_input_peer() def search_all_messages( @@ -483,7 +504,9 @@ class MessageMap: def _empty(self, id: int = 0) -> Message: return Message._from_raw( - types.MessageEmpty(id=id, peer_id=self._client._input_to_peer(self._peer)) + self._client, + types.MessageEmpty(id=id, peer_id=self._client._input_to_peer(self._peer)), + {}, ) @@ -492,13 +515,12 @@ def build_message_map( result: abcs.Updates, peer: Optional[abcs.InputPeer], ) -> MessageMap: - if isinstance(result, types.UpdateShort): - updates = [result.update] - entities: Dict[int, object] = {} - elif isinstance(result, (types.Updates, types.UpdatesCombined)): + if isinstance(result, (types.Updates, types.UpdatesCombined)): updates = result.updates - entities = {} - raise NotImplementedError() + chat_map = build_chat_map(result.users, result.chats) + elif isinstance(result, types.UpdateShort): + updates = [result.update] + chat_map = {} else: return MessageMap(client, peer, {}, {}) @@ -518,14 +540,8 @@ def build_message_map( types.UpdateNewScheduledMessage, ), ): - assert isinstance( - update.message, - (types.Message, types.MessageService, types.MessageEmpty), - ) - id_to_message[update.message.id] = Message._from_raw(update.message) - - elif isinstance(update, types.UpdateMessagePoll): - raise NotImplementedError() + msg = Message._from_raw(client, update.message, chat_map) + id_to_message[msg.id] = msg return MessageMap( client, diff --git a/client/src/telethon/_impl/client/client/net.py b/client/src/telethon/_impl/client/client/net.py index 41fd04c8..86911deb 100644 --- a/client/src/telethon/_impl/client/client/net.py +++ b/client/src/telethon/_impl/client/client/net.py @@ -14,6 +14,7 @@ from ...mtsender import connect_with_auth from ...session import DataCenter, Session from ...session import User as SessionUser from ...tl import LAYER, Request, functions +from ..errors import adapt_rpc from .updates import dispatcher, process_socket_updates if TYPE_CHECKING: @@ -217,7 +218,7 @@ async def invoke_request( rx = sender.enqueue(request) continue else: - raise + raise adapt_rpc(e) from None return request.deserialize_response(response) diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index b900a31b..0eb4be0e 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -19,6 +19,7 @@ from ...session import Gap from ...tl import abcs from ..events import Event as EventBase from ..events.filters import Filter +from ..utils import build_chat_map if TYPE_CHECKING: from .client import Client @@ -107,14 +108,11 @@ def extend_update_queue( users: List[abcs.User], chats: List[abcs.Chat], ) -> None: - entities: Dict[int, Union[abcs.User, abcs.Chat]] = { - getattr(u, "id", None) or 0: u for u in users - } - entities.update({getattr(c, "id", None) or 0: c for c in chats}) + chat_map = build_chat_map(users, chats) for update in updates: try: - client._updates.put_nowait((update, entities)) + client._updates.put_nowait((update, chat_map)) except asyncio.QueueFull: now = asyncio.get_running_loop().time() if client._last_update_limit_warn is None or ( @@ -128,9 +126,9 @@ def extend_update_queue( async def dispatcher(client: Client) -> None: while client.connected: - update, entities = await client._updates.get() + update, chat_map = await client._updates.get() for event_cls, handlers in client._handlers.items(): - if event := event_cls._try_from_update(client, update): + if event := event_cls._try_from_update(client, update, chat_map): for handler, filter in handlers: if not filter or filter(event): try: diff --git a/client/src/telethon/_impl/client/client/users.py b/client/src/telethon/_impl/client/client/users.py index 0d74b7df..3b23e4a4 100644 --- a/client/src/telethon/_impl/client/client/users.py +++ b/client/src/telethon/_impl/client/client/users.py @@ -2,27 +2,67 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional +from ...mtproto import RpcError from ...session import PackedChat, PackedType -from ...tl import abcs, types +from ...tl import abcs, functions, types from ..types import AsyncList, Channel, Chat, ChatLike, Group, User +from ..utils import build_chat_map, peer_id if TYPE_CHECKING: from .client import Client async def get_me(self: Client) -> Optional[User]: - self - raise NotImplementedError + try: + result = await self(functions.users.get_users(id=[types.InputUserSelf()])) + except RpcError as e: + if e.code == 401: + return None + else: + raise + + assert len(result) == 1 + return User._from_raw(result[0]) -async def get_contacts(self: Client) -> AsyncList[User]: - self - raise NotImplementedError +class ContactList(AsyncList[User]): + def __init__(self, client: Client): + super().__init__() + self._client = client + + async def _fetch_next(self) -> None: + result = await self._client(functions.contacts.get_contacts(hash=0)) + assert isinstance(result, types.contacts.Contacts) + + self._buffer.extend(User._from_raw(u) for u in result.users) + self._total = len(self._buffer) + self._done = True -async def resolve_username(self: Client) -> Chat: - self - raise NotImplementedError +def get_contacts(self: Client) -> AsyncList[User]: + return ContactList(self) + + +def resolved_peer_to_chat(resolved: abcs.contacts.ResolvedPeer) -> Chat: + assert isinstance(resolved, types.contacts.ResolvedPeer) + + map = build_chat_map(resolved.users, resolved.chats) + if chat := map.get(peer_id(resolved.peer)): + return chat + else: + raise ValueError(f"no matching chat found in response") + + +async def resolve_phone(client: Client, phone: str) -> Chat: + return resolved_peer_to_chat( + await client(functions.contacts.resolve_phone(phone=phone)) + ) + + +async def resolve_username(self: Client, username: str) -> Chat: + return resolved_peer_to_chat( + await self(functions.contacts.resolve_username(username=username)) + ) async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: @@ -68,6 +108,24 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: else: raise RuntimeError("unexpected case") + if isinstance(chat, str): + if chat.startswith("+"): + resolved = await resolve_phone(self, chat) + elif chat == "me": + if me := self._config.session.user: + return PackedChat( + ty=PackedType.BOT if me.bot else PackedType.USER, + id=me.id, + access_hash=0, + ) + else: + resolved = None + else: + resolved = await resolve_username(self, username=chat) + + if resolved and (packed := resolved.pack()) is not None: + return packed + raise ValueError("Cannot resolve chat") diff --git a/client/src/telethon/_impl/client/errors.py b/client/src/telethon/_impl/client/errors.py new file mode 100644 index 00000000..07f104bc --- /dev/null +++ b/client/src/telethon/_impl/client/errors.py @@ -0,0 +1,65 @@ +import re +from typing import Dict, Tuple, Type + +from ..mtproto import RpcError + + +def canonicalize_code(code: int) -> int: + return abs(code) # -503 Timeout -> 503 + + +def canonicalize_name(name: str) -> str: + return name.upper() # -503 Timeout -> TIMEOUT + + +def adapt_user_name(name: str) -> str: + return re.sub(r"[A-Z]", lambda m: "_" + m[0].lower(), name).strip("_").upper() + + +def pretty_name(name: str) -> str: + return "".join(map(str.title, name.split("_"))) + + +def from_code(code: int, *, _cache: Dict[int, Type[RpcError]] = {}) -> Type[RpcError]: + code = canonicalize_code(code) + if code not in _cache: + _cache[code] = type(f"Code{code}", (RpcError,), {}) + return _cache[code] + + +def from_name(name: str, *, _cache: Dict[str, Type[RpcError]] = {}) -> Type[RpcError]: + name = canonicalize_name(name) + if name not in _cache: + _cache[name] = type(pretty_name(name), (RpcError,), {}) + return _cache[name] + + +def adapt_rpc( + error: RpcError, *, _cache: Dict[Tuple[int, str], Type[RpcError]] = {} +) -> RpcError: + code = canonicalize_code(error.code) + name = canonicalize_name(error.name) + tup = code, name + if tup not in _cache: + _cache[tup] = type(pretty_name(name), (from_code(code), from_name(name)), {}) + return _cache[tup]( + code=error.code, name=error.name, value=error.value, caused_by=error._caused_by + ) + + +class ErrorFactory: + __slots__ = () + + def __getattr__(self, name: str) -> Type[RpcError]: + if m := re.match(r"Code(\d+)$", name): + return from_code(int(m[1])) + else: + adapted = adapt_user_name(name) + if pretty_name(canonicalize_name(adapted)) != name or re.match( + r"[A-Z]{2}", name + ): + raise AttributeError(f"error subclass names must be CamelCase: {name}") + return from_name(adapted) + + +errors = ErrorFactory() diff --git a/client/src/telethon/_impl/client/events/event.py b/client/src/telethon/_impl/client/events/event.py index 8ad81801..6294ce15 100644 --- a/client/src/telethon/_impl/client/events/event.py +++ b/client/src/telethon/_impl/client/events/event.py @@ -1,10 +1,10 @@ from __future__ import annotations import abc -from typing import TYPE_CHECKING, Optional, Self +from typing import TYPE_CHECKING, Dict, Optional, Self from ...tl import abcs -from ..types.meta import NoPublicConstructor +from ..types import Chat, NoPublicConstructor if TYPE_CHECKING: from ..client.client import Client @@ -17,5 +17,7 @@ class Event(metaclass=NoPublicConstructor): @classmethod @abc.abstractmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: pass diff --git a/client/src/telethon/_impl/client/events/filters/common.py b/client/src/telethon/_impl/client/events/filters/common.py index e2b9c499..874edb23 100644 --- a/client/src/telethon/_impl/client/events/filters/common.py +++ b/client/src/telethon/_impl/client/events/filters/common.py @@ -1,4 +1,4 @@ -from typing import Callable, Literal, Sequence, Tuple, Union +from typing import Callable, Literal, Sequence, Tuple, Type, Union from ...types import Channel, Group, User from ..event import Event @@ -66,7 +66,7 @@ class ChatType: type: Union[Literal["user"], Literal["group"], Literal["broadcast"]], ) -> None: if type == "user": - self._type: Union[User, Group, Channel] = User + self._type: Union[Type[User], Type[Group], Type[Channel]] = User elif type == "group": self._type = Group elif type == "broadcast": @@ -85,6 +85,8 @@ class ChatType: return "group" elif self._type == Channel: return "broadcast" + else: + raise RuntimeError("unexpected case") def __call__(self, event: Event) -> bool: sender = getattr(event, "chat", None) diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index dbf97df4..824ac96c 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -1,8 +1,13 @@ +from __future__ import annotations + import re -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from ..event import Event +if TYPE_CHECKING: + from ...client import Client + class Text: """ @@ -34,17 +39,46 @@ class Command: filter ``Command('/help')`` will match both ``"/help"`` and ``"/help@bot"``, but not ``"/list"`` or ``"/help@other"``. - Note that the leading forward-slash is not automatically added, - which allows for using a different prefix or no prefix at all. + .. note:: + + The leading forward-slash is not automatically added! + This allows for using a different prefix or no prefix at all. + + .. note:: + + The username is taken from the :term:`session` to avoid network calls. + If a custom storage returns the incorrect username, the filter will misbehave. + If there is no username, then the ``"/help@other"`` syntax will be ignored. """ - __slots__ = ("_cmd",) + __slots__ = ("_cmd", "_username") def __init__(self, command: str) -> None: + if re.match(r"\s", command): + raise ValueError(f"command cannot contain spaces: {command}") + self._cmd = command + self._username: Optional[str] = None def __call__(self, event: Event) -> bool: - raise NotImplementedError + text: Optional[str] = getattr(event, "text", None) + if not text: + return False + + if self._username is None: + self._username = "" + client: Optional[Client] + if (client := getattr(event, "_client", None)) is not None: + user = client._config.session.user + if user and user.username: + self._username = user.username + + cmd = text.split(maxsplit=1)[0] + if cmd == self._cmd: + return True + if self._username: + return cmd == f"{self._cmd}@{self._username}" + return False class Incoming: diff --git a/client/src/telethon/_impl/client/events/messages.py b/client/src/telethon/_impl/client/events/messages.py index d58b4153..027c73d2 100644 --- a/client/src/telethon/_impl/client/events/messages.py +++ b/client/src/telethon/_impl/client/events/messages.py @@ -1,13 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Self +from typing import TYPE_CHECKING, Dict, List, Optional, Self -from ...session.message_box.adaptor import ( - update_short_chat_message, - update_short_message, -) from ...tl import abcs, types -from ..types import Message +from ..types import Chat, Message from .event import Event if TYPE_CHECKING: @@ -26,10 +22,12 @@ class NewMessage(Event, Message): """ @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: if isinstance(update, (types.UpdateNewMessage, types.UpdateNewChannelMessage)): if isinstance(update.message, types.Message): - return cls._from_raw(update.message) + return cls._from_raw(client, update.message, chat_map) elif isinstance( update, (types.UpdateShortMessage, types.UpdateShortChatMessage) ): @@ -38,24 +36,49 @@ class NewMessage(Event, Message): return None -class MessageEdited(Event): +class MessageEdited(Event, Message): """ Occurs when a new message is sent or received. """ @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: - raise NotImplementedError() + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: + if isinstance( + update, (types.UpdateEditMessage, types.UpdateEditChannelMessage) + ): + return cls._from_raw(client, update.message, chat_map) + else: + return None class MessageDeleted(Event): """ Occurs when one or more messages are deleted. + + .. note:: + + Telegram does not send the contents of the deleted messages. + Because they are deleted, it's also impossible to fetch them. + + The chat is only known when the deletion occurs in broadcast channels or supergroups. """ + def __init__(self, msg_ids: List[int], channel_id: Optional[int]) -> None: + self._msg_ids = msg_ids + self._channel_id = channel_id + @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: - raise NotImplementedError() + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: + if isinstance(update, types.UpdateDeleteMessages): + return cls._create(update.messages, None) + elif isinstance(update, types.UpdateDeleteChannelMessages): + return cls._create(update.messages, update.channel_id) + else: + return None class MessageRead(Event): @@ -63,6 +86,26 @@ 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 + @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: - raise NotImplementedError() + 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 + ) + else: + return None diff --git a/client/src/telethon/_impl/client/events/queries.py b/client/src/telethon/_impl/client/events/queries.py index b7455b87..3582a659 100644 --- a/client/src/telethon/_impl/client/events/queries.py +++ b/client/src/telethon/_impl/client/events/queries.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Self +from typing import TYPE_CHECKING, Dict, Optional, Self -from ...tl import abcs +from ...tl import abcs, types +from ..types import Chat from .event import Event if TYPE_CHECKING: @@ -16,9 +17,17 @@ class CallbackQuery(Event): Only bot accounts can receive this event. """ + def __init__(self, update: types.UpdateBotCallbackQuery): + self._update = update + @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: - raise NotImplementedError() + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: + if isinstance(update, types.UpdateBotCallbackQuery): + return cls._create(update) + else: + return None class InlineQuery(Event): @@ -28,6 +37,14 @@ class InlineQuery(Event): Only bot accounts can receive this event. """ + def __init__(self, update: types.UpdateBotInlineQuery): + self._update = update + @classmethod - def _try_from_update(cls, client: Client, update: abcs.Update) -> Optional[Self]: - raise NotImplementedError() + def _try_from_update( + cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] + ) -> Optional[Self]: + if isinstance(update, types.UpdateBotInlineQuery): + return cls._create(update) + else: + return None diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py index 6360105e..406db0bc 100644 --- a/client/src/telethon/_impl/client/types/__init__.py +++ b/client/src/telethon/_impl/client/types/__init__.py @@ -1,9 +1,11 @@ from .async_list import AsyncList from .chat import Channel, Chat, ChatLike, Group, RestrictionReason, User -from .file import File, InFileLike, MediaLike, OutFileLike +from .dialog import Dialog +from .file import File, InFileLike, InWrapper, OutFileLike, OutWrapper from .login_token import LoginToken from .message import Message from .meta import NoPublicConstructor +from .participant import Participant from .password_token import PasswordToken __all__ = [ @@ -14,12 +16,15 @@ __all__ = [ "Group", "RestrictionReason", "User", + "Dialog", "File", "InFileLike", - "MediaLike", + "InWrapper", "OutFileLike", + "OutWrapper", "LoginToken", "Message", "NoPublicConstructor", + "Participant", "PasswordToken", ] diff --git a/client/src/telethon/_impl/client/types/dialog.py b/client/src/telethon/_impl/client/types/dialog.py new file mode 100644 index 00000000..aff46fe0 --- /dev/null +++ b/client/src/telethon/_impl/client/types/dialog.py @@ -0,0 +1,26 @@ +from typing import Dict, List, Optional, Self + +from ...session import PackedChat, PackedType +from ...tl import abcs, types +from .chat import Chat +from .meta import NoPublicConstructor + + +class Dialog(metaclass=NoPublicConstructor): + """ + A dialog. + + This represents an open conversation your chat list. + + This includes the groups you've joined, channels you've subscribed to, and open one-to-one private conversations. + """ + + __slots__ = ("_raw", "_chat_map") + + def __init__(self, raw: abcs.Dialog, chat_map: Dict[int, Chat]) -> None: + self._raw = raw + self._chat_map = chat_map + + @classmethod + def _from_raw(cls, dialog: abcs.Dialog, chat_map: Dict[int, Chat]) -> Self: + return cls._create(dialog, chat_map) diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py index f28670d9..1d073b54 100644 --- a/client/src/telethon/_impl/client/types/file.py +++ b/client/src/telethon/_impl/client/types/file.py @@ -1,7 +1,10 @@ import os +from inspect import isawaitable +from io import BufferedReader, BufferedWriter from mimetypes import guess_type from pathlib import Path -from typing import Any, Coroutine, List, Optional, Protocol, Self, Union +from types import TracebackType +from typing import Any, Coroutine, List, Optional, Protocol, Self, Type, Union from ...tl import abcs, types from .meta import NoPublicConstructor @@ -29,9 +32,6 @@ def photo_size_byte_count(size: abcs.PhotoSize) -> int: raise RuntimeError("unexpected case") -MediaLike = object - - class InFileLike(Protocol): """ A :term:`file-like object` used for input only. @@ -52,6 +52,57 @@ class OutFileLike(Protocol): pass +class InWrapper: + __slots__ = ("_fd", "_owned") + + def __init__(self, file: Union[str, Path, InFileLike]): + if isinstance(file, str): + file = Path(file) + + if isinstance(file, Path): + self._fd: Union[InFileLike, BufferedReader] = file.open("rb") + self._owned = True + else: + self._fd = file + self._owned = False + + async def read(self, n: int) -> bytes: + ret = self._fd.read(n) + chunk = await ret if isawaitable(ret) else ret + assert isinstance(chunk, bytes) + return chunk + + def close(self) -> None: + if self._owned: + assert hasattr(self._fd, "close") + self._fd.close() + + +class OutWrapper: + __slots__ = ("_fd", "_owned") + + def __init__(self, file: Union[str, Path, OutFileLike]): + if isinstance(file, str): + file = Path(file) + + if isinstance(file, Path): + self._fd: Union[OutFileLike, BufferedWriter] = file.open("wb") + self._owned = True + else: + self._fd = file + self._owned = False + + async def write(self, chunk: bytes) -> None: + ret = self._fd.write(chunk) + if isawaitable(ret): + await ret + + def close(self) -> None: + if self._owned: + assert hasattr(self._fd, "close") + self._fd.close() + + class File(metaclass=NoPublicConstructor): """ File information of uploaded media. @@ -264,8 +315,6 @@ class File(metaclass=NoPublicConstructor): preload_prefix_size=None, ) ) - else: - raise NotImplementedError("sticker") photo = compress and mime_type.startswith("image/") @@ -296,8 +345,37 @@ class File(metaclass=NoPublicConstructor): ) @property - def ext(self): - raise NotImplementedError + def ext(self) -> str: + return self._path.suffix if self._path else "" - async def _read(self, n: int) -> bytes: - raise NotImplementedError + def _open(self) -> InWrapper: + file = self._file or self._path + if file is None: + raise TypeError(f"cannot use file for uploading: {self}") + return InWrapper(file) + + def _input_location(self) -> abcs.InputFileLocation: + if isinstance(self._input_media, types.InputMediaDocument): + assert isinstance(self._input_media.id, types.InputDocument) + return types.InputDocumentFileLocation( + id=self._input_media.id.id, + access_hash=self._input_media.id.access_hash, + file_reference=self._input_media.id.file_reference, + thumb_size="", + ) + elif isinstance(self._input_media, types.InputMediaPhoto): + assert isinstance(self._input_media.id, types.InputPhoto) + assert isinstance(self._raw, types.MessageMediaPhoto) + assert isinstance(self._raw.photo, types.Photo) + + size = max(self._raw.photo.sizes, key=photo_size_byte_count) + assert hasattr(size, "type") + + return types.InputPhotoFileLocation( + id=self._input_media.id.id, + access_hash=self._input_media.id.access_hash, + file_reference=self._input_media.id.file_reference, + thumb_size=size.type, + ) + else: + raise TypeError(f"cannot use file for downloading: {self}") diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index b2ca1cc5..1cb46188 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -1,28 +1,41 @@ +from __future__ import annotations + import datetime -from typing import Optional, Self +from typing import TYPE_CHECKING, Dict, Optional, Self from ...tl import abcs, types -from .chat import Chat +from ..parsers import generate_html_message, generate_markdown_message +from ..utils import expand_peer, peer_id +from .chat import Chat, ChatLike from .file import File from .meta import NoPublicConstructor +if TYPE_CHECKING: + from ..client import Client + class Message(metaclass=NoPublicConstructor): """ A sent message. """ - __slots__ = ("_raw",) + __slots__ = ("_client", "_raw", "_chat_map") - def __init__(self, message: abcs.Message) -> None: + def __init__( + self, client: Client, message: abcs.Message, chat_map: Dict[int, Chat] + ) -> None: assert isinstance( message, (types.Message, types.MessageService, types.MessageEmpty) ) + self._client = client self._raw = message + self._chat_map = chat_map @classmethod - def _from_raw(cls, message: abcs.Message) -> Self: - return cls._create(message) + def _from_raw( + cls, client: Client, message: abcs.Message, chat_map: Dict[int, Chat] + ) -> Self: + return cls._create(client, message, chat_map) @property def id(self) -> int: @@ -34,11 +47,21 @@ class Message(metaclass=NoPublicConstructor): @property def text_html(self) -> Optional[str]: - raise NotImplementedError + if text := getattr(self._raw, "message", None): + return generate_html_message( + text, getattr(self._raw, "entities", None) or [] + ) + else: + return None @property def text_markdown(self) -> Optional[str]: - raise NotImplementedError + if text := getattr(self._raw, "message", None): + return generate_markdown_message( + text, getattr(self._raw, "entities", None) or [] + ) + else: + return None @property def date(self) -> Optional[datetime.datetime]: @@ -51,11 +74,20 @@ class Message(metaclass=NoPublicConstructor): @property def chat(self) -> Chat: - raise NotImplementedError + peer = self._raw.peer_id or types.PeerUser(user_id=0) + broadcast = broadcast = getattr(self._raw, "post", None) + return self._chat_map.get(peer_id(peer)) or expand_peer( + peer, broadcast=broadcast + ) @property - def sender(self) -> Chat: - raise NotImplementedError + def sender(self) -> Optional[Chat]: + if (from_ := getattr(self._raw, "from_id", None)) is not None: + return self._chat_map.get(peer_id(from_)) or expand_peer( + from_, broadcast=getattr(self._raw, "post", None) + ) + else: + return None def _file(self) -> Optional[File]: return ( @@ -97,11 +129,39 @@ class Message(metaclass=NoPublicConstructor): def file(self) -> Optional[File]: return self._file() - async def delete(self) -> None: - raise NotImplementedError + async def delete(self, *, revoke: bool = True) -> int: + """ + Alias for :meth:`telethon.Client.delete_messages`. - async def edit(self) -> None: - raise NotImplementedError + See the documentation of :meth:`~telethon.Client.delete_messages` for an explanation of the parameters. + """ + return await self._client.delete_messages(self.chat, [self.id], revoke=revoke) - async def forward_to(self) -> None: - raise NotImplementedError + async def edit( + self, + text: Optional[str] = None, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: Optional[bool] = None, + ) -> Message: + """ + Alias for :meth:`telethon.Client.edit_message`. + + See the documentation of :meth:`~telethon.Client.edit_message` for an explanation of the parameters. + """ + return await self._client.edit_message( + self.chat, + self.id, + text=text, + markdown=markdown, + html=html, + link_preview=link_preview, + ) + + async def forward_to(self, target: ChatLike) -> Message: + """ + Alias for :meth:`telethon.Client.forward_messages`. + + See the documentation of :meth:`~telethon.Client.forward_messages` for an explanation of the parameters. + """ + return (await self._client.forward_messages(target, [self.id], self.chat))[0] diff --git a/client/src/telethon/_impl/client/types/participant.py b/client/src/telethon/_impl/client/types/participant.py new file mode 100644 index 00000000..fd41910e --- /dev/null +++ b/client/src/telethon/_impl/client/types/participant.py @@ -0,0 +1,67 @@ +from typing import Dict, List, Optional, Self, Union + +from ...session import PackedChat, PackedType +from ...tl import abcs, types +from .chat import Chat +from .meta import NoPublicConstructor + + +class Participant(metaclass=NoPublicConstructor): + """ + A participant in a chat, including the corresponding user and permissions. + """ + + __slots__ = ("_raw", "_chat_map") + + def __init__( + self, + participant: Union[ + types.ChannelParticipant, + types.ChannelParticipantSelf, + types.ChannelParticipantCreator, + types.ChannelParticipantAdmin, + types.ChannelParticipantBanned, + types.ChannelParticipantLeft, + types.ChatParticipant, + types.ChatParticipantCreator, + types.ChatParticipantAdmin, + ], + chat_map: Dict[int, Chat], + ) -> None: + self._raw = participant + self._chat_map = chat_map + + @classmethod + def _from_raw_channel( + cls, participant: abcs.ChannelParticipant, chat_map: Dict[int, Chat] + ) -> Self: + if isinstance( + participant, + ( + types.ChannelParticipant, + types.ChannelParticipantSelf, + types.ChannelParticipantCreator, + types.ChannelParticipantAdmin, + types.ChannelParticipantBanned, + types.ChannelParticipantLeft, + ), + ): + return cls._create(participant, chat_map) + else: + raise RuntimeError("unexpected case") + + @classmethod + def _from_raw_chat( + cls, participant: abcs.ChatParticipant, chat_map: Dict[int, Chat] + ) -> Self: + if isinstance( + participant, + ( + types.ChatParticipant, + types.ChatParticipantCreator, + types.ChatParticipantAdmin, + ), + ): + return cls._create(participant, chat_map) + else: + raise RuntimeError("unexpected case") diff --git a/client/src/telethon/_impl/client/utils.py b/client/src/telethon/_impl/client/utils.py index 8753dbd9..1fba9fa2 100644 --- a/client/src/telethon/_impl/client/utils.py +++ b/client/src/telethon/_impl/client/utils.py @@ -1,4 +1,21 @@ +import itertools +import sys import time +from collections import defaultdict +from typing import ( + Any, + Callable, + DefaultDict, + Dict, + Iterator, + List, + Optional, + Union, + cast, +) + +from ..tl import abcs, types +from .types import Channel, Chat, Group, User _last_id = 0 @@ -9,3 +26,74 @@ def generate_random_id() -> int: _last_id = int(time.time() * 1e9) _last_id += 1 return _last_id + + +def build_chat_map(users: List[abcs.User], chats: List[abcs.Chat]) -> Dict[int, Chat]: + users_iter = (User._from_raw(u) for u in users) + chats_iter = ( + Channel._from_raw(c) + if isinstance(c, (types.Channel, types.ChannelForbidden)) and c.broadcast + else Group._from_raw(c) + for c in chats + ) + + # https://github.com/python/mypy/issues/2115 + result: Dict[int, Chat] = { + c.id: c for c in itertools.chain(users_iter, chats_iter) # type: ignore [attr-defined, misc] + } + + if len(result) != len(users) + len(chats): + # The fabled ID collision between different chat types. + counter: DefaultDict[int, List[Union[abcs.User, abcs.Chat]]] = defaultdict(list) + for user in users: + if (id := getattr(user, "id", None)) is not None: + counter[id].append(user) + for chat in chats: + if (id := getattr(chat, "id", None)) is not None: + counter[id].append(chat) + + for k, v in counter.items(): + if len(v) > 1: + # TODO proper logger + for x in v: + print(x, file=sys.stderr) + + raise RuntimeError( + f"chat identifier collision: {k}; please report this" + ) + + return result + + +def peer_id(peer: abcs.Peer) -> int: + if isinstance(peer, types.PeerUser): + return peer.user_id + elif isinstance(peer, types.PeerChat): + return peer.chat_id + elif isinstance(peer, types.PeerChannel): + return peer.channel_id + else: + raise RuntimeError("unexpected case") + + +def expand_peer(peer: abcs.Peer, *, broadcast: Optional[bool]) -> Chat: + if isinstance(peer, types.PeerUser): + return User._from_raw(types.UserEmpty(id=peer.user_id)) + elif isinstance(peer, types.PeerChat): + return Group._from_raw(types.ChatEmpty(id=peer.chat_id)) + elif isinstance(peer, types.PeerChannel): + if broadcast is None: + broadcast = True # assume broadcast by default (Channel type is more accurate than Group) + + channel = types.ChannelForbidden( + broadcast=broadcast, + megagroup=not broadcast, + id=peer.channel_id, + access_hash=0, + title="", + until_date=None, + ) + + return Channel._from_raw(channel) if broadcast else Group._from_raw(channel) + else: + raise RuntimeError("unexpected case") diff --git a/client/src/telethon/_impl/errors.py b/client/src/telethon/_impl/errors.py deleted file mode 100644 index 41e5b525..00000000 --- a/client/src/telethon/_impl/errors.py +++ /dev/null @@ -1,8 +0,0 @@ -class ErrorFactory: - __slots__ = () - - def __getattribute__(self, name: str) -> ValueError: - raise NotImplementedError - - -errors = ErrorFactory() diff --git a/client/src/telethon/_impl/mtproto/mtp/types.py b/client/src/telethon/_impl/mtproto/mtp/types.py index dde413ad..bf13638b 100644 --- a/client/src/telethon/_impl/mtproto/mtp/types.py +++ b/client/src/telethon/_impl/mtproto/mtp/types.py @@ -30,7 +30,7 @@ class RpcError(ValueError): value: Optional[int] = None, caused_by: Optional[int] = None, ) -> None: - append_value = f" ({value})" if value else None + append_value = f" ({value})" if value else "" super().__init__(f"rpc error {code}: {name}{append_value}") self._code = code diff --git a/client/src/telethon/_impl/session/storage/sqlite.py b/client/src/telethon/_impl/session/storage/sqlite.py index f16e9097..d4fe85c7 100644 --- a/client/src/telethon/_impl/session/storage/sqlite.py +++ b/client/src/telethon/_impl/session/storage/sqlite.py @@ -38,7 +38,9 @@ class SqliteSession(Storage): if version == 7: session = self._load_v7(c) else: - raise NotImplementedError + raise ValueError( + "only migration from sqlite session format 7 supported" + ) self._reset(c) self._get_or_init_version(c) diff --git a/client/src/telethon/types.py b/client/src/telethon/types.py index 4e9504c0..6612ed84 100644 --- a/client/src/telethon/types.py +++ b/client/src/telethon/types.py @@ -4,13 +4,14 @@ from ._impl.client.types import ( Channel, Chat, ChatLike, + Dialog, File, Group, InFileLike, LoginToken, - MediaLike, Message, OutFileLike, + Participant, PasswordToken, RestrictionReason, User, @@ -24,13 +25,14 @@ __all__ = [ "Channel", "Chat", "ChatLike", + "Dialog", "File", "Group", "InFileLike", "LoginToken", - "MediaLike", "Message", "OutFileLike", + "Participant", "PasswordToken", "RestrictionReason", "User",