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.')
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_generic:
desc.append('A list of other Requests must be supplied.')
@ -221,7 +228,11 @@ def get_description(arg):
desc.insert(1, 'Otherwise,')
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):

View File

@ -108,6 +108,10 @@ span.sh4 {
color: #06c;
}
span.tooltip {
border-bottom: 1px dashed #444;
}
#searchBox {
width: 100%;
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()``.
.. 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::
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
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)
===========================

View File

@ -3,86 +3,90 @@ AES IGE implementation in Python. This module may use libssl if available.
"""
import os
import pyaes
from . import libssl
try:
import cryptg
except ImportError:
cryptg = None
if libssl.AES is not None:
# Use libssl if available, since it will be faster
AES = libssl.AES
else:
# Fallback to a pure Python implementation
class AES:
class AES:
"""
Class that servers as an interface to encrypt and decrypt
text through the AES IGE mode.
"""
@staticmethod
def decrypt_ige(cipher_text, key, iv):
"""
Class that servers as an interface to encrypt and decrypt
text through the AES IGE mode.
Decrypts the given text in 16-bytes blocks by using the
given key and 32-bytes initialization vector.
"""
@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.
"""
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
if cryptg:
return cryptg.decrypt_ige(cipher_text, key, iv)
aes = pyaes.AES(key)
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
plain_text = []
blocks_count = len(cipher_text) // 16
aes = pyaes.AES(key)
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]
plain_text = []
blocks_count = len(cipher_text) // 16
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[i] ^= iv1[i]
plain_text_block = aes.decrypt(cipher_text_block)
iv1 = cipher_text[block_index * 16:block_index * 16 + 16]
iv2 = plain_text_block
for i in range(16):
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
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.
"""
return bytes(plain_text)
# 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)
@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)
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
if cryptg:
return cryptg.encrypt_ige(plain_text, key, iv)
aes = pyaes.AES(key)
iv1 = iv[:len(iv) // 2]
iv2 = iv[len(iv) // 2:]
cipher_text = []
blocks_count = len(plain_text) // 16
aes = pyaes.AES(key)
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]
cipher_text = []
blocks_count = len(plain_text) // 16
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[i] ^= iv2[i]
cipher_text_block = aes.encrypt(plain_text_block)
iv1 = cipher_text_block
iv2 = plain_text[block_index * 16:block_index * 16 + 16]
for i in range(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):
"""Occurs when a read operation was cancelled."""
def __init__(self):
super().__init__(self, 'The read operation was cancelled.')
super().__init__('The read operation was cancelled.')
class TypeNotFoundError(Exception):
@ -14,7 +14,7 @@ class TypeNotFoundError(Exception):
"""
def __init__(self, invalid_constructor_id):
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 '
'was trying to be read when it should not be read.'
.format(hex(invalid_constructor_id)))
@ -29,7 +29,6 @@ class InvalidChecksumError(Exception):
"""
def __init__(self, checksum, valid_checksum):
super().__init__(
self,
'Invalid checksum ({} when {} was expected). '
'This packet should be skipped.'
.format(checksum, valid_checksum))
@ -44,7 +43,6 @@ class BrokenAuthKeyError(Exception):
"""
def __init__(self):
super().__init__(
self,
'The authorization key is broken, and it must be reset.'
)
@ -56,7 +54,7 @@ class SecurityError(Exception):
def __init__(self, *args):
if not args:
args = ['A security check failed.']
super().__init__(self, *args)
super().__init__(*args)
class CdnFileTamperedError(SecurityError):

View File

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

View File

