Merge branch 'master' into asyncio

This commit is contained in:
Lonami Exo 2018-02-16 18:42:09 +01:00
commit 6e854325a8
17 changed files with 371 additions and 241 deletions

View File

@ -207,6 +207,13 @@ def get_description(arg):
desc.append('This argument can be omitted.') desc.append('This argument can be omitted.')
otherwise = True otherwise = True
if arg.type in {'InputPeer', 'InputUser', 'InputChannel'}:
desc.append(
'Anything entity-like will work if the library can find its '
'<code>Input</code> version (e.g., usernames, <code>Peer</code>, '
'<code>User</code> or <code>Channel</code> objects, etc.).'
)
if arg.is_vector: if arg.is_vector:
if arg.is_generic: if arg.is_generic:
desc.append('A list of other Requests must be supplied.') desc.append('A list of other Requests must be supplied.')
@ -221,7 +228,11 @@ def get_description(arg):
desc.insert(1, 'Otherwise,') desc.insert(1, 'Otherwise,')
desc[-1] = desc[-1][:1].lower() + desc[-1][1:] desc[-1] = desc[-1][:1].lower() + desc[-1][1:]
return ' '.join(desc) return ' '.join(desc).replace(
'list',
'<span class="tooltip" title="Any iterable that supports len() '
'will work too">list</span>'
)
def copy_replace(src, dst, replacements): def copy_replace(src, dst, replacements):

View File

@ -108,6 +108,10 @@ span.sh4 {
color: #06c; color: #06c;
} }
span.tooltip {
border-bottom: 1px dashed #444;
}
#searchBox { #searchBox {
width: 100%; width: 100%;
border: none; border: none;

View File

@ -95,6 +95,11 @@ is just a matter of taste, and how much control you need.
Remember that you can get yourself at any time with ``client.get_me()``. Remember that you can get yourself at any time with ``client.get_me()``.
.. warning::
Please note that if you fail to login around 5 times (or change the first
parameter of the ``TelegramClient``, which is the session name) you will
receive a ``FloodWaitError`` of around 22 hours, so be careful not to mess
this up! This shouldn't happen if you're doing things as explained, though.
.. note:: .. note::
If you want to use a **proxy**, you have to `install PySocks`__ If you want to use a **proxy**, you have to `install PySocks`__

View File

@ -14,6 +14,41 @@ it can take advantage of new goodies!
.. contents:: List of All Versions .. contents:: List of All Versions
New small convenience functions (v0.17.2)
=========================================
*Published at 2018/02/15*
Primarily bug fixing and a few welcomed additions.
Additions
~~~~~~~~~
- New convenience ``.edit_message()`` method on the ``TelegramClient``.
- New ``.edit()`` and ``.delete()`` shorthands on the ``NewMessage`` event.
- Default to markdown parsing when sending and editing messages.
- Support for inline mentions when sending and editing messages. They work
like inline urls (e.g. ``[text](@username)``) and also support the Bot-API
style (see `here <https://core.telegram.org/bots/api#formatting-options>`__).
Bug fixes
~~~~~~~~~
- Periodically send ``GetStateRequest`` automatically to keep the server
sending updates even if you're not invoking any request yourself.
- HTML parsing was failing due to not handling surrogates properly.
- ``.sign_up`` was not accepting ``int`` codes.
- Whitelisting more than one chat on ``events`` wasn't working.
- Video files are sent as a video by default unless ``force_document``.
Internal changes
~~~~~~~~~~~~~~~~
- More ``logging`` calls to help spot some bugs in the future.
- Some more logic to retrieve input entities on events.
- Clarified a few parts of the documentation.
Updates as Events (v0.17.1) Updates as Events (v0.17.1)
=========================== ===========================

View File

