Merge pull request #843 from LonamiWebs/core-rewrite

Core rewrite
This commit is contained in:
Lonami
2018-06-14 16:25:28 +02:00
committed by GitHub
51 changed files with 4839 additions and 5040 deletions

View File

@@ -1,4 +1 @@
from .tlobject import TLObject
from .gzip_packed import GzipPacked
from .tl_message import TLMessage
from .message_container import MessageContainer
from .tlobject import TLObject, TLRequest

View File

@@ -0,0 +1,26 @@
"""
This module holds core "special" types, which are more convenient ways
to do stuff in a `telethon.network.mtprotosender.MTProtoSender` instance.
Only special cases are gzip-packed data, the response message (not a
client message), the message container which references these messages
and would otherwise conflict with the rest, and finally the RpcResult:
rpc_result#f35c6d01 req_msg_id:long result:bytes = RpcResult;
Three things to note with this definition:
1. The constructor ID is actually ``42d36c2c``.
2. Those bytes are not read like the rest of bytes (length + payload).
They are actually the raw bytes of another object, which can't be
read directly because it depends on per-request information (since
some can return ``Vector<int>`` and ``Vector<long>``).
3. Those bytes may be gzipped data, which needs to be treated early.
"""
from .tlmessage import TLMessage
from .gzippacked import GzipPacked
from .messagecontainer import MessageContainer
from .rpcresult import RpcResult
core_objects = {x.CONSTRUCTOR_ID: x for x in (
GzipPacked, MessageContainer, RpcResult
)}

View File