@ -136,6 +136,11 @@ class NewMessage(_EventBuilder):
blacklist_chats (:obj:`bool`, optional):
Whether to treat the the list of chats as a blacklist (if
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,
chats=None, blacklist_chats=False):
@ -149,8 +154,8 @@ class NewMessage(_EventBuilder):
async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(x)
for x in await client.get_input_entity(self.chats))
self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in self.chats)
elif self.chats is not None:
self.chats = {utils.get_peer_id(
await client.get_input_entity(self.chats))}
@ -169,6 +174,7 @@ class NewMessage(_EventBuilder):
silent=update.silent,
id=update.id,
to_id=types.PeerUser(update.user_id),
from_id=types.InputPeerSelf() if update.out else update.user_id,
message=update.message,
date=update.date,
fwd_from=update.fwd_from,
@ -249,6 +255,32 @@ class NewMessage(_EventBuilder):
reply_to=self.message.id,
*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
async def input_sender(self):
"""
@ -257,21 +289,23 @@ class NewMessage(_EventBuilder):
things like username or similar, but still useful in some cases.
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.is_channel and not self.is_group:
return None
try:
self._input_sender = await self._client.get_input_entity(
self.message.from_id
)
except (ValueError, TypeError):
if isinstance(self.message.to_id, types.PeerChannel):
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
)
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
)
return self._input_sender
@ -397,8 +431,8 @@ class ChatAction(_EventBuilder):
async def resolve(self, client):
if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str):
self.chats = set(utils.get_peer_id(x)
for x in await client.get_input_entity(self.chats))
self.chats = set(utils.get_peer_id(await client.get_input_entity(x))
for x in self.chats)
elif self.chats is not None:
self.chats = {utils.get_peer_id(
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.
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
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:
self._input_sender = await self._client.get_input_entity(
self.message.from_id
)
except (ValueError, TypeError):
if isinstance(self.message.to_id, types.PeerChannel):
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
)
# We can rely on self.input_chat for this
self._input_sender = await self._get_input_entity(
self.message.id,
self.message.from_id,
chat=await self.input_chat
)
return self._input_sender

View File

@ -1,9 +1,10 @@
"""
Simple HTML -> Telegram entity parser.
"""
import struct
from collections import deque
from html import escape, unescape
from html.parser import HTMLParser
from collections import deque
from ..tl.types import (
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):
def __init__(self):
super().__init__()
@ -109,8 +122,8 @@ def parse(html):
:return: a tuple consisting of (clean message, [message entities]).
"""
parser = HTMLToTelegramParser()
parser.feed(html)
return parser.text, parser.entities
parser.feed(_add_surrogate(html))
return _del_surrogate(parser.text), parser.entities
def unparse(text, entities):
@ -124,6 +137,8 @@ def unparse(text, entities):
"""
if not entities:
return text
text = _add_surrogate(text)
html = []
last_offset = 0
for entity in entities:
@ -164,4 +179,4 @@ def unparse(text, entities):
skip_entity = True
last_offset = entity.offset + (0 if skip_entity else entity.length)
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.
:return: a markdown-like text representing the combination of both inputs.
"""
if not entities:
return text
if not delimiters:
if delimiters is not None:
return text

View File

@ -92,6 +92,11 @@ class MtProtoSender:
messages = [TLMessage(self.session, r) for r in requests]
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:
message = messages[0]
else:
@ -468,6 +473,7 @@ class MtProtoSender:
request_id = reader.read_long()
inner_code = reader.read_int(signed=False)
__log__.debug('Received response for request with ID %d', request_id)
request = self._pop_request(request_id)
if inner_code == 0x2144ca19: # RPC Error
@ -502,8 +508,18 @@ class MtProtoSender:
return True
# If it's really a result for RPC from previous connection
# session, it will be skipped by the handle_container()
__log__.warning('Lost request will be skipped')
# session, it will be skipped by the handle_container().
# 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
async def _handle_gzip_packed(self, msg_id, sequence, reader, state):

View File

@ -101,8 +101,6 @@ class TelegramBareClient:
self.session = session
self.api_id = int(api_id)
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
# that calls .connect(). Every other thread will spawn a new
@ -152,10 +150,17 @@ class TelegramBareClient:
self._recv_loop = None
self._ping_loop = None
self._state_loop = None
self._idling = asyncio.Event()
# Default PingRequest delay
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
@ -570,6 +575,8 @@ class TelegramBareClient:
self._recv_loop = asyncio.ensure_future(self._recv_loop_impl(), loop=self._loop)
if self._ping_loop is None:
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):
while self._user_connected:
@ -577,6 +584,11 @@ class TelegramBareClient:
await asyncio.sleep(self._ping_delay.seconds, loop=self._loop)
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):
__log__.info('Starting to wait for items from the network')
self._idling.set()

View File