@ -3,86 +3,90 @@ AES IGE implementation in Python. This module may use libssl if available.
""" """
import os import os
import pyaes import pyaes
from . import libssl
try:
import cryptg
except ImportError:
cryptg = None
if libssl.AES is not None: class AES:
# Use libssl if available, since it will be faster """
AES = libssl.AES Class that servers as an interface to encrypt and decrypt
else: text through the AES IGE mode.
# Fallback to a pure Python implementation """
class AES: @staticmethod
def decrypt_ige(cipher_text, key, iv):
""" """
Class that servers as an interface to encrypt and decrypt Decrypts the given text in 16-bytes blocks by using the
text through the AES IGE mode. given key and 32-bytes initialization vector.
""" """
@staticmethod if cryptg:
def decrypt_ige(cipher_text, key, iv): return cryptg.decrypt_ige(cipher_text, key, iv)
"""
Decrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
aes = pyaes.AES(key) iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
plain_text = [] aes = pyaes.AES(key)
blocks_count = len(cipher_text) // 16
cipher_text_block = [0] * 16 plain_text = []
for block_index in range(blocks_count): blocks_count = len(cipher_text) // 16
for i in range(16):
cipher_text_block[i] = \
cipher_text[block_index * 16 + i] ^ iv2[i]
plain_text_block = aes.decrypt(cipher_text_block) cipher_text_block = [0] * 16
for block_index in range(blocks_count):
for i in range(16):
cipher_text_block[i] = \
cipher_text[block_index * 16 + i] ^ iv2[i]
for i in range(16): plain_text_block = aes.decrypt(cipher_text_block)
plain_text_block[i] ^= iv1[i]
iv1 = cipher_text[block_index * 16:block_index * 16 + 16] for i in range(16):
iv2 = plain_text_block plain_text_block[i] ^= iv1[i]
plain_text.extend(plain_text_block) iv1 = cipher_text[block_index * 16:block_index * 16 + 16]
iv2 = plain_text_block
return bytes(plain_text) plain_text.extend(plain_text_block)
@staticmethod return bytes(plain_text)
def encrypt_ige(plain_text, key, iv):
"""
Encrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
# Add random padding iff it's not evenly divisible by 16 already @staticmethod
if len(plain_text) % 16 != 0: def encrypt_ige(plain_text, key, iv):
padding_count = 16 - len(plain_text) % 16 """
plain_text += os.urandom(padding_count) Encrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
# Add random padding iff it's not evenly divisible by 16 already
if len(plain_text) % 16 != 0:
padding_count = 16 - len(plain_text) % 16
plain_text += os.urandom(padding_count)
iv1 = iv[:len(iv) // 2] if cryptg:
iv2 = iv[len(iv) // 2:] return cryptg.encrypt_ige(plain_text, key, iv)
aes = pyaes.AES(key) iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
cipher_text = [] aes = pyaes.AES(key)
blocks_count = len(plain_text) // 16
for block_index in range(blocks_count): cipher_text = []
plain_text_block = list( blocks_count = len(plain_text) // 16
plain_text[block_index * 16:block_index * 16 + 16]
)
for i in range(16):
plain_text_block[i] ^= iv1[i]
cipher_text_block = aes.encrypt(plain_text_block) for block_index in range(blocks_count):
plain_text_block = list(
plain_text[block_index * 16:block_index * 16 + 16]
)
for i in range(16):
plain_text_block[i] ^= iv1[i]
for i in range(16): cipher_text_block = aes.encrypt(plain_text_block)
cipher_text_block[i] ^= iv2[i]
iv1 = cipher_text_block for i in range(16):
iv2 = plain_text[block_index * 16:block_index * 16 + 16] cipher_text_block[i] ^= iv2[i]
cipher_text.extend(cipher_text_block) iv1 = cipher_text_block
iv2 = plain_text[block_index * 16:block_index * 16 + 16]
return bytes(cipher_text) cipher_text.extend(cipher_text_block)
return bytes(cipher_text)

View File

@ -1,107 +0,0 @@
"""
This module holds an AES IGE class, if libssl is available on the system.
"""
import os
import ctypes
from ctypes.util import find_library
lib = find_library('ssl')
if not lib:
AES = None
else:
""" <aes.h>
# define AES_ENCRYPT 1
# define AES_DECRYPT 0
# define AES_MAXNR 14
struct aes_key_st {
# ifdef AES_LONG
unsigned long rd_key[4 * (AES_MAXNR + 1)];
# else
unsigned int rd_key[4 * (AES_MAXNR + 1)];
# endif
int rounds;
};
typedef struct aes_key_st AES_KEY;
int AES_set_encrypt_key(const unsigned char *userKey, const int bits,
AES_KEY *key);
int AES_set_decrypt_key(const unsigned char *userKey, const int bits,
AES_KEY *key);
void AES_ige_encrypt(const unsigned char *in, unsigned char *out,
size_t length, const AES_KEY *key,
unsigned char *ivec, const int enc);
"""
_libssl = ctypes.cdll.LoadLibrary(lib)
AES_MAXNR = 14
AES_ENCRYPT = ctypes.c_int(1)
AES_DECRYPT = ctypes.c_int(0)
class AES_KEY(ctypes.Structure):
"""Helper class representing an AES key"""
_fields_ = [
('rd_key', ctypes.c_uint32 * (4*(AES_MAXNR + 1))),
('rounds', ctypes.c_uint),
]
class AES:
"""
Class that servers as an interface to encrypt and decrypt
text through the AES IGE mode, using the system's libssl.
"""
@staticmethod
def decrypt_ige(cipher_text, key, iv):
"""
Decrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
aeskey = AES_KEY()
ckey = (ctypes.c_ubyte * len(key))(*key)
cklen = ctypes.c_int(len(key)*8)
cin = (ctypes.c_ubyte * len(cipher_text))(*cipher_text)
ctlen = ctypes.c_size_t(len(cipher_text))
cout = (ctypes.c_ubyte * len(cipher_text))()
civ = (ctypes.c_ubyte * len(iv))(*iv)
_libssl.AES_set_decrypt_key(ckey, cklen, ctypes.byref(aeskey))
_libssl.AES_ige_encrypt(
ctypes.byref(cin),
ctypes.byref(cout),
ctlen,
ctypes.byref(aeskey),
ctypes.byref(civ),
AES_DECRYPT
)
return bytes(cout)
@staticmethod
def encrypt_ige(plain_text, key, iv):
"""
Encrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
# Add random padding iff it's not evenly divisible by 16 already
if len(plain_text) % 16 != 0:
padding_count = 16 - len(plain_text) % 16
plain_text += os.urandom(padding_count)
aeskey = AES_KEY()
ckey = (ctypes.c_ubyte * len(key))(*key)
cklen = ctypes.c_int(len(key)*8)
cin = (ctypes.c_ubyte * len(plain_text))(*plain_text)
ctlen = ctypes.c_size_t(len(plain_text))
cout = (ctypes.c_ubyte * len(plain_text))()
civ = (ctypes.c_ubyte * len(iv))(*iv)
_libssl.AES_set_encrypt_key(ckey, cklen, ctypes.byref(aeskey))
_libssl.AES_ige_encrypt(
ctypes.byref(cin),
ctypes.byref(cout),
ctlen,
ctypes.byref(aeskey),
ctypes.byref(civ),
AES_ENCRYPT
)
return bytes(cout)

View File

@ -4,7 +4,7 @@
class ReadCancelledError(Exception): class ReadCancelledError(Exception):
"""Occurs when a read operation was cancelled.""" """Occurs when a read operation was cancelled."""
def __init__(self): def __init__(self):
super().__init__(self, 'The read operation was cancelled.') super().__init__('The read operation was cancelled.')
class TypeNotFoundError(Exception): class TypeNotFoundError(Exception):
@ -14,7 +14,7 @@ class TypeNotFoundError(Exception):
""" """
def __init__(self, invalid_constructor_id): def __init__(self, invalid_constructor_id):
super().__init__( super().__init__(
self, 'Could not find a matching Constructor ID for the TLObject ' 'Could not find a matching Constructor ID for the TLObject '
'that was supposed to be read with ID {}. Most likely, a TLObject ' 'that was supposed to be read with ID {}. Most likely, a TLObject '
'was trying to be read when it should not be read.' 'was trying to be read when it should not be read.'
.format(hex(invalid_constructor_id))) .format(hex(invalid_constructor_id)))
@ -29,7 +29,6 @@ class InvalidChecksumError(Exception):
""" """
def __init__(self, checksum, valid_checksum): def __init__(self, checksum, valid_checksum):
super().__init__( super().__init__(
self,
'Invalid checksum ({} when {} was expected). ' 'Invalid checksum ({} when {} was expected). '
'This packet should be skipped.' 'This packet should be skipped.'
.format(checksum, valid_checksum)) .format(checksum, valid_checksum))
@ -44,7 +43,6 @@ class BrokenAuthKeyError(Exception):
""" """
def __init__(self): def __init__(self):
super().__init__( super().__init__(
self,
'The authorization key is broken, and it must be reset.' 'The authorization key is broken, and it must be reset.'
) )
@ -56,7 +54,7 @@ class SecurityError(Exception):
def __init__(self, *args): def __init__(self, *args):
if not args: if not args:
args = ['A security check failed.'] args = ['A security check failed.']
super().__init__(self, *args) super().__init__(*args)
class CdnFileTamperedError(SecurityError): class CdnFileTamperedError(SecurityError):

View File

@ -40,7 +40,7 @@ class ForbiddenError(RPCError):
message = 'FORBIDDEN' message = 'FORBIDDEN'
def __init__(self, message): def __init__(self, message):
super().__init__(self, message) super().__init__(message)
self.message = message self.message = message
@ -52,7 +52,7 @@ class NotFoundError(RPCError):
message = 'NOT_FOUND' message = 'NOT_FOUND'
def __init__(self, message): def __init__(self, message):
super().__init__(self, message) super().__init__(message)
self.message = message self.message = message
@ -77,7 +77,7 @@ class ServerError(RPCError):
message = 'INTERNAL' message = 'INTERNAL'
def __init__(self, message): def __init__(self, message):
super().__init__(self, message) super().__init__(message)
self.message = message self.message = message
@ -121,7 +121,7 @@ class BadMessageError(Exception):
} }
def __init__(self, code): def __init__(self, code):
super().__init__(self, self.ErrorMessages.get( super().__init__(self.ErrorMessages.get(
code, code,
'Unknown error code (this should not happen): {}.'.format(code))) 'Unknown error code (this should not happen): {}.'.format(code)))

View File

@ -136,6 +136,11 @@ class NewMessage(_EventBuilder):
blacklist_chats (:obj:`bool`, optional): blacklist_chats (:obj:`bool`, optional):
Whether to treat the the list of chats as a blacklist (if Whether to treat the the list of chats as a blacklist (if
it matches it will NOT be handled) or a whitelist (default). it matches it will NOT be handled) or a whitelist (default).
Notes:
The ``message.from_id`` might not only be an integer or ``None``,
but also ``InputPeerSelf()`` for short private messages (the API
would not return such thing, this is a custom modification).
""" """
def __init__(self, incoming=None, outgoing=None, def __init__(self, incoming=None, outgoing=None,
chats=None, blacklist_chats=False): chats=None, blacklist_chats=False):
@ -149,8 +154,8 @@ class NewMessage(_EventBuilder):
async def resolve(self, client): async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str): if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(x) self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in await client.get_input_entity(self.chats)) for x in self.chats)
elif self.chats is not None: elif self.chats is not None:
self.chats = {utils.get_peer_id( self.chats = {utils.get_peer_id(
await client.get_input_entity(self.chats))} await client.get_input_entity(self.chats))}
@ -169,6 +174,7 @@ class NewMessage(_EventBuilder):
silent=update.silent, silent=update.silent,
id=update.id, id=update.id,
to_id=types.PeerUser(update.user_id), to_id=types.PeerUser(update.user_id),
from_id=types.InputPeerSelf() if update.out else update.user_id,
message=update.message, message=update.message,
date=update.date, date=update.date,
fwd_from=update.fwd_from, fwd_from=update.fwd_from,
@ -249,6 +255,32 @@ class NewMessage(_EventBuilder):
reply_to=self.message.id, reply_to=self.message.id,
*args, **kwargs) *args, **kwargs)
async def edit(self, *args, **kwargs):
"""
Edits the message iff it's outgoing. This is a shorthand for
``client.edit_message(event.chat, event.message, ...)``.
Returns ``None`` if the message was incoming,
or the edited message otherwise.
"""
if not self.message.out:
return None
return await self._client.edit_message(self.input_chat,
self.message,
*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.
This is a shorthand for
``client.delete_messages(event.chat, event.message, ...)``.
"""
return await self._client.delete_messages(self.input_chat,
[self.message],
*args, **kwargs)
@property @property
async def input_sender(self): async def input_sender(self):
""" """
@ -257,21 +289,23 @@ class NewMessage(_EventBuilder):
things like username or similar, but still useful in some cases. things like username or similar, but still useful in some cases.
Note that this might not be available if the library can't Note that this might not be available if the library can't
find the input chat. find the input chat, or if the message a broadcast on a channel.
""" """
if self._input_sender is None: if self._input_sender is None:
if self.is_channel and not self.is_group:
return None
try: try:
self._input_sender = await self._client.get_input_entity( self._input_sender = await self._client.get_input_entity(
self.message.from_id self.message.from_id
) )
except (ValueError, TypeError): except (ValueError, TypeError):
if isinstance(self.message.to_id, types.PeerChannel): # We can rely on self.input_chat for this
# We can rely on self.input_chat for this self._input_sender = await self._get_input_entity(
self._input_sender = await self._get_input_entity( self.message.id,
self.message.id, self.message.from_id,
self.message.from_id, chat=await self.input_chat
chat=await self.input_chat )
)
return self._input_sender return self._input_sender
@ -397,8 +431,8 @@ class ChatAction(_EventBuilder):
async def resolve(self, client): async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str): if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(x) self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in await client.get_input_entity(self.chats)) for x in self.chats)
elif self.chats is not None: elif self.chats is not None:
self.chats = {utils.get_peer_id( self.chats = {utils.get_peer_id(
await client.get_input_entity(self.chats))} await client.get_input_entity(self.chats))}
@ -835,22 +869,24 @@ class MessageChanged(_EventBuilder):
things like username or similar, but still useful in some cases. things like username or similar, but still useful in some cases.
Note that this might not be available if the library can't Note that this might not be available if the library can't
find the input chat. find the input chat, or if the message a broadcast on a channel.
""" """
# TODO Code duplication # TODO Code duplication
if self._input_sender is None and self.message: if self._input_sender is None:
if self.is_channel and not self.is_group:
return None
try: try:
self._input_sender = await self._client.get_input_entity( self._input_sender = await self._client.get_input_entity(
self.message.from_id self.message.from_id
) )
except (ValueError, TypeError): except (ValueError, TypeError):
if isinstance(self.message.to_id, types.PeerChannel): # We can rely on self.input_chat for this
# We can rely on self.input_chat for this self._input_sender = await self._get_input_entity(
self._input_sender = await self._get_input_entity( self.message.id,
self.message.id, self.message.from_id,
self.message.from_id, chat=await self.input_chat
chat=await self.input_chat )
)
return self._input_sender return self._input_sender

