mirror of
https://github.com/LonamiWebs/Telethon.git
synced 2025-02-03 13:14:31 +03:00
Add missing implementations
This commit is contained in:
parent
5f4470dd57
commit
6a880f1ff1
|
@ -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__
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
65
client/src/telethon/_impl/client/errors.py
Normal file
65
client/src/telethon/_impl/client/errors.py
Normal file
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
26
client/src/telethon/_impl/client/types/dialog.py
Normal file
26
client/src/telethon/_impl/client/types/dialog.py
Normal file
|
@ -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)
|
|
@ -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}")
|
||||
|
|
|
@ -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]
|
||||
|
|
67
client/src/telethon/_impl/client/types/participant.py
Normal file
67
client/src/telethon/_impl/client/types/participant.py
Normal file
|
@ -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")
|
|
@ -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")
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
class ErrorFactory:
|
||||
__slots__ = ()
|
||||
|
||||
def __getattribute__(self, name: str) -> ValueError:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
errors = ErrorFactory()
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user