@ -4,6 +4,7 @@ import io
import itertools
import logging
import os
import re
import sys
from collections import OrderedDict, UserList
from datetime import datetime, timedelta
@ -45,8 +46,8 @@ from .tl.functions.contacts import (
from .tl.functions.messages import (
GetDialogsRequest, GetHistoryRequest, SendMediaRequest,
SendMessageRequest, GetChatsRequest, GetAllDraftsRequest,
CheckChatInviteRequest, ReadMentionsRequest,
SendMultiMediaRequest, UploadMediaRequest
CheckChatInviteRequest, ReadMentionsRequest, SendMultiMediaRequest,
UploadMediaRequest, EditMessageRequest
)
from .tl.functions import channels
@ -68,7 +69,9 @@ from .tl.types import (
PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel, MessageEmpty,
ChatInvite, ChatInviteAlready, PeerChannel, Photo, InputPeerSelf,
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 .extensions import markdown, html
@ -414,7 +417,7 @@ class TelegramClient(TelegramBareClient):
result = await self(SignUpRequest(
phone_number=self._phone,
phone_code_hash=self._phone_code_hash.get(self._phone, ''),
phone_code=code,
phone_code=str(code),
first_name=first_name,
last_name=last_name
))
@ -561,13 +564,62 @@ class TelegramClient(TelegramBareClient):
msg_id = update.id
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 update.message.id == msg_id:
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,
parse_mode=None, link_preview=True):
parse_mode='md', link_preview=True):
"""
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.
parse_mode (:obj:`str`, optional):
Can be 'md' or 'markdown' for markdown-like parsing, in a similar
fashion how official clients work.
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?
@ -593,23 +647,14 @@ class TelegramClient(TelegramBareClient):
the sent message
"""
entity = await self.get_input_entity(entity)
if 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 = []
message, msg_entities = self._parse_message_text(message, parse_mode)
request = SendMessageRequest(
peer=entity,
message=message,
entities=msg_entities,
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)
@ -626,6 +671,51 @@ class TelegramClient(TelegramBareClient):
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):
"""
Deletes a message from a chat, optionally "for everyone".
@ -849,22 +939,22 @@ class TelegramClient(TelegramBareClient):
return False
@staticmethod
def _get_reply_to(reply_to):
def _get_message_id(message):
"""Sanitizes the 'reply_to' parameter a user may send"""
if reply_to is None:
if message is None:
return None
if isinstance(reply_to, int):
return reply_to
if isinstance(message, int):
return message
try:
if reply_to.SUBCLASS_OF_ID == 0x790009e3:
if message.SUBCLASS_OF_ID == 0x790009e3:
# hex(crc32(b'Message')) = 0x790009e3
return reply_to.id
return message.id
except AttributeError:
pass
raise TypeError('Invalid reply_to type: {}'.format(type(reply_to)))
raise TypeError('Invalid message type: {}'.format(type(message)))
# endregion
@ -902,8 +992,8 @@ class TelegramClient(TelegramBareClient):
force_document (:obj:`bool`, optional):
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.
Otherwise always as a document.
the extension of an image file or a video file, it will be
sent as such. Otherwise always as a document.
progress_callback (:obj:`callable`, optional):
A callback function accepting two parameters:
@ -953,7 +1043,7 @@ class TelegramClient(TelegramBareClient):
]
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)):
# 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:
# Performer and song title and add DocumentAttributeAudio
}
if not force_document and utils.is_video(file):
attr_dict[DocumentAttributeVideo] = \
DocumentAttributeVideo(0, 0, 0)
else:
attr_dict = {
DocumentAttributeFilename:
@ -1063,7 +1156,7 @@ class TelegramClient(TelegramBareClient):
# cache only makes a difference for documents where the user may
# want the attributes used on them to change. Caption's ignored.
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
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)
"""
import math
import mimetypes
import re
from mimetypes import add_type, guess_extension
from .tl.types.contacts import ResolvedPeer
from .tl.types import (
Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull,
ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty,
@ -24,6 +24,7 @@ from .tl.types import (
InputMediaUploadedPhoto, DocumentAttributeFilename, photos,
TopPeer, InputNotifyPeer
)
from .tl.types.contacts import ResolvedPeer
USERNAME_RE = re.compile(
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):
"""Returns True if the file extension looks like an image file"""
return (isinstance(file, str) and
bool(re.search(r'\.(png|jpe?g|gif)$', file, re.IGNORECASE)))
return (mimetypes.guess_type(file)[0] or '').startswith('image/')
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):

View File

@ -1,3 +1,3 @@
# Versions should comply with PEP440.
# 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(
"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:
f.write('.format(self.{})'.format(capture_name))
f.write(')\n')