View File

@ -1,9 +1,10 @@
""" """
Simple HTML -> Telegram entity parser. Simple HTML -> Telegram entity parser.
""" """
import struct
from collections import deque
from html import escape, unescape from html import escape, unescape
from html.parser import HTMLParser from html.parser import HTMLParser
from collections import deque
from ..tl.types import ( from ..tl.types import (
MessageEntityBold, MessageEntityItalic, MessageEntityCode, MessageEntityBold, MessageEntityItalic, MessageEntityCode,
@ -12,6 +13,18 @@ from ..tl.types import (
) )
# Helpers from markdown.py
def _add_surrogate(text):
return ''.join(
''.join(chr(y) for y in struct.unpack('<HH', x.encode('utf-16le')))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text
)
def _del_surrogate(text):
return text.encode('utf-16', 'surrogatepass').decode('utf-16')
class HTMLToTelegramParser(HTMLParser): class HTMLToTelegramParser(HTMLParser):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -109,8 +122,8 @@ def parse(html):
:return: a tuple consisting of (clean message, [message entities]). :return: a tuple consisting of (clean message, [message entities]).
""" """
parser = HTMLToTelegramParser() parser = HTMLToTelegramParser()
parser.feed(html) parser.feed(_add_surrogate(html))
return parser.text, parser.entities return _del_surrogate(parser.text), parser.entities
def unparse(text, entities): def unparse(text, entities):
@ -124,6 +137,8 @@ def unparse(text, entities):
""" """
if not entities: if not entities:
return text return text
text = _add_surrogate(text)
html = [] html = []
last_offset = 0 last_offset = 0
for entity in entities: for entity in entities:
@ -164,4 +179,4 @@ def unparse(text, entities):
skip_entity = True skip_entity = True
last_offset = entity.offset + (0 if skip_entity else entity.length) last_offset = entity.offset + (0 if skip_entity else entity.length)
html.append(text[last_offset:]) html.append(text[last_offset:])
return ''.join(html) return _del_surrogate(''.join(html))

View File

@ -152,6 +152,9 @@ def unparse(text, entities, delimiters=None, url_fmt=None):
:param entities: the MessageEntity's applied to the text. :param entities: the MessageEntity's applied to the text.
:return: a markdown-like text representing the combination of both inputs. :return: a markdown-like text representing the combination of both inputs.
""" """
if not entities:
return text
if not delimiters: if not delimiters:
if delimiters is not None: if delimiters is not None:
return text return text

View File

@ -92,6 +92,11 @@ class MtProtoSender:
messages = [TLMessage(self.session, r) for r in requests] messages = [TLMessage(self.session, r) for r in requests]
self._pending_receive.update({m.msg_id: m for m in messages}) self._pending_receive.update({m.msg_id: m for m in messages})
__log__.debug('Sending requests with IDs: %s', ', '.join(
'{}: {}'.format(m.request.__class__.__name__, m.msg_id)
for m in messages
))
if len(messages) == 1: if len(messages) == 1:
message = messages[0] message = messages[0]
else: else:
@ -468,6 +473,7 @@ class MtProtoSender:
request_id = reader.read_long() request_id = reader.read_long()
inner_code = reader.read_int(signed=False) inner_code = reader.read_int(signed=False)
__log__.debug('Received response for request with ID %d', request_id)
request = self._pop_request(request_id) request = self._pop_request(request_id)
if inner_code == 0x2144ca19: # RPC Error if inner_code == 0x2144ca19: # RPC Error
@ -502,8 +508,18 @@ class MtProtoSender:
return True return True
# If it's really a result for RPC from previous connection # If it's really a result for RPC from previous connection
# session, it will be skipped by the handle_container() # session, it will be skipped by the handle_container().
__log__.warning('Lost request will be skipped') # For some reason this also seems to happen when downloading
# photos, where the server responds with FileJpeg().
try:
obj = reader.tgread_object()
except Exception as e:
obj = '(failed to read: %s)' % e
__log__.warning(
'Lost request (ID %d) with code %s will be skipped, contents: %s',
request_id, hex(inner_code), obj
)
return False return False
async def _handle_gzip_packed(self, msg_id, sequence, reader, state): async def _handle_gzip_packed(self, msg_id, sequence, reader, state):

View File

@ -101,8 +101,6 @@ class TelegramBareClient:
self.session = session self.session = session
self.api_id = int(api_id) self.api_id = int(api_id)
self.api_hash = api_hash self.api_hash = api_hash
if self.api_id < 20: # official apps must use obfuscated
connection_mode = ConnectionMode.TCP_OBFUSCATED
# This is the main sender, which will be used from the thread # This is the main sender, which will be used from the thread
# that calls .connect(). Every other thread will spawn a new # that calls .connect(). Every other thread will spawn a new
@ -152,10 +150,17 @@ class TelegramBareClient:
self._recv_loop = None self._recv_loop = None
self._ping_loop = None self._ping_loop = None
self._state_loop = None
self._idling = asyncio.Event() self._idling = asyncio.Event()
# Default PingRequest delay # Default PingRequest delay
self._ping_delay = timedelta(minutes=1) self._ping_delay = timedelta(minutes=1)
# Also have another delay for GetStateRequest.
#
# If the connection is kept alive for long without invoking any
# high level request the server simply stops sending updates.
# TODO maybe we can have ._last_request instead if any req works?
self._state_delay = timedelta(hours=1)
# endregion # endregion
@ -570,6 +575,8 @@ class TelegramBareClient:
self._recv_loop = asyncio.ensure_future(self._recv_loop_impl(), loop=self._loop) self._recv_loop = asyncio.ensure_future(self._recv_loop_impl(), loop=self._loop)
if self._ping_loop is None: if self._ping_loop is None:
self._ping_loop = asyncio.ensure_future(self._ping_loop_impl(), loop=self._loop) self._ping_loop = asyncio.ensure_future(self._ping_loop_impl(), loop=self._loop)
if self._state_loop is None:
self._state_loop = asyncio.ensure_future(self._state_loop_impl(), loop=self._loop)
async def _ping_loop_impl(self): async def _ping_loop_impl(self):
while self._user_connected: while self._user_connected:
@ -577,6 +584,11 @@ class TelegramBareClient:
await asyncio.sleep(self._ping_delay.seconds, loop=self._loop) await asyncio.sleep(self._ping_delay.seconds, loop=self._loop)
self._ping_loop = None self._ping_loop = None
async def _state_loop_impl(self):
while self._user_connected:
await asyncio.sleep(self._state_delay.seconds, loop=self._loop)
await self._sender.send(GetStateRequest())
async def _recv_loop_impl(self): async def _recv_loop_impl(self):
__log__.info('Starting to wait for items from the network') __log__.info('Starting to wait for items from the network')
self._idling.set() self._idling.set()

View File

@ -4,6 +4,7 @@ import io
import itertools import itertools
import logging import logging
import os import os
import re
import sys import sys
from collections import OrderedDict, UserList from collections import OrderedDict, UserList
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -45,8 +46,8 @@ from .tl.functions.contacts import (
from .tl.functions.messages import ( from .tl.functions.messages import (
GetDialogsRequest, GetHistoryRequest, SendMediaRequest, GetDialogsRequest, GetHistoryRequest, SendMediaRequest,
SendMessageRequest, GetChatsRequest, GetAllDraftsRequest, SendMessageRequest, GetChatsRequest, GetAllDraftsRequest,
CheckChatInviteRequest, ReadMentionsRequest, CheckChatInviteRequest, ReadMentionsRequest, SendMultiMediaRequest,
SendMultiMediaRequest, UploadMediaRequest UploadMediaRequest, EditMessageRequest
) )
from .tl.functions import channels from .tl.functions import channels
@ -68,7 +69,9 @@ from .tl.types import (
PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel, MessageEmpty, PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel, MessageEmpty,
ChatInvite, ChatInviteAlready, PeerChannel, Photo, InputPeerSelf, ChatInvite, ChatInviteAlready, PeerChannel, Photo, InputPeerSelf,
InputSingleMedia, InputMediaPhoto, InputPhoto, InputFile, InputFileBig, InputSingleMedia, InputMediaPhoto, InputPhoto, InputFile, InputFileBig,
InputDocument, InputMediaDocument, Document InputDocument, InputMediaDocument, Document, MessageEntityTextUrl,
InputMessageEntityMentionName, DocumentAttributeVideo,
UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates
) )
from .tl.types.messages import DialogsSlice from .tl.types.messages import DialogsSlice
from .extensions import markdown, html from .extensions import markdown, html
@ -414,7 +417,7 @@ class TelegramClient(TelegramBareClient):
result = await self(SignUpRequest( result = await self(SignUpRequest(
phone_number=self._phone, phone_number=self._phone,
phone_code_hash=self._phone_code_hash.get(self._phone, ''), phone_code_hash=self._phone_code_hash.get(self._phone, ''),
phone_code=code, phone_code=str(code),
first_name=first_name, first_name=first_name,
last_name=last_name last_name=last_name
)) ))
@ -561,13 +564,62 @@ class TelegramClient(TelegramBareClient):
msg_id = update.id msg_id = update.id
break break
for update in result.updates: if isinstance(result, UpdateShort):
updates = [result.update]
elif isinstance(result, Updates):
updates = result.updates
else:
return
for update in updates:
if isinstance(update, (UpdateNewChannelMessage, UpdateNewMessage)): if isinstance(update, (UpdateNewChannelMessage, UpdateNewMessage)):
if update.message.id == msg_id: if update.message.id == msg_id:
return update.message return update.message
elif (isinstance(update, UpdateEditMessage) and
not isinstance(request.peer, InputPeerChannel)):
if request.id == update.message.id:
return update.message
elif (isinstance(update, UpdateEditChannelMessage) and
utils.get_peer_id(request.peer) ==
utils.get_peer_id(update.message.to_id)):
if request.id == update.message.id:
return update.message
def _parse_message_text(self, message, parse_mode):
"""
Returns a (parsed message, entities) tuple depending on parse_mode.
"""
if not parse_mode:
return message, []
parse_mode = parse_mode.lower()
if parse_mode in {'md', 'markdown'}:
message, msg_entities = markdown.parse(message)
elif parse_mode.startswith('htm'):
message, msg_entities = html.parse(message)
else:
raise ValueError('Unknown parsing mode: {}'.format(parse_mode))
for i, e in enumerate(msg_entities):
if isinstance(e, MessageEntityTextUrl):
m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url)
if m:
try:
msg_entities[i] = InputMessageEntityMentionName(
e.offset, e.length, self.get_input_entity(
int(m.group(1)) if m.group(1) else e.url
)
)
except (ValueError, TypeError):
# Make no replacement
pass
return message, msg_entities
async def send_message(self, entity, message, reply_to=None, async def send_message(self, entity, message, reply_to=None,
parse_mode=None, link_preview=True): parse_mode='md', link_preview=True):
""" """
Sends the given message to the specified entity (user/chat/channel). Sends the given message to the specified entity (user/chat/channel).
@ -583,8 +635,10 @@ class TelegramClient(TelegramBareClient):
it should be the ID of the message that it should reply to. it should be the ID of the message that it should reply to.
parse_mode (:obj:`str`, optional): parse_mode (:obj:`str`, optional):
Can be 'md' or 'markdown' for markdown-like parsing, in a similar Can be 'md' or 'markdown' for markdown-like parsing (default),
fashion how official clients work. or 'htm' or 'html' for HTML-like parsing. If ``None`` or any
other false-y value is provided, the message will be sent with
no formatting.
link_preview (:obj:`bool`, optional): link_preview (:obj:`bool`, optional):
Should the link preview be shown? Should the link preview be shown?
@ -593,23 +647,14 @@ class TelegramClient(TelegramBareClient):
the sent message the sent message
""" """
entity = await self.get_input_entity(entity) entity = await self.get_input_entity(entity)
if parse_mode: message, msg_entities = self._parse_message_text(message, parse_mode)
parse_mode = parse_mode.lower()
if parse_mode in {'md', 'markdown'}:
message, msg_entities = markdown.parse(message)
elif parse_mode.startswith('htm'):
message, msg_entities = html.parse(message)
else:
raise ValueError('Unknown parsing mode: {}'.format(parse_mode))
else:
msg_entities = []
request = SendMessageRequest( request = SendMessageRequest(
peer=entity, peer=entity,
message=message, message=message,
entities=msg_entities, entities=msg_entities,
no_webpage=not link_preview, no_webpage=not link_preview,
reply_to_msg_id=self._get_reply_to(reply_to) reply_to_msg_id=self._get_message_id(reply_to)
) )
result = await self(request) result = await self(request)
@ -626,6 +671,51 @@ class TelegramClient(TelegramBareClient):
return self._get_response_message(request, result) return self._get_response_message(request, result)
async def edit_message(self, entity, message_id, message=None,
parse_mode='md', link_preview=True):
"""
Edits the given message ID (to change its contents or disable preview).
Args:
entity (:obj:`entity`):
From which chat to edit the message.
message_id (:obj:`str`):
The ID of the message (or ``Message`` itself) to be edited.
message (:obj:`str`, optional):
The new text of the message.
parse_mode (:obj:`str`, optional):
Can be 'md' or 'markdown' for markdown-like parsing (default),
or 'htm' or 'html' for HTML-like parsing. If ``None`` or any
other false-y value is provided, the message will be sent with
no formatting.
link_preview (:obj:`bool`, optional):
Should the link preview be shown?
Raises:
``MessageAuthorRequiredError`` if you're not the author of the
message but try editing it anyway.
``MessageNotModifiedError`` if the contents of the message were
not modified at all.
Returns:
the edited message
"""
message, msg_entities = self._parse_message_text(message, parse_mode)
request = EditMessageRequest(
peer=await self.get_input_entity(entity),
id=self._get_message_id(message_id),
message=message,
no_webpage=not link_preview,
entities=msg_entities
)
result = await self(request)
return self._get_response_message(request, result)
async def delete_messages(self, entity, message_ids, revoke=True): async def delete_messages(self, entity, message_ids, revoke=True):
""" """
Deletes a message from a chat, optionally "for everyone". Deletes a message from a chat, optionally "for everyone".
@ -849,22 +939,22 @@ class TelegramClient(TelegramBareClient):
return False return False
@staticmethod @staticmethod
def _get_reply_to(reply_to): def _get_message_id(message):
"""Sanitizes the 'reply_to' parameter a user may send""" """Sanitizes the 'reply_to' parameter a user may send"""
if reply_to is None: if message is None:
return None return None
if isinstance(reply_to, int): if isinstance(message, int):
return reply_to return message
try: try:
if reply_to.SUBCLASS_OF_ID == 0x790009e3: if message.SUBCLASS_OF_ID == 0x790009e3:
# hex(crc32(b'Message')) = 0x790009e3 # hex(crc32(b'Message')) = 0x790009e3
return reply_to.id return message.id
except AttributeError: except AttributeError:
pass pass
raise TypeError('Invalid reply_to type: {}'.format(type(reply_to))) raise TypeError('Invalid message type: {}'.format(type(message)))
# endregion # endregion
@ -902,8 +992,8 @@ class TelegramClient(TelegramBareClient):
force_document (:obj:`bool`, optional): force_document (:obj:`bool`, optional):
If left to ``False`` and the file is a path that ends with If left to ``False`` and the file is a path that ends with
``.png``, ``.jpg`` and such, the file will be sent as a photo. the extension of an image file or a video file, it will be
Otherwise always as a document. sent as such. Otherwise always as a document.
progress_callback (:obj:`callable`, optional): progress_callback (:obj:`callable`, optional):
A callback function accepting two parameters: A callback function accepting two parameters:
@ -953,7 +1043,7 @@ class TelegramClient(TelegramBareClient):
] ]
entity = await self.get_input_entity(entity) entity = await self.get_input_entity(entity)
reply_to = self._get_reply_to(reply_to) reply_to = self._get_message_id(reply_to)
if not isinstance(file, (str, bytes, io.IOBase)): if not isinstance(file, (str, bytes, io.IOBase)):
# The user may pass a Message containing media (or the media, # The user may pass a Message containing media (or the media,
@ -995,6 +1085,9 @@ class TelegramClient(TelegramBareClient):
# TODO If the input file is an audio, find out: # TODO If the input file is an audio, find out:
# Performer and song title and add DocumentAttributeAudio # Performer and song title and add DocumentAttributeAudio
} }
if not force_document and utils.is_video(file):
attr_dict[DocumentAttributeVideo] = \
DocumentAttributeVideo(0, 0, 0)
else: else:
attr_dict = { attr_dict = {
DocumentAttributeFilename: DocumentAttributeFilename:
@ -1063,7 +1156,7 @@ class TelegramClient(TelegramBareClient):
# cache only makes a difference for documents where the user may # cache only makes a difference for documents where the user may
# want the attributes used on them to change. Caption's ignored. # want the attributes used on them to change. Caption's ignored.
entity = await self.get_input_entity(entity) entity = await self.get_input_entity(entity)
reply_to = self._get_reply_to(reply_to) reply_to = self._get_message_id(reply_to)
# Need to upload the media first, but only if they're not cached yet # Need to upload the media first, but only if they're not cached yet
media = [] media = []

View File

@ -3,10 +3,10 @@ Utilities for working with the Telegram API itself (such as handy methods
to convert between an entity like an User, Chat, etc. into its Input version) to convert between an entity like an User, Chat, etc. into its Input version)
""" """
import math import math
import mimetypes
import re import re
from mimetypes import add_type, guess_extension from mimetypes import add_type, guess_extension
from .tl.types.contacts import ResolvedPeer
from .tl.types import ( from .tl.types import (
Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull,
ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty, ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty,
@ -24,6 +24,7 @@ from .tl.types import (
InputMediaUploadedPhoto, DocumentAttributeFilename, photos, InputMediaUploadedPhoto, DocumentAttributeFilename, photos,
TopPeer, InputNotifyPeer TopPeer, InputNotifyPeer
) )
from .tl.types.contacts import ResolvedPeer
USERNAME_RE = re.compile( USERNAME_RE = re.compile(
r'@|(?:https?://)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?' r'@|(?:https?://)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?'
@ -322,8 +323,12 @@ def get_input_media(media, user_caption=None, is_photo=False):
def is_image(file): def is_image(file):
"""Returns True if the file extension looks like an image file""" """Returns True if the file extension looks like an image file"""
return (isinstance(file, str) and return (mimetypes.guess_type(file)[0] or '').startswith('image/')
bool(re.search(r'\.(png|jpe?g|gif)$', file, re.IGNORECASE)))
def is_video(file):
"""Returns True if the file extension looks like a video file"""
return (mimetypes.guess_type(file)[0] or '').startswith('video/')
def parse_phone(phone): def parse_phone(phone):

View File

@ -1,3 +1,3 @@
# Versions should comply with PEP440. # Versions should comply with PEP440.
# This line is parsed in setup.py: # This line is parsed in setup.py:
__version__ = '0.17.1' __version__ = '0.17.2'

View File

@ -68,7 +68,7 @@ def write_error(f, code, name, desc, capture_name):
f.write( f.write(
"self.{} = int(kwargs.get('capture', 0))\n ".format(capture_name) "self.{} = int(kwargs.get('capture', 0))\n ".format(capture_name)
) )
f.write('super(Exception, self).__init__(self, {}'.format(repr(desc))) f.write('super(Exception, self).__init__({}'.format(repr(desc)))
if capture_name: if capture_name:
f.write('.format(self.{})'.format(capture_name)) f.write('.format(self.{})'.format(capture_name))
f.write(')\n') f.write(')\n')