@@ -1,7 +1,7 @@
import gzip
import struct
from . import TLObject
from .. import TLObject, TLRequest
class GzipPacked(TLObject):
@@ -21,7 +21,7 @@ class GzipPacked(TLObject):
"""
data = bytes(request)
# TODO This threshold could be configurable
if request.content_related and len(data) > 512:
if isinstance(request, TLRequest) and len(data) > 512:
gzipped = bytes(GzipPacked(data))
return gzipped if len(gzipped) < len(data) else data
else:
@@ -36,3 +36,7 @@ class GzipPacked(TLObject):
def read(reader):
assert reader.read_int(signed=False) == GzipPacked.CONSTRUCTOR_ID
return gzip.decompress(reader.tgread_bytes())
@classmethod
def from_reader(cls, reader):
return GzipPacked(gzip.decompress(reader.tgread_bytes()))

View File

@@ -0,0 +1,50 @@
import logging
import struct
from .tlmessage import TLMessage
from ..tlobject import TLObject
__log__ = logging.getLogger(__name__)
class MessageContainer(TLObject):
CONSTRUCTOR_ID = 0x73f1f8dc
def __init__(self, messages):
self.messages = messages
def to_dict(self, recursive=True):
return {
'messages':
([] if self.messages is None else [
None if x is None else x.to_dict() for x in self.messages
]) if recursive else self.messages,
}
def __bytes__(self):
return struct.pack(
'<Ii', MessageContainer.CONSTRUCTOR_ID, len(self.messages)
) + b''.join(bytes(m) for m in self.messages)
def __str__(self):
return TLObject.pretty_format(self)
def stringify(self):
return TLObject.pretty_format(self, indent=0)
@classmethod
def from_reader(cls, reader):
# This assumes that .read_* calls are done in the order they appear
messages = []
for _ in range(reader.read_int()):
msg_id = reader.read_long()
seq_no = reader.read_int()
length = reader.read_int()
before = reader.tell_position()
obj = reader.tgread_object()
messages.append(TLMessage(msg_id, seq_no, obj))
if reader.tell_position() != before + length:
reader.set_position(before)
__log__.warning('Data left after TLObject {}: {!r}'
.format(obj, reader.read(length)))
return MessageContainer(messages)

View File

@@ -0,0 +1,23 @@
from .gzippacked import GzipPacked
from ..types import RpcError
class RpcResult:
CONSTRUCTOR_ID = 0xf35c6d01
def __init__(self, req_msg_id, body, error):
self.req_msg_id = req_msg_id
self.body = body
self.error = error
@classmethod
def from_reader(cls, reader):
msg_id = reader.read_long()
inner_code = reader.read_int(signed=False)
if inner_code == RpcError.CONSTRUCTOR_ID:
return RpcResult(msg_id, None, RpcError.from_reader(reader))
if inner_code == GzipPacked.CONSTRUCTOR_ID:
return RpcResult(msg_id, GzipPacked.from_reader(reader).data, None)
reader.seek(-4)
return RpcResult(msg_id, reader.read(), None)

View File

@@ -0,0 +1,59 @@
import asyncio
import struct
from .gzippacked import GzipPacked
from .. import TLObject
from ..functions import InvokeAfterMsgRequest
class TLMessage(TLObject):
"""
https://core.telegram.org/mtproto/service_messages#simple-container.
Messages are what's ultimately sent to Telegram:
message msg_id:long seqno:int bytes:int body:bytes = Message;
Each message has its own unique identifier, and the body is simply
the serialized request that should be executed on the server. Then
Telegram will, at some point, respond with the result for this msg.
Thus it makes sense that requests and their result are bound to a
sent `TLMessage`, and this result can be represented as a `Future`
that will eventually be set with either a result, error or cancelled.
"""
def __init__(self, msg_id, seq_no, obj=None, after_id=0):
super().__init__()
self.msg_id = msg_id
self.seq_no = seq_no
self.obj = obj
self.container_msg_id = None
self.future = asyncio.Future()
# After which message ID this one should run. We do this so
# InvokeAfterMsgRequest is transparent to the user and we can
# easily invoke after while confirming the original request.
self.after_id = after_id
def to_dict(self, recursive=True):
return {
'msg_id': self.msg_id,
'seq_no': self.seq_no,
'obj': self.obj,
'container_msg_id': self.container_msg_id,
'after_id': self.after_id
}
def __bytes__(self):
if self.after_id is None:
body = GzipPacked.gzip_if_smaller(self.obj)
else:
body = GzipPacked.gzip_if_smaller(
InvokeAfterMsgRequest(self.after_id, self.obj))
return struct.pack('<qii', self.msg_id, self.seq_no, len(body)) + body
def __str__(self):
return TLObject.pretty_format(self)
def stringify(self):
return TLObject.pretty_format(self, indent=0)

View File

@@ -88,12 +88,13 @@ class Dialog:
)
self.is_channel = isinstance(self.entity, types.Channel)
def send_message(self, *args, **kwargs):
async def send_message(self, *args, **kwargs):
"""
Sends a message to this dialog. This is just a wrapper around
``client.send_message(dialog.input_entity, *args, **kwargs)``.
"""
return self._client.send_message(self.input_entity, *args, **kwargs)
return await self._client.send_message(
self.input_entity, *args, **kwargs)
def to_dict(self):
return {

View File

@@ -47,18 +47,18 @@ class Draft:
return cls(client=client, peer=update.peer, draft=update.draft)
@property
def entity(self):
async def entity(self):
"""
The entity that belongs to this dialog (user, chat or channel).
"""
return self._client.get_entity(self._peer)
return await self._client.get_entity(self._peer)
@property
def input_entity(self):
async def input_entity(self):
"""
Input version of the entity.
"""
return self._client.get_input_entity(self._peer)
return await self._client.get_input_entity(self._peer)
@property
def text(self):
@@ -83,8 +83,9 @@ class Draft:
"""
return not self._text
def set_message(self, text=None, reply_to=0, parse_mode=Default,
link_preview=None):
async def set_message(
self, text=None, reply_to=0, parse_mode=Default,
link_preview=None):
"""
Changes the draft message on the Telegram servers. The changes are
reflected in this object.
@@ -110,8 +111,10 @@ class Draft:
if link_preview is None:
link_preview = self.link_preview
raw_text, entities = self._client._parse_message_text(text, parse_mode)
result = self._client(SaveDraftRequest(
raw_text, entities =\
await self._client._parse_message_text(text, parse_mode)
result = await self._client(SaveDraftRequest(
peer=self._peer,
message=raw_text,
no_webpage=not link_preview,
@@ -128,22 +131,22 @@ class Draft:
return result
def send(self, clear=True, parse_mode=Default):
async def send(self, clear=True, parse_mode=Default):
"""
Sends the contents of this draft to the dialog. This is just a
wrapper around ``send_message(dialog.input_entity, *args, **kwargs)``.
"""
self._client.send_message(self._peer, self.text,
reply_to=self.reply_to_msg_id,
link_preview=self.link_preview,
parse_mode=parse_mode,
clear_draft=clear)
await self._client.send_message(
self._peer, self.text, reply_to=self.reply_to_msg_id,
link_preview=self.link_preview, parse_mode=parse_mode,
clear_draft=clear
)
def delete(self):
async def delete(self):
"""
Deletes this draft, and returns ``True`` on success.
"""
return self.set_message(text='')
return await self.set_message(text='')
def to_dict(self):
try:

View File

@@ -134,14 +134,15 @@ class Message:
if isinstance(self.original_message, types.MessageService):
return self.original_message.action
def _reload_message(self):
async def _reload_message(self):
"""
Re-fetches this message to reload the sender and chat entities,
along with their input versions.
"""
try:
chat = self.input_chat if self.is_channel else None
msg = self._client.get_messages(chat, ids=self.original_message.id)
chat = await self.input_chat if self.is_channel else None
msg = await self._client.get_messages(
chat, ids=self.original_message.id)
except ValueError:
return # We may not have the input chat/get message failed
if not msg:
@@ -153,7 +154,7 @@ class Message:
self._input_chat = msg._input_chat
@property
def sender(self):
async def sender(self):
"""
This (:tl:`User`) may make an API call the first time to get
the most up to date version of the sender (mostly when the event
@@ -163,22 +164,24 @@ class Message:
"""
if self._sender is None:
try:
self._sender = self._client.get_entity(self.input_sender)
self._sender =\
await self._client.get_entity(await self.input_sender)
except ValueError:
self._reload_message()
await self._reload_message()
return self._sender
@property
def chat(self):
async def chat(self):
if self._chat is None:
try:
self._chat = self._client.get_entity(self.input_chat)
self._chat =\
await self._client.get_entity(await self.input_chat)
except ValueError:
self._reload_message()
await self._reload_message()
return self._chat
@property
def input_sender(self):
async def input_sender(self):
"""
This (:tl:`InputPeer`) is the input version of the user who
sent the message. Similarly to `input_chat`, this doesn't have
@@ -194,14 +197,14 @@ class Message:
self._input_sender = get_input_peer(self._sender)
else:
try:
self._input_sender = self._client.get_input_entity(
self._input_sender = await self._client.get_input_entity(
self.original_message.from_id)
except ValueError:
self._reload_message()
await self._reload_message()
return self._input_sender
@property
def input_chat(self):
async def input_chat(self):
"""
This (:tl:`InputPeer`) is the input version of the chat where the
message was sent. Similarly to `input_sender`, this doesn't have
@@ -214,14 +217,14 @@ class Message:
if self._input_chat is None:
if self._chat is None:
try:
self._chat = self._client.get_input_entity(
self._chat = await self._client.get_input_entity(
self.original_message.to_id)
except ValueError:
# There's a chance that the chat is a recent new dialog.
# The input chat cannot rely on ._reload_message() because
# said method may need the input chat.
target = self.chat_id
for d in self._client.iter_dialogs(100):
async for d in self._client.iter_dialogs(100):
if d.id == target:
self._chat = d.entity
break
@@ -269,24 +272,26 @@ class Message:
return bool(self.original_message.reply_to_msg_id)
@property
def buttons(self):
async def buttons(self):
"""
Returns a matrix (list of lists) containing all buttons of the message
as `telethon.tl.custom.messagebutton.MessageButton` instances.
"""
if self._buttons is None and self.original_message.reply_markup:
sender = await self.input_sender
chat = await self.input_chat
if isinstance(self.original_message.reply_markup, (
types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)):
self._buttons = [[
MessageButton(self._client, button, self.input_sender,
self.input_chat, self.original_message.id)
MessageButton(self._client, button, sender, chat,
self.original_message.id)
for button in row.buttons
] for row in self.original_message.reply_markup.rows]
self._buttons_flat = [x for row in self._buttons for x in row]
return self._buttons
@property
def button_count(self):
async def button_count(self):
"""
Returns the total button count.
"""
@@ -386,7 +391,7 @@ class Message:
return self.original_message.out
@property
def reply_message(self):
async def reply_message(self):
"""
The `telethon.tl.custom.message.Message` that this message is replying
to, or ``None``.
@@ -397,15 +402,15 @@ class Message:
if self._reply_message is None:
if not self.original_message.reply_to_msg_id:
return None
self._reply_message = self._client.get_messages(
self.input_chat if self.is_channel else None,
self._reply_message = await self._client.get_messages(
await self.input_chat if self.is_channel else None,
ids=self.original_message.reply_to_msg_id
)
return self._reply_message
@property
def fwd_from_entity(self):
async def fwd_from_entity(self):
"""
If the :tl:`Message` is a forwarded message, returns the :tl:`User`
or :tl:`Channel` who originally sent the message, or ``None``.
@@ -414,32 +419,33 @@ class Message:
if getattr(self.original_message, 'fwd_from', None):
fwd = self.original_message.fwd_from
if fwd.from_id:
self._fwd_from_entity = self._client.get_entity(
fwd.from_id)
self._fwd_from_entity =\
await self._client.get_entity(fwd.from_id)
elif fwd.channel_id:
self._fwd_from_entity = self._client.get_entity(
self._fwd_from_entity = await self._client.get_entity(
get_peer_id(types.PeerChannel(fwd.channel_id)))
return self._fwd_from_entity
def respond(self, *args, **kwargs):
async def respond(self, *args, **kwargs):
"""
Responds to the message (not as a reply). Shorthand for
`telethon.telegram_client.TelegramClient.send_message` with
``entity`` already set.
"""
return self._client.send_message(self.input_chat, *args, **kwargs)
return await self._client.send_message(
await self.input_chat, *args, **kwargs)
def reply(self, *args, **kwargs):
async def reply(self, *args, **kwargs):
"""
Replies to the message (as a reply). Shorthand for
`telethon.telegram_client.TelegramClient.send_message` with
both ``entity`` and ``reply_to`` already set.
"""
kwargs['reply_to'] = self.original_message.id
return self._client.send_message(self.original_message.to_id,
*args, **kwargs)
return await self._client.send_message(
await self.input_chat, *args, **kwargs)
def forward_to(self, *args, **kwargs):
async def forward_to(self, *args, **kwargs):
"""
Forwards the message. Shorthand for
`telethon.telegram_client.TelegramClient.forward_messages` with
@@ -450,10 +456,10 @@ class Message:
`telethon.telegram_client.TelegramClient` instance directly.
"""
kwargs['messages'] = self.original_message.id
kwargs['from_peer'] = self.input_chat
return self._client.forward_messages(*args, **kwargs)
kwargs['from_peer'] = await self.input_chat
return await self._client.forward_messages(*args, **kwargs)
def edit(self, *args, **kwargs):
async def edit(self, *args, **kwargs):
"""
Edits the message iff it's outgoing. Shorthand for
`telethon.telegram_client.TelegramClient.edit_message` with
@@ -471,10 +477,10 @@ class Message:
if self.original_message.to_id.user_id != me.user_id:
return None
return self._client.edit_message(
self.input_chat, self.original_message, *args, **kwargs)
return await self._client.edit_message(
await self.input_chat, self.original_message, *args, **kwargs)
def delete(self, *args, **kwargs):
async def delete(self, *args, **kwargs):
"""
Deletes the message. You're responsible for checking whether you
have the permission to do so, or to except the error otherwise.
@@ -486,17 +492,17 @@ class Message:
this `delete` method. Use a
`telethon.telegram_client.TelegramClient` instance directly.
"""
return self._client.delete_messages(
self.input_chat, [self.original_message], *args, **kwargs)
return await self._client.delete_messages(
await self.input_chat, [self.original_message], *args, **kwargs)
def download_media(self, *args, **kwargs):
async def download_media(self, *args, **kwargs):
"""
Downloads the media contained in the message, if any.
`telethon.telegram_client.TelegramClient.download_media` with
the ``message`` already set.
"""
return self._client.download_media(self.original_message,
*args, **kwargs)
return await self._client.download_media(
self.original_message, *args, **kwargs)
def get_entities_text(self, cls=None):
"""
@@ -525,12 +531,10 @@ class Message:
self.original_message.entities)
return list(zip(self.original_message.entities, texts))
def click(self, i=None, j=None, *, text=None, filter=None):
async def click(self, i=None, j=None, *, text=None, filter=None):
"""
Clicks the inline keyboard button of the message, if any.
If the message has a non-inline keyboard, clicking it will
send the message, switch to inline, or open its URL.
Calls `telethon.tl.custom.messagebutton.MessageButton.click`
for the specified button.
Does nothing if the message has no buttons.
@@ -571,32 +575,32 @@ class Message:
if sum(int(x is not None) for x in (i, text, filter)) >= 2:
raise ValueError('You can only set either of i, text or filter')
if not self.buttons:
if not await self.buttons:
return # Accessing the property sets self._buttons[_flat]
if text is not None:
if callable(text):
for button in self._buttons_flat:
if text(button.text):
return button.click()
return await button.click()
else:
for button in self._buttons_flat:
if button.text == text:
return button.click()
return await button.click()
return
if filter is not None:
for button in self._buttons_flat:
if filter(button):
return button.click()
return await button.click()
return
if i is None:
i = 0
if j is None:
return self._buttons_flat[i].click()
return await self._buttons_flat[i].click()
else:
return self._buttons[i][j].click()
return await self._buttons[i][j].click()
class _CustomMessage(Message, types.Message):

View File

@@ -1,4 +1,5 @@
from .. import types, functions
from ...errors import BotTimeout
import webbrowser
@@ -51,23 +52,37 @@ class MessageButton:
if isinstance(self.button, types.KeyboardButtonUrl):
return self.button.url
def click(self):
async def click(self):
"""
Clicks the inline keyboard button of the message, if any.
Emulates the behaviour of clicking this button.
If the message has a non-inline keyboard, clicking it will
send the message, switch to inline, or open its URL.
If it's a normal :tl:`KeyboardButton` with text, a message will be
sent, and the sent `telethon.tl.custom.message.Message` returned.
If it's an inline :tl:`KeyboardButtonCallback` with text and data,
it will be "clicked" and the :tl:`BotCallbackAnswer` returned.
If it's an inline :tl:`KeyboardButtonSwitchInline` button, the
:tl:`StartBotRequest` will be invoked and the resulting updates
returned.
If it's a :tl:`KeyboardButtonUrl`, the URL of the button will
be passed to ``webbrowser.open`` and return ``True`` on success.
"""
if isinstance(self.button, types.KeyboardButton):
return self._client.send_message(
return await self._client.send_message(
self._chat, self.button.text, reply_to=self._msg_id)
elif isinstance(self.button, types.KeyboardButtonCallback):
return self._client(functions.messages.GetBotCallbackAnswerRequest(
req = functions.messages.GetBotCallbackAnswerRequest(
peer=self._chat, msg_id=self._msg_id, data=self.button.data
), retries=1)
)
try:
return await self._client(req)
except BotTimeout:
return None
elif isinstance(self.button, types.KeyboardButtonSwitchInline):
return self._client(functions.messages.StartBotRequest(
return await self._client(functions.messages.StartBotRequest(
bot=self._from, peer=self._chat, start_param=self.button.query
), retries=1)
))
elif isinstance(self.button, types.KeyboardButtonUrl):
return webbrowser.open(self.button.url)

View File

@@ -1,42 +0,0 @@
import struct
from . import TLObject
class MessageContainer(TLObject):
CONSTRUCTOR_ID = 0x73f1f8dc
def __init__(self, messages):
super().__init__()
self.content_related = False
self.messages = messages
def to_dict(self, recursive=True):
return {
'content_related': self.content_related,
'messages':
([] if self.messages is None else [
None if x is None else x.to_dict() for x in self.messages
]) if recursive else self.messages,
}
def __bytes__(self):
return struct.pack(
'<Ii', MessageContainer.CONSTRUCTOR_ID, len(self.messages)
) + b''.join(bytes(m) for m in self.messages)
@staticmethod
def iter_read(reader):
reader.read_int(signed=False) # code
size = reader.read_int()
for _ in range(size):
inner_msg_id = reader.read_long()
inner_sequence = reader.read_int()
inner_length = reader.read_int()
yield inner_msg_id, inner_sequence, inner_length
def __str__(self):
return TLObject.pretty_format(self)
def stringify(self):
return TLObject.pretty_format(self, indent=0)

View File

@@ -1,44 +0,0 @@
import struct
from . import TLObject, GzipPacked
from ..tl.functions import InvokeAfterMsgRequest
class TLMessage(TLObject):
"""https://core.telegram.org/mtproto/service_messages#simple-container"""
def __init__(self, session, request, after_id=None):
super().__init__()
del self.content_related
self.msg_id = session.get_new_msg_id()
self.seq_no = session.generate_sequence(request.content_related)
self.request = request
self.container_msg_id = None
# After which message ID this one should run. We do this so
# InvokeAfterMsgRequest is transparent to the user and we can
# easily invoke after while confirming the original request.
self.after_id = after_id
def to_dict(self, recursive=True):
return {
'msg_id': self.msg_id,
'seq_no': self.seq_no,
'request': self.request,
'container_msg_id': self.container_msg_id,
'after_id': self.after_id
}
def __bytes__(self):
if self.after_id is None:
body = GzipPacked.gzip_if_smaller(self.request)
else:
body = GzipPacked.gzip_if_smaller(
InvokeAfterMsgRequest(self.after_id, self.request))
return struct.pack('<qii', self.msg_id, self.seq_no, len(body)) + body
def __str__(self):
return TLObject.pretty_format(self)
def stringify(self):
return TLObject.pretty_format(self, indent=0)

View File

@@ -1,45 +1,13 @@
import struct
from datetime import datetime, date
from threading import Event
class TLObject:
def __init__(self):
self.rpc_error = None
self.result = None
# These should be overrode
self.content_related = False # Only requests/functions/queries are
# Internal parameter to tell pickler in which state Event object was
self._event_is_set = False
self._set_event()
def _set_event(self):
self.confirm_received = Event()
# Set Event state to 'set' if needed
if self._event_is_set:
self.confirm_received.set()
def __getstate__(self):
# Save state of the Event object
self._event_is_set = self.confirm_received.is_set()
# Exclude Event object from dict and return new state
new_dct = dict(self.__dict__)
del new_dct["confirm_received"]
return new_dct
def __setstate__(self, state):
self.__dict__ = state
self._set_event()
# These should not be overrode
@staticmethod
def pretty_format(obj, indent=None):
"""Pretty formats the given object as a string which is returned.
If indent is None, a single line will be returned.
"""
Pretty formats the given object as a string which is returned.
If indent is None, a single line will be returned.
"""
if indent is None:
if isinstance(obj, TLObject):
@@ -163,10 +131,6 @@ class TLObject:
raise TypeError('Cannot interpret "{}" as a date.'.format(dt))
# These are nearly always the same for all subclasses
def on_response(self, reader):
self.result = reader.tgread_object()
def __eq__(self, o):
return isinstance(o, type(self)) and self.to_dict() == o.to_dict()
@@ -179,16 +143,24 @@ class TLObject:
def stringify(self):
return TLObject.pretty_format(self, indent=0)
# These should be overrode
def resolve(self, client, utils):
pass
def to_dict(self):
return {}
raise NotImplementedError
def __bytes__(self):
return b''
raise NotImplementedError
@classmethod
def from_reader(cls, reader):
return TLObject()
raise NotImplementedError
class TLRequest(TLObject):
"""
Represents a content-related `TLObject` (a request that can be sent).
"""
@staticmethod
def read_result(reader):
return reader.tgread_object()
async def resolve(self, client, utils):
pass