From 4f880dcd569247e8ef9f0d2a4d59a7dd7f2f45f3 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 6 Mar 2018 12:09:37 +0100 Subject: [PATCH 01/27] Replace BLOB with LargeBinary in sqlalchemy.py (closes #670) --- telethon/sessions/sqlalchemy.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/telethon/sessions/sqlalchemy.py b/telethon/sessions/sqlalchemy.py index d4e72f16..dc9040a1 100644 --- a/telethon/sessions/sqlalchemy.py +++ b/telethon/sessions/sqlalchemy.py @@ -1,6 +1,6 @@ try: from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy import Column, String, Integer, BLOB, orm + from sqlalchemy import Column, String, Integer, LargeBinary, orm import sqlalchemy as sql except ImportError: sql = None @@ -59,7 +59,7 @@ class AlchemySessionContainer: dc_id = Column(Integer, primary_key=True) server_address = Column(String) port = Column(Integer) - auth_key = Column(BLOB) + auth_key = Column(LargeBinary) class Entity(Base): query = db.query_property() @@ -77,7 +77,7 @@ class AlchemySessionContainer: __tablename__ = '{prefix}sent_files'.format(prefix=prefix) session_id = Column(String, primary_key=True) - md5_digest = Column(BLOB, primary_key=True) + md5_digest = Column(LargeBinary, primary_key=True) file_size = Column(Integer, primary_key=True) type = Column(Integer, primary_key=True) id = Column(Integer) From 7201482ebdb002e47b27aba65f7b67da8093e4a1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 6 Mar 2018 12:24:37 +0100 Subject: [PATCH 02/27] Support limit=0 on .get_participants to fetch count only --- telethon/telegram_client.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index f79721a6..f514e7c8 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -809,7 +809,6 @@ class TelegramClient(TelegramBareClient): return [id_to_message[random_to_id[rnd]] for rnd in req.random_id] - def edit_message(self, entity, message_id, message=None, parse_mode='md', link_preview=True): """ @@ -963,7 +962,8 @@ class TelegramClient(TelegramBareClient): # No messages, but we still need to know the total message count result = self(GetHistoryRequest( peer=entity, limit=1, - offset_date=None, offset_id=0, max_id=0, min_id=0, add_offset=0 + offset_date=None, offset_id=0, max_id=0, min_id=0, + add_offset=0, hash=0 )) return getattr(result, 'count', len(result.messages)), [], [] @@ -1131,6 +1131,10 @@ class TelegramClient(TelegramBareClient): total = self(GetFullChannelRequest( entity )).full_chat.participants_count + if limit == 0: + users = UserList() + users.total = total + return users all_participants = {} if total > 10000 and aggressive: @@ -1188,7 +1192,7 @@ class TelegramClient(TelegramBareClient): users = UserList(users) users.total = len(users) else: - users = UserList([entity]) + users = UserList(None if limit == 0 else [entity]) users.total = 1 return users From e3adec5ea9fe554bffd2d63bd759c68a0bd19e4d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 09:09:05 +0100 Subject: [PATCH 03/27] Fix caption being None This would later be an empty string with some modifications that were removed upon upgrading to layer 75, which changed where the captions are used and their naming. --- telethon/telegram_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index f514e7c8..dbe026c6 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -1200,7 +1200,7 @@ class TelegramClient(TelegramBareClient): # region Uploading files - def send_file(self, entity, file, caption=None, + def send_file(self, entity, file, caption='', force_document=False, progress_callback=None, reply_to=None, attributes=None, @@ -1420,7 +1420,7 @@ class TelegramClient(TelegramBareClient): kwargs['is_voice_note'] = True return self.send_file(*args, **kwargs) - def _send_album(self, entity, files, caption=None, + def _send_album(self, entity, files, caption='', progress_callback=None, reply_to=None, parse_mode='md'): """Specialized version of .send_file for albums""" From dd6802e032a1f3c162bd9e3404b00ad3c2f5818f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 11:45:37 +0100 Subject: [PATCH 04/27] Support PhotoSize in .download_media (#669) This simplifies downloading thumbnails (and any other PhotoSize). --- telethon/telegram_client.py | 42 +++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index dbe026c6..e1914eb5 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -82,8 +82,8 @@ from .tl.types import ( InputDocument, InputMediaDocument, Document, MessageEntityTextUrl, InputMessageEntityMentionName, DocumentAttributeVideo, UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates, - MessageMediaWebPage, ChannelParticipantsSearch -) + MessageMediaWebPage, ChannelParticipantsSearch, PhotoSize, PhotoCachedSize, + PhotoSizeEmpty) from .tl.types.messages import DialogsSlice from .extensions import markdown, html @@ -1720,7 +1720,8 @@ class TelegramClient(TelegramBareClient): date = datetime.now() media = message - if isinstance(media, (MessageMediaPhoto, Photo)): + if isinstance(media, (MessageMediaPhoto, Photo, + PhotoSize, PhotoCachedSize)): return self._download_photo( media, file, date, progress_callback ) @@ -1738,24 +1739,39 @@ class TelegramClient(TelegramBareClient): # Determine the photo and its largest size if isinstance(photo, MessageMediaPhoto): photo = photo.photo - if not isinstance(photo, Photo): + if isinstance(photo, Photo): + for size in reversed(photo.sizes): + if not isinstance(size, PhotoSizeEmpty): + photo = size + break + else: + return + if not isinstance(photo, (PhotoSize, PhotoCachedSize)): return - largest_size = photo.sizes[-1] - file_size = largest_size.size - largest_size = largest_size.location - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + if isinstance(photo, PhotoCachedSize): + # No need to download anything, simply write the bytes + if isinstance(file, str): + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + try: + f.write(photo.bytes) + finally: + if isinstance(file, str): + f.close() + return file - # Download the media with the largest size input file location self.download_file( InputFileLocation( - volume_id=largest_size.volume_id, - local_id=largest_size.local_id, - secret=largest_size.secret + volume_id=photo.location.volume_id, + local_id=photo.location.local_id, + secret=photo.location.secret ), file, - file_size=file_size, + file_size=photo.size, progress_callback=progress_callback ) return file From d0bdb7ea3f381a536ea3226ac28c485a3ae3fd46 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 11:13:55 +0100 Subject: [PATCH 05/27] Lower message severity when retrying invoke the first time --- telethon/telegram_bare_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index bf33a7dc..7164bb17 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -450,9 +450,10 @@ class TelegramBareClient: if result is not None: return result - __log__.warning('Invoking %s failed %d times, ' - 'reconnecting and retrying', - [str(x) for x in requests], retry + 1) + log = __log__.info if retry == 0 else __log__.warning + log('Invoking %s failed %d times, connecting again and retrying', + [str(x) for x in requests], retry + 1) + sleep(1) # The ReadThread has priority when attempting reconnection, # since this thread is constantly running while __call__ is From fca4904d0f7fbad067ca40a3f64e78fe98947e35 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 11:30:03 +0100 Subject: [PATCH 06/27] Add more logging calls when confirming a request --- telethon/network/mtproto_sender.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index cbcdc76d..532a8da7 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -69,6 +69,7 @@ class MtProtoSender: def disconnect(self): """Disconnects from the server.""" + __log__.info('Disconnecting MtProtoSender...') self.connection.close() self._need_confirmation.clear() self._clear_all_pending() @@ -244,6 +245,7 @@ class MtProtoSender: if r: r.result = True # Telegram won't send this value r.confirm_received.set() + __log__.debug('Confirmed %s through ack', type(r).__name__) return True @@ -252,6 +254,7 @@ class MtProtoSender: if r: r.result = obj r.confirm_received.set() + __log__.debug('Confirmed %s through salt', type(r).__name__) # If the object isn't any of the above, then it should be an Update. self.session.process_entities(obj) @@ -308,6 +311,7 @@ class MtProtoSender: """ for r in self._pending_receive.values(): r.request.confirm_received.set() + __log__.info('Abruptly confirming %s', type(r).__name__) self._pending_receive.clear() def _resend_request(self, msg_id): @@ -337,6 +341,7 @@ class MtProtoSender: if request: request.result = pong request.confirm_received.set() + __log__.debug('Confirmed %s through pong', type(request).__name__) return True @@ -490,6 +495,9 @@ class MtProtoSender: if request: request.rpc_error = error request.confirm_received.set() + + __log__.debug('Confirmed %s through error %s', + type(request).__name__, error) # else TODO Where should this error be reported? # Read may be async. Can an error not-belong to a request? return True # All contents were read okay @@ -505,6 +513,10 @@ class MtProtoSender: self.session.process_entities(request.result) request.confirm_received.set() + __log__.debug( + 'Confirmed %s through normal result %s', + type(request).__name__, type(request.result).__name__ + ) return True # If it's really a result for RPC from previous connection From dc99d119c321613a4c0b226dfe23175f84e86deb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 17:29:05 +0100 Subject: [PATCH 07/27] Fix events.MessageDeleted always failing due to extra "self." --- telethon/events/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index 5c0f2d07..f12af888 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -908,7 +908,7 @@ class MessageDeleted(_EventBuilder): types.Message((deleted_ids or [0])[0], peer, None, '') ) self.deleted_id = None if not deleted_ids else deleted_ids[0] - self.deleted_ids = self.deleted_ids + self.deleted_ids = deleted_ids class StopPropagation(Exception): From 801018fa9b84b2b8e67a512b1a4cc748d2771564 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 17:51:59 +0100 Subject: [PATCH 08/27] Add respond, reply and delete methods to events.ChatAction Also introduces the new .action_message member. --- telethon/events/__init__.py | 63 ++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index f12af888..d84ed043 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -518,37 +518,37 @@ class ChatAction(_EventBuilder): msg = update.message action = update.message.action if isinstance(action, types.MessageActionChatJoinedByLink): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, added_by=True, users=msg.from_id) elif isinstance(action, types.MessageActionChatAddUser): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, added_by=msg.from_id or True, users=action.users) elif isinstance(action, types.MessageActionChatDeleteUser): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, kicked_by=msg.from_id or True, users=action.user_id) elif isinstance(action, types.MessageActionChatCreate): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, users=action.users, created=True, new_title=action.title) elif isinstance(action, types.MessageActionChannelCreate): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, created=True, users=msg.from_id, new_title=action.title) elif isinstance(action, types.MessageActionChatEditTitle): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, users=msg.from_id, new_title=action.title) elif isinstance(action, types.MessageActionChatEditPhoto): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, users=msg.from_id, new_photo=action.photo) elif isinstance(action, types.MessageActionChatDeletePhoto): - event = ChatAction.Event(msg.to_id, + event = ChatAction.Event(msg, users=msg.from_id, new_photo=True) else: @@ -591,10 +591,17 @@ class ChatAction(_EventBuilder): new_title (:obj:`bool`, optional): The new title string for the chat, if applicable. """ - def __init__(self, chat_peer, new_pin=None, new_photo=None, + def __init__(self, where, new_pin=None, new_photo=None, added_by=None, kicked_by=None, created=None, - users=None, new_title=None): - super().__init__(chat_peer=chat_peer, msg_id=new_pin) + users=None, new_title=None, action_message=None): + if isinstance(where, types.MessageService): + self.action_message = where + where = where.to_id + else: + self.action_message = None + + super().__init__(chat_peer=where, msg_id=new_pin) + self.action_message = action_message self.new_pin = isinstance(new_pin, int) self._pinned_message = new_pin @@ -626,6 +633,40 @@ class ChatAction(_EventBuilder): self._input_users = None self.new_title = new_title + def respond(self, *args, **kwargs): + """ + Responds to the chat action message (not as a reply). + Shorthand for ``client.send_message(event.chat, ...)``. + """ + return self._client.send_message(self.input_chat, *args, **kwargs) + + def reply(self, *args, **kwargs): + """ + Replies to the chat action message (as a reply). Shorthand for + ``client.send_message(event.chat, ..., reply_to=event.message.id)``. + + Has the same effect as ``.respond()`` if there is no message. + """ + if not self.action_message: + return self.respond(*args, **kwargs) + + kwargs['reply_to'] = self.action_message.id + return self._client.send_message(self.input_chat, *args, **kwargs) + + def delete(self, *args, **kwargs): + """ + Deletes the chat action 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, ...)``. + + Does nothing if no message action triggered this event. + """ + if self.action_message: + return self._client.delete_messages(self.input_chat, + [self.action_message], + *args, **kwargs) + @property def pinned_message(self): """ From d3d190f36ecfb92b3ec0b48794070b1f0ea8cbe1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 7 Mar 2018 17:57:54 +0100 Subject: [PATCH 09/27] Fix-up previous commit overriding .action_message with None --- telethon/events/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index d84ed043..ec866dd5 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -593,7 +593,7 @@ class ChatAction(_EventBuilder): """ def __init__(self, where, new_pin=None, new_photo=None, added_by=None, kicked_by=None, created=None, - users=None, new_title=None, action_message=None): + users=None, new_title=None): if isinstance(where, types.MessageService): self.action_message = where where = where.to_id @@ -601,7 +601,6 @@ class ChatAction(_EventBuilder): self.action_message = None super().__init__(chat_peer=where, msg_id=new_pin) - self.action_message = action_message self.new_pin = isinstance(new_pin, int) self._pinned_message = new_pin From ce0dee63b1609c1a848f837e47977aaac6945038 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 10:05:40 +0100 Subject: [PATCH 10/27] Support getting any entity by just their positive ID --- telethon/sessions/memory.py | 25 ++++++++++++++------ telethon/sessions/sqlalchemy.py | 23 +++++++++++++----- telethon/sessions/sqlite.py | 19 +++++++++++---- telethon/telegram_client.py | 42 +++++++++++++++++++-------------- 4 files changed, 74 insertions(+), 35 deletions(-) diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 4d7e6778..43ddde4b 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -1,9 +1,8 @@ from enum import Enum -from .. import utils from .abstract import Session +from .. import utils from ..tl import TLObject - from ..tl.types import ( PeerUser, PeerChat, PeerChannel, InputPeerUser, InputPeerChat, InputPeerChannel, @@ -148,10 +147,19 @@ class MemorySession(Session): except StopIteration: pass - def get_entity_rows_by_id(self, id): + def get_entity_rows_by_id(self, id, exact=True): try: - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id == id) + if exact: + return next((id, hash) for found_id, hash, _, _, _ + in self._entities if found_id == id) + else: + ids = ( + utils.get_peer_id(PeerUser(id)), + utils.get_peer_id(PeerChat(id)), + utils.get_peer_id(PeerChannel(id)) + ) + return next((id, hash) for found_id, hash, _, _, _ + in self._entities if found_id in ids) except StopIteration: pass @@ -167,6 +175,9 @@ class MemorySession(Session): # Not a TLObject or can't be cast into InputPeer if isinstance(key, TLObject): key = utils.get_peer_id(key) + exact = True + else: + exact = False result = None if isinstance(key, str): @@ -178,8 +189,8 @@ class MemorySession(Session): if username: result = self.get_entity_rows_by_username(username) - if isinstance(key, int): - result = self.get_entity_rows_by_id(key) + elif isinstance(key, int): + result = self.get_entity_rows_by_id(key, exact) if not result and isinstance(key, str): result = self.get_entity_rows_by_name(key) diff --git a/telethon/sessions/sqlalchemy.py b/telethon/sessions/sqlalchemy.py index dc9040a1..ceaa0847 100644 --- a/telethon/sessions/sqlalchemy.py +++ b/telethon/sessions/sqlalchemy.py @@ -4,12 +4,13 @@ try: import sqlalchemy as sql except ImportError: sql = None - pass - -from ..crypto import AuthKey -from ..tl.types import InputPhoto, InputDocument from .memory import MemorySession, _SentFileType +from .. import utils +from ..crypto import AuthKey +from ..tl.types import ( + InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel +) LATEST_VERSION = 1 @@ -201,8 +202,18 @@ class AlchemySession(MemorySession): self.Entity.name == key).one_or_none() return row.id, row.hash if row else None - def get_entity_rows_by_id(self, key): - row = self._db_query(self.Entity, self.Entity.id == key).one_or_none() + def get_entity_rows_by_id(self, key, exact=True): + if exact: + query = self._db_query(self.Entity, self.Entity.id == key) + else: + ids = ( + utils.get_peer_id(PeerUser(key)), + utils.get_peer_id(PeerChat(key)), + utils.get_peer_id(PeerChannel(key)) + ) + query = self._db_query(self.Entity, self.Entity.id in ids) + + row = query.one_or_none() return row.id, row.hash if row else None def get_file(self, md5_digest, file_size, cls): diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index c764cd21..e9a4a723 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -6,9 +6,10 @@ from os.path import isfile as file_exists from threading import Lock, RLock from .memory import MemorySession, _SentFileType +from .. import utils from ..crypto import AuthKey from ..tl.types import ( - InputPhoto, InputDocument + InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel ) EXTENSION = '.session' @@ -282,9 +283,19 @@ class SQLiteSession(MemorySession): return self._fetchone_entity( 'select id, hash from entities where name=?', (name,)) - def get_entity_rows_by_id(self, id): - return self._fetchone_entity( - 'select id, hash from entities where id=?', (id,)) + def get_entity_rows_by_id(self, id, exact=True): + if exact: + return self._fetchone_entity( + 'select id, hash from entities where id=?', (id,)) + else: + ids = ( + utils.get_peer_id(PeerUser(id)), + utils.get_peer_id(PeerChat(id)), + utils.get_peer_id(PeerChannel(id)) + ) + return self._fetchone_entity( + 'select id, hash from entities where id in (?,?,?)', ids + ) # File processing diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index e1914eb5..87f57945 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -2272,23 +2272,21 @@ class TelegramClient(TelegramBareClient): return InputPeerSelf() return utils.get_input_peer(self._get_entity_from_string(peer)) - if isinstance(peer, int): - peer, kind = utils.resolve_id(peer) - peer = kind(peer) + if not isinstance(peer, int): + try: + if peer.SUBCLASS_OF_ID != 0x2d45687: # crc32(b'Peer') + return utils.get_input_peer(peer) + except (AttributeError, TypeError): + peer = None - try: - is_peer = peer.SUBCLASS_OF_ID == 0x2d45687 # crc32(b'Peer') - if not is_peer: - return utils.get_input_peer(peer) - except (AttributeError, TypeError): - is_peer = False - - if not is_peer: + if not peer: raise TypeError( 'Cannot turn "{}" into an input entity.'.format(peer) ) - # Not found, look in the dialogs with the hope to find it. + # Add the mark to the peers if the user passed a Peer (not an int) + # Look in the dialogs with the hope to find it. + mark = not isinstance(peer, int) target_id = utils.get_peer_id(peer) req = GetDialogsRequest( offset_date=None, @@ -2299,12 +2297,20 @@ class TelegramClient(TelegramBareClient): while True: result = self(req) entities = {} - for x in itertools.chain(result.users, result.chats): - x_id = utils.get_peer_id(x) - if x_id == target_id: - return utils.get_input_peer(x) - else: - entities[x_id] = x + if mark: + for x in itertools.chain(result.users, result.chats): + x_id = utils.get_peer_id(x) + if x_id == target_id: + return utils.get_input_peer(x) + else: + entities[x_id] = x + else: + for x in itertools.chain(result.users, result.chats): + if x.id == target_id: + return utils.get_input_peer(x) + else: + entities[utils.get_peer_id(x)] = x + if len(result.dialogs) < req.limit: break From 0f34a9b3336117aadfc6309d997e7e4a12871d5a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 10:08:26 +0100 Subject: [PATCH 11/27] Fix .get_input_entity error message always showing None --- telethon/telegram_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 87f57945..937540da 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -2272,6 +2272,7 @@ class TelegramClient(TelegramBareClient): return InputPeerSelf() return utils.get_input_peer(self._get_entity_from_string(peer)) + original_peer = peer if not isinstance(peer, int): try: if peer.SUBCLASS_OF_ID != 0x2d45687: # crc32(b'Peer') @@ -2281,7 +2282,7 @@ class TelegramClient(TelegramBareClient): if not peer: raise TypeError( - 'Cannot turn "{}" into an input entity.'.format(peer) + 'Cannot turn "{}" into an input entity.'.format(original_peer) ) # Add the mark to the peers if the user passed a Peer (not an int) From 3a3ae75b4615e1116b88e73b3e121c0943e3be4c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 10:12:43 +0100 Subject: [PATCH 12/27] Fix-up bot API style IDs not working on .get_input_entity --- telethon/sessions/memory.py | 2 +- telethon/telegram_client.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 43ddde4b..e5223cac 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -177,7 +177,7 @@ class MemorySession(Session): key = utils.get_peer_id(key) exact = True else: - exact = False + exact = not isinstance(key, int) or key < 0 result = None if isinstance(key, str): diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 937540da..e35b996c 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -2285,9 +2285,10 @@ class TelegramClient(TelegramBareClient): 'Cannot turn "{}" into an input entity.'.format(original_peer) ) - # Add the mark to the peers if the user passed a Peer (not an int) + # Add the mark to the peers if the user passed a Peer (not an int), + # or said ID is negative. If it's negative it's been marked already. # Look in the dialogs with the hope to find it. - mark = not isinstance(peer, int) + mark = not isinstance(peer, int) or peer < 0 target_id = utils.get_peer_id(peer) req = GetDialogsRequest( offset_date=None, From 841aed13da0ec4baed362725ab3c5a89e5bf2db7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 10:16:46 +0100 Subject: [PATCH 13/27] Fix tuple/ternary operator fail on SQLAlchemy session (#671) --- telethon/sessions/sqlalchemy.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/telethon/sessions/sqlalchemy.py b/telethon/sessions/sqlalchemy.py index ceaa0847..a24df485 100644 --- a/telethon/sessions/sqlalchemy.py +++ b/telethon/sessions/sqlalchemy.py @@ -159,8 +159,9 @@ class AlchemySession(MemorySession): self.db.merge(new) def _db_query(self, dbclass, *args): - return dbclass.query.filter(dbclass.session_id == self.session_id, - *args) + return dbclass.query.filter( + dbclass.session_id == self.session_id, *args + ) def save(self): self.container.save() @@ -190,17 +191,17 @@ class AlchemySession(MemorySession): def get_entity_rows_by_phone(self, key): row = self._db_query(self.Entity, self.Entity.phone == key).one_or_none() - return row.id, row.hash if row else None + return (row.id, row.hash) if row else None def get_entity_rows_by_username(self, key): row = self._db_query(self.Entity, self.Entity.username == key).one_or_none() - return row.id, row.hash if row else None + return (row.id, row.hash) if row else None def get_entity_rows_by_name(self, key): row = self._db_query(self.Entity, self.Entity.name == key).one_or_none() - return row.id, row.hash if row else None + return (row.id, row.hash) if row else None def get_entity_rows_by_id(self, key, exact=True): if exact: @@ -214,7 +215,7 @@ class AlchemySession(MemorySession): query = self._db_query(self.Entity, self.Entity.id in ids) row = query.one_or_none() - return row.id, row.hash if row else None + return (row.id, row.hash) if row else None def get_file(self, md5_digest, file_size, cls): row = self._db_query(self.SentFile, @@ -222,7 +223,7 @@ class AlchemySession(MemorySession): self.SentFile.file_size == file_size, self.SentFile.type == _SentFileType.from_type( cls).value).one_or_none() - return row.id, row.hash if row else None + return (row.id, row.hash) if row else None def cache_file(self, md5_digest, file_size, instance): if not isinstance(instance, (InputDocument, InputPhoto)): From 09f0f86f1e6fe6ef8df5527f09dcf1a71496175d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 10:30:49 +0100 Subject: [PATCH 14/27] Add convenience NewMessage attrs to get media of specific types --- telethon/events/__init__.py | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index ec866dd5..8bf7b51c 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -482,6 +482,52 @@ class NewMessage(_EventBuilder): if isinstance(doc, types.Document): return doc + def _document_by_attribute(self, kind, condition=None): + """ + Helper method to return the document only if it has an attribute + that's an instance of the given kind, and passes the condition. + """ + doc = self.document + if doc: + for attr in doc.attributes: + if isinstance(attr, kind): + if not condition or condition(doc): + return doc + + @property + def audio(self): + """ + If the message media is a document with an Audio attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeAudio, + lambda attr: not attr.voice) + + @property + def voice(self): + """ + If the message media is a document with a Voice attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeAudio, + lambda attr: attr.voice) + + @property + def video(self): + """ + If the message media is a document with a Video attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeVideo) + + @property + def sticker(self): + """ + If the message media is a document with a Sticker attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeSticker) + @property def out(self): """ From 567386655338e66a0c4018cdca2537c76810b533 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 11:44:13 +0100 Subject: [PATCH 15/27] Create client.iter_ versions for all client.get_ methods While doing so, the client.iter_drafts method has been simplified as it made some unnecessary calls. client.get_message_history has been shortened to client.get_messages, and fixes a bug where the limit being zero made it return a tuple. client.iter_messages also uses a local dictionary for entities so it should become less big in memory (and possibly faster). client.get_participants would fail with user entities, returning only their input version. --- telethon/telegram_client.py | 259 ++++++++++++++++++++++-------------- 1 file changed, 158 insertions(+), 101 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index e35b996c..d62b15fe 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -90,6 +90,12 @@ from .extensions import markdown, html __log__ = logging.getLogger(__name__) +class _Box: + """Helper class to pass parameters by reference""" + def __init__(self, x=None): + self.x = x + + class TelegramClient(TelegramBareClient): """ Initializes the Telegram client with the specified API ID and Hash. @@ -508,10 +514,11 @@ class TelegramClient(TelegramBareClient): # region Dialogs ("chats") requests - def get_dialogs(self, limit=10, offset_date=None, offset_id=0, - offset_peer=InputPeerEmpty()): + def iter_dialogs(self, limit=None, offset_date=None, offset_id=0, + offset_peer=InputPeerEmpty(), _total_box=None): """ - Gets N "dialogs" (open "chats" or conversations with other people). + Returns an iterator over the dialogs, yielding 'limit' at most. + Dialogs are the open "chats" or conversations with other people. Args: limit (:obj:`int` | :obj:`None`): @@ -530,11 +537,16 @@ class TelegramClient(TelegramBareClient): offset_peer (:obj:`InputPeer`, optional): The peer to be used as an offset. - Returns: - A list dialogs, with an additional .total attribute on the list. + _total_box (:obj:`_Box`, optional): + A _Box instance to pass the total parameter by reference. + + Yields: + Instances of ``telethon.tl.custom.Dialog``. """ limit = float('inf') if limit is None else int(limit) if limit == 0: + if not _total_box: + return # Special case, get a single dialog and determine count dialogs = self(GetDialogsRequest( offset_date=offset_date, @@ -542,14 +554,12 @@ class TelegramClient(TelegramBareClient): offset_peer=offset_peer, limit=1 )) - result = UserList() - result.total = getattr(dialogs, 'count', len(dialogs.dialogs)) - return result + _total_box.x = getattr(dialogs, 'count', len(dialogs.dialogs)) + return - total_count = 0 - dialogs = OrderedDict() # Use peer id as identifier to avoid dupes - while len(dialogs) < limit: - real_limit = min(limit - len(dialogs), 100) + seen = set() + while len(seen) < limit: + real_limit = min(limit - len(seen), 100) r = self(GetDialogsRequest( offset_date=offset_date, offset_id=offset_id, @@ -557,14 +567,17 @@ class TelegramClient(TelegramBareClient): limit=real_limit )) - total_count = getattr(r, 'count', len(r.dialogs)) + if _total_box: + _total_box.x = getattr(r, 'count', len(r.dialogs)) messages = {m.id: m for m in r.messages} entities = {utils.get_peer_id(x): x for x in itertools.chain(r.users, r.chats)} for d in r.dialogs: - dialogs[utils.get_peer_id(d.peer)] = \ - Dialog(self, d, entities, messages) + peer_id = utils.get_peer_id(d.peer) + if peer_id not in seen: + seen.add(peer_id) + yield Dialog(self, d, entities, messages) if len(r.dialogs) < real_limit or not isinstance(r, DialogsSlice): # Less than we requested means we reached the end, or @@ -575,26 +588,33 @@ class TelegramClient(TelegramBareClient): offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] offset_id = r.messages[-1].id - dialogs = UserList( - itertools.islice(dialogs.values(), min(limit, len(dialogs))) - ) - dialogs.total = total_count + def get_dialogs(self, *args, **kwargs): + """ + Same as :meth:`iter_dialogs`, but returns a list instead + with an additional .total attribute on the list. + """ + total_box = _Box(0) + kwargs['_total_box'] = total_box + dialogs = UserList(self.iter_dialogs(*args, **kwargs)) + dialogs.total = total_box.x return dialogs - def get_drafts(self): # TODO: Ability to provide a `filter` + def iter_drafts(self): # TODO: Ability to provide a `filter` """ - Gets all open draft messages. + Iterator over all open draft messages. - Returns: - A list of custom ``Draft`` objects that are easy to work with: - You can call ``draft.set_message('text')`` to change the message, - or delete it through :meth:`draft.delete()`. + The yielded items are custom ``Draft`` objects that are easier to use. + You can call ``draft.set_message('text')`` to change the message, + or delete it through :meth:`draft.delete()`. """ - response = self(GetAllDraftsRequest()) - self.session.process_entities(response) - self.session.generate_sequence(response.seq) - drafts = [Draft._from_update(self, u) for u in response.updates] - return drafts + for update in self(GetAllDraftsRequest()).updates: + yield Draft._from_update(self, update) + + def get_drafts(self): + """ + Same as :meth:`iter_drafts`, but returns a list instead. + """ + return list(self.iter_drafts()) @staticmethod def _get_response_message(request, result): @@ -891,11 +911,11 @@ class TelegramClient(TelegramBareClient): else: return self(messages.DeleteMessagesRequest(message_ids, revoke=revoke)) - def get_message_history(self, entity, limit=20, offset_date=None, - offset_id=0, max_id=0, min_id=0, add_offset=0, - batch_size=100, wait_time=None): + def iter_messages(self, entity, limit=20, offset_date=None, + offset_id=0, max_id=0, min_id=0, add_offset=0, + batch_size=100, wait_time=None, _total_box=None): """ - Gets the message history for the specified entity + Iterator over the message history for the specified entity. Args: entity (:obj:`entity`): @@ -939,10 +959,12 @@ class TelegramClient(TelegramBareClient): If left to ``None``, it will default to 1 second only if the limit is higher than 3000. - Returns: - A list of messages with extra attributes: + _total_box (:obj:`_Box`, optional): + A _Box instance to pass the total parameter by reference. + + Yields: + Instances of ``telethon.tl.types.Message`` with extra attributes: - * ``.total`` = (on the list) total amount of messages sent. * ``.sender`` = entity of the sender. * ``.fwd_from.sender`` = if fwd_from, who sent it originally. * ``.fwd_from.channel`` = if fwd_from, original channel. @@ -959,25 +981,26 @@ class TelegramClient(TelegramBareClient): entity = self.get_input_entity(entity) limit = float('inf') if limit is None else int(limit) if limit == 0: + if not _total_box: + return # No messages, but we still need to know the total message count result = self(GetHistoryRequest( peer=entity, limit=1, offset_date=None, offset_id=0, max_id=0, min_id=0, add_offset=0, hash=0 )) - return getattr(result, 'count', len(result.messages)), [], [] + _total_box.x = getattr(result, 'count', len(result.messages)) + return if wait_time is None: wait_time = 1 if limit > 3000 else 0 + have = 0 batch_size = min(max(batch_size, 1), 100) - total_messages = 0 - messages = UserList() - entities = {} - while len(messages) < limit: + while have < limit: # Telegram has a hard limit of 100 - real_limit = min(limit - len(messages), batch_size) - result = self(GetHistoryRequest( + real_limit = min(limit - have, batch_size) + r = self(GetHistoryRequest( peer=entity, limit=real_limit, offset_date=offset_date, @@ -987,48 +1010,63 @@ class TelegramClient(TelegramBareClient): add_offset=add_offset, hash=0 )) - messages.extend( - m for m in result.messages if not isinstance(m, MessageEmpty) - ) - total_messages = getattr(result, 'count', len(result.messages)) + if _total_box: + _total_box.x = getattr(r, 'count', len(r.messages)) - for u in result.users: - entities[utils.get_peer_id(u)] = u - for c in result.chats: - entities[utils.get_peer_id(c)] = c + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} - if len(result.messages) < real_limit: + for message in r.messages: + if isinstance(message, MessageEmpty): + continue + + # Add a few extra attributes to the Message to be friendlier. + # To make messages more friendly, always add message + # to service messages, and action to normal messages. + message.message = getattr(message, 'message', None) + message.action = getattr(message, 'action', None) + message.to = entities[utils.get_peer_id(message.to_id)] + message.sender = ( + None if not message.from_id else + entities[utils.get_peer_id(message.from_id)] + ) + if getattr(message, 'fwd_from', None): + message.fwd_from.sender = ( + None if not message.fwd_from.from_id else + entities[utils.get_peer_id(message.fwd_from.from_id)] + ) + message.fwd_from.channel = ( + None if not message.fwd_from.channel_id else + entities[utils.get_peer_id( + PeerChannel(message.fwd_from.channel_id) + )] + ) + yield message + have += 1 + + if len(r.messages) < real_limit: break - offset_id = result.messages[-1].id - offset_date = result.messages[-1].date + offset_id = r.messages[-1].id + offset_date = r.messages[-1].date time.sleep(wait_time) - # Add a few extra attributes to the Message to make it friendlier. - messages.total = total_messages - for m in messages: - # To make messages more friendly, always add message - # to service messages, and action to normal messages. - m.message = getattr(m, 'message', None) - m.action = getattr(m, 'action', None) - m.sender = (None if not m.from_id else - entities[utils.get_peer_id(m.from_id)]) + def get_messages(self, *args, **kwargs): + """ + Same as :meth:`iter_messages`, but returns a list instead + with an additional .total attribute on the list. + """ + total_box = _Box(0) + kwargs['_total_box'] = total_box + msgs = UserList(self.iter_messages(*args, **kwargs)) + msgs.total = total_box.x + return msgs - if getattr(m, 'fwd_from', None): - m.fwd_from.sender = ( - None if not m.fwd_from.from_id else - entities[utils.get_peer_id(m.fwd_from.from_id)] - ) - m.fwd_from.channel = ( - None if not m.fwd_from.channel_id else - entities[utils.get_peer_id( - PeerChannel(m.fwd_from.channel_id) - )] - ) - - m.to = entities[utils.get_peer_id(m.to_id)] - - return messages + def get_message_history(self, *args, **kwargs): + warnings.warn( + 'get_message_history is deprecated, use get_messages instead' + ) + return self.get_messages(*args, **kwargs) def send_read_acknowledge(self, entity, message=None, max_id=None, clear_mentions=False): @@ -1096,8 +1134,8 @@ class TelegramClient(TelegramBareClient): raise TypeError('Invalid message type: {}'.format(type(message))) - def get_participants(self, entity, limit=None, search='', - aggressive=False): + def iter_participants(self, entity, limit=None, search='', + aggressive=False, _total_box=None): """ Gets the list of participants from the specified entity. @@ -1121,6 +1159,9 @@ class TelegramClient(TelegramBareClient): This has no effect for groups or channels with less than 10,000 members. + _total_box (:obj:`_Box`, optional): + A _Box instance to pass the total parameter by reference. + Returns: A list of participants with an additional .total variable on the list indicating the total amount of members in this group/channel. @@ -1131,12 +1172,13 @@ class TelegramClient(TelegramBareClient): total = self(GetFullChannelRequest( entity )).full_chat.participants_count - if limit == 0: - users = UserList() - users.total = total - return users + if _total_box: + _total_box.x = total - all_participants = {} + if limit == 0: + return + + seen = set() if total > 10000 and aggressive: requests = [GetParticipantsRequest( channel=entity, @@ -1176,25 +1218,40 @@ class TelegramClient(TelegramBareClient): else: requests[i].offset += len(participants.users) for user in participants.users: - if len(all_participants) < limit: - all_participants[user.id] = user - if limit < float('inf'): - values = itertools.islice(all_participants.values(), limit) - else: - values = all_participants.values() + if user.id not in seen: + seen.add(user.id) + yield user + if len(seen) >= limit: + return - users = UserList(values) - users.total = total elif isinstance(entity, InputPeerChat): users = self(GetFullChatRequest(entity.chat_id)).users - if len(users) > limit: - users = users[:limit] - users = UserList(users) - users.total = len(users) + if _total_box: + _total_box.x = len(users) + + have = 0 + for user in users: + have += 1 + if have > limit: + break + else: + yield user else: - users = UserList(None if limit == 0 else [entity]) - users.total = 1 - return users + if _total_box: + _total_box.x = 1 + if limit != 0: + yield self.get_entity(entity) + + def get_participants(self, *args, **kwargs): + """ + Same as :meth:`iter_participants`, but returns a list instead + with an additional .total attribute on the list. + """ + total_box = _Box(0) + kwargs['_total_box'] = total_box + dialogs = UserList(self.iter_participants(*args, **kwargs)) + dialogs.total = total_box.x + return dialogs # endregion From 3d49f740dfe8ea41282a06a85bc49d90f71c841a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 11:48:59 +0100 Subject: [PATCH 16/27] Use the new client.iter_dialogs() in client.get_input_entity() --- telethon/telegram_client.py | 40 ++++++++----------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index d62b15fe..c6ce80e4 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -2347,38 +2347,14 @@ class TelegramClient(TelegramBareClient): # Look in the dialogs with the hope to find it. mark = not isinstance(peer, int) or peer < 0 target_id = utils.get_peer_id(peer) - req = GetDialogsRequest( - offset_date=None, - offset_id=0, - offset_peer=InputPeerEmpty(), - limit=100 - ) - while True: - result = self(req) - entities = {} - if mark: - for x in itertools.chain(result.users, result.chats): - x_id = utils.get_peer_id(x) - if x_id == target_id: - return utils.get_input_peer(x) - else: - entities[x_id] = x - else: - for x in itertools.chain(result.users, result.chats): - if x.id == target_id: - return utils.get_input_peer(x) - else: - entities[utils.get_peer_id(x)] = x - - if len(result.dialogs) < req.limit: - break - - req.offset_id = result.messages[-1].id - req.offset_date = result.messages[-1].date - req.offset_peer = entities[utils.get_peer_id( - result.dialogs[-1].peer - )] - time.sleep(1) + if mark: + for dialog in self.iter_dialogs(): + if utils.get_peer_id(dialog.entity) == target_id: + return utils.get_input_peer(dialog.entity) + else: + for dialog in self.iter_dialogs(): + if dialog.entity.id == target_id: + return utils.get_input_peer(dialog.entity) raise TypeError( 'Could not find the input entity corresponding to "{}". ' From 6e6d40be1881b58e47e60ca5e95204b384d65de5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 12:37:06 +0100 Subject: [PATCH 17/27] Implement Draft.send() (closes #673) --- telethon/telegram_client.py | 13 ++++++++++--- telethon/tl/custom/draft.py | 6 ++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index c6ce80e4..ef707c99 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -689,7 +689,8 @@ class TelegramClient(TelegramBareClient): return message, msg_entities def send_message(self, entity, message='', reply_to=None, parse_mode='md', - link_preview=True, file=None, force_document=False): + link_preview=True, file=None, force_document=False, + clear_draft=False): """ Sends the given message to the specified entity (user/chat/channel). @@ -720,6 +721,10 @@ class TelegramClient(TelegramBareClient): force_document (:obj:`bool`, optional): Whether to send the given file as a document or not. + clear_draft (:obj:`bool`, optional): + Whether the existing draft should be cleared or not. + Has no effect when sending a file. + Returns: the sent message """ @@ -750,7 +755,8 @@ class TelegramClient(TelegramBareClient): reply_to_msg_id=reply_id, reply_markup=message.reply_markup, entities=message.entities, - no_webpage=not isinstance(message.media, MessageMediaWebPage) + no_webpage=not isinstance(message.media, MessageMediaWebPage), + clear_draft=clear_draft ) message = message.message else: @@ -760,7 +766,8 @@ class TelegramClient(TelegramBareClient): message=message, entities=msg_ent, no_webpage=not link_preview, - reply_to_msg_id=self._get_message_id(reply_to) + reply_to_msg_id=self._get_message_id(reply_to), + clear_draft=clear_draft ) result = self(request) diff --git a/telethon/tl/custom/draft.py b/telethon/tl/custom/draft.py index 9b800d4c..bea57f49 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/tl/custom/draft.py @@ -74,6 +74,12 @@ class Draft: return result + def send(self, clear=True): + self._client.send_message(self._peer, self.text, + reply_to=self.reply_to_msg_id, + link_preview=not self.no_webpage, + clear_draft=clear) + def delete(self): """ Deletes this draft From 8cefb22e142e043a763ac2159f919141d972423f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 12:56:20 +0100 Subject: [PATCH 18/27] Add .text and .raw_text properties to the Draft class (#673) --- telethon/tl/custom/draft.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/telethon/tl/custom/draft.py b/telethon/tl/custom/draft.py index bea57f49..1c28a007 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/tl/custom/draft.py @@ -1,5 +1,6 @@ from ..functions.messages import SaveDraftRequest from ..types import UpdateDraftMessage, DraftMessage +from ...extensions import markdown class Draft: @@ -14,11 +15,11 @@ class Draft: if not draft: draft = DraftMessage('', None, None, None, None) - self.text = draft.message + self._text = markdown.unparse(draft.message, draft.entities) + self._raw_text = draft.message self.date = draft.date self.no_webpage = draft.no_webpage self.reply_to_msg_id = draft.reply_to_msg_id - self.entities = draft.entities @classmethod def _from_update(cls, client, update): @@ -38,7 +39,16 @@ class Draft: def input_entity(self): return self._client.get_input_entity(self._peer) - def set_message(self, text, no_webpage=None, reply_to_msg_id=None, entities=None): + @property + def text(self): + return self._text + + @property + def raw_text(self): + return self._raw_text + + def set_message(self, text, no_webpage=None, reply_to_msg_id=None, + parse_mode='md'): """ Changes the draft message on the Telegram servers. The changes are reflected in this object. Changing only individual attributes like for @@ -52,32 +62,34 @@ class Draft: entities=draft.entities ) - :param str text: New text of the draft - :param bool no_webpage: Whether to attach a web page preview - :param int reply_to_msg_id: Message id to reply to - :param list entities: A list of formatting entities - :return bool: ``True`` on success + :param str text: New text of the draft. + :param bool no_webpage: Whether to attach a web page preview. + :param int reply_to_msg_id: Message id to reply to. + :param str parse_mode: The parse mode to be used for the text. + :return bool: ``True`` on success. """ + raw_text, entities = self._client._parse_message_text(text, parse_mode) result = self._client(SaveDraftRequest( peer=self._peer, - message=text, + message=raw_text, no_webpage=no_webpage, reply_to_msg_id=reply_to_msg_id, entities=entities )) if result: - self.text = text + self._text = text + self._raw_text = raw_text self.no_webpage = no_webpage self.reply_to_msg_id = reply_to_msg_id - self.entities = entities return result - def send(self, clear=True): + def send(self, clear=True, parse_mode='md'): self._client.send_message(self._peer, self.text, reply_to=self.reply_to_msg_id, link_preview=not self.no_webpage, + parse_mode=parse_mode, clear_draft=clear) def delete(self): From 9d46bb35c86ae7ff9112283602d9d6f37364db33 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 13:04:01 +0100 Subject: [PATCH 19/27] Rename and reorder some params in Draft for consistency (#673) --- telethon/tl/custom/draft.py | 46 +++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/telethon/tl/custom/draft.py b/telethon/tl/custom/draft.py index 1c28a007..8f3aac60 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/tl/custom/draft.py @@ -18,7 +18,7 @@ class Draft: self._text = markdown.unparse(draft.message, draft.entities) self._raw_text = draft.message self.date = draft.date - self.no_webpage = draft.no_webpage + self.link_preview = not draft.no_webpage self.reply_to_msg_id = draft.reply_to_msg_id @classmethod @@ -47,48 +47,54 @@ class Draft: def raw_text(self): return self._raw_text - def set_message(self, text, no_webpage=None, reply_to_msg_id=None, - parse_mode='md'): + def set_message(self, text=None, reply_to=0, parse_mode='md', + link_preview=None): """ Changes the draft message on the Telegram servers. The changes are - reflected in this object. Changing only individual attributes like for - example the ``reply_to_msg_id`` should be done by providing the current - values of this object, like so: - - draft.set_message( - draft.text, - no_webpage=draft.no_webpage, - reply_to_msg_id=NEW_VALUE, - entities=draft.entities - ) + reflected in this object. :param str text: New text of the draft. - :param bool no_webpage: Whether to attach a web page preview. - :param int reply_to_msg_id: Message id to reply to. + Preserved if left as None. + + :param int reply_to: Message ID to reply to. + Preserved if left as 0, erased if set to None. + + :param bool link_preview: Whether to attach a web page preview. + Preserved if left as None. + :param str parse_mode: The parse mode to be used for the text. :return bool: ``True`` on success. """ + if text is None: + text = self._text + + if reply_to == 0: + reply_to = self.reply_to_msg_id + + 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( peer=self._peer, message=raw_text, - no_webpage=no_webpage, - reply_to_msg_id=reply_to_msg_id, + no_webpage=not link_preview, + reply_to_msg_id=reply_to, entities=entities )) if result: self._text = text self._raw_text = raw_text - self.no_webpage = no_webpage - self.reply_to_msg_id = reply_to_msg_id + self.link_preview = link_preview + self.reply_to_msg_id = reply_to return result def send(self, clear=True, parse_mode='md'): self._client.send_message(self._peer, self.text, reply_to=self.reply_to_msg_id, - link_preview=not self.no_webpage, + link_preview=self.link_preview, parse_mode=parse_mode, clear_draft=clear) From cf650e061e14cacb1e55d3a5e330730f99360ebf Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 20:18:10 +0100 Subject: [PATCH 20/27] Avoid editing events.NewMessage that are forwards --- telethon/events/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index 8bf7b51c..1bd9b8e7 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -340,6 +340,8 @@ class NewMessage(_EventBuilder): Returns ``None`` if the message was incoming, or the edited message otherwise. """ + if self.message.fwd_from: + return None if not self.message.out: if not isinstance(self.message.to_id, types.PeerUser): return None From 2fb42772c606382cbe2b88944bc5c1b22ce1444e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 8 Mar 2018 20:21:56 +0100 Subject: [PATCH 21/27] Add .video_note and .gif convenience properties to NewMessage --- telethon/events/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index 1bd9b8e7..76aab2a5 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -522,6 +522,23 @@ class NewMessage(_EventBuilder): """ return self._document_by_attribute(types.DocumentAttributeVideo) + @property + def video_note(self): + """ + If the message media is a document with a Video attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeVideo, + lambda attr: attr.round_message) + + @property + def gif(self): + """ + If the message media is a document with an Animated attribute, + this returns the (:obj:`Document`) object. + """ + return self._document_by_attribute(types.DocumentAttributeAnimated) + @property def sticker(self): """ From 1ad7712fde65985e5ca6d1c875f47e91bb2d9991 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 10 Mar 2018 11:52:31 +0100 Subject: [PATCH 22/27] Automatically redirect on documentation for exact matches --- docs/res/js/search.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/res/js/search.js b/docs/res/js/search.js index c63672e7..1b33980b 100644 --- a/docs/res/js/search.js +++ b/docs/res/js/search.js @@ -147,6 +147,7 @@ function updateSearch() { } else { exactMatch.style.display = ""; buildList(null, exactList, [destination, destinationu]); + return destinationu[0]; } } else { contentDiv.style.display = ""; @@ -169,4 +170,8 @@ if (query) { searchBox.value = query; } -updateSearch(); +var exactUrl = updateSearch(); +var redirect = getQuery('redirect'); +if (exactUrl && redirect != 'no') { + window.location = exactUrl; +} From e088fc3a4e6835939562b27a7cbe248761b9809c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 10 Mar 2018 12:13:17 +0100 Subject: [PATCH 23/27] Add extra safety checks when getting peer ID --- telethon/utils.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/telethon/utils.py b/telethon/utils.py index a9311521..8e37714e 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -381,6 +381,17 @@ def parse_username(username): return None, False +def _fix_peer_id(peer_id): + """ + Fixes the peer ID for chats and channels, in case the users + mix marking the ID with the ``Peer()`` constructors. + """ + peer_id = abs(peer_id) + if str(peer_id).startswith('100'): + peer_id = str(peer_id)[3:] + return int(peer_id) + + def get_peer_id(peer): """ Finds the ID of the given peer, and converts it to the "bot api" format @@ -408,6 +419,10 @@ def get_peer_id(peer): if isinstance(peer, (PeerUser, InputPeerUser)): return peer.user_id elif isinstance(peer, (PeerChat, InputPeerChat)): + # Check in case the user mixed things up to avoid blowing up + if not (0 < peer.chat_id <= 0x7fffffff): + peer.chat_id = _fix_peer_id(peer.chat_id) + return -peer.chat_id elif isinstance(peer, (PeerChannel, InputPeerChannel, ChannelFull)): if isinstance(peer, ChannelFull): @@ -416,6 +431,15 @@ def get_peer_id(peer): i = peer.id else: i = peer.channel_id + + # Check in case the user mixed things up to avoid blowing up + if not (0 < i <= 0x7fffffff): + i = _fix_peer_id(i) + if isinstance(peer, ChannelFull): + peer.id = i + else: + peer.channel_id = i + # Concat -100 through math tricks, .to_supergroup() on Madeline # IDs will be strictly positive -> log works return -(i + pow(10, math.floor(math.log10(i) + 3))) From 70ef93a62ecf1a5aadb87276e8c2d3c4a48e38db Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 11 Mar 2018 09:38:52 +0100 Subject: [PATCH 24/27] Stop treating image/webp as images as Telegram throws error --- telethon/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/telethon/utils.py b/telethon/utils.py index 8e37714e..daf8c875 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -55,9 +55,6 @@ def get_display_name(entity): return '' -# For some reason, .webp (stickers' format) is not registered -add_type('image/webp', '.webp') - def get_extension(media): """Gets the corresponding extension for any Telegram media""" @@ -319,8 +316,10 @@ def get_input_media(media, is_photo=False): def is_image(file): """Returns True if the file extension looks like an image file""" - return (isinstance(file, str) and - (mimetypes.guess_type(file)[0] or '').startswith('image/')) + if not isinstance(file, str): + return False + mime = mimetypes.guess_type(file)[0] or '' + return mime.startswith('image/') and not mime.endswith('/webp') def is_audio(file): From 055aa7fe433f0ac847a6556564748e7a347765f5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 11 Mar 2018 09:43:55 +0100 Subject: [PATCH 25/27] Fix MessageService not handled on .delete_messages (closes #681) --- telethon/telegram_client.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index ef707c99..f7e5d1a9 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -8,7 +8,7 @@ import re import sys import time import warnings -from collections import OrderedDict, UserList +from collections import UserList from datetime import datetime, timedelta from io import BytesIO from mimetypes import guess_type @@ -83,7 +83,8 @@ from .tl.types import ( InputMessageEntityMentionName, DocumentAttributeVideo, UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates, MessageMediaWebPage, ChannelParticipantsSearch, PhotoSize, PhotoCachedSize, - PhotoSizeEmpty) + PhotoSizeEmpty, MessageService +) from .tl.types.messages import DialogsSlice from .extensions import markdown, html @@ -903,10 +904,13 @@ class TelegramClient(TelegramBareClient): Returns: The affected messages. """ + if not utils.is_list_like(message_ids): + message_ids = (message_ids,) - if not isinstance(message_ids, list): - message_ids = [message_ids] - message_ids = [m.id if isinstance(m, Message) else int(m) for m in message_ids] + message_ids = [ + m.id if isinstance(m, (Message, MessageService, MessageEmpty)) + else int(m) for m in message_ids + ] if entity is None: return self(messages.DeleteMessagesRequest(message_ids, revoke=revoke)) From a596f88497921856fbf5b1639a0a9cf925739e80 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 11 Mar 2018 09:48:48 +0100 Subject: [PATCH 26/27] Fix wrong super() args for events.MessageDeleted (fix #675) --- telethon/events/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index 76aab2a5..a4ec83b2 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -1010,7 +1010,7 @@ class MessageDeleted(_EventBuilder): class Event(_EventCommon): def __init__(self, deleted_ids, peer): super().__init__( - types.Message((deleted_ids or [0])[0], peer, None, '') + chat_peer=peer, msg_id=(deleted_ids or [0])[0] ) self.deleted_id = None if not deleted_ids else deleted_ids[0] self.deleted_ids = deleted_ids From 8b1cc4c8cbf4685d75161d6497788afd9a944bed Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 11 Mar 2018 09:55:31 +0100 Subject: [PATCH 27/27] Better handle pinned dialogs and limit on .get_dialogs() --- telethon/telegram_client.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index f7e5d1a9..2c5bbaf2 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -559,14 +559,15 @@ class TelegramClient(TelegramBareClient): return seen = set() + req = GetDialogsRequest( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=0 + ) while len(seen) < limit: - real_limit = min(limit - len(seen), 100) - r = self(GetDialogsRequest( - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - limit=real_limit - )) + req.limit = min(limit - len(seen), 100) + r = self(req) if _total_box: _total_box.x = getattr(r, 'count', len(r.dialogs)) @@ -574,20 +575,25 @@ class TelegramClient(TelegramBareClient): entities = {utils.get_peer_id(x): x for x in itertools.chain(r.users, r.chats)} + # Happens when there are pinned dialogs + if len(r.dialogs) > limit: + r.dialogs = r.dialogs[:limit] + for d in r.dialogs: peer_id = utils.get_peer_id(d.peer) if peer_id not in seen: seen.add(peer_id) yield Dialog(self, d, entities, messages) - if len(r.dialogs) < real_limit or not isinstance(r, DialogsSlice): + if len(r.dialogs) < req.limit or not isinstance(r, DialogsSlice): # Less than we requested means we reached the end, or # we didn't get a DialogsSlice which means we got all. break - offset_date = r.messages[-1].date - offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] - offset_id = r.messages[-1].id + req.offset_date = r.messages[-1].date + req.offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] + req.offset_id = r.messages[-1].id + req.exclude_pinned = True def get_dialogs(self, *args, **kwargs): """