From 4328663c78c98bf4033ea87a2c2fcf5a5e8bc4bb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 9 Jul 2018 13:36:52 +0200 Subject: [PATCH 01/70] Support timedelta as datetimes --- telethon/tl/tlobject.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 3eb24eb7..3561f18f 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -1,5 +1,5 @@ import struct -from datetime import datetime, date +from datetime import datetime, date, timedelta class TLObject: @@ -125,6 +125,9 @@ class TLObject: dt = int(datetime(dt.year, dt.month, dt.day).timestamp()) elif isinstance(dt, float): dt = int(dt) + elif isinstance(dt, timedelta): + # Timezones are tricky. datetime.now() + ... timestamp() works + dt = int((datetime.now() + dt).timestamp()) if isinstance(dt, int): return struct.pack(' Date: Mon, 9 Jul 2018 20:54:43 +0200 Subject: [PATCH 02/70] Remove empty except (#887) --- readthedocs/extra/developing/test-servers.rst | 4 ++-- telethon/client/updates.py | 7 +++++-- telethon/errors/__init__.py | 2 +- telethon/network/mtprotosender.py | 12 +++++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/readthedocs/extra/developing/test-servers.rst b/readthedocs/extra/developing/test-servers.rst index bf8fd97e..98f93755 100644 --- a/readthedocs/extra/developing/test-servers.rst +++ b/readthedocs/extra/developing/test-servers.rst @@ -32,6 +32,6 @@ times, in this case, ``22222`` so we can hardcode that: client = TelegramClient(None, api_id, api_hash) client.session.set_dc(2, '149.154.167.40', 80) - loop.run_until_complete(client.start( + client.start( phone='9996621234', code_callback=lambda: '22222' - )) + ) diff --git a/telethon/client/updates.py b/telethon/client/updates.py index 220a08ae..2b6f6a4e 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -210,7 +210,10 @@ class UpdateMethods(UserMethods): continue # We actually just want to act upon timeout except asyncio.TimeoutError: pass - except: + except asyncio.CancelledError: + await self.disconnect() + return + except Exception as e: continue # Any disconnected exception should be ignored # We also don't really care about their result. @@ -273,7 +276,7 @@ class UpdateMethods(UserMethods): type(event).__name__) ) break - except: + except Exception: __log__.exception('Unhandled exception on {}' .format(callback.__name__)) diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index c1c7a6b3..a88459eb 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -36,7 +36,7 @@ def report_error(code, message, report_method): ) url.read() url.close() - except: + except Exception as e: "We really don't want to crash when just reporting an error" diff --git a/telethon/network/mtprotosender.py b/telethon/network/mtprotosender.py index 81deae64..481dd974 100644 --- a/telethon/network/mtprotosender.py +++ b/telethon/network/mtprotosender.py @@ -386,6 +386,7 @@ class MTProtoSender: except asyncio.TimeoutError: continue except asyncio.CancelledError: + await self.disconnect() return except Exception as e: if isinstance(e, ConnectionError): @@ -425,6 +426,7 @@ class MTProtoSender: except asyncio.TimeoutError: continue except asyncio.CancelledError: + await self.disconnect() return except Exception as e: if isinstance(e, ConnectionError): @@ -467,15 +469,19 @@ class MTProtoSender: __log__.info('Server replied with an unknown type {:08x}: {!r}' .format(e.invalid_constructor_id, e.remaining)) continue - except: - __log__.exception('Unhandled exception while unpacking') + except asyncio.CancelledError: + await self.disconnect() + return + except Exception as e: + __log__.exception('Unhandled exception while unpacking %s',e) await asyncio.sleep(1) else: try: await self._process_message(message) except asyncio.CancelledError: + await self.disconnect() return - except: + except Exception as e: __log__.exception('Unhandled exception while ' 'processing %s', message) await asyncio.sleep(1) From ac5f8da50c3364692f8d3c2d2b423a18e4800940 Mon Sep 17 00:00:00 2001 From: Lonami Date: Tue, 10 Jul 2018 16:59:40 +0200 Subject: [PATCH 03/70] Fix update.pts may be None --- telethon/client/updates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/client/updates.py b/telethon/client/updates.py index 2b6f6a4e..32e39eac 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -188,7 +188,7 @@ class UpdateMethods(UserMethods): self._loop.create_task(self._dispatch_queue_updates()) need_diff = False - if hasattr(update, 'pts'): + if hasattr(update, 'pts') and update.pts is not None: if self._state.pts and (update.pts - self._state.pts) > 1: need_diff = True self._state.pts = update.pts From a50d013ee6e1b474ab639d7143a64a8b696fbee8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 10:21:15 +0200 Subject: [PATCH 04/70] Support interactively signing in as a bot --- telethon/client/auth.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/telethon/client/auth.py b/telethon/client/auth.py index 90d3da05..8ed62b5a 100644 --- a/telethon/client/auth.py +++ b/telethon/client/auth.py @@ -16,7 +16,7 @@ class AuthMethods(MessageParseMethods, UserMethods): def start( self, - phone=lambda: input('Please enter your phone: '), + phone=lambda: input('Please enter your phone (or bot token): '), password=lambda: getpass.getpass('Please enter your password: '), *, bot_token=None, force_sms=False, code_callback=None, @@ -45,7 +45,8 @@ class AuthMethods(MessageParseMethods, UserMethods): Args: phone (`str` | `int` | `callable`): The phone (or callable without arguments to get it) - to which the code will be sent. + to which the code will be sent. If a bot-token-like + string is given, it will be used as such instead. password (`callable`, optional): The password for 2 Factor Authentication (2FA). @@ -119,14 +120,21 @@ class AuthMethods(MessageParseMethods, UserMethods): if await self.is_user_authorized(): return self + if not bot_token: + # Turn the callable into a valid phone number (or bot token) + while callable(phone): + value = phone() + if ':' in value: + # Bot tokens have 'user_id:access_hash' format + bot_token = value + break + + phone = utils.parse_phone(value) or phone + if bot_token: await self.sign_in(bot_token=bot_token) return self - # Turn the callable into a valid phone number - while callable(phone): - phone = utils.parse_phone(phone()) or phone - me = None attempts = 0 two_step_detected = False From 8c28be04bc2d24a143ac85b9b222cb2836500c12 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 12:42:57 +0200 Subject: [PATCH 05/70] Create a custom.Button class and support send_message(buttons=...) --- readthedocs/telethon.tl.custom.rst | 8 ++ telethon/client/__init__.py | 3 +- telethon/client/buttons.py | 48 +++++++++++ telethon/client/messages.py | 24 ++++-- telethon/client/telegramclient.py | 12 +-- telethon/tl/custom/__init__.py | 1 + telethon/tl/custom/button.py | 129 +++++++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 telethon/client/buttons.py create mode 100644 telethon/tl/custom/button.py diff --git a/readthedocs/telethon.tl.custom.rst b/readthedocs/telethon.tl.custom.rst index e8b6c8e6..74828cb4 100644 --- a/readthedocs/telethon.tl.custom.rst +++ b/readthedocs/telethon.tl.custom.rst @@ -45,3 +45,11 @@ telethon\.tl\.custom\.forward module :members: :undoc-members: :show-inheritance: + +telethon\.tl\.custom\.button module +----------------------------------- + +.. automodule:: telethon.tl.custom.button + :members: + :undoc-members: + :show-inheritance: diff --git a/telethon/client/__init__.py b/telethon/client/__init__.py index 4c133065..0e8d2377 100644 --- a/telethon/client/__init__.py +++ b/telethon/client/__init__.py @@ -13,10 +13,11 @@ from .telegrambaseclient import TelegramBaseClient from .users import UserMethods # Required for everything from .messageparse import MessageParseMethods # Required for messages from .uploads import UploadMethods # Required for messages to send files +from .updates import UpdateMethods # Required for buttons (register callbacks) +from .buttons import ButtonMethods # Required for messages to use buttons from .messages import MessageMethods from .chats import ChatMethods from .dialogs import DialogMethods from .downloads import DownloadMethods from .auth import AuthMethods -from .updates import UpdateMethods from .telegramclient import TelegramClient diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py new file mode 100644 index 00000000..536c6e6f --- /dev/null +++ b/telethon/client/buttons.py @@ -0,0 +1,48 @@ +from .updates import UpdateMethods +from ..tl import types, custom +from .. import utils + + +class ButtonMethods(UpdateMethods): + def _build_reply_markup(self, buttons): + if buttons is None: + return None + + try: + if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: + return buttons # crc32(b'ReplyMarkup'): + except AttributeError: + pass + + if not utils.is_list_like(buttons): + buttons = [[buttons]] + elif not utils.is_list_like(buttons[0]): + buttons = [buttons] + + is_inline = False + is_normal = False + + rows = [] + for row in buttons: + current = [] + for button in row: + inline = custom.Button._is_inline(button) + is_inline |= inline + is_normal |= not inline + if isinstance(button, custom.Button): + # TODO actually register callbacks + button = button.button + + if button.SUBCLASS_OF_ID == 0xbad74a3: + # 0xbad74a3 == crc32(b'KeyboardButton') + current.append(button) + + if current: + rows.append(types.KeyboardButtonRow(current)) + + if is_inline == is_normal and is_normal: + raise ValueError('You cannot mix inline with normal buttons') + elif is_inline: + return types.ReplyInlineMarkup(rows) + elif is_normal: + return types.ReplyKeyboardMarkup(rows) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 49d7a369..cb563f7e 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -8,13 +8,14 @@ from async_generator import async_generator, yield_ from .messageparse import MessageParseMethods from .uploads import UploadMethods +from .buttons import ButtonMethods from .. import utils from ..tl import types, functions, custom __log__ = logging.getLogger(__name__) -class MessageMethods(UploadMethods, MessageParseMethods): +class MessageMethods(ButtonMethods, UploadMethods, MessageParseMethods): # region Public methods @@ -333,7 +334,7 @@ class MessageMethods(UploadMethods, MessageParseMethods): async def send_message( self, entity, message='', *, reply_to=None, parse_mode=utils.Default, link_preview=True, file=None, - force_document=False, clear_draft=False): + force_document=False, clear_draft=False, buttons=None): """ Sends the given message to the specified entity (user/chat/channel). @@ -382,8 +383,15 @@ class MessageMethods(UploadMethods, MessageParseMethods): Whether the existing draft should be cleared or not. Has no effect when sending a file. + buttons (`list`, `custom.Button `, + :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + Returns: - The sent `telethon.tl.custom.message.Message`. + The sent `custom.Message `. """ if file is not None: return await self.send_file( @@ -417,12 +425,17 @@ class MessageMethods(UploadMethods, MessageParseMethods): else: reply_id = None + if buttons is None: + markup = message.reply_markup + else: + markup = self._build_reply_markup(buttons) + request = functions.messages.SendMessageRequest( peer=entity, message=message.message or '', silent=message.silent, reply_to_msg_id=reply_id, - reply_markup=message.reply_markup, + reply_markup=markup, entities=message.entities, clear_draft=clear_draft, no_webpage=not isinstance( @@ -438,7 +451,8 @@ class MessageMethods(UploadMethods, MessageParseMethods): entities=msg_ent, no_webpage=not link_preview, reply_to_msg_id=utils.get_message_id(reply_to), - clear_draft=clear_draft + clear_draft=clear_draft, + reply_markup=self._build_reply_markup(buttons) ) result = await self(request) diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index fecede72..fa50ec6e 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -1,13 +1,13 @@ from . import ( - UpdateMethods, AuthMethods, DownloadMethods, DialogMethods, - ChatMethods, MessageMethods, UploadMethods, MessageParseMethods, - UserMethods + AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + MessageMethods, ButtonMethods, UpdateMethods, UploadMethods, + MessageParseMethods, UserMethods ) class TelegramClient( - UpdateMethods, AuthMethods, DownloadMethods, DialogMethods, - ChatMethods, MessageMethods, UploadMethods, MessageParseMethods, - UserMethods + AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + MessageMethods, ButtonMethods, UpdateMethods, UploadMethods, + MessageParseMethods, UserMethods ): pass diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index 8414d794..d54c27b8 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -4,3 +4,4 @@ from .input_sized_file import InputSizedFile from .messagebutton import MessageButton from .forward import Forward from .message import Message +from .button import Button diff --git a/telethon/tl/custom/button.py b/telethon/tl/custom/button.py new file mode 100644 index 00000000..b0094dfd --- /dev/null +++ b/telethon/tl/custom/button.py @@ -0,0 +1,129 @@ +from .. import types + + +class Button: + """ + Helper class to allow defining ``reply_markup`` when + sending a message with inline or keyboard buttons. + + You should make use of the defined class methods to create button + instances instead making them yourself (i.e. don't do ``Button(...)`` + but instead use methods line `Button.inline(...) ` etc.) + + You can use `inline`, `switch_inline` and `url` + together to create inline buttons (under the message). + + You can use `text`, `request_location` and `request_phone` + together to create a reply markup (replaces the user keyboard). + + You **cannot** mix the two type of buttons together, + and it will error if you try to do so. + + The text for all buttons may be at most 142 characters. + If more characters are given, Telegram will cut the text + to 128 characters and add the ellipsis (…) character as + the 129. + """ + def __init__(self, button, callback=None): + self.button = button + self.callback = callback + self.is_inline = self._is_inline(button) + + @classmethod + def _is_inline(cls, button): + """ + Returns ``True`` if the button belongs to an inline keyboard. + """ + if isinstance(button, cls): + return button.is_inline + else: + return isinstance(button, ( + types.KeyboardButtonCallback, + types.KeyboardButtonSwitchInline, + types.KeyboardButtonUrl + )) + + @classmethod + def inline(cls, text, callback=None, data=None): + """ + Creates a new inline button. + + The `callback` parameter should be a function callback accepting + a single parameter (the triggered event on click) if specified. + Otherwise, you should register the event manually. + + If `data` is omitted, the given `text` will be used as `data`. + In any case `data` should be either ``bytes`` or ``str``. + + Note that the given `data` must be less or equal to 64 bytes. + If more than 64 bytes are passed as data, ``ValueError`` is raised. + """ + if not data: + data = text.encode('utf-8') + + if len(data) > 64: + raise ValueError('Too many bytes for the data') + + return cls(types.KeyboardButtonCallback(text, data), callback) + + @classmethod + def switch_inline(cls, text, query='', same_peer=False): + """ + Creates a new button to switch to inline query. + + If `query` is given, it will be the default text to be used + when making the inline query. + + If ``same_peer is True`` the inline query will directly be + set under the currently opened chat. Otherwise, the user will + have to select a different dialog to make the query. + """ + return cls(types.KeyboardButtonSwitchInline(text, query, same_peer)) + + @classmethod + def url(cls, text, url=None): + """ + Creates a new button to open the desired URL upon clicking it. + + If no `url` is given, the `text` will be used as said URL instead. + """ + return cls(types.KeyboardButtonUrl(text, url or text)) + + @classmethod + def text(cls, text): + """ + Creates a new button with the given text. + """ + return cls(types.KeyboardButton(text)) + + @classmethod + def request_location(cls, text): + """ + Creates a new button that will request + the user's location upon being clicked. + """ + return cls(types.KeyboardButtonRequestGeoLocation(text)) + + @classmethod + def request_phone(cls, text): + """ + Creates a new button that will request + the user's phone number upon being clicked. + """ + return cls(types.KeyboardButtonRequestPhone(text)) + + @classmethod + def clear(cls): + """ + Clears all the buttons. When used, no other + button should be present or it will be ignored. + """ + return types.ReplyKeyboardHide() + + @classmethod + def force_reply(cls): + """ + Forces a reply. If used, no other button + should be present or it will be ignored. + """ + return types.ReplyKeyboardForceReply() From 531a02a2a178ed2792fadad0a30df05e5b45bf8c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 13:11:56 +0200 Subject: [PATCH 06/70] Support buttons when sending a file too --- telethon/client/messages.py | 5 +++-- telethon/client/telegramclient.py | 2 +- telethon/client/uploads.py | 17 +++++++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index cb563f7e..84cb6e02 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -15,7 +15,7 @@ from ..tl import types, functions, custom __log__ = logging.getLogger(__name__) -class MessageMethods(ButtonMethods, UploadMethods, MessageParseMethods): +class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): # region Public methods @@ -396,7 +396,8 @@ class MessageMethods(ButtonMethods, UploadMethods, MessageParseMethods): if file is not None: return await self.send_file( entity, file, caption=message, reply_to=reply_to, - parse_mode=parse_mode, force_document=force_document + parse_mode=parse_mode, force_document=force_document, + buttons=buttons ) elif not message: raise ValueError( diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index fa50ec6e..e10dc43f 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -7,7 +7,7 @@ from . import ( class TelegramClient( AuthMethods, DownloadMethods, DialogMethods, ChatMethods, - MessageMethods, ButtonMethods, UpdateMethods, UploadMethods, + MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, MessageParseMethods, UserMethods ): pass diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index 52cc8203..c987e464 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -9,6 +9,7 @@ from mimetypes import guess_type from .messageparse import MessageParseMethods from .users import UserMethods +from .buttons import ButtonMethods from .. import utils, helpers from ..tl import types, functions, custom @@ -22,7 +23,7 @@ except ImportError: __log__ = logging.getLogger(__name__) -class UploadMethods(MessageParseMethods, UserMethods): +class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): # region Public methods @@ -30,7 +31,7 @@ class UploadMethods(MessageParseMethods, UserMethods): self, entity, file, *, caption='', force_document=False, progress_callback=None, reply_to=None, attributes=None, thumb=None, allow_cache=True, parse_mode=utils.Default, - voice_note=False, video_note=False, **kwargs): + voice_note=False, video_note=False, buttons=None, **kwargs): """ Sends a file to the specified entity. @@ -98,6 +99,13 @@ class UploadMethods(MessageParseMethods, UserMethods): Set `allow_cache` to ``False`` if you sent the same file without this setting before for it to work. + buttons (`list`, `custom.Button `, + :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + Notes: If the ``hachoir3`` package (``hachoir`` module) is installed, it will be used to determine metadata from audio and video files. @@ -136,7 +144,7 @@ class UploadMethods(MessageParseMethods, UserMethods): caption=caption, force_document=force_document, progress_callback=progress_callback, reply_to=reply_to, attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, **kwargs + video_note=video_note, buttons=buttons, **kwargs )) return result @@ -159,9 +167,10 @@ class UploadMethods(MessageParseMethods, UserMethods): voice_note=voice_note, video_note=video_note ) + markup = self._build_reply_markup(buttons) request = functions.messages.SendMediaRequest( entity, media, reply_to_msg_id=reply_to, message=caption, - entities=msg_entities + entities=msg_entities, reply_markup=markup ) msg = self._get_response_message(request, await self(request), entity) self._cache_media(msg, file, file_handle, force_document=force_document) From 8eecd9c22619b4f00ecf562d747bab164e0512b0 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 15:15:22 +0200 Subject: [PATCH 07/70] Reuse code to get chat and sender --- readthedocs/telethon.tl.custom.rst | 16 +++ telethon/events/common.py | 109 ++++-------------- telethon/sync.py | 5 +- telethon/tl/custom/chatgetter.py | 114 +++++++++++++++++++ telethon/tl/custom/forward.py | 122 ++++---------------- telethon/tl/custom/message.py | 175 +++-------------------------- telethon/tl/custom/sendergetter.py | 74 ++++++++++++ 7 files changed, 267 insertions(+), 348 deletions(-) create mode 100644 telethon/tl/custom/chatgetter.py create mode 100644 telethon/tl/custom/sendergetter.py diff --git a/readthedocs/telethon.tl.custom.rst b/readthedocs/telethon.tl.custom.rst index 74828cb4..1099fa5e 100644 --- a/readthedocs/telethon.tl.custom.rst +++ b/readthedocs/telethon.tl.custom.rst @@ -53,3 +53,19 @@ telethon\.tl\.custom\.button module :members: :undoc-members: :show-inheritance: + +telethon\.tl\.custom\.chatgetter module +--------------------------------------- + +.. automodule:: telethon.tl.custom.chatgetter + :members: + :undoc-members: + :show-inheritance: + +telethon\.tl\.custom\.sendergetter module +----------------------------------------- + +.. automodule:: telethon.tl.custom.sendergetter + :members: + :undoc-members: + :show-inheritance: diff --git a/telethon/events/common.py b/telethon/events/common.py index 47235a9d..2e7c2d26 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -3,6 +3,7 @@ import warnings from .. import utils from ..tl import TLObject, types +from ..tl.custom.chatgetter import ChatGetter async def _into_id_set(client, chats): @@ -79,13 +80,16 @@ class EventBuilder(abc.ABC): return event -class EventCommon(abc.ABC): +class EventCommon(ChatGetter, abc.ABC): """ Intermediate class with common things to all events. - All events (except `Raw`) have ``is_private``, ``is_group`` - and ``is_channel`` boolean properties, as well as an - ``original_update`` field containing the original :tl:`Update`. + Remember that this class implements `ChatGetter + ` which + means you have access to all chat properties and methods. + + In addition, you can access the `original_update` + field which contains the original :tl:`Update`. """ _event_name = 'Event' @@ -96,64 +100,27 @@ class EventCommon(abc.ABC): self._message_id = msg_id self._input_chat = None self._chat = None + self._broadcast = broadcast self.original_update = None - self.is_private = isinstance(chat_peer, types.PeerUser) - self.is_group = ( - isinstance(chat_peer, (types.PeerChat, types.PeerChannel)) - and not broadcast - ) - self.is_channel = isinstance(chat_peer, types.PeerChannel) - def _set_client(self, client): """ Setter so subclasses can act accordingly when the client is set. """ self._client = client + self._chat = self._entities.get(self.chat_id) + if not self._chat: + return - @property - def input_chat(self): - """ - This (:tl:`InputPeer`) is the input version of the chat where the - event occurred. This doesn't have things like username or similar, - but is still useful in some cases. - - Note that this might not be available if the library doesn't have - enough information available. - """ - if self._input_chat is None and self._chat_peer is not None: + self._input_chat = utils.get_input_peer(self._chat) + if not getattr(self._input_chat, 'access_hash', True): + # getattr with True to handle the InputPeerSelf() case try: - self._input_chat =\ - self._client.session.get_input_entity(self._chat_peer) + self._input_chat = self._client.session.get_input_entity( + self._chat_peer + ) except ValueError: - pass - - return self._input_chat - - async def get_input_chat(self): - """ - Returns `input_chat`, but will make an API call to find the - input chat unless it's already cached. - """ - if self.input_chat is None and self._chat_peer is not None: - ch = isinstance(self._chat_peer, types.PeerChannel) - if not ch and self._message_id is not None: - msg = await self._client.get_messages( - None, ids=self._message_id) - self._chat = msg._chat - self._input_chat = msg._input_chat - else: - target = utils.get_peer_id(self._chat_peer) - async for d in self._client.iter_dialogs(100): - if d.id == target: - self._chat = d.entity - self._input_chat = d.input_entity - # TODO Don't break, exhaust the iterator, otherwise - # async_generator raises RuntimeError: partially- - # exhausted async_generator 'xyz' garbage collected - # break - - return self._input_chat + self._input_chat = None @property def client(self): @@ -162,44 +129,6 @@ class EventCommon(abc.ABC): """ return self._client - @property - def chat(self): - """ - The :tl:`User`, :tl:`Chat` or :tl:`Channel` on which - the event occurred. This property may make an API call the first time - to get the most up to date version of the chat (mostly when the event - doesn't belong to a channel), so keep that in mind. You should use - `get_chat` instead, unless you want to avoid an API call. - """ - if not self.input_chat: - return None - - if self._chat is None: - self._chat = self._entities.get(utils.get_peer_id(self._chat_peer)) - - return self._chat - - async def get_chat(self): - """ - Returns `chat`, but will make an API call to find the - chat unless it's already cached. - """ - if self.chat is None and await self.get_input_chat(): - try: - self._chat =\ - await self._client.get_entity(self._input_chat) - except ValueError: - pass - return self._chat - - @property - def chat_id(self): - """ - Returns the marked integer ID of the chat, if any. - """ - if self._chat_peer: - return utils.get_peer_id(self._chat_peer) - def __str__(self): return TLObject.pretty_format(self.to_dict()) diff --git a/telethon/sync.py b/telethon/sync.py index 51a41f27..9e79a112 100644 --- a/telethon/sync.py +++ b/telethon/sync.py @@ -18,6 +18,8 @@ from async_generator import isasyncgenfunction from .client.telegramclient import TelegramClient from .tl.custom import Draft, Dialog, MessageButton, Forward, Message +from .tl.custom.chatgetter import ChatGetter +from .tl.custom.sendergetter import SenderGetter def _syncify_coro(t, method_name): @@ -78,4 +80,5 @@ def syncify(*types): _syncify_gen(t, method_name) -syncify(TelegramClient, Draft, Dialog, MessageButton, Forward, Message) +syncify(TelegramClient, Draft, Dialog, MessageButton, + ChatGetter, SenderGetter, Forward, Message) diff --git a/telethon/tl/custom/chatgetter.py b/telethon/tl/custom/chatgetter.py new file mode 100644 index 00000000..fb733d9a --- /dev/null +++ b/telethon/tl/custom/chatgetter.py @@ -0,0 +1,114 @@ +import abc + +from ... import errors, utils +from ...tl import types + + +class ChatGetter(abc.ABC): + """ + Helper base class that introduces the `chat`, `input_chat` + and `chat_id` properties and `get_chat` and `get_input_chat` + methods. + + Subclasses **must** have the following private members: `_chat`, + `_input_chat`, `_chat_peer`, `_broadcast` and `_client`. As an end + user, you should not worry about this. + """ + @property + def chat(self): + """ + Returns the :tl:`User`, :tl:`Chat` or :tl:`Channel` where this object + belongs to. It may be ``None`` if Telegram didn't send the chat. + + If you're using `telethon.events`, use `get_chat` instead. + """ + return self._chat + + async def get_chat(self): + """ + Returns `chat`, but will make an API call to find the + chat unless it's already cached. + """ + if self._chat is None and await self.get_input_chat(): + try: + self._chat =\ + await self._client.get_entity(self._input_chat) + except ValueError: + await self._refetch_chat() + return self._chat + + @property + def input_chat(self): + """ + This :tl:`InputPeer` is the input version of the chat where the + message was sent. Similarly to `input_sender`, this doesn't have + things like username or similar, but still useful in some cases. + + Note that this might not be available if the library doesn't + have enough information available. + """ + if self._input_chat is None and self._chat_peer: + try: + self._input_chat =\ + self._client.session.get_input_entity(self._chat_peer) + except ValueError: + pass + + return self._input_chat + + async def get_input_chat(self): + """ + Returns `input_chat`, but will make an API call to find the + input chat unless it's already cached. + """ + if self.input_chat is None and self.chat_id: + try: + # The chat may be recent, look in dialogs + target = self.chat_id + async for d in self._client.iter_dialogs(100): + if d.id == target: + self._chat = d.entity + self._input_chat = d.input_entity + break + except errors.RPCError: + pass + + return self._input_chat + + @property + def chat_id(self): + """ + Returns the marked chat integer ID. Note that this value **will + be different** from `to_id` for incoming private messages, since + the chat *to* which the messages go is to your own person, but + the *chat* itself is with the one who sent the message. + + TL;DR; this gets the ID that you expect. + """ + return utils.get_peer_id(self._chat_peer) if self._chat_peer else None + + @property + def is_private(self): + """True if the message was sent as a private message.""" + return isinstance(self._chat_peer, types.PeerUser) + + @property + def is_group(self): + """True if the message was sent on a group or megagroup.""" + if self._broadcast is None and self.chat: + self._broadcast = getattr(self.chat, 'broadcast', None) + + return ( + isinstance(self._chat_peer, (types.PeerChat, types.PeerChannel)) + and not self._broadcast + ) + + @property + def is_channel(self): + """True if the message was sent on a megagroup or channel.""" + return isinstance(self._chat_peer, types.PeerChannel) + + async def _refetch_chat(self): + """ + Re-fetches chat information through other means. + """ diff --git a/telethon/tl/custom/forward.py b/telethon/tl/custom/forward.py index 737b2119..52603f9b 100644 --- a/telethon/tl/custom/forward.py +++ b/telethon/tl/custom/forward.py @@ -1,11 +1,19 @@ -from ...utils import get_input_peer +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from ... import utils +from ...tl import types -class Forward: +class Forward(ChatGetter, SenderGetter): """ Custom class that encapsulates a :tl:`MessageFwdHeader` providing an abstraction to easily access information like the original sender. + Remember that this class implements `ChatGetter + ` and `SenderGetter + ` which means you + have access to all their sender and chat properties and methods. + Attributes: original_fwd (:tl:`MessageFwdHeader`): @@ -19,105 +27,21 @@ class Forward: self.__dict__ = original.__dict__ self._client = client self.original_fwd = original + + self._sender_id = original.from_id self._sender = entities.get(original.from_id) - self._chat = entities.get(original.channel_id) - self._input_sender =\ - get_input_peer(self._sender) if self._sender else None - self._input_chat =\ - get_input_peer(self._chat) if self._chat else None + utils.get_input_peer(self._sender) if self._sender else None - # TODO The pattern to get sender and chat is very similar - # and copy pasted in/to several places. Reuse the code. - # - # It could be an ABC with some ``resolve_sender`` abstract, - # so every subclass knew what tricks it can make to get - # the sender. + self._broadcast = None + if original.channel_id: + self._chat_peer = types.PeerChannel(original.channel_id) + self._chat = entities.get(utils.get_peer_id(self._chat_peer)) + else: + self._chat_peer = None + self._chat = None - @property - def sender(self): - """ - The :tl:`User` that sent the original message. This may be ``None`` - if it couldn't be found or the message wasn't forwarded from an user - but instead was forwarded from e.g. a channel. - """ - return self._sender + self._input_chat = \ + utils.get_input_peer(self._chat) if self._chat else None - async def get_sender(self): - """ - Returns `sender` but will make an API if necessary. - """ - if not self.sender and self.original_fwd.from_id: - try: - self._sender = await self._client.get_entity( - await self.get_input_sender()) - except ValueError: - # TODO We could reload the message - pass - - return self._sender - - @property - def input_sender(self): - """ - Returns the input version of `user`. - """ - if not self._input_sender and self.original_fwd.from_id: - try: - self._input_sender = self._client.session.get_input_entity( - self.original_fwd.from_id) - except ValueError: - pass - - return self._input_sender - - async def get_input_sender(self): - """ - Returns `input_sender` but will make an API call if necessary. - """ - # TODO We could reload the message - return self.input_sender - - @property - def chat(self): - """ - The :tl:`Channel` where the original message was sent. This may be - ``None`` if it couldn't be found or the message wasn't forwarded - from a channel but instead was forwarded from e.g. an user. - """ - return self._chat - - async def get_chat(self): - """ - Returns `chat` but will make an API if necessary. - """ - if not self.chat and self.original_fwd.channel_id: - try: - self._chat = await self._client.get_entity( - await self.get_input_chat()) - except ValueError: - # TODO We could reload the message - pass - - return self._chat - - @property - def input_chat(self): - """ - Returns the input version of `chat`. - """ - if not self._input_chat and self.original_fwd.channel_id: - try: - self._input_chat = self._client.session.get_input_entity( - self.original_fwd.channel_id) - except ValueError: - pass - - return self._input_chat - - async def get_input_chat(self): - """ - Returns `input_chat` but will make an API call if necessary. - """ - # TODO We could reload the message - return self.input_chat + # TODO We could reload the message diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 40236c6c..2436876b 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -1,15 +1,22 @@ from .. import types -from ...utils import get_input_peer, get_peer_id, get_inner_text +from ...utils import get_input_peer, get_inner_text +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter from .messagebutton import MessageButton from .forward import Forward -class Message: +class Message(ChatGetter, SenderGetter): """ Custom class that encapsulates a message providing an abstraction to easily access some commonly needed features (such as the markdown text or the text for a given message entity). + Remember that this class implements `ChatGetter + ` and `SenderGetter + ` which means you + have access to all their sender and chat properties and methods. + Attributes: original_message (:tl:`Message`): @@ -34,7 +41,8 @@ class Message: self._buttons_flat = None self._buttons_count = None - self._sender = entities.get(self.original_message.from_id) + self._sender_id = self.original_message.from_id + self._sender = entities.get(self._sender_id) if self._sender: self._input_sender = get_input_peer(self._sender) if not getattr(self._input_sender, 'access_hash', None): @@ -46,10 +54,11 @@ class Message: # was sent, not *to which ID* it was sent. if not self.original_message.out \ and isinstance(self.original_message.to_id, types.PeerUser): - self._chat_peer = types.PeerUser(self.original_message.from_id) + self._chat_peer = types.PeerUser(self._sender_id) else: self._chat_peer = self.original_message.to_id + self._broadcast = bool(self.original_message.post) self._chat = entities.get(self.chat_id) self._input_chat = input_chat if not self._input_chat and self._chat: @@ -171,158 +180,8 @@ class Message: self._chat = msg._chat self._input_chat = msg._input_chat - @property - def sender(self): - """ - Returns the :tl:`User` that sent this message. It may be ``None`` - if the message has no sender or if Telegram didn't send the sender - inside message events. - - If you're using `telethon.events`, use `get_sender` instead. - """ - return self._sender - - async def get_sender(self): - """ - Returns `sender`, but will make an API call to find the - sender unless it's already cached. - """ - if self._sender is None and await self.get_input_sender(): - try: - self._sender =\ - await self._client.get_entity(self._input_sender) - except ValueError: - await self._reload_message() - return self._sender - - @property - def chat(self): - """ - Returns the :tl:`User`, :tl:`Chat` or :tl:`Channel` where this message - was sent. It may be ``None`` if Telegram didn't send the chat inside - message events. - - If you're using `telethon.events`, use `get_chat` instead. - """ - return self._chat - - async def get_chat(self): - """ - Returns `chat`, but will make an API call to find the - chat unless it's already cached. - """ - if self._chat is None and await self.get_input_chat(): - try: - self._chat =\ - await self._client.get_entity(self._input_chat) - except ValueError: - await self._reload_message() - return self._chat - - @property - def input_sender(self): - """ - This (:tl:`InputPeer`) is the input version of the user who - sent the message. Similarly to `input_chat`, this doesn't have - 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, 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 = self._client.session\ - .get_input_entity(self.original_message.from_id) - except ValueError: - pass - return self._input_sender - - async def get_input_sender(self): - """ - Returns `input_sender`, but will make an API call to find the - input sender unless it's already cached. - """ - if self.input_sender is None\ - and not self.is_channel and not self.is_group: - await self._reload_message() - return self._input_sender - - @property - def input_chat(self): - """ - This (:tl:`InputPeer`) is the input version of the chat where the - message was sent. Similarly to `input_sender`, this doesn't have - things like username or similar, but still useful in some cases. - - Note that this might not be available if the library doesn't know - where the message came from. - """ - if self._input_chat is None: - try: - self._input_chat =\ - self._client.session.get_input_entity(self._chat_peer) - except ValueError: - pass - - return self._input_chat - - async def get_input_chat(self): - """ - Returns `input_chat`, but will make an API call to find the - input chat unless it's already cached. - """ - if self.input_chat is None: - # There's a chance that the chat is a recent new dialog. - # The input chat cannot rely on ._reload_message() because - # said method may need the input chat. - target = self.chat_id - async for d in self._client.iter_dialogs(100): - if d.id == target: - self._chat = d.entity - self._input_chat = d.input_entity - break - - return self._input_chat - - @property - def sender_id(self): - """ - Returns the marked sender integer ID, if present. - """ - return self.original_message.from_id - - @property - def chat_id(self): - """ - Returns the marked chat integer ID. Note that this value **will - be different** from `to_id` for incoming private messages, since - the chat *to* which the messages go is to your own person, but - the *chat* itself is with the one who sent the message. - - TL;DR; this gets the ID that you expect. - """ - return get_peer_id(self._chat_peer) - - @property - def is_private(self): - """True if the message was sent as a private message.""" - return isinstance(self.original_message.to_id, types.PeerUser) - - @property - def is_group(self): - """True if the message was sent on a group or megagroup.""" - return ( - isinstance(self.original_message.to_id, (types.PeerChat, - types.PeerChannel)) - and not self.original_message.post - ) - - @property - def is_channel(self): - """True if the message was sent on a megagroup or channel.""" - return isinstance(self.original_message.to_id, types.PeerChannel) + async def _refetch_sender(self): + await self._reload_message() @property def is_reply(self): @@ -602,10 +461,10 @@ class Message: if self.original_message.fwd_from: return None if not self.original_message.out: - if not isinstance(self.original_message.to_id, types.PeerUser): + if not isinstance(self._chat_peer, types.PeerUser): return None me = await self._client.get_me(input_peer=True) - if self.original_message.to_id.user_id != me.user_id: + if self._chat_peer.user_id != me.user_id: return None return await self._client.edit_message( diff --git a/telethon/tl/custom/sendergetter.py b/telethon/tl/custom/sendergetter.py new file mode 100644 index 00000000..a0359550 --- /dev/null +++ b/telethon/tl/custom/sendergetter.py @@ -0,0 +1,74 @@ +import abc + + +class SenderGetter(abc.ABC): + """ + Helper base class that introduces the `sender`, `input_sender` + and `sender_id` properties and `get_sender` and `get_input_sender` + methods. + + Subclasses **must** have the following private members: `_sender`, + `_input_sender`, `_sender_id` and `_client`. As an end user, you + should not worry about this. + """ + @property + def sender(self): + """ + Returns the :tl:`User` that created this object. It may be ``None`` + if the object has no sender or if Telegram didn't send the sender. + + If you're using `telethon.events`, use `get_sender` instead. + """ + return self._sender + + async def get_sender(self): + """ + Returns `sender`, but will make an API call to find the + sender unless it's already cached. + """ + if self._sender is None and await self.get_input_sender(): + try: + self._sender =\ + await self._client.get_entity(self._input_sender) + except ValueError: + await self._reload_message() + return self._sender + + @property + def input_sender(self): + """ + This :tl:`InputPeer` is the input version of the user who + sent the message. Similarly to `input_chat`, this doesn't have + 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, or if the message a broadcast on a channel. + """ + if self._input_sender is None and self._sender_id: + try: + self._input_sender = self._client.session\ + .get_input_entity(self._sender_id) + except ValueError: + pass + return self._input_sender + + async def get_input_sender(self): + """ + Returns `input_sender`, but will make an API call to find the + input sender unless it's already cached. + """ + if self.input_sender is None and self._sender_id: + await self._refetch_sender() + return self._input_sender + + @property + def sender_id(self): + """ + Returns the marked sender integer ID, if present. + """ + return self._sender_id + + async def _refetch_sender(self): + """ + Re-fetches sender information through other means. + """ From 05e8e60291cd6f11c7c4f892dabb4ab4dd631235 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 16:03:30 +0200 Subject: [PATCH 08/70] Create events.CallbackQuery --- telethon/events/__init__.py | 1 + telethon/events/callbackquery.py | 187 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 telethon/events/callbackquery.py diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index 08ef1701..d0faad3e 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -5,6 +5,7 @@ from .messageedited import MessageEdited from .messageread import MessageRead from .newmessage import NewMessage from .userupdate import UserUpdate +from .callbackquery import CallbackQuery class StopPropagation(Exception): diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py new file mode 100644 index 00000000..e12b1744 --- /dev/null +++ b/telethon/events/callbackquery.py @@ -0,0 +1,187 @@ +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types, functions +from ..tl.custom.sendergetter import SenderGetter + + +@name_inner_event +class CallbackQuery(EventBuilder): + """ + Represents a callback query event (when an inline button is clicked). + """ + def build(self, update): + if isinstance(update, types.UpdateBotCallbackQuery): + event = CallbackQuery.Event(update) + else: + return + + event._entities = update._entities + return self._filter_event(event) + + class Event(EventCommon, SenderGetter): + """ + Represents the event of a new callback query. + + Members: + query (:tl:`UpdateBotCallbackQuery`): + The original :tl:`UpdateBotCallbackQuery`. + """ + def __init__(self, query): + super().__init__(chat_peer=query.peer, msg_id=query.msg_id) + self.query = query + self._sender_id = query.user_id + self._input_sender = None + self._sender = None + self._message = None + self._answered = False + + @property + def id(self): + """ + Returns the query ID. The user clicking the inline + button is the one who generated this random ID. + """ + return self.query.query_id + + @property + def message_id(self): + """ + Returns the message ID to which the clicked inline button belongs. + """ + return self.query.msg_id + + @property + def data(self): + """ + Returns the data payload from the original inline button. + """ + return self.query.data + + async def get_message(self): + """ + Returns the message to which the clicked inline button belongs. + """ + if self._message is not None: + return self._message + + try: + chat = await self.get_input_chat() if self.is_channel else None + self._message = await self._client.get_messages( + chat, ids=self.query.msg_id) + except ValueError: + return + + return self._message + + async def _refetch_sender(self): + self._sender = self._entities.get(self.sender_id) + if not self._sender: + return + + self._input_sender = utils.get_input_peer(self._chat) + if not getattr(self._input_sender, 'access_hash', True): + # getattr with True to handle the InputPeerSelf() case + try: + self._input_sender = self._client.session.get_input_entity( + self._sender_id + ) + except ValueError: + m = await self.get_message() + if m: + self._sender = m._sender + self._input_sender = m._input_sender + + async def answer( + self, message=None, cache_time=0, *, url=None, alert=False): + """ + Answers the callback query (and stops the loading circle). + + Args: + message (`str`, optional): + The toast message to show feedback to the user. + + cache_time (`int`, optional): + For how long this result should be cached on + the user's client. Defaults to 0 for no cache. + + url (`str`, optional): + The URL to be opened in the user's client. Note that + the only valid URLs are those of games your bot has, + or alternatively a 't.me/your_bot?start=xyz' parameter. + + alert (`bool`, optional): + Whether an alert (a pop-up dialog) should be used + instead of showing a toast. Defaults to ``False``. + """ + if self._answered: + return + + self._answered = True + return await self._client( + functions.messages.SetBotCallbackAnswerRequest( + query_id=self.query.query_id, + cache_time=cache_time, + alert=alert, + message=message, + url=url + ) + ) + + async def respond(self, *args, **kwargs): + """ + Responds to the message (not as a reply). Shorthand for + `telethon.telegram_client.TelegramClient.send_message` with + ``entity`` already set. + + This method also creates a task to `answer` the callback. + """ + self._client.loop.create_task(self.answer()) + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the message (as a reply). Shorthand for + `telethon.telegram_client.TelegramClient.send_message` with + both ``entity`` and ``reply_to`` already set. + + This method also creates a task to `answer` the callback. + """ + self._client.loop.create_task(self.answer()) + kwargs['reply_to'] = self.query.msg_id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def edit(self, *args, **kwargs): + """ + Edits the message iff it's outgoing. Shorthand for + `telethon.telegram_client.TelegramClient.edit_message` with + both ``entity`` and ``message`` already set. + + Returns the edited :tl:`Message`. + + This method also creates a task to `answer` the callback. + """ + self._client.loop.create_task(self.answer()) + return await self._client.edit_message( + await self.get_input_chat(), self.query.msg_id, + *args, **kwargs + ) + + async def delete(self, *args, **kwargs): + """ + Deletes the message. Shorthand for + `telethon.telegram_client.TelegramClient.delete_messages` with + ``entity`` and ``message_ids`` already set. + + If you need to delete more than one message at once, don't use + this `delete` method. Use a + `telethon.telegram_client.TelegramClient` instance directly. + + This method also creates a task to `answer` the callback. + """ + self._client.loop.create_task(self.answer()) + return await self._client.delete_messages( + await self.get_input_chat(), [self.query.msg_id], + *args, **kwargs + ) From ea07cf8d128db619e3e611f409826048467adbe6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 16:13:58 +0200 Subject: [PATCH 09/70] Add buttons parameter to client.edit_message --- telethon/client/messages.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 84cb6e02..4932639d 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -538,7 +538,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): async def edit_message( self, entity, message=None, text=None, - *, parse_mode=utils.Default, link_preview=True, file=None): + *, parse_mode=utils.Default, link_preview=True, file=None, + buttons=None): """ Edits the given message ID (to change its contents or disable preview). @@ -569,6 +570,13 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): The file object that should replace the existing media in the message. + buttons (`list`, `custom.Button `, + :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + Examples: >>> client = ... @@ -604,7 +612,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): message=text, no_webpage=not link_preview, entities=msg_entities, - media=media + media=media, + reply_markup=self._build_reply_markup(buttons) ) msg = self._get_response_message(request, await self(request), entity) self._cache_media(msg, file, file_handle) From f6c45dcc639abc677fb47c8b54e067605343bad8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 17:58:40 +0200 Subject: [PATCH 10/70] Support filtering events.CallbackQuery --- telethon/events/callbackquery.py | 70 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index e12b1744..b50d680b 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -1,3 +1,5 @@ +import re + from .common import EventBuilder, EventCommon, name_inner_event from .. import utils from ..tl import types, functions @@ -8,9 +10,39 @@ from ..tl.custom.sendergetter import SenderGetter class CallbackQuery(EventBuilder): """ Represents a callback query event (when an inline button is clicked). + + Note that the `chats` parameter will **not** work with normal + IDs or peers if the clicked inline button comes from a "via bot" + message. The `chats` parameter also supports checking against the + `chat_instance` which should be used for inline callbacks. + + Args: + data (`bytes` | `str` | `callable`, optional): + If set, the inline button payload data must match this data. + A UTF-8 string can also be given, a regex or a callable. For + instance, to check against ``'data_1'`` and ``'data_2'`` you + can use ``re.compile(b'data_')``. """ + def __init__(self, chats=None, *, blacklist_chats=False, data=None): + super().__init__(chats=chats, blacklist_chats=blacklist_chats) + + if isinstance(data, bytes): + self.data = data + elif isinstance(data, str): + self.data = data.encode('utf-8') + elif not data or callable(data): + self.data = data + elif hasattr(data, 'match') and callable(data.match): + if not isinstance(getattr(data, 'pattern', b''), bytes): + data = re.compile(data.pattern.encode('utf-8'), data.flags) + + self.data = data.match + else: + raise TypeError('Invalid data type given') + def build(self, update): - if isinstance(update, types.UpdateBotCallbackQuery): + if isinstance(update, (types.UpdateBotCallbackQuery, + types.UpdateInlineBotCallbackQuery)): event = CallbackQuery.Event(update) else: return @@ -18,6 +50,25 @@ class CallbackQuery(EventBuilder): event._entities = update._entities return self._filter_event(event) + def _filter_event(self, event): + if self.chats is not None: + inside = event.query.chat_instance in self.chats + if event.chat_id: + inside |= event.chat_id in self.chats + + if inside == self.blacklist_chats: + return None + + if self.data: + if callable(self.data): + event.data_match = self.data(event.query.data) + if not event.data_match: + return None + elif event.query.data != self.data: + return None + + return event + class Event(EventCommon, SenderGetter): """ Represents the event of a new callback query. @@ -25,10 +76,17 @@ class CallbackQuery(EventBuilder): Members: query (:tl:`UpdateBotCallbackQuery`): The original :tl:`UpdateBotCallbackQuery`. + + data_match (`obj`, optional): + The object returned by the ``data=`` parameter + when creating the event builder, if any. Similar + to ``pattern_match`` for the new message event. """ def __init__(self, query): - super().__init__(chat_peer=query.peer, msg_id=query.msg_id) + super().__init__(chat_peer=getattr(query, 'peer', None), + msg_id=query.msg_id) self.query = query + self.data_match = None self._sender_id = query.user_id self._input_sender = None self._sender = None @@ -57,6 +115,14 @@ class CallbackQuery(EventBuilder): """ return self.query.data + @property + def chat_instance(self): + """ + Unique identifier for the chat where the callback occurred. + Useful for high scores in games. + """ + return self.query.chat_instance + async def get_message(self): """ Returns the message to which the clicked inline button belongs. From a3d6baf40896aa160b84b5bdbe72c94c27c8fa23 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 19:50:18 +0200 Subject: [PATCH 11/70] Actually add callbacks registered through Button --- telethon/client/buttons.py | 9 +++++++-- telethon/tl/custom/button.py | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py index 536c6e6f..4741cbda 100644 --- a/telethon/client/buttons.py +++ b/telethon/client/buttons.py @@ -1,6 +1,6 @@ from .updates import UpdateMethods from ..tl import types, custom -from .. import utils +from .. import utils, events class ButtonMethods(UpdateMethods): @@ -30,7 +30,12 @@ class ButtonMethods(UpdateMethods): is_inline |= inline is_normal |= not inline if isinstance(button, custom.Button): - # TODO actually register callbacks + if button.callback: + self.add_event_handler( + button.callback, + events.CallbackQuery(data=button.data) + ) + button = button.button if button.SUBCLASS_OF_ID == 0xbad74a3: diff --git a/telethon/tl/custom/button.py b/telethon/tl/custom/button.py index b0094dfd..689d7b51 100644 --- a/telethon/tl/custom/button.py +++ b/telethon/tl/custom/button.py @@ -29,6 +29,11 @@ class Button: self.callback = callback self.is_inline = self._is_inline(button) + @property + def data(self): + if isinstance(self.button, types.KeyboardButtonCallback): + return self.button.data + @classmethod def _is_inline(cls, button): """ From 71309c886e755a4f104233a091f92d901cf55735 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 10 Jul 2018 21:07:21 +0200 Subject: [PATCH 12/70] Document usage of the TelegramClient with examples --- .../extra/examples/telegram-client.rst | 395 ++++++++++++++++++ readthedocs/index.rst | 1 + 2 files changed, 396 insertions(+) create mode 100644 readthedocs/extra/examples/telegram-client.rst diff --git a/readthedocs/extra/examples/telegram-client.rst b/readthedocs/extra/examples/telegram-client.rst new file mode 100644 index 00000000..64303fce --- /dev/null +++ b/readthedocs/extra/examples/telegram-client.rst @@ -0,0 +1,395 @@ +======================== +Examples with the Client +======================== + +This section explores the methods defined in the :ref:`telegram-client` +with some practical examples. The section assumes that you have imported +the ``telethon.sync`` package and that you have a client ready to use. + +.. contents:: + +Authorization +************* + +Starting the client is as easy as calling `client.start() +`: + +.. code-block:: python + + client.start() + ... # code using the client + client.disconnect() + +And you can even use a ``with`` block: + +.. code-block:: python + + with client: + ... # code using the client + + +Group Chats +*********** + +You can easily iterate over all the :tl:`User` in a chat and +do anything you want with them by using `client.iter_participants +`: + +.. code-block:: python + + for user in client.iter_participants(chat): + ... # do something with the user + +You can also search by their name: + +.. code-block:: python + + for user in client.iter_participants(chat, search='name'): + ... + +Or by their type (e.g. if they are admin) with :tl:`ChannelParticipantsFilter`: + +.. code-block:: python + + from telethon.tl.types import ChannelParticipantsAdmins + + for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): + ... + + +Open Conversations and Joined Channels +************************************** + +The conversations you have open and the channels you have joined +are in your "dialogs", so to get them you need to `client.get_dialogs +`: + +.. code-block:: python + + dialogs = client.get_dialogs() + first = dialogs[0] + print(first.title) + +You can then use the dialog as if it were a peer: + +.. code-block:: python + + client.send_message(first, 'hi') + + +You can access `dialog.draft ` or you can +get them all at once without getting the dialogs: + +.. code-block:: python + + drafts = client.get_drafts() + + +Downloading Media +***************** + +It's easy to `download_profile_photo +`: + +.. code-block:: python + + client.download_profile_method(user) + +Or `download_media ` +from a message: + +.. code-block:: python + + client.download_media(message) + client.download_media(message, filename) + # or + message.download_media() + message.download_media(filename) + +Remember that these methods return the final filename where the +media was downloaded (e.g. it may add the extension automatically). + +Getting Messages +**************** + +You can easily iterate over all the `messages +` of a chat with `iter_messages +`: + +.. code-block:: python + + for message in client.iter_messages(chat): + ... # do something with the message from recent to older + + for message in client.iter_messages(chat, reverse=True): + ... # going from the oldest to the most recent + +You can also use it to search for messages from a specific person: + +.. code-block:: python + + for message in client.iter_messages(chat, from_user='me'): + ... + +Or you can search by text: + +.. code-block:: python + + for message in client.iter_messages(chat, search='hello'): + ... + +Or you can search by media with a :tl:`MessagesFilter`: + +.. code-block:: python + + from telethon.tl.types import InputMessagesFilterPhotos + + for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): + ... + +If you want a list instead, use the get variant. The second +argument is the limit, and ``None`` means "get them all": + +.. code-block:: python + + + from telethon.tl.types import InputMessagesFilterPhotos + + # Get 0 photos and print the total + photos = client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) + print(photos.total) + + # Get all the photos + photos = client.get_messages(chat, None, filter=InputMessagesFilterPhotos) + +Or just some IDs: + +.. code-block:: python + + message_1337 = client.get_messages(chats, ids=1337) + + +Sending Messages +**************** + +Just use `send_message `: + +.. code-block:: python + + client.send_message('lonami', 'Thanks for the Telethon library!') + +The function returns the `custom.Message ` +that was sent so you can do more things with it if you want. + +You can also `reply ` or +`respond ` to messages: + +.. code-block:: python + + message.reply('Hello') + message.respond('World') + + +Sending Messages with Media +*************************** + +Sending media can be done with `send_file +`: + +.. code-block:: python + + client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") + # or + client.send_message(chat, "It's me!", file='/my/photos/me.jpg') + +You can send voice notes or round videos by setting the right arguments: + +.. code-block:: python + + client.send_file(chat, '/my/songs/song.mp3', voice_note=True) + client.send_file(chat, '/my/videos/video.mp3', video_note=True) + +You can set a JPG thumbnail for any document: + +.. code-block:: python + + client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') + +You can force sending images as documents: + +.. code-block:: python + + client.send_file(chat, '/my/photos/photo.png', force_document=True) + +You can send albums if you pass more than one file: + +.. code-block:: python + + client.send_file(chat, [ + '/my/photos/holiday1.jpg', + '/my/photos/holiday2.jpg', + '/my/drawings/portrait.png' + ]) + +The caption can also be a list to match the different photos. + +Sending Messages with Buttons +***************************** + +You must sign in as a bot in order to add inline buttons (or normal +keyboards) to your messages. Once you have signed in as a bot specify +the `Button ` or buttons to use: + +.. code-block:: python + + from telethon.tl.custom import Button + + async def callback(event): + await event.edit('Thank you!') + + client.send_message(chat, 'Hello!', + buttons=Button.inline('Click me', callback)) + + +You can also add the event handler yourself, or change the data payload: + +.. code-block:: python + + from telethon import events + + @client.on(events.CallbackQuery) + async def handler(event): + await event.answer('You clicked {}!'.format(event.data)) + + client.send_message(chat, 'Pick one', buttons=[ + [Button.inline('Left'), Button.inline('Right')], + [Button.url('Check my site!', 'https://lonamiwebs.github.io')] + ]) + +You can also use normal buttons (not inline) to request the user's +location, phone number, or simply for them to easily send a message: + +.. code-block:: python + + client.send_message(chat, 'Welcome', buttons=[ + Button.text('Thanks!'), + Button.request_phone('Send phone'), + Button.request_location('Send location') + ]) + +Forcing a reply or removing the keyboard can also be done: + +.. code-block:: python + + client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) + client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) + +Remember to check `Button ` for more. + +Forwarding Messages +******************* + +You can forward up to 100 messages with `forward_messages +`, +or a single one if you have the message with `forward_to +`: + +.. code-block:: python + + # a single one + client.forward_messages(chat, message) + # or + client.forward_messages(chat, message_id, from_chat) + # or + message.forward_to(chat) + + # multiple + client.forward_messages(chat, messages) + # or + client.forward_messages(chat, message_ids, from_chat) + +You can also "forward" messages without showing "Forwarded from" by +re-sending the message: + +.. code-block:: python + + client.send_message(chat, message) + + +Editing Messages +**************** + +With `edit_message ` +or `message.edit `: + +.. code-block:: python + + client.edit_message(message, 'New text') + # or + message.edit('New text') + # or + client.edit_message(chat, message_id, 'New text') + +Deleting Messages +***************** + +With `delete_messages ` +or `message.delete `. Note that the +first one supports deleting entire chats at once!: + +.. code-block:: python + + client.delete_messages(chat, messages) + # or + message.delete() + + +Marking Messages as Read +************************ + +Marking messages up to a certain point as read with `send_read_acknowledge +`: + +.. code-block:: python + + client.send_read_acknowledge(last_message) + # or + client.send_read_acknowledge(last_message_id) + # or + client.send_read_acknowledge(messages) + + +Getting Entities +**************** + +Entities are users, chats, or channels. You can get them by their ID if +you have seen them before (e.g. you probably need to get all dialogs or +all the members from a chat first): + +.. code-block:: python + + from telethon import utils + + me = client.get_entity('me') + print(utils.get_display_name(me)) + + chat = client.get_input_entity('username') + for message in client.iter_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_input_entity if you will reuse it a lot. + for message in client.iter_messages('username'): + ... + + some_id = client.get_peer_id('+34123456789') + +The documentation for shown methods are `get_entity +`, `get_input_entity +` and `get_peer_id +`. + +Note that the utils package also has a `get_peer_id +` but it won't work with things +that need access to the network such as usernames or phones, +which need to be in your contact list. diff --git a/readthedocs/index.rst b/readthedocs/index.rst index 090b1c69..8656a7a6 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -77,6 +77,7 @@ heavy job for you, so you can focus on developing an application. :maxdepth: 2 :caption: Examples + extra/examples/telegram-client extra/examples/working-with-messages extra/examples/chats-and-channels extra/examples/users From e9023043600e3ad15d1d4bef88298360065fc99d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 11 Jul 2018 10:16:21 +0200 Subject: [PATCH 13/70] Expose silent parameter when sending messages --- telethon/client/messages.py | 25 +++++++++++++++++++++---- telethon/client/uploads.py | 19 +++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 4932639d..62b6ef10 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -334,7 +334,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): async def send_message( self, entity, message='', *, reply_to=None, parse_mode=utils.Default, link_preview=True, file=None, - force_document=False, clear_draft=False, buttons=None): + force_document=False, clear_draft=False, buttons=None, + silent=None): """ Sends the given message to the specified entity (user/chat/channel). @@ -390,6 +391,11 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): you have signed in as a bot. You can also pass your own :tl:`ReplyMarkup` here. + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to ``False``, which means it will + notify them. Set it to ``True`` to alter this behaviour. + Returns: The sent `custom.Message `. """ @@ -431,10 +437,13 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): else: markup = self._build_reply_markup(buttons) + if silent is None: + silent = message.silent + request = functions.messages.SendMessageRequest( peer=entity, message=message.message or '', - silent=message.silent, + silent=silent, reply_to_msg_id=reply_id, reply_markup=markup, entities=message.entities, @@ -453,6 +462,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): no_webpage=not link_preview, reply_to_msg_id=utils.get_message_id(reply_to), clear_draft=clear_draft, + silent=silent, reply_markup=self._build_reply_markup(buttons) ) @@ -471,7 +481,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): return self._get_response_message(request, result, entity) - async def forward_messages(self, entity, messages, from_peer=None): + async def forward_messages(self, entity, messages, from_peer=None, + *, silent=None): """ Forwards the given message(s) to the specified entity. @@ -487,6 +498,11 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): of the ``Message`` class, this *must* be specified in order for the forward to work. + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to ``False``, which means it will + notify them. Set it to ``True`` to alter this behaviour. + Returns: The list of forwarded `telethon.tl.custom.message.Message`, or a single one if a list wasn't provided as input. @@ -514,7 +530,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): req = functions.messages.ForwardMessagesRequest( from_peer=from_peer, id=[m if isinstance(m, int) else m.id for m in messages], - to_peer=entity + to_peer=entity, + silent=silent ) result = await self(req) if isinstance(result, (types.Updates, types.UpdatesCombined)): diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index c987e464..f7f4171d 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -31,7 +31,8 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): self, entity, file, *, caption='', force_document=False, progress_callback=None, reply_to=None, attributes=None, thumb=None, allow_cache=True, parse_mode=utils.Default, - voice_note=False, video_note=False, buttons=None, **kwargs): + voice_note=False, video_note=False, buttons=None, silent=None, + **kwargs): """ Sends a file to the specified entity. @@ -106,6 +107,11 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): you have signed in as a bot. You can also pass your own :tl:`ReplyMarkup` here. + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to ``False``, which means it will + notify them. Set it to ``True`` to alter this behaviour. + Notes: If the ``hachoir3`` package (``hachoir`` module) is installed, it will be used to determine metadata from audio and video files. @@ -134,7 +140,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): result += await self._send_album( entity, images[:10], caption=caption, progress_callback=progress_callback, reply_to=reply_to, - parse_mode=parse_mode + parse_mode=parse_mode, silent=silent ) images = images[10:] @@ -144,7 +150,8 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): caption=caption, force_document=force_document, progress_callback=progress_callback, reply_to=reply_to, attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, buttons=buttons, **kwargs + video_note=video_note, buttons=buttons, silent=silent, + **kwargs )) return result @@ -170,7 +177,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): markup = self._build_reply_markup(buttons) request = functions.messages.SendMediaRequest( entity, media, reply_to_msg_id=reply_to, message=caption, - entities=msg_entities, reply_markup=markup + entities=msg_entities, reply_markup=markup, silent=silent ) msg = self._get_response_message(request, await self(request), entity) self._cache_media(msg, file, file_handle, force_document=force_document) @@ -179,7 +186,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): async def _send_album(self, entity, files, caption='', progress_callback=None, reply_to=None, - parse_mode=utils.Default): + parse_mode=utils.Default, silent=None): """Specialized version of .send_file for albums""" # We don't care if the user wants to avoid cache, we will use it # anyway. Why? The cached version will be exactly the same thing @@ -222,7 +229,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): # Now we can construct the multi-media request result = await self(functions.messages.SendMultiMediaRequest( - entity, reply_to_msg_id=reply_to, multi_media=media + entity, reply_to_msg_id=reply_to, multi_media=media, silent=silent )) return [ self._get_response_message(update.id, result, entity) From 81f31e09c8917d78552b936ad13bf4ba77cecd1d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 11 Jul 2018 10:50:19 +0200 Subject: [PATCH 14/70] Keep track of how many events for each type were added --- telethon/client/telegrambaseclient.py | 6 ++++++ telethon/client/updates.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 53d8a4e3..700054f4 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -1,5 +1,6 @@ import abc import asyncio +import collections import inspect import logging import platform @@ -258,6 +259,11 @@ class TelegramBaseClient(abc.ABC): self._events_pending_resolve = [] self._event_resolve_lock = asyncio.Lock() + # Keep track of how many event builders there are for + # each type {type: count}. If there's at least one then + # the event will be built, and the same event be reused. + self._event_builders_count = collections.defaultdict(int) + # Default parse mode self._parse_mode = markdown diff --git a/telethon/client/updates.py b/telethon/client/updates.py index 32e39eac..644e8d39 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -90,6 +90,7 @@ class UpdateMethods(UserMethods): event = events.Raw() self._events_pending_resolve.append(event) + self._event_builders_count[type(event)] += 1 self._event_builders.append((event, callback)) def remove_event_handler(self, callback, event=None): @@ -108,6 +109,11 @@ class UpdateMethods(UserMethods): i -= 1 ev, cb = self._event_builders[i] if cb == callback and (not event or isinstance(ev, event)): + type_ev = type(ev) + self._event_builders_count[type_ev] -= 1 + if not self._event_builders_count[type_ev]: + del self._event_builders_count[type_ev] + del self._event_builders[i] found += 1 From 1d0fd6801dfba8e8326437f2d9960c858f25f952 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 11 Jul 2018 11:22:43 +0200 Subject: [PATCH 15/70] Build events only once per update --- telethon/client/updates.py | 50 ++++++++++++++++++------------- telethon/events/callbackquery.py | 7 +++-- telethon/events/chataction.py | 5 ++-- telethon/events/common.py | 10 +++++-- telethon/events/messagedeleted.py | 5 ++-- telethon/events/messageedited.py | 5 ++-- telethon/events/messageread.py | 10 +++++-- telethon/events/newmessage.py | 16 +++++----- telethon/events/raw.py | 10 +++++-- telethon/events/userupdate.py | 5 ++-- 10 files changed, 74 insertions(+), 49 deletions(-) diff --git a/telethon/client/updates.py b/telethon/client/updates.py index 644e8d39..da58e5f1 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -263,28 +263,36 @@ class UpdateMethods(UserMethods): self._events_pending_resolve.clear() - for builder, callback in self._event_builders: - event = builder.build(update) - if event: - if hasattr(event, '_set_client'): - event._set_client(self) - else: - event._client = self + # TODO We can improve this further + # If we had a way to get all event builders for + # a type instead looping over them all always. + built = {builder: builder.build(update) + for builder in self._event_builders_count} - event.original_update = update - try: - await callback(event) - except events.StopPropagation: - __log__.debug( - "Event handler '{}' stopped chain of " - "propagation for event {}." - .format(callback.__name__, - type(event).__name__) - ) - break - except Exception: - __log__.exception('Unhandled exception on {}' - .format(callback.__name__)) + for builder, callback in self._event_builders: + event = built[type(builder)] + if not event or not builder.filter(event): + continue + + if hasattr(event, '_set_client'): + event._set_client(self) + else: + event._client = self + + event.original_update = update + try: + await callback(event) + except events.StopPropagation: + __log__.debug( + "Event handler '{}' stopped chain of " + "propagation for event {}." + .format(callback.__name__, + type(event).__name__) + ) + break + except Exception: + __log__.exception('Unhandled exception on {}' + .format(callback.__name__)) async def _handle_auto_reconnect(self): # Upon reconnection, we want to send getState diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index b50d680b..d9051347 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -40,7 +40,8 @@ class CallbackQuery(EventBuilder): else: raise TypeError('Invalid data type given') - def build(self, update): + @staticmethod + def build(update): if isinstance(update, (types.UpdateBotCallbackQuery, types.UpdateInlineBotCallbackQuery)): event = CallbackQuery.Event(update) @@ -48,9 +49,9 @@ class CallbackQuery(EventBuilder): return event._entities = update._entities - return self._filter_event(event) + return event - def _filter_event(self, event): + def filter(self, event): if self.chats is not None: inside = event.query.chat_instance in self.chats if event.chat_id: diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index 3452889e..1a36b931 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -8,7 +8,8 @@ class ChatAction(EventBuilder): """ Represents an action in a chat (such as user joined, left, or new pin). """ - def build(self, update): + @staticmethod + def build(update): if isinstance(update, types.UpdateChannelPinnedMessage) and update.id == 0: # Telegram does not always send # UpdateChannelPinnedMessage for new pins @@ -78,7 +79,7 @@ class ChatAction(EventBuilder): return event._entities = update._entities - return self._filter_event(event) + return event class Event(EventCommon): """ diff --git a/telethon/events/common.py b/telethon/events/common.py index 2e7c2d26..aca7f892 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -52,21 +52,25 @@ class EventBuilder(abc.ABC): will be handled *except* those specified in ``chats`` which will be ignored if ``blacklist_chats=True``. """ + self_id = None + def __init__(self, chats=None, blacklist_chats=False): self.chats = chats self.blacklist_chats = blacklist_chats self._self_id = None + @staticmethod @abc.abstractmethod - def build(self, update): + def build(update): """Builds an event for the given update if possible, or returns None""" async def resolve(self, client): """Helper method to allow event builders to be resolved before usage""" self.chats = await _into_id_set(client, self.chats) - self._self_id = (await client.get_me(input_peer=True)).user_id + if not EventBuilder.self_id: + EventBuilder.self_id = await client.get_peer_id('me') - def _filter_event(self, event): + def filter(self, event): """ If the ID of ``event._chat_peer`` isn't in the chats set (or it is but the set is a blacklist) returns ``None``, otherwise the event. diff --git a/telethon/events/messagedeleted.py b/telethon/events/messagedeleted.py index cbbac8cd..c3c82c99 100644 --- a/telethon/events/messagedeleted.py +++ b/telethon/events/messagedeleted.py @@ -7,7 +7,8 @@ class MessageDeleted(EventBuilder): """ Event fired when one or more messages are deleted. """ - def build(self, update): + @staticmethod + def build(update): if isinstance(update, types.UpdateDeleteMessages): event = MessageDeleted.Event( deleted_ids=update.messages, @@ -22,7 +23,7 @@ class MessageDeleted(EventBuilder): return event._entities = update._entities - return self._filter_event(event) + return event class Event(EventCommon): def __init__(self, deleted_ids, peer): diff --git a/telethon/events/messageedited.py b/telethon/events/messageedited.py index 5ea42a50..8bb121d3 100644 --- a/telethon/events/messageedited.py +++ b/telethon/events/messageedited.py @@ -8,7 +8,8 @@ class MessageEdited(NewMessage): """ Event fired when a message has been edited. """ - def build(self, update): + @staticmethod + def build(update): if isinstance(update, (types.UpdateEditMessage, types.UpdateEditChannelMessage)): event = MessageEdited.Event(update.message) @@ -16,7 +17,7 @@ class MessageEdited(NewMessage): return event._entities = update._entities - return self._message_filter_event(event) + return event class Event(NewMessage.Event): pass # Required if we want a different name for it diff --git a/telethon/events/messageread.py b/telethon/events/messageread.py index 6e781fa2..91496c40 100644 --- a/telethon/events/messageread.py +++ b/telethon/events/messageread.py @@ -18,7 +18,8 @@ class MessageRead(EventBuilder): super().__init__(chats, blacklist_chats) self.inbox = inbox - def build(self, update): + @staticmethod + def build(update): if isinstance(update, types.UpdateReadHistoryInbox): event = MessageRead.Event(update.peer, update.max_id, False) elif isinstance(update, types.UpdateReadHistoryOutbox): @@ -39,11 +40,14 @@ class MessageRead(EventBuilder): else: return + event._entities = update._entities + return event + + def filter(self, event): if self.inbox == event.outbox: return - event._entities = update._entities - return self._filter_event(event) + return super().filter(event) class Event(EventCommon): """ diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index a43febd5..02034824 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -75,7 +75,8 @@ class NewMessage(EventBuilder): await super().resolve(client) self.from_users = await _into_id_set(client, self.from_users) - def build(self, update): + @staticmethod + def build(update): if isinstance(update, (types.UpdateNewMessage, types.UpdateNewChannelMessage)): if not isinstance(update.message, types.Message): @@ -91,9 +92,9 @@ class NewMessage(EventBuilder): # Note that to_id/from_id complement each other in private # messages, depending on whether the message was outgoing. to_id=types.PeerUser( - update.user_id if update.out else self._self_id + update.user_id if update.out else EventBuilder.self_id ), - from_id=self._self_id if update.out else update.user_id, + from_id=EventBuilder.self_id if update.out else update.user_id, message=update.message, date=update.date, fwd_from=update.fwd_from, @@ -120,8 +121,6 @@ class NewMessage(EventBuilder): else: return - event._entities = update._entities - # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. ori = event.message @@ -129,9 +128,10 @@ class NewMessage(EventBuilder): if ori.from_id == ori.to_id.user_id and not ori.fwd_from: event.message.out = True - return self._message_filter_event(event) + event._entities = update._entities + return event - def _message_filter_event(self, event): + def filter(self, event): if self._no_check: return event @@ -153,7 +153,7 @@ class NewMessage(EventBuilder): return event.pattern_match = match - return self._filter_event(event) + return super().filter(event) class Event(EventCommon): """ diff --git a/telethon/events/raw.py b/telethon/events/raw.py index a4a3fc19..befce28f 100644 --- a/telethon/events/raw.py +++ b/telethon/events/raw.py @@ -25,6 +25,10 @@ class Raw(EventBuilder): async def resolve(self, client): pass - def build(self, update): - if not self.types or isinstance(update, self.types): - return update + @staticmethod + def build(update): + return update + + def filter(self, event): + if not self.types or isinstance(event, self.types): + return event diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py index ad2d198e..5a67d40b 100644 --- a/telethon/events/userupdate.py +++ b/telethon/events/userupdate.py @@ -9,7 +9,8 @@ class UserUpdate(EventBuilder): """ Represents an user update (gone online, offline, joined Telegram). """ - def build(self, update): + @staticmethod + def build(update): if isinstance(update, types.UpdateUserStatus): event = UserUpdate.Event(update.user_id, status=update.status) @@ -17,7 +18,7 @@ class UserUpdate(EventBuilder): return event._entities = update._entities - return self._filter_event(event) + return event class Event(EventCommon): """ From e2ffa816dc77f73d4ffb03be08c249153bb7dd74 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 11 Jul 2018 11:23:11 +0200 Subject: [PATCH 16/70] Fix infinite recursion --- telethon/tl/custom/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 2436876b..6b1c00a4 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -396,7 +396,7 @@ class Message(ChatGetter, SenderGetter): *not* considered outgoing, just like official clients display them. """ - return self.original_message.out + return self.__dict__['out'] async def get_reply_message(self): """ From 8b4c8d30e70134a422576178534d41ebc9a92c88 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 11 Jul 2018 11:34:20 +0200 Subject: [PATCH 17/70] Fix events.MessageDeleted setting readonly properties --- telethon/events/messagedeleted.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/telethon/events/messagedeleted.py b/telethon/events/messagedeleted.py index c3c82c99..ffcb5c23 100644 --- a/telethon/events/messagedeleted.py +++ b/telethon/events/messagedeleted.py @@ -30,12 +30,5 @@ class MessageDeleted(EventBuilder): super().__init__( chat_peer=peer, msg_id=(deleted_ids or [0])[0] ) - if peer is None: - # If it's not a channel ID, then it was private/small group. - # We can't know which one was exactly unless we logged all - # messages, but we can indicate that it was maybe either of - # both by setting them both to True. - self.is_private = self.is_group = True - self.deleted_id = None if not deleted_ids else deleted_ids[0] self.deleted_ids = deleted_ids From 38c65adf358ac42033cb2382b850fde9a82bcb71 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 12 Jul 2018 00:30:57 +0200 Subject: [PATCH 18/70] Set timezone info when reading datetimes --- telethon/extensions/binaryreader.py | 7 +++++-- telethon/tl/tlobject.py | 4 ---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/telethon/extensions/binaryreader.py b/telethon/extensions/binaryreader.py index a138c255..5382caaf 100644 --- a/telethon/extensions/binaryreader.py +++ b/telethon/extensions/binaryreader.py @@ -2,7 +2,7 @@ This module contains the BinaryReader utility class. """ import os -from datetime import datetime +from datetime import datetime, timezone from io import BufferedReader, BytesIO from struct import unpack @@ -120,7 +120,10 @@ class BinaryReader: into a Python datetime object. """ value = self.read_int() - return None if value == 0 else datetime.utcfromtimestamp(value) + if value == 0: + return None + else: + return datetime.fromtimestamp(value, tz=timezone.utc) def tgread_object(self): """Reads a Telegram object.""" diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 3561f18f..52b86b71 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -24,10 +24,6 @@ class TLObject: return '[{}]'.format( ', '.join(TLObject.pretty_format(x) for x in obj) ) - elif isinstance(obj, datetime): - return 'datetime.utcfromtimestamp({})'.format( - int(obj.timestamp()) - ) else: return repr(obj) else: From 22c8fd7378dbe1df001d365d76ce0534c548ab8b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 12 Jul 2018 09:45:29 +0200 Subject: [PATCH 19/70] Fix Updates object being dispatched to user handlers --- telethon/client/updates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/client/updates.py b/telethon/client/updates.py index da58e5f1..9092a13b 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -181,7 +181,7 @@ class UpdateMethods(UserMethods): for u in update.updates: u._entities = entities self._handle_update(u) - if isinstance(update, types.UpdateShort): + elif isinstance(update, types.UpdateShort): self._handle_update(update.update) else: update._entities = getattr(update, '_entities', {}) From 051d56af8844f70050a58783d0721b840aec1828 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 12 Jul 2018 16:26:22 +0200 Subject: [PATCH 20/70] Support clicking buttons known their data --- telethon/tl/custom/message.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 6b1c00a4..e40f210f 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -1,4 +1,5 @@ -from .. import types +from .. import types, functions +from ...errors import BotTimeout from ...utils import get_input_peer, get_inner_text from .chatgetter import ChatGetter from .sendergetter import SenderGetter @@ -524,7 +525,8 @@ class Message(ChatGetter, SenderGetter): texts = get_inner_text(self.original_message.message, ent) return list(zip(ent, texts)) - async def click(self, i=None, j=None, *, text=None, filter=None): + async def click(self, i=None, j=None, + *, text=None, filter=None, data=None): """ Calls `telethon.tl.custom.messagebutton.MessageButton.click` for the specified button. @@ -564,7 +566,29 @@ class Message(ChatGetter, SenderGetter): Clicks the first button for which the callable returns ``True``. The callable should accept a single `telethon.tl.custom.messagebutton.MessageButton` argument. + + data (`bytes`): + This argument overrides the rest and will not search any + buttons. Instead, it will directly send the request to + behave as if it clicked a button with said data. Note + that if the message does not have this data, it will + ``raise DataInvalidError``. """ + if data: + if not await self.get_input_chat(): + return None + + try: + return await self._client( + functions.messages.GetBotCallbackAnswerRequest( + peer=self._input_chat, + msg_id=self.original_message.id, + data=data + ) + ) + except BotTimeout: + return None + if sum(int(x is not None) for x in (i, text, filter)) >= 2: raise ValueError('You can only set either of i, text or filter') From dc3d28127431d1c03e3c15ade96054ad7a3aba74 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 14 Jul 2018 00:01:45 +0200 Subject: [PATCH 21/70] Load update state date with explicit timezone (#808) --- telethon/sessions/sqlite.py | 3 ++- telethon/tl/tlobject.py | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 4f6967aa..7d81f00c 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -211,7 +211,8 @@ class SQLiteSession(MemorySession): 'where id = ?', entity_id) if row: pts, qts, date, seq = row - date = datetime.datetime.utcfromtimestamp(date) + date = datetime.datetime.fromtimestamp( + date, tz=datetime.timezone.utc) return types.updates.State(pts, qts, date, seq, unread_count=0) def set_update_state(self, entity_id, state): diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 52b86b71..cc0d9ab3 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -65,11 +65,6 @@ class TLObject: result.append('\t' * indent) result.append(']') - elif isinstance(obj, datetime): - result.append('datetime.utcfromtimestamp(') - result.append(repr(int(obj.timestamp()))) - result.append(')') - else: result.append(repr(obj)) From 5017a9d1da19729daeeb3d5d4182b9ba696ac752 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 14 Jul 2018 10:43:24 +0200 Subject: [PATCH 22/70] Fix typos and add the URL_INVALID error --- telethon/tl/custom/draft.py | 2 +- telethon_examples/assistant.py | 2 +- telethon_generator/data/error_descriptions | 1 + telethon_generator/data/errors.json | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/telethon/tl/custom/draft.py b/telethon/tl/custom/draft.py index 334b6f26..6e931dbc 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/tl/custom/draft.py @@ -78,7 +78,7 @@ class Draft: if not self.entity and await self.get_input_entity(): try: self._entity =\ - self._client.get_entity(self._input_entity) + await self._client.get_entity(self._input_entity) except ValueError: pass diff --git a/telethon_examples/assistant.py b/telethon_examples/assistant.py index 8fadd239..d7650543 100644 --- a/telethon_examples/assistant.py +++ b/telethon_examples/assistant.py @@ -180,7 +180,7 @@ async def handler(event): reply_to=event.reply_to_msg_id ) - # We have two @client.on, both could fire, stop stop that + # We have two @client.on, both could fire, stop that raise events.StopPropagation diff --git a/telethon_generator/data/error_descriptions b/telethon_generator/data/error_descriptions index 6e840d65..20c35c1b 100644 --- a/telethon_generator/data/error_descriptions +++ b/telethon_generator/data/error_descriptions @@ -66,3 +66,4 @@ FLOOD_WAIT_X=A wait of {} seconds is required FLOOD_TEST_PHONE_WAIT_X=A wait of {} seconds is required in the test servers TAKEOUT_INIT_DELAY_X=A wait of {} seconds is required before being able to initiate the takeout CHAT_NOT_MODIFIED=The chat or channel wasn't modified (title, invites, username, admins, etc. are the same) +URL_INVALID=The URL used was invalid (e.g. when answering a callback with an URL that's not t.me/yourbot or your game's URL) diff --git a/telethon_generator/data/errors.json b/telethon_generator/data/errors.json index 815439ce..e9a1b2df 100644 --- a/telethon_generator/data/errors.json +++ b/telethon_generator/data/errors.json @@ -1 +1 @@ -{"human_result": {"-429": ["Too many requests"], "ABOUT_TOO_LONG": ["The provided bio is too long"], "ACCESS_TOKEN_EXPIRED": ["Bot token expired"], "ACCESS_TOKEN_INVALID": ["The provided token is not valid"], "ACTIVE_USER_REQUIRED": ["The method is only available to already activated users"], "ADMINS_TOO_MUCH": ["Too many admins"], "API_ID_INVALID": ["The api_id/api_hash combination is invalid"], "API_ID_PUBLISHED_FLOOD": ["This API id was published somewhere, you can't use it now"], "ARTICLE_TITLE_EMPTY": ["The title of the article is empty"], "AUTH_BYTES_INVALID": ["The provided authorization is invalid"], "AUTH_KEY_DUPLICATED": ["An auth key with the same ID was already generated"], "AUTH_KEY_PERM_EMPTY": ["The temporary auth key must be binded to the permanent auth key to use these methods."], "AUTH_KEY_UNREGISTERED": ["The authorization key has expired"], "AUTH_RESTART": ["Restart the authorization process"], "BOTS_TOO_MUCH": ["There are too many bots in this chat/channel"], "BOT_CHANNELS_NA": ["Bots can't edit admin privileges"], "BOT_GROUPS_BLOCKED": ["This bot can't be added to groups"], "BOT_INLINE_DISABLED": ["This bot can't be used in inline mode"], "BOT_INVALID": ["This is not a valid bot"], "BOT_METHOD_INVALID": ["This method cannot be run by a bot"], "BOT_MISSING": ["This method can only be run by a bot"], "BUTTON_DATA_INVALID": ["The provided button data is invalid"], "BUTTON_TYPE_INVALID": ["The type of one of the buttons you provided is invalid"], "BUTTON_URL_INVALID": ["Button URL invalid"], "CALL_ALREADY_ACCEPTED": ["The call was already accepted"], "CALL_ALREADY_DECLINED": ["The call was already declined"], "CALL_OCCUPY_FAILED": ["The call failed because the user is already making another call"], "CALL_PEER_INVALID": ["The provided call peer object is invalid"], "CALL_PROTOCOL_FLAGS_INVALID": ["Call protocol flags invalid"], "CDN_METHOD_INVALID": ["You can't call this method in a CDN DC"], "CHANNELS_ADMIN_PUBLIC_TOO_MUCH": ["You're admin of too many public channels, make some channels private to change the username of this channel"], "CHANNELS_TOO_MUCH": ["You have joined too many channels/supergroups"], "CHANNEL_INVALID": ["The provided channel is invalid"], "CHANNEL_PRIVATE": ["You haven't joined this channel/supergroup"], "CHANNEL_PUBLIC_GROUP_NA": ["channel/supergroup not available"], "CHAT_ABOUT_NOT_MODIFIED": ["About text has not changed"], "CHAT_ABOUT_TOO_LONG": ["Chat about too long"], "CHAT_ADMIN_INVITE_REQUIRED": ["You do not have the rights to do this"], "CHAT_ADMIN_REQUIRED": ["You must be an admin in this chat to do this"], "CHAT_FORBIDDEN": ["You cannot write in this chat"], "CHAT_ID_EMPTY": ["The provided chat ID is empty"], "CHAT_ID_INVALID": ["The provided chat id is invalid"], "CHAT_NOT_MODIFIED": ["The pinned message wasn't modified"], "CHAT_SEND_GIFS_FORBIDDEN": ["You can't send gifs in this chat"], "CHAT_SEND_MEDIA_FORBIDDEN": ["You can't send media in this chat"], "CHAT_SEND_STICKERS_FORBIDDEN": ["You can't send stickers in this chat."], "CHAT_TITLE_EMPTY": ["No chat title provided"], "CHAT_WRITE_FORBIDDEN": ["You can't write in this chat"], "CODE_EMPTY": ["The provided code is empty"], "CODE_HASH_INVALID": ["Code hash invalid"], "CONNECTION_API_ID_INVALID": ["The provided API id is invalid"], "CONNECTION_DEVICE_MODEL_EMPTY": ["Device model empty"], "CONNECTION_LANG_PACK_INVALID": ["Language pack invalid"], "CONNECTION_LAYER_INVALID": ["Layer invalid"], "CONNECTION_NOT_INITED": ["Connection not initialized"], "CONNECTION_SYSTEM_EMPTY": ["Connection system empty"], "CONTACT_ID_INVALID": ["The provided contact ID is invalid"], "DATA_INVALID": ["Encrypted data invalid"], "DATA_JSON_INVALID": ["The provided JSON data is invalid"], "DATE_EMPTY": ["Date empty"], "DC_ID_INVALID": ["The provided DC ID is invalid"], "DH_G_A_INVALID": ["g_a invalid"], "EMAIL_UNCONFIRMED": ["Email unconfirmed"], "ENCRYPTED_MESSAGE_INVALID": ["Encrypted message invalid"], "ENCRYPTION_ALREADY_ACCEPTED": ["Secret chat already accepted"], "ENCRYPTION_ALREADY_DECLINED": ["The secret chat was already declined"], "ENCRYPTION_DECLINED": ["The secret chat was declined"], "ENCRYPTION_ID_INVALID": ["The provided secret chat ID is invalid"], "ENCRYPTION_OCCUPY_FAILED": ["Internal server error while accepting secret chat"], "ENTITY_MENTION_USER_INVALID": ["You can't use this entity"], "ERROR_TEXT_EMPTY": ["The provided error message is empty"], "EXPORT_CARD_INVALID": ["Provided card is invalid"], "EXTERNAL_URL_INVALID": ["External URL invalid"], "FIELD_NAME_EMPTY": ["The field with the name FIELD_NAME is missing"], "FIELD_NAME_INVALID": ["The field with the name FIELD_NAME is invalid"], "FILE_ID_INVALID": ["The provided file id is invalid"], "FILE_PARTS_INVALID": ["The number of file parts is invalid"], "FILE_PART_0_MISSING": ["File part 0 missing"], "FILE_PART_EMPTY": ["The provided file part is empty"], "FILE_PART_INVALID": ["The file part number is invalid"], "FILE_PART_LENGTH_INVALID": ["The length of a file part is invalid"], "FILE_PART_SIZE_INVALID": ["The provided file part size is invalid"], "FIRSTNAME_INVALID": ["The first name is invalid"], "FLOOD_WAIT_666": ["Spooky af m8"], "GIF_ID_INVALID": ["The provided GIF ID is invalid"], "GROUPED_MEDIA_INVALID": ["Invalid grouped media"], "HASH_INVALID": ["The provided hash is invalid"], "HISTORY_GET_FAILED": ["Fetching of history failed"], "IMAGE_PROCESS_FAILED": ["Failure while processing image"], "INLINE_RESULT_EXPIRED": ["The inline query expired"], "INPUT_CONSTRUCTOR_INVALID": ["The provided constructor is invalid"], "INPUT_FETCH_ERROR": ["An error occurred while deserializing TL parameters"], "INPUT_FETCH_FAIL": ["Failed deserializing TL payload"], "INPUT_LAYER_INVALID": ["The provided layer is invalid"], "INPUT_METHOD_INVALID": ["The provided method is invalid"], "INPUT_REQUEST_TOO_LONG": ["The request is too big"], "INPUT_USER_DEACTIVATED": ["The specified user was deleted"], "INTERDC_1_CALL_ERROR": ["An error occurred while communicating with DC 1"], "INTERDC_1_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 1"], "INTERDC_2_CALL_ERROR": ["An error occurred while communicating with DC 2"], "INTERDC_2_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 2"], "INTERDC_3_CALL_ERROR": ["An error occurred while communicating with DC 3"], "INTERDC_3_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 3"], "INTERDC_4_CALL_ERROR": ["An error occurred while communicating with DC 4"], "INTERDC_4_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 4"], "INTERDC_5_CALL_ERROR": ["An error occurred while communicating with DC 5"], "INTERDC_5_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 5"], "INVITE_HASH_EMPTY": ["The invite hash is empty"], "INVITE_HASH_EXPIRED": ["The invite link has expired"], "INVITE_HASH_INVALID": ["The invite hash is invalid"], "LANG_PACK_INVALID": ["The provided language pack is invalid"], "LASTNAME_INVALID": ["The last name is invalid"], "LIMIT_INVALID": ["The provided limit is invalid"], "LOCATION_INVALID": ["The provided location is invalid"], "MAX_ID_INVALID": ["The provided max ID is invalid"], "MD5_CHECKSUM_INVALID": ["The MD5 checksums do not match"], "MEDIA_CAPTION_TOO_LONG": ["The caption is too long"], "MEDIA_EMPTY": ["The provided media object is invalid"], "MEDIA_INVALID": ["Media invalid"], "MEMBER_NO_LOCATION": ["An internal failure occurred while fetching user info (couldn't find location)"], "MEMBER_OCCUPY_PRIMARY_LOC_FAILED": ["Occupation of primary member location failed"], "MESSAGE_AUTHOR_REQUIRED": ["Message author required"], "MESSAGE_DELETE_FORBIDDEN": ["You can't delete one of the messages you tried to delete, most likely because it is a service message."], "MESSAGE_EDIT_TIME_EXPIRED": ["You can't edit this message anymore, too much time has passed since its creation."], "MESSAGE_EMPTY": ["The provided message is empty"], "MESSAGE_IDS_EMPTY": ["No message ids were provided"], "MESSAGE_ID_INVALID": ["The provided message id is invalid"], "MESSAGE_NOT_MODIFIED": ["The message text has not changed"], "MESSAGE_TOO_LONG": ["The provided message is too long"], "MSG_WAIT_FAILED": ["A waiting call returned an error"], "NEED_CHAT_INVALID": ["The provided chat is invalid"], "NEED_MEMBER_INVALID": ["The provided member is invalid"], "NEW_SALT_INVALID": ["The new salt is invalid"], "NEW_SETTINGS_INVALID": ["The new settings are invalid"], "OFFSET_INVALID": ["The provided offset is invalid"], "OFFSET_PEER_ID_INVALID": ["The provided offset peer is invalid"], "PACK_SHORT_NAME_INVALID": ["Short pack name invalid"], "PACK_SHORT_NAME_OCCUPIED": ["A stickerpack with this name already exists"], "PARTICIPANTS_TOO_FEW": ["Not enough participants"], "PARTICIPANT_CALL_FAILED": ["Failure while making call"], "PARTICIPANT_VERSION_OUTDATED": ["The other participant does not use an up to date telegram client with support for calls"], "PASSWORD_EMPTY": ["The provided password is empty"], "PASSWORD_HASH_INVALID": ["The provided password hash is invalid"], "PEER_FLOOD": ["Too many requests"], "PEER_ID_INVALID": ["The provided peer id is invalid"], "PEER_ID_NOT_SUPPORTED": ["The provided peer ID is not supported"], "PERSISTENT_TIMESTAMP_EMPTY": ["Persistent timestamp empty"], "PERSISTENT_TIMESTAMP_INVALID": ["Persistent timestamp invalid"], "PERSISTENT_TIMESTAMP_OUTDATED": ["Persistent timestamp outdated"], "PHONE_CODE_EMPTY": ["phone_code is missing"], "PHONE_CODE_EXPIRED": ["The phone code you provided has expired, this may happen if it was sent to any chat on telegram (if the code is sent through a telegram chat (not the official account) to avoid it append or prepend to the code some chars)"], "PHONE_CODE_HASH_EMPTY": ["phone_code_hash is missing"], "PHONE_CODE_INVALID": ["The provided phone code is invalid"], "PHONE_NUMBER_APP_SIGNUP_FORBIDDEN": [""], "PHONE_NUMBER_BANNED": ["The provided phone number is banned from telegram"], "PHONE_NUMBER_FLOOD": ["You asked for the code too many times."], "PHONE_NUMBER_INVALID": ["The phone number is invalid"], "PHONE_NUMBER_OCCUPIED": ["The phone number is already in use"], "PHONE_NUMBER_UNOCCUPIED": ["The phone number is not yet being used"], "PHONE_PASSWORD_FLOOD": ["You have tried logging in too many times"], "PHONE_PASSWORD_PROTECTED": ["This phone is password protected"], "PHOTO_CROP_SIZE_SMALL": ["Photo is too small"], "PHOTO_EXT_INVALID": ["The extension of the photo is invalid"], "PHOTO_INVALID": ["Photo invalid"], "PHOTO_INVALID_DIMENSIONS": ["The photo dimensions are invalid"], "PRIVACY_KEY_INVALID": ["The privacy key is invalid"], "PTS_CHANGE_EMPTY": ["No PTS change"], "QUERY_ID_EMPTY": ["The query ID is empty"], "QUERY_ID_INVALID": ["The query ID is invalid"], "QUERY_TOO_SHORT": ["The query string is too short"], "RANDOM_ID_DUPLICATE": ["You provided a random ID that was already used"], "RANDOM_ID_INVALID": ["A provided random ID is invalid"], "RANDOM_LENGTH_INVALID": ["Random length invalid"], "RANGES_INVALID": ["Invalid range provided"], "REG_ID_GENERATE_FAILED": ["Failure while generating registration ID"], "REPLY_MARKUP_INVALID": ["The provided reply markup is invalid"], "RESULT_TYPE_INVALID": ["Result type invalid"], "RIGHT_FORBIDDEN": ["Your admin rights do not allow you to do this"], "RPC_CALL_FAIL": ["Telegram is having internal issues, please try again later."], "RPC_MCGET_FAIL": ["Telegram is having internal issues, please try again later."], "RSA_DECRYPT_FAILED": ["Internal RSA decryption failed"], "SEARCH_QUERY_EMPTY": ["The search query is empty"], "SEND_MESSAGE_TYPE_INVALID": ["The message type is invalid"], "SESSION_PASSWORD_NEEDED": ["2FA is enabled, use a password to login"], "SHA256_HASH_INVALID": ["The provided SHA256 hash is invalid"], "START_PARAM_EMPTY": ["The start parameter is empty"], "START_PARAM_INVALID": ["Start parameter invalid"], "STICKERSET_INVALID": ["The provided sticker set is invalid"], "STICKERS_EMPTY": ["No sticker provided"], "STICKER_EMOJI_INVALID": ["Sticker emoji invalid"], "STICKER_FILE_INVALID": ["Sticker file invalid"], "STICKER_ID_INVALID": ["The provided sticker ID is invalid"], "STICKER_INVALID": ["The provided sticker is invalid"], "STICKER_PNG_DIMENSIONS": ["Sticker png dimensions invalid"], "STORAGE_CHECK_FAILED": ["Server storage check failed"], "STORE_INVALID_SCALAR_TYPE": [""], "TEMP_AUTH_KEY_EMPTY": ["No temporary auth key provided"], "TMP_PASSWORD_DISABLED": ["The temporary password is disabled"], "TOKEN_INVALID": ["The provided token is invalid"], "TTL_DAYS_INVALID": ["The provided TTL is invalid"], "TYPES_EMPTY": ["The types field is empty"], "TYPE_CONSTRUCTOR_INVALID": ["The type constructor is invalid"], "Timeout": ["A timeout occurred while fetching data from the bot"], "UNKNOWN_METHOD": ["The method you tried to call cannot be called on non-CDN DCs"], "USERNAME_INVALID": ["The provided username is not valid"], "USERNAME_NOT_MODIFIED": ["The username was not modified"], "USERNAME_NOT_OCCUPIED": ["The provided username is not occupied"], "USERNAME_OCCUPIED": ["The provided username is already occupied"], "USERS_TOO_FEW": ["Not enough users (to create a chat, for example)"], "USERS_TOO_MUCH": ["The maximum number of users has been exceeded (to create a chat, for example)"], "USER_ADMIN_INVALID": ["You're not an admin"], "USER_ALREADY_PARTICIPANT": ["The user is already in the group"], "USER_BANNED_IN_CHANNEL": ["You're banned from sending messages in supergroups/channels"], "USER_BLOCKED": ["User blocked"], "USER_BOT": ["Bots can only be admins in channels."], "USER_BOT_INVALID": ["This method can only be called by a bot"], "USER_BOT_REQUIRED": ["This method can only be called by a bot"], "USER_CHANNELS_TOO_MUCH": ["One of the users you tried to add is already in too many channels/supergroups"], "USER_CREATOR": ["You can't leave this channel, because you're its creator"], "USER_DEACTIVATED": ["The user was deactivated"], "USER_ID_INVALID": ["The provided user ID is invalid"], "USER_IS_BLOCKED": ["User is blocked"], "USER_IS_BOT": ["Bots can't send messages to other bots"], "USER_KICKED": ["This user was kicked from this supergroup/channel"], "USER_NOT_MUTUAL_CONTACT": ["The provided user is not a mutual contact"], "USER_NOT_PARTICIPANT": ["You're not a member of this supergroup/channel"], "USER_PRIVACY_RESTRICTED": ["The user's privacy settings do not allow you to do this"], "USER_RESTRICTED": ["You're spamreported, you can't create channels or chats."], "WC_CONVERT_URL_INVALID": ["WC convert URL invalid"], "WEBPAGE_CURL_FAILED": ["Failure while fetching the webpage with cURL"], "WEBPAGE_MEDIA_EMPTY": ["Webpage media empty"], "YOU_BLOCKED_USER": ["You blocked this user"]}, "result": {"-503": {"auth.bindTempAuthKey": ["Timeout"], "auth.resetAuthorizations": ["Timeout"], "channels.getFullChannel": ["Timeout"], "channels.getParticipants": ["Timeout"], "contacts.deleteContacts": ["Timeout"], "contacts.search": ["Timeout"], "help.getCdnConfig": ["Timeout"], "help.getConfig": ["Timeout"], "invokeWithLayer": ["Timeout"], "messages.forwardMessages": ["Timeout"], "messages.getBotCallbackAnswer": ["Timeout"], "messages.getDialogs": ["Timeout"], "messages.getHistory": ["Timeout"], "messages.getInlineBotResults": ["Timeout"], "messages.readHistory": ["Timeout"], "messages.sendMedia": ["Timeout"], "messages.sendMessage": ["Timeout"], "updates.getChannelDifference": ["Timeout"], "updates.getDifference": ["Timeout"], "updates.getState": ["Timeout"], "upload.getFile": ["Timeout"], "upload.saveBigFilePart": ["Timeout"], "users.getFullUser": ["Timeout"], "users.getUsers": ["Timeout"]}, "400": {"account.changePhone": ["PHONE_NUMBER_INVALID"], "account.checkUsername": ["USERNAME_INVALID"], "account.confirmPhone": ["CODE_HASH_INVALID", "PHONE_CODE_EMPTY"], "account.getNotifySettings": ["PEER_ID_INVALID"], "account.getPasswordSettings": ["PASSWORD_HASH_INVALID"], "account.getPrivacy": ["PRIVACY_KEY_INVALID"], "account.getTmpPassword": ["PASSWORD_HASH_INVALID", "TMP_PASSWORD_DISABLED"], "account.registerDevice": ["TOKEN_INVALID"], "account.reportPeer": ["PEER_ID_INVALID"], "account.resetAuthorization": ["HASH_INVALID"], "account.sendChangePhoneCode": ["PHONE_NUMBER_INVALID"], "account.sendConfirmPhoneCode": ["HASH_INVALID"], "account.setAccountTTL": ["TTL_DAYS_INVALID"], "account.setPrivacy": ["PRIVACY_KEY_INVALID"], "account.unregisterDevice": ["TOKEN_INVALID"], "account.updateNotifySettings": ["PEER_ID_INVALID"], "account.updatePasswordSettings": ["EMAIL_UNCONFIRMED", "NEW_SALT_INVALID", "NEW_SETTINGS_INVALID", "PASSWORD_HASH_INVALID"], "account.updateProfile": ["ABOUT_TOO_LONG", "FIRSTNAME_INVALID"], "account.updateUsername": ["USERNAME_INVALID", "USERNAME_NOT_MODIFIED", "USERNAME_OCCUPIED"], "auth.bindTempAuthKey": ["ENCRYPTED_MESSAGE_INVALID", "INPUT_REQUEST_TOO_LONG", "TEMP_AUTH_KEY_EMPTY"], "auth.cancelCode": ["PHONE_NUMBER_INVALID"], "auth.checkPassword": ["PASSWORD_HASH_INVALID"], "auth.checkPhone": ["PHONE_NUMBER_BANNED", "PHONE_NUMBER_INVALID"], "auth.exportAuthorization": ["DC_ID_INVALID"], "auth.importAuthorization": ["AUTH_BYTES_INVALID", "USER_ID_INVALID"], "auth.importBotAuthorization": ["ACCESS_TOKEN_EXPIRED", "ACCESS_TOKEN_INVALID", "API_ID_INVALID"], "auth.recoverPassword": ["CODE_EMPTY"], "auth.requestPasswordRecovery": ["PASSWORD_EMPTY"], "auth.resendCode": ["PHONE_NUMBER_INVALID"], "auth.sendCode": ["API_ID_INVALID", "API_ID_PUBLISHED_FLOOD", "INPUT_REQUEST_TOO_LONG", "PHONE_NUMBER_APP_SIGNUP_FORBIDDEN", "PHONE_NUMBER_BANNED", "PHONE_NUMBER_FLOOD", "PHONE_NUMBER_INVALID", "PHONE_PASSWORD_PROTECTED"], "auth.sendInvites": ["MESSAGE_EMPTY"], "auth.signIn": ["PHONE_CODE_EMPTY", "PHONE_CODE_EXPIRED", "PHONE_CODE_INVALID", "PHONE_NUMBER_INVALID", "PHONE_NUMBER_UNOCCUPIED"], "auth.signUp": ["FIRSTNAME_INVALID", "PHONE_CODE_EMPTY", "PHONE_CODE_EXPIRED", "PHONE_CODE_INVALID", "PHONE_NUMBER_FLOOD", "PHONE_NUMBER_INVALID", "PHONE_NUMBER_OCCUPIED"], "bots.answerWebhookJSONQuery": ["QUERY_ID_INVALID", "USER_BOT_INVALID"], "bots.sendCustomRequest": ["USER_BOT_INVALID"], "channels.checkUsername": ["CHANNEL_INVALID", "CHAT_ID_INVALID", "USERNAME_INVALID"], "channels.createChannel": ["CHAT_TITLE_EMPTY"], "channels.deleteChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.deleteMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.deleteUserHistory": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED"], "channels.editAbout": ["CHANNEL_INVALID", "CHAT_ABOUT_NOT_MODIFIED", "CHAT_ABOUT_TOO_LONG", "CHAT_ADMIN_REQUIRED"], "channels.editAdmin": ["ADMINS_TOO_MUCH", "BOT_CHANNELS_NA", "CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "USER_CREATOR", "USER_ID_INVALID", "USER_NOT_MUTUAL_CONTACT"], "channels.editBanned": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "USER_ADMIN_INVALID", "USER_ID_INVALID"], "channels.editPhoto": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "PHOTO_INVALID"], "channels.editTitle": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_NOT_MODIFIED"], "channels.exportInvite": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "INVITE_HASH_EXPIRED"], "channels.exportMessageLink": ["CHANNEL_INVALID"], "channels.getAdminLog": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED"], "channels.getChannels": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.getFullChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.getMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "MESSAGE_IDS_EMPTY"], "channels.getParticipant": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "USER_ID_INVALID", "USER_NOT_PARTICIPANT"], "channels.getParticipants": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "INPUT_CONSTRUCTOR_INVALID"], "channels.inviteToChannel": ["BOT_GROUPS_BLOCKED", "BOTS_TOO_MUCH", "CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "INPUT_USER_DEACTIVATED", "USER_BANNED_IN_CHANNEL", "USER_BLOCKED", "USER_BOT", "USER_ID_INVALID", "USER_KICKED", "USER_NOT_MUTUAL_CONTACT", "USERS_TOO_MUCH"], "channels.joinChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHANNELS_TOO_MUCH"], "channels.leaveChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "USER_CREATOR", "USER_NOT_PARTICIPANT"], "channels.readHistory": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.readMessageContents": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.reportSpam": ["CHANNEL_INVALID", "INPUT_USER_DEACTIVATED"], "channels.setStickers": ["CHANNEL_INVALID", "PARTICIPANTS_TOO_FEW"], "channels.toggleInvites": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_NOT_MODIFIED"], "channels.toggleSignatures": ["CHANNEL_INVALID"], "channels.updatePinnedMessage": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "CHAT_NOT_MODIFIED"], "channels.updateUsername": ["CHANNEL_INVALID", "CHANNELS_ADMIN_PUBLIC_TOO_MUCH", "CHAT_ADMIN_REQUIRED", "USERNAME_INVALID", "USERNAME_OCCUPIED"], "contacts.block": ["CONTACT_ID_INVALID"], "contacts.deleteContact": ["CONTACT_ID_INVALID"], "contacts.getTopPeers": ["TYPES_EMPTY"], "contacts.importCard": ["EXPORT_CARD_INVALID"], "contacts.resetTopPeerRating": ["PEER_ID_INVALID"], "contacts.resolveUsername": ["USERNAME_INVALID", "USERNAME_NOT_OCCUPIED"], "contacts.search": ["QUERY_TOO_SHORT", "SEARCH_QUERY_EMPTY"], "contacts.unblock": ["CONTACT_ID_INVALID"], "initConnection": ["CONNECTION_LAYER_INVALID", "INPUT_FETCH_FAIL"], "invokeWithLayer": ["AUTH_BYTES_INVALID", "CDN_METHOD_INVALID", "CONNECTION_API_ID_INVALID", "CONNECTION_DEVICE_MODEL_EMPTY", "CONNECTION_LANG_PACK_INVALID", "CONNECTION_NOT_INITED", "CONNECTION_SYSTEM_EMPTY", "INPUT_LAYER_INVALID", "INVITE_HASH_EXPIRED"], "langpack.getDifference": ["LANG_PACK_INVALID"], "langpack.getLangPack": ["LANG_PACK_INVALID"], "langpack.getLanguages": ["LANG_PACK_INVALID"], "langpack.getStrings": ["LANG_PACK_INVALID"], "messages.acceptEncryption": ["CHAT_ID_INVALID", "ENCRYPTION_ALREADY_ACCEPTED", "ENCRYPTION_ALREADY_DECLINED"], "messages.addChatUser": ["CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "INPUT_USER_DEACTIVATED", "PEER_ID_INVALID", "USER_ALREADY_PARTICIPANT", "USER_ID_INVALID", "USERS_TOO_MUCH"], "messages.checkChatInvite": ["INVITE_HASH_EMPTY", "INVITE_HASH_EXPIRED", "INVITE_HASH_INVALID"], "messages.createChat": ["USERS_TOO_FEW"], "messages.deleteChatUser": ["CHAT_ID_INVALID", "PEER_ID_INVALID", "USER_NOT_PARTICIPANT"], "messages.deleteHistory": ["PEER_ID_INVALID"], "messages.discardEncryption": ["CHAT_ID_EMPTY", "ENCRYPTION_ALREADY_DECLINED", "ENCRYPTION_ID_INVALID"], "messages.editChatAdmin": ["CHAT_ID_INVALID"], "messages.editChatPhoto": ["CHAT_ID_INVALID", "INPUT_CONSTRUCTOR_INVALID", "INPUT_FETCH_FAIL", "PEER_ID_INVALID", "PHOTO_EXT_INVALID"], "messages.editChatTitle": ["CHAT_ID_INVALID"], "messages.editInlineBotMessage": ["MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED"], "messages.editMessage": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "INPUT_USER_DEACTIVATED", "MESSAGE_EDIT_TIME_EXPIRED", "MESSAGE_EMPTY", "MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED", "PEER_ID_INVALID"], "messages.exportChatInvite": ["CHAT_ID_INVALID"], "messages.faveSticker": ["STICKER_ID_INVALID"], "messages.forwardMessage": ["CHAT_ID_INVALID", "MESSAGE_ID_INVALID", "PEER_ID_INVALID", "YOU_BLOCKED_USER"], "messages.forwardMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "GROUPED_MEDIA_INVALID", "INPUT_USER_DEACTIVATED", "MEDIA_EMPTY", "MESSAGE_ID_INVALID", "MESSAGE_IDS_EMPTY", "PEER_ID_INVALID", "RANDOM_ID_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "YOU_BLOCKED_USER"], "messages.getBotCallbackAnswer": ["CHANNEL_INVALID", "DATA_INVALID", "MESSAGE_ID_INVALID", "PEER_ID_INVALID"], "messages.getChats": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getCommonChats": ["USER_ID_INVALID"], "messages.getDhConfig": ["RANDOM_LENGTH_INVALID"], "messages.getDialogs": ["INPUT_CONSTRUCTOR_INVALID", "OFFSET_PEER_ID_INVALID"], "messages.getDocumentByHash": ["SHA256_HASH_INVALID"], "messages.getFullChat": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getGameHighScores": ["PEER_ID_INVALID", "USER_BOT_REQUIRED"], "messages.getHistory": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getInlineBotResults": ["BOT_INLINE_DISABLED", "BOT_INVALID", "CHANNEL_PRIVATE"], "messages.getInlineGameHighScores": ["MESSAGE_ID_INVALID", "USER_BOT_REQUIRED"], "messages.getMessageEditData": ["PEER_ID_INVALID"], "messages.getMessagesViews": ["CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getPeerDialogs": ["CHANNEL_PRIVATE", "PEER_ID_INVALID"], "messages.getPeerSettings": ["CHANNEL_INVALID", "PEER_ID_INVALID"], "messages.getStickerSet": ["STICKERSET_INVALID"], "messages.getUnreadMentions": ["PEER_ID_INVALID"], "messages.getWebPage": ["WC_CONVERT_URL_INVALID"], "messages.hideReportSpam": ["PEER_ID_INVALID"], "messages.importChatInvite": ["CHANNELS_TOO_MUCH", "INVITE_HASH_EMPTY", "INVITE_HASH_EXPIRED", "INVITE_HASH_INVALID", "USER_ALREADY_PARTICIPANT", "USERS_TOO_MUCH"], "messages.installStickerSet": ["STICKERSET_INVALID"], "messages.migrateChat": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.readEncryptedHistory": ["MSG_WAIT_FAILED"], "messages.readHistory": ["PEER_ID_INVALID"], "messages.receivedQueue": ["MSG_WAIT_FAILED"], "messages.reorderPinnedDialogs": ["PEER_ID_INVALID"], "messages.reportEncryptedSpam": ["CHAT_ID_INVALID"], "messages.reportSpam": ["PEER_ID_INVALID"], "messages.requestEncryption": ["DH_G_A_INVALID", "USER_ID_INVALID"], "messages.saveDraft": ["PEER_ID_INVALID"], "messages.saveGif": ["GIF_ID_INVALID"], "messages.saveRecentSticker": ["STICKER_ID_INVALID"], "messages.search": ["CHAT_ADMIN_REQUIRED", "INPUT_CONSTRUCTOR_INVALID", "INPUT_USER_DEACTIVATED", "PEER_ID_INVALID", "PEER_ID_NOT_SUPPORTED", "SEARCH_QUERY_EMPTY", "USER_ID_INVALID"], "messages.searchGifs": ["SEARCH_QUERY_EMPTY"], "messages.searchGlobal": ["SEARCH_QUERY_EMPTY"], "messages.sendEncrypted": ["CHAT_ID_INVALID", "DATA_INVALID", "ENCRYPTION_DECLINED", "MSG_WAIT_FAILED"], "messages.sendEncryptedFile": ["MSG_WAIT_FAILED"], "messages.sendEncryptedService": ["DATA_INVALID", "ENCRYPTION_DECLINED", "MSG_WAIT_FAILED"], "messages.sendInlineBotResult": ["INLINE_RESULT_EXPIRED", "PEER_ID_INVALID", "QUERY_ID_EMPTY", "WEBPAGE_CURL_FAILED", "WEBPAGE_MEDIA_EMPTY"], "messages.sendMedia": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "EXTERNAL_URL_INVALID", "FILE_PART_LENGTH_INVALID", "FILE_PARTS_INVALID", "INPUT_USER_DEACTIVATED", "MEDIA_CAPTION_TOO_LONG", "MEDIA_EMPTY", "PEER_ID_INVALID", "PHOTO_EXT_INVALID", "PHOTO_INVALID_DIMENSIONS", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "WEBPAGE_CURL_FAILED", "WEBPAGE_MEDIA_EMPTY"], "messages.sendMessage": ["BUTTON_DATA_INVALID", "BUTTON_TYPE_INVALID", "BUTTON_URL_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "ENTITY_MENTION_USER_INVALID", "INPUT_USER_DEACTIVATED", "MESSAGE_EMPTY", "MESSAGE_TOO_LONG", "PEER_ID_INVALID", "REPLY_MARKUP_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "YOU_BLOCKED_USER"], "messages.sendScreenshotNotification": ["PEER_ID_INVALID"], "messages.setBotCallbackAnswer": ["QUERY_ID_INVALID"], "messages.setBotPrecheckoutResults": ["ERROR_TEXT_EMPTY"], "messages.setBotShippingResults": ["QUERY_ID_INVALID"], "messages.setEncryptedTyping": ["CHAT_ID_INVALID"], "messages.setGameScore": ["PEER_ID_INVALID", "USER_BOT_REQUIRED"], "messages.setInlineBotResults": ["ARTICLE_TITLE_EMPTY", "BUTTON_DATA_INVALID", "BUTTON_TYPE_INVALID", "BUTTON_URL_INVALID", "MESSAGE_EMPTY", "QUERY_ID_INVALID", "REPLY_MARKUP_INVALID", "RESULT_TYPE_INVALID", "SEND_MESSAGE_TYPE_INVALID", "START_PARAM_INVALID"], "messages.setInlineGameScore": ["MESSAGE_ID_INVALID", "USER_BOT_REQUIRED"], "messages.setTyping": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT"], "messages.startBot": ["BOT_INVALID", "PEER_ID_INVALID", "START_PARAM_EMPTY", "START_PARAM_INVALID"], "messages.toggleChatAdmins": ["CHAT_ID_INVALID", "CHAT_NOT_MODIFIED"], "messages.toggleDialogPin": ["PEER_ID_INVALID"], "messages.uninstallStickerSet": ["STICKERSET_INVALID"], "messages.uploadMedia": ["BOT_MISSING", "MEDIA_INVALID", "PEER_ID_INVALID"], "payments.getPaymentForm": ["MESSAGE_ID_INVALID"], "payments.getPaymentReceipt": ["MESSAGE_ID_INVALID"], "payments.sendPaymentForm": ["MESSAGE_ID_INVALID"], "payments.validateRequestedInfo": ["MESSAGE_ID_INVALID"], "phone.acceptCall": ["CALL_ALREADY_ACCEPTED", "CALL_ALREADY_DECLINED", "CALL_PEER_INVALID", "CALL_PROTOCOL_FLAGS_INVALID"], "phone.confirmCall": ["CALL_ALREADY_DECLINED", "CALL_PEER_INVALID"], "phone.discardCall": ["CALL_ALREADY_ACCEPTED", "CALL_PEER_INVALID"], "phone.receivedCall": ["CALL_ALREADY_DECLINED", "CALL_PEER_INVALID"], "phone.requestCall": ["CALL_PROTOCOL_FLAGS_INVALID", "PARTICIPANT_VERSION_OUTDATED", "USER_ID_INVALID"], "phone.saveCallDebug": ["CALL_PEER_INVALID", "DATA_JSON_INVALID"], "phone.setCallRating": ["CALL_PEER_INVALID"], "photos.getUserPhotos": ["MAX_ID_INVALID", "USER_ID_INVALID"], "photos.uploadProfilePhoto": ["FILE_PARTS_INVALID", "IMAGE_PROCESS_FAILED", "PHOTO_CROP_SIZE_SMALL", "PHOTO_EXT_INVALID"], "stickers.addStickerToSet": ["BOT_MISSING", "STICKERSET_INVALID"], "stickers.changeStickerPosition": ["BOT_MISSING", "STICKER_INVALID"], "stickers.createStickerSet": ["BOT_MISSING", "PACK_SHORT_NAME_INVALID", "PACK_SHORT_NAME_OCCUPIED", "PEER_ID_INVALID", "STICKER_EMOJI_INVALID", "STICKER_FILE_INVALID", "STICKER_PNG_DIMENSIONS", "STICKERS_EMPTY", "USER_ID_INVALID"], "stickers.removeStickerFromSet": ["BOT_MISSING", "STICKER_INVALID"], "updates.getChannelDifference": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "PERSISTENT_TIMESTAMP_EMPTY", "PERSISTENT_TIMESTAMP_INVALID", "RANGES_INVALID"], "updates.getDifference": ["CDN_METHOD_INVALID", "DATE_EMPTY", "PERSISTENT_TIMESTAMP_EMPTY", "PERSISTENT_TIMESTAMP_INVALID"], "upload.getCdnFileHashes": ["CDN_METHOD_INVALID", "RSA_DECRYPT_FAILED"], "upload.getFile": ["FILE_ID_INVALID", "INPUT_FETCH_FAIL", "LIMIT_INVALID", "LOCATION_INVALID", "OFFSET_INVALID"], "upload.getWebFile": ["LOCATION_INVALID"], "upload.reuploadCdnFile": ["RSA_DECRYPT_FAILED"], "upload.saveBigFilePart": ["FILE_PART_EMPTY", "FILE_PART_INVALID", "FILE_PART_SIZE_INVALID", "FILE_PARTS_INVALID"], "upload.saveFilePart": ["FILE_PART_EMPTY", "FILE_PART_INVALID", "INPUT_FETCH_FAIL"], "users.getFullUser": ["USER_ID_INVALID"], "{}": ["INVITE_HASH_EXPIRED"]}, "401": {"account.updateStatus": ["SESSION_PASSWORD_NEEDED"], "auth.signIn": ["SESSION_PASSWORD_NEEDED"], "contacts.resolveUsername": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"], "help.getCdnConfig": ["AUTH_KEY_PERM_EMPTY"], "messages.getDialogs": ["SESSION_PASSWORD_NEEDED"], "messages.getHistory": ["AUTH_KEY_PERM_EMPTY"], "messages.importChatInvite": ["SESSION_PASSWORD_NEEDED"], "updates.getDifference": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"], "updates.getState": ["SESSION_PASSWORD_NEEDED"], "upload.getFile": ["AUTH_KEY_PERM_EMPTY"], "upload.saveFilePart": ["SESSION_PASSWORD_NEEDED"], "users.getUsers": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"]}, "403": {"channels.createChannel": ["USER_RESTRICTED"], "channels.deleteMessages": ["MESSAGE_DELETE_FORBIDDEN"], "channels.editAdmin": ["CHAT_ADMIN_INVITE_REQUIRED", "RIGHT_FORBIDDEN", "USER_PRIVACY_RESTRICTED"], "channels.getFullChannel": ["CHANNEL_PUBLIC_GROUP_NA"], "channels.inviteToChannel": ["CHAT_WRITE_FORBIDDEN", "USER_CHANNELS_TOO_MUCH", "USER_PRIVACY_RESTRICTED"], "channels.leaveChannel": ["CHANNEL_PUBLIC_GROUP_NA"], "invokeWithLayer": ["CHAT_WRITE_FORBIDDEN"], "messages.addChatUser": ["USER_NOT_MUTUAL_CONTACT", "USER_PRIVACY_RESTRICTED"], "messages.createChat": ["USER_RESTRICTED"], "messages.deleteMessages": ["MESSAGE_DELETE_FORBIDDEN"], "messages.editMessage": ["CHAT_WRITE_FORBIDDEN", "MESSAGE_AUTHOR_REQUIRED"], "messages.forwardMessages": ["CHAT_SEND_GIFS_FORBIDDEN", "CHAT_SEND_MEDIA_FORBIDDEN", "CHAT_SEND_STICKERS_FORBIDDEN", "CHAT_WRITE_FORBIDDEN"], "messages.getMessageEditData": ["MESSAGE_AUTHOR_REQUIRED"], "messages.migrateChat": ["CHAT_ADMIN_REQUIRED"], "messages.sendEncryptedService": ["USER_IS_BLOCKED"], "messages.sendInlineBotResult": ["CHAT_WRITE_FORBIDDEN"], "messages.sendMedia": ["CHAT_SEND_MEDIA_FORBIDDEN", "CHAT_WRITE_FORBIDDEN"], "messages.sendMessage": ["CHAT_WRITE_FORBIDDEN"], "messages.setInlineBotResults": ["USER_BOT_INVALID"], "messages.setTyping": ["CHAT_WRITE_FORBIDDEN"], "phone.requestCall": ["USER_IS_BLOCKED", "USER_PRIVACY_RESTRICTED"], "updates.getChannelDifference": ["CHANNEL_PUBLIC_GROUP_NA"]}, "406": {"auth.checkPhone": ["PHONE_NUMBER_INVALID"], "auth.sendCode": ["PHONE_NUMBER_INVALID", "PHONE_PASSWORD_FLOOD"], "help.getConfig": ["AUTH_KEY_DUPLICATED"], "invokeWithLayer": ["AUTH_KEY_DUPLICATED"], "messages.getHistory": ["AUTH_KEY_DUPLICATED"], "messages.sendMessage": ["AUTH_KEY_DUPLICATED"], "updates.getState": ["AUTH_KEY_DUPLICATED"]}, "500": {"auth.sendCode": ["AUTH_RESTART"], "auth.signUp": ["MEMBER_OCCUPY_PRIMARY_LOC_FAILED", "REG_ID_GENERATE_FAILED"], "channels.getChannels": ["NEED_CHAT_INVALID"], "contacts.deleteContacts": ["NEED_MEMBER_INVALID"], "contacts.importCard": ["NEED_MEMBER_INVALID"], "invokeWithLayer": ["NEED_MEMBER_INVALID"], "messages.acceptEncryption": ["ENCRYPTION_OCCUPY_FAILED"], "messages.editChatTitle": ["NEED_CHAT_INVALID"], "messages.forwardMessages": ["PTS_CHANGE_EMPTY", "RANDOM_ID_DUPLICATE"], "messages.sendMedia": ["RANDOM_ID_DUPLICATE", "STORAGE_CHECK_FAILED"], "messages.sendMessage": ["RANDOM_ID_DUPLICATE"], "phone.acceptCall": ["CALL_OCCUPY_FAILED"], "phone.requestCall": ["PARTICIPANT_CALL_FAILED"], "updates.getChannelDifference": ["HISTORY_GET_FAILED", "PERSISTENT_TIMESTAMP_OUTDATED"], "updates.getDifference": ["NEED_MEMBER_INVALID", "STORE_INVALID_SCALAR_TYPE"], "upload.getCdnFile": ["UNKNOWN_METHOD"], "users.getUsers": ["MEMBER_NO_LOCATION", "NEED_MEMBER_INVALID"]}}} \ No newline at end of file +{"human_result": {"-429": ["Too many requests"], "ABOUT_TOO_LONG": ["The provided bio is too long"], "ACCESS_TOKEN_EXPIRED": ["Bot token expired"], "ACCESS_TOKEN_INVALID": ["The provided token is not valid"], "ACTIVE_USER_REQUIRED": ["The method is only available to already activated users"], "ADMINS_TOO_MUCH": ["Too many admins"], "API_ID_INVALID": ["The api_id/api_hash combination is invalid"], "API_ID_PUBLISHED_FLOOD": ["This API id was published somewhere, you can't use it now"], "ARTICLE_TITLE_EMPTY": ["The title of the article is empty"], "AUTH_BYTES_INVALID": ["The provided authorization is invalid"], "AUTH_KEY_DUPLICATED": ["An auth key with the same ID was already generated"], "AUTH_KEY_PERM_EMPTY": ["The temporary auth key must be binded to the permanent auth key to use these methods."], "AUTH_KEY_UNREGISTERED": ["The authorization key has expired"], "AUTH_RESTART": ["Restart the authorization process"], "BOTS_TOO_MUCH": ["There are too many bots in this chat/channel"], "BOT_CHANNELS_NA": ["Bots can't edit admin privileges"], "BOT_GROUPS_BLOCKED": ["This bot can't be added to groups"], "BOT_INLINE_DISABLED": ["This bot can't be used in inline mode"], "BOT_INVALID": ["This is not a valid bot"], "BOT_METHOD_INVALID": ["This method cannot be run by a bot"], "BOT_MISSING": ["This method can only be run by a bot"], "BUTTON_DATA_INVALID": ["The provided button data is invalid"], "BUTTON_TYPE_INVALID": ["The type of one of the buttons you provided is invalid"], "BUTTON_URL_INVALID": ["Button URL invalid"], "CALL_ALREADY_ACCEPTED": ["The call was already accepted"], "CALL_ALREADY_DECLINED": ["The call was already declined"], "CALL_OCCUPY_FAILED": ["The call failed because the user is already making another call"], "CALL_PEER_INVALID": ["The provided call peer object is invalid"], "CALL_PROTOCOL_FLAGS_INVALID": ["Call protocol flags invalid"], "CDN_METHOD_INVALID": ["You can't call this method in a CDN DC"], "CHANNELS_ADMIN_PUBLIC_TOO_MUCH": ["You're admin of too many public channels, make some channels private to change the username of this channel"], "CHANNELS_TOO_MUCH": ["You have joined too many channels/supergroups"], "CHANNEL_INVALID": ["The provided channel is invalid"], "CHANNEL_PRIVATE": ["You haven't joined this channel/supergroup"], "CHANNEL_PUBLIC_GROUP_NA": ["channel/supergroup not available"], "CHAT_ABOUT_NOT_MODIFIED": ["About text has not changed"], "CHAT_ABOUT_TOO_LONG": ["Chat about too long"], "CHAT_ADMIN_INVITE_REQUIRED": ["You do not have the rights to do this"], "CHAT_ADMIN_REQUIRED": ["You must be an admin in this chat to do this"], "CHAT_FORBIDDEN": ["You cannot write in this chat"], "CHAT_ID_EMPTY": ["The provided chat ID is empty"], "CHAT_ID_INVALID": ["The provided chat id is invalid"], "CHAT_NOT_MODIFIED": ["The pinned message wasn't modified"], "CHAT_SEND_GIFS_FORBIDDEN": ["You can't send gifs in this chat"], "CHAT_SEND_MEDIA_FORBIDDEN": ["You can't send media in this chat"], "CHAT_SEND_STICKERS_FORBIDDEN": ["You can't send stickers in this chat."], "CHAT_TITLE_EMPTY": ["No chat title provided"], "CHAT_WRITE_FORBIDDEN": ["You can't write in this chat"], "CODE_EMPTY": ["The provided code is empty"], "CODE_HASH_INVALID": ["Code hash invalid"], "CONNECTION_API_ID_INVALID": ["The provided API id is invalid"], "CONNECTION_DEVICE_MODEL_EMPTY": ["Device model empty"], "CONNECTION_LANG_PACK_INVALID": ["Language pack invalid"], "CONNECTION_LAYER_INVALID": ["Layer invalid"], "CONNECTION_NOT_INITED": ["Connection not initialized"], "CONNECTION_SYSTEM_EMPTY": ["Connection system empty"], "CONTACT_ID_INVALID": ["The provided contact ID is invalid"], "DATA_INVALID": ["Encrypted data invalid"], "DATA_JSON_INVALID": ["The provided JSON data is invalid"], "DATE_EMPTY": ["Date empty"], "DC_ID_INVALID": ["The provided DC ID is invalid"], "DH_G_A_INVALID": ["g_a invalid"], "EMAIL_UNCONFIRMED": ["Email unconfirmed"], "ENCRYPTED_MESSAGE_INVALID": ["Encrypted message invalid"], "ENCRYPTION_ALREADY_ACCEPTED": ["Secret chat already accepted"], "ENCRYPTION_ALREADY_DECLINED": ["The secret chat was already declined"], "ENCRYPTION_DECLINED": ["The secret chat was declined"], "ENCRYPTION_ID_INVALID": ["The provided secret chat ID is invalid"], "ENCRYPTION_OCCUPY_FAILED": ["Internal server error while accepting secret chat"], "ENTITY_MENTION_USER_INVALID": ["You can't use this entity"], "ERROR_TEXT_EMPTY": ["The provided error message is empty"], "EXPORT_CARD_INVALID": ["Provided card is invalid"], "EXTERNAL_URL_INVALID": ["External URL invalid"], "FIELD_NAME_EMPTY": ["The field with the name FIELD_NAME is missing"], "FIELD_NAME_INVALID": ["The field with the name FIELD_NAME is invalid"], "FILE_ID_INVALID": ["The provided file id is invalid"], "FILE_PARTS_INVALID": ["The number of file parts is invalid"], "FILE_PART_0_MISSING": ["File part 0 missing"], "FILE_PART_EMPTY": ["The provided file part is empty"], "FILE_PART_INVALID": ["The file part number is invalid"], "FILE_PART_LENGTH_INVALID": ["The length of a file part is invalid"], "FILE_PART_SIZE_INVALID": ["The provided file part size is invalid"], "FIRSTNAME_INVALID": ["The first name is invalid"], "FLOOD_WAIT_666": ["Spooky af m8"], "GIF_ID_INVALID": ["The provided GIF ID is invalid"], "GROUPED_MEDIA_INVALID": ["Invalid grouped media"], "HASH_INVALID": ["The provided hash is invalid"], "HISTORY_GET_FAILED": ["Fetching of history failed"], "IMAGE_PROCESS_FAILED": ["Failure while processing image"], "INLINE_RESULT_EXPIRED": ["The inline query expired"], "INPUT_CONSTRUCTOR_INVALID": ["The provided constructor is invalid"], "INPUT_FETCH_ERROR": ["An error occurred while deserializing TL parameters"], "INPUT_FETCH_FAIL": ["Failed deserializing TL payload"], "INPUT_LAYER_INVALID": ["The provided layer is invalid"], "INPUT_METHOD_INVALID": ["The provided method is invalid"], "INPUT_REQUEST_TOO_LONG": ["The request is too big"], "INPUT_USER_DEACTIVATED": ["The specified user was deleted"], "INTERDC_1_CALL_ERROR": ["An error occurred while communicating with DC 1"], "INTERDC_1_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 1"], "INTERDC_2_CALL_ERROR": ["An error occurred while communicating with DC 2"], "INTERDC_2_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 2"], "INTERDC_3_CALL_ERROR": ["An error occurred while communicating with DC 3"], "INTERDC_3_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 3"], "INTERDC_4_CALL_ERROR": ["An error occurred while communicating with DC 4"], "INTERDC_4_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 4"], "INTERDC_5_CALL_ERROR": ["An error occurred while communicating with DC 5"], "INTERDC_5_CALL_RICH_ERROR": ["A rich error occurred while communicating with DC 5"], "INVITE_HASH_EMPTY": ["The invite hash is empty"], "INVITE_HASH_EXPIRED": ["The invite link has expired"], "INVITE_HASH_INVALID": ["The invite hash is invalid"], "LANG_PACK_INVALID": ["The provided language pack is invalid"], "LASTNAME_INVALID": ["The last name is invalid"], "LIMIT_INVALID": ["The provided limit is invalid"], "LOCATION_INVALID": ["The provided location is invalid"], "MAX_ID_INVALID": ["The provided max ID is invalid"], "MD5_CHECKSUM_INVALID": ["The MD5 checksums do not match"], "MEDIA_CAPTION_TOO_LONG": ["The caption is too long"], "MEDIA_EMPTY": ["The provided media object is invalid"], "MEDIA_INVALID": ["Media invalid"], "MEMBER_NO_LOCATION": ["An internal failure occurred while fetching user info (couldn't find location)"], "MEMBER_OCCUPY_PRIMARY_LOC_FAILED": ["Occupation of primary member location failed"], "MESSAGE_AUTHOR_REQUIRED": ["Message author required"], "MESSAGE_DELETE_FORBIDDEN": ["You can't delete one of the messages you tried to delete, most likely because it is a service message."], "MESSAGE_EDIT_TIME_EXPIRED": ["You can't edit this message anymore, too much time has passed since its creation."], "MESSAGE_EMPTY": ["The provided message is empty"], "MESSAGE_IDS_EMPTY": ["No message ids were provided"], "MESSAGE_ID_INVALID": ["The provided message id is invalid"], "MESSAGE_NOT_MODIFIED": ["The message text has not changed"], "MESSAGE_TOO_LONG": ["The provided message is too long"], "MSG_WAIT_FAILED": ["A waiting call returned an error"], "NEED_CHAT_INVALID": ["The provided chat is invalid"], "NEED_MEMBER_INVALID": ["The provided member is invalid"], "NEW_SALT_INVALID": ["The new salt is invalid"], "NEW_SETTINGS_INVALID": ["The new settings are invalid"], "OFFSET_INVALID": ["The provided offset is invalid"], "OFFSET_PEER_ID_INVALID": ["The provided offset peer is invalid"], "PACK_SHORT_NAME_INVALID": ["Short pack name invalid"], "PACK_SHORT_NAME_OCCUPIED": ["A stickerpack with this name already exists"], "PARTICIPANTS_TOO_FEW": ["Not enough participants"], "PARTICIPANT_CALL_FAILED": ["Failure while making call"], "PARTICIPANT_VERSION_OUTDATED": ["The other participant does not use an up to date telegram client with support for calls"], "PASSWORD_EMPTY": ["The provided password is empty"], "PASSWORD_HASH_INVALID": ["The provided password hash is invalid"], "PEER_FLOOD": ["Too many requests"], "PEER_ID_INVALID": ["The provided peer id is invalid"], "PEER_ID_NOT_SUPPORTED": ["The provided peer ID is not supported"], "PERSISTENT_TIMESTAMP_EMPTY": ["Persistent timestamp empty"], "PERSISTENT_TIMESTAMP_INVALID": ["Persistent timestamp invalid"], "PERSISTENT_TIMESTAMP_OUTDATED": ["Persistent timestamp outdated"], "PHONE_CODE_EMPTY": ["phone_code is missing"], "PHONE_CODE_EXPIRED": ["The phone code you provided has expired, this may happen if it was sent to any chat on telegram (if the code is sent through a telegram chat (not the official account) to avoid it append or prepend to the code some chars)"], "PHONE_CODE_HASH_EMPTY": ["phone_code_hash is missing"], "PHONE_CODE_INVALID": ["The provided phone code is invalid"], "PHONE_NUMBER_APP_SIGNUP_FORBIDDEN": [""], "PHONE_NUMBER_BANNED": ["The provided phone number is banned from telegram"], "PHONE_NUMBER_FLOOD": ["You asked for the code too many times."], "PHONE_NUMBER_INVALID": ["The phone number is invalid"], "PHONE_NUMBER_OCCUPIED": ["The phone number is already in use"], "PHONE_NUMBER_UNOCCUPIED": ["The phone number is not yet being used"], "PHONE_PASSWORD_FLOOD": ["You have tried logging in too many times"], "PHONE_PASSWORD_PROTECTED": ["This phone is password protected"], "PHOTO_CROP_SIZE_SMALL": ["Photo is too small"], "PHOTO_EXT_INVALID": ["The extension of the photo is invalid"], "PHOTO_INVALID": ["Photo invalid"], "PHOTO_INVALID_DIMENSIONS": ["The photo dimensions are invalid"], "PRIVACY_KEY_INVALID": ["The privacy key is invalid"], "PTS_CHANGE_EMPTY": ["No PTS change"], "QUERY_ID_EMPTY": ["The query ID is empty"], "QUERY_ID_INVALID": ["The query ID is invalid"], "QUERY_TOO_SHORT": ["The query string is too short"], "RANDOM_ID_DUPLICATE": ["You provided a random ID that was already used"], "RANDOM_ID_INVALID": ["A provided random ID is invalid"], "RANDOM_LENGTH_INVALID": ["Random length invalid"], "RANGES_INVALID": ["Invalid range provided"], "REG_ID_GENERATE_FAILED": ["Failure while generating registration ID"], "REPLY_MARKUP_INVALID": ["The provided reply markup is invalid"], "RESULT_TYPE_INVALID": ["Result type invalid"], "RIGHT_FORBIDDEN": ["Your admin rights do not allow you to do this"], "RPC_CALL_FAIL": ["Telegram is having internal issues, please try again later."], "RPC_MCGET_FAIL": ["Telegram is having internal issues, please try again later."], "RSA_DECRYPT_FAILED": ["Internal RSA decryption failed"], "SEARCH_QUERY_EMPTY": ["The search query is empty"], "SEND_MESSAGE_TYPE_INVALID": ["The message type is invalid"], "SESSION_PASSWORD_NEEDED": ["2FA is enabled, use a password to login"], "SHA256_HASH_INVALID": ["The provided SHA256 hash is invalid"], "START_PARAM_EMPTY": ["The start parameter is empty"], "START_PARAM_INVALID": ["Start parameter invalid"], "STICKERSET_INVALID": ["The provided sticker set is invalid"], "STICKERS_EMPTY": ["No sticker provided"], "STICKER_EMOJI_INVALID": ["Sticker emoji invalid"], "STICKER_FILE_INVALID": ["Sticker file invalid"], "STICKER_ID_INVALID": ["The provided sticker ID is invalid"], "STICKER_INVALID": ["The provided sticker is invalid"], "STICKER_PNG_DIMENSIONS": ["Sticker png dimensions invalid"], "STORAGE_CHECK_FAILED": ["Server storage check failed"], "STORE_INVALID_SCALAR_TYPE": [""], "TEMP_AUTH_KEY_EMPTY": ["No temporary auth key provided"], "TMP_PASSWORD_DISABLED": ["The temporary password is disabled"], "TOKEN_INVALID": ["The provided token is invalid"], "TTL_DAYS_INVALID": ["The provided TTL is invalid"], "TYPES_EMPTY": ["The types field is empty"], "TYPE_CONSTRUCTOR_INVALID": ["The type constructor is invalid"], "Timeout": ["A timeout occurred while fetching data from the bot"], "UNKNOWN_METHOD": ["The method you tried to call cannot be called on non-CDN DCs"], "USERNAME_INVALID": ["The provided username is not valid"], "USERNAME_NOT_MODIFIED": ["The username was not modified"], "USERNAME_NOT_OCCUPIED": ["The provided username is not occupied"], "USERNAME_OCCUPIED": ["The provided username is already occupied"], "USERS_TOO_FEW": ["Not enough users (to create a chat, for example)"], "USERS_TOO_MUCH": ["The maximum number of users has been exceeded (to create a chat, for example)"], "USER_ADMIN_INVALID": ["You're not an admin"], "USER_ALREADY_PARTICIPANT": ["The user is already in the group"], "USER_BANNED_IN_CHANNEL": ["You're banned from sending messages in supergroups/channels"], "USER_BLOCKED": ["User blocked"], "USER_BOT": ["Bots can only be admins in channels."], "USER_BOT_INVALID": ["This method can only be called by a bot"], "USER_BOT_REQUIRED": ["This method can only be called by a bot"], "USER_CHANNELS_TOO_MUCH": ["One of the users you tried to add is already in too many channels/supergroups"], "USER_CREATOR": ["You can't leave this channel, because you're its creator"], "USER_DEACTIVATED": ["The user was deactivated"], "USER_ID_INVALID": ["The provided user ID is invalid"], "USER_IS_BLOCKED": ["User is blocked"], "USER_IS_BOT": ["Bots can't send messages to other bots"], "USER_KICKED": ["This user was kicked from this supergroup/channel"], "USER_NOT_MUTUAL_CONTACT": ["The provided user is not a mutual contact"], "USER_NOT_PARTICIPANT": ["You're not a member of this supergroup/channel"], "USER_PRIVACY_RESTRICTED": ["The user's privacy settings do not allow you to do this"], "USER_RESTRICTED": ["You're spamreported, you can't create channels or chats."], "WC_CONVERT_URL_INVALID": ["WC convert URL invalid"], "WEBPAGE_CURL_FAILED": ["Failure while fetching the webpage with cURL"], "WEBPAGE_MEDIA_EMPTY": ["Webpage media empty"], "YOU_BLOCKED_USER": ["You blocked this user"]}, "result": {"-503": {"auth.bindTempAuthKey": ["Timeout"], "auth.resetAuthorizations": ["Timeout"], "channels.getFullChannel": ["Timeout"], "channels.getParticipants": ["Timeout"], "contacts.deleteContacts": ["Timeout"], "contacts.search": ["Timeout"], "help.getCdnConfig": ["Timeout"], "help.getConfig": ["Timeout"], "invokeWithLayer": ["Timeout"], "messages.forwardMessages": ["Timeout"], "messages.getBotCallbackAnswer": ["Timeout"], "messages.getDialogs": ["Timeout"], "messages.getHistory": ["Timeout"], "messages.getInlineBotResults": ["Timeout"], "messages.readHistory": ["Timeout"], "messages.sendMedia": ["Timeout"], "messages.sendMessage": ["Timeout"], "updates.getChannelDifference": ["Timeout"], "updates.getDifference": ["Timeout"], "updates.getState": ["Timeout"], "upload.getFile": ["Timeout"], "upload.saveBigFilePart": ["Timeout"], "users.getFullUser": ["Timeout"], "users.getUsers": ["Timeout"]}, "400": {"account.changePhone": ["PHONE_NUMBER_INVALID"], "account.checkUsername": ["USERNAME_INVALID"], "account.confirmPhone": ["CODE_HASH_INVALID", "PHONE_CODE_EMPTY"], "account.getNotifySettings": ["PEER_ID_INVALID"], "account.getPasswordSettings": ["PASSWORD_HASH_INVALID"], "account.getPrivacy": ["PRIVACY_KEY_INVALID"], "account.getTmpPassword": ["PASSWORD_HASH_INVALID", "TMP_PASSWORD_DISABLED"], "account.registerDevice": ["TOKEN_INVALID"], "account.reportPeer": ["PEER_ID_INVALID"], "account.resetAuthorization": ["HASH_INVALID"], "account.sendChangePhoneCode": ["PHONE_NUMBER_INVALID"], "account.sendConfirmPhoneCode": ["HASH_INVALID"], "account.setAccountTTL": ["TTL_DAYS_INVALID"], "account.setPrivacy": ["PRIVACY_KEY_INVALID"], "account.unregisterDevice": ["TOKEN_INVALID"], "account.updateNotifySettings": ["PEER_ID_INVALID"], "account.updatePasswordSettings": ["EMAIL_UNCONFIRMED", "NEW_SALT_INVALID", "NEW_SETTINGS_INVALID", "PASSWORD_HASH_INVALID"], "account.updateProfile": ["ABOUT_TOO_LONG", "FIRSTNAME_INVALID"], "account.updateUsername": ["USERNAME_INVALID", "USERNAME_NOT_MODIFIED", "USERNAME_OCCUPIED"], "auth.bindTempAuthKey": ["ENCRYPTED_MESSAGE_INVALID", "INPUT_REQUEST_TOO_LONG", "TEMP_AUTH_KEY_EMPTY"], "auth.cancelCode": ["PHONE_NUMBER_INVALID"], "auth.checkPassword": ["PASSWORD_HASH_INVALID"], "auth.checkPhone": ["PHONE_NUMBER_BANNED", "PHONE_NUMBER_INVALID"], "auth.exportAuthorization": ["DC_ID_INVALID"], "auth.importAuthorization": ["AUTH_BYTES_INVALID", "USER_ID_INVALID"], "auth.importBotAuthorization": ["ACCESS_TOKEN_EXPIRED", "ACCESS_TOKEN_INVALID", "API_ID_INVALID"], "auth.recoverPassword": ["CODE_EMPTY"], "auth.requestPasswordRecovery": ["PASSWORD_EMPTY"], "auth.resendCode": ["PHONE_NUMBER_INVALID"], "auth.sendCode": ["API_ID_INVALID", "API_ID_PUBLISHED_FLOOD", "INPUT_REQUEST_TOO_LONG", "PHONE_NUMBER_APP_SIGNUP_FORBIDDEN", "PHONE_NUMBER_BANNED", "PHONE_NUMBER_FLOOD", "PHONE_NUMBER_INVALID", "PHONE_PASSWORD_PROTECTED"], "auth.sendInvites": ["MESSAGE_EMPTY"], "auth.signIn": ["PHONE_CODE_EMPTY", "PHONE_CODE_EXPIRED", "PHONE_CODE_INVALID", "PHONE_NUMBER_INVALID", "PHONE_NUMBER_UNOCCUPIED"], "auth.signUp": ["FIRSTNAME_INVALID", "PHONE_CODE_EMPTY", "PHONE_CODE_EXPIRED", "PHONE_CODE_INVALID", "PHONE_NUMBER_FLOOD", "PHONE_NUMBER_INVALID", "PHONE_NUMBER_OCCUPIED"], "bots.answerWebhookJSONQuery": ["QUERY_ID_INVALID", "USER_BOT_INVALID"], "bots.sendCustomRequest": ["USER_BOT_INVALID"], "channels.checkUsername": ["CHANNEL_INVALID", "CHAT_ID_INVALID", "USERNAME_INVALID"], "channels.createChannel": ["CHAT_TITLE_EMPTY"], "channels.deleteChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.deleteMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.deleteUserHistory": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED"], "channels.editAbout": ["CHANNEL_INVALID", "CHAT_ABOUT_NOT_MODIFIED", "CHAT_ABOUT_TOO_LONG", "CHAT_ADMIN_REQUIRED"], "channels.editAdmin": ["ADMINS_TOO_MUCH", "BOT_CHANNELS_NA", "CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "USER_CREATOR", "USER_ID_INVALID", "USER_NOT_MUTUAL_CONTACT"], "channels.editBanned": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "USER_ADMIN_INVALID", "USER_ID_INVALID"], "channels.editPhoto": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "PHOTO_INVALID"], "channels.editTitle": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_NOT_MODIFIED"], "channels.exportInvite": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "INVITE_HASH_EXPIRED"], "channels.exportMessageLink": ["CHANNEL_INVALID"], "channels.getAdminLog": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED"], "channels.getChannels": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.getFullChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.getMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "MESSAGE_IDS_EMPTY"], "channels.getParticipant": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "USER_ID_INVALID", "USER_NOT_PARTICIPANT"], "channels.getParticipants": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "INPUT_CONSTRUCTOR_INVALID"], "channels.inviteToChannel": ["BOT_GROUPS_BLOCKED", "BOTS_TOO_MUCH", "CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "INPUT_USER_DEACTIVATED", "USER_BANNED_IN_CHANNEL", "USER_BLOCKED", "USER_BOT", "USER_ID_INVALID", "USER_KICKED", "USER_NOT_MUTUAL_CONTACT", "USERS_TOO_MUCH"], "channels.joinChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHANNELS_TOO_MUCH"], "channels.leaveChannel": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "USER_CREATOR", "USER_NOT_PARTICIPANT"], "channels.readHistory": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.readMessageContents": ["CHANNEL_INVALID", "CHANNEL_PRIVATE"], "channels.reportSpam": ["CHANNEL_INVALID", "INPUT_USER_DEACTIVATED"], "channels.setStickers": ["CHANNEL_INVALID", "PARTICIPANTS_TOO_FEW"], "channels.toggleInvites": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_NOT_MODIFIED"], "channels.toggleSignatures": ["CHANNEL_INVALID"], "channels.updatePinnedMessage": ["CHANNEL_INVALID", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "CHAT_NOT_MODIFIED"], "channels.updateUsername": ["CHANNEL_INVALID", "CHANNELS_ADMIN_PUBLIC_TOO_MUCH", "CHAT_ADMIN_REQUIRED", "USERNAME_INVALID", "USERNAME_OCCUPIED"], "contacts.block": ["CONTACT_ID_INVALID"], "contacts.deleteContact": ["CONTACT_ID_INVALID"], "contacts.getTopPeers": ["TYPES_EMPTY"], "contacts.importCard": ["EXPORT_CARD_INVALID"], "contacts.resetTopPeerRating": ["PEER_ID_INVALID"], "contacts.resolveUsername": ["USERNAME_INVALID", "USERNAME_NOT_OCCUPIED"], "contacts.search": ["QUERY_TOO_SHORT", "SEARCH_QUERY_EMPTY"], "contacts.unblock": ["CONTACT_ID_INVALID"], "initConnection": ["CONNECTION_LAYER_INVALID", "INPUT_FETCH_FAIL"], "invokeWithLayer": ["AUTH_BYTES_INVALID", "CDN_METHOD_INVALID", "CONNECTION_API_ID_INVALID", "CONNECTION_DEVICE_MODEL_EMPTY", "CONNECTION_LANG_PACK_INVALID", "CONNECTION_NOT_INITED", "CONNECTION_SYSTEM_EMPTY", "INPUT_LAYER_INVALID", "INVITE_HASH_EXPIRED"], "langpack.getDifference": ["LANG_PACK_INVALID"], "langpack.getLangPack": ["LANG_PACK_INVALID"], "langpack.getLanguages": ["LANG_PACK_INVALID"], "langpack.getStrings": ["LANG_PACK_INVALID"], "messages.acceptEncryption": ["CHAT_ID_INVALID", "ENCRYPTION_ALREADY_ACCEPTED", "ENCRYPTION_ALREADY_DECLINED"], "messages.addChatUser": ["CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "INPUT_USER_DEACTIVATED", "PEER_ID_INVALID", "USER_ALREADY_PARTICIPANT", "USER_ID_INVALID", "USERS_TOO_MUCH"], "messages.checkChatInvite": ["INVITE_HASH_EMPTY", "INVITE_HASH_EXPIRED", "INVITE_HASH_INVALID"], "messages.createChat": ["USERS_TOO_FEW"], "messages.deleteChatUser": ["CHAT_ID_INVALID", "PEER_ID_INVALID", "USER_NOT_PARTICIPANT"], "messages.deleteHistory": ["PEER_ID_INVALID"], "messages.discardEncryption": ["CHAT_ID_EMPTY", "ENCRYPTION_ALREADY_DECLINED", "ENCRYPTION_ID_INVALID"], "messages.editChatAdmin": ["CHAT_ID_INVALID"], "messages.editChatPhoto": ["CHAT_ID_INVALID", "INPUT_CONSTRUCTOR_INVALID", "INPUT_FETCH_FAIL", "PEER_ID_INVALID", "PHOTO_EXT_INVALID"], "messages.editChatTitle": ["CHAT_ID_INVALID"], "messages.editInlineBotMessage": ["MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED"], "messages.editMessage": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "INPUT_USER_DEACTIVATED", "MESSAGE_EDIT_TIME_EXPIRED", "MESSAGE_EMPTY", "MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED", "PEER_ID_INVALID"], "messages.exportChatInvite": ["CHAT_ID_INVALID"], "messages.faveSticker": ["STICKER_ID_INVALID"], "messages.forwardMessage": ["CHAT_ID_INVALID", "MESSAGE_ID_INVALID", "PEER_ID_INVALID", "YOU_BLOCKED_USER"], "messages.forwardMessages": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "GROUPED_MEDIA_INVALID", "INPUT_USER_DEACTIVATED", "MEDIA_EMPTY", "MESSAGE_ID_INVALID", "MESSAGE_IDS_EMPTY", "PEER_ID_INVALID", "RANDOM_ID_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "YOU_BLOCKED_USER"], "messages.getBotCallbackAnswer": ["CHANNEL_INVALID", "DATA_INVALID", "MESSAGE_ID_INVALID", "PEER_ID_INVALID"], "messages.getChats": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getCommonChats": ["USER_ID_INVALID"], "messages.getDhConfig": ["RANDOM_LENGTH_INVALID"], "messages.getDialogs": ["INPUT_CONSTRUCTOR_INVALID", "OFFSET_PEER_ID_INVALID"], "messages.getDocumentByHash": ["SHA256_HASH_INVALID"], "messages.getFullChat": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getGameHighScores": ["PEER_ID_INVALID", "USER_BOT_REQUIRED"], "messages.getHistory": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getInlineBotResults": ["BOT_INLINE_DISABLED", "BOT_INVALID", "CHANNEL_PRIVATE"], "messages.getInlineGameHighScores": ["MESSAGE_ID_INVALID", "USER_BOT_REQUIRED"], "messages.getMessageEditData": ["PEER_ID_INVALID"], "messages.getMessagesViews": ["CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.getPeerDialogs": ["CHANNEL_PRIVATE", "PEER_ID_INVALID"], "messages.getPeerSettings": ["CHANNEL_INVALID", "PEER_ID_INVALID"], "messages.getStickerSet": ["STICKERSET_INVALID"], "messages.getUnreadMentions": ["PEER_ID_INVALID"], "messages.getWebPage": ["WC_CONVERT_URL_INVALID"], "messages.hideReportSpam": ["PEER_ID_INVALID"], "messages.importChatInvite": ["CHANNELS_TOO_MUCH", "INVITE_HASH_EMPTY", "INVITE_HASH_EXPIRED", "INVITE_HASH_INVALID", "USER_ALREADY_PARTICIPANT", "USERS_TOO_MUCH"], "messages.installStickerSet": ["STICKERSET_INVALID"], "messages.migrateChat": ["CHAT_ID_INVALID", "PEER_ID_INVALID"], "messages.readEncryptedHistory": ["MSG_WAIT_FAILED"], "messages.readHistory": ["PEER_ID_INVALID"], "messages.receivedQueue": ["MSG_WAIT_FAILED"], "messages.reorderPinnedDialogs": ["PEER_ID_INVALID"], "messages.reportEncryptedSpam": ["CHAT_ID_INVALID"], "messages.reportSpam": ["PEER_ID_INVALID"], "messages.requestEncryption": ["DH_G_A_INVALID", "USER_ID_INVALID"], "messages.saveDraft": ["PEER_ID_INVALID"], "messages.saveGif": ["GIF_ID_INVALID"], "messages.saveRecentSticker": ["STICKER_ID_INVALID"], "messages.search": ["CHAT_ADMIN_REQUIRED", "INPUT_CONSTRUCTOR_INVALID", "INPUT_USER_DEACTIVATED", "PEER_ID_INVALID", "PEER_ID_NOT_SUPPORTED", "SEARCH_QUERY_EMPTY", "USER_ID_INVALID"], "messages.searchGifs": ["SEARCH_QUERY_EMPTY"], "messages.searchGlobal": ["SEARCH_QUERY_EMPTY"], "messages.sendEncrypted": ["CHAT_ID_INVALID", "DATA_INVALID", "ENCRYPTION_DECLINED", "MSG_WAIT_FAILED"], "messages.sendEncryptedFile": ["MSG_WAIT_FAILED"], "messages.sendEncryptedService": ["DATA_INVALID", "ENCRYPTION_DECLINED", "MSG_WAIT_FAILED"], "messages.sendInlineBotResult": ["INLINE_RESULT_EXPIRED", "PEER_ID_INVALID", "QUERY_ID_EMPTY", "WEBPAGE_CURL_FAILED", "WEBPAGE_MEDIA_EMPTY"], "messages.sendMedia": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "EXTERNAL_URL_INVALID", "FILE_PART_LENGTH_INVALID", "FILE_PARTS_INVALID", "INPUT_USER_DEACTIVATED", "MEDIA_CAPTION_TOO_LONG", "MEDIA_EMPTY", "PEER_ID_INVALID", "PHOTO_EXT_INVALID", "PHOTO_INVALID_DIMENSIONS", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "WEBPAGE_CURL_FAILED", "WEBPAGE_MEDIA_EMPTY"], "messages.sendMessage": ["BUTTON_DATA_INVALID", "BUTTON_TYPE_INVALID", "BUTTON_URL_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ADMIN_REQUIRED", "CHAT_ID_INVALID", "ENTITY_MENTION_USER_INVALID", "INPUT_USER_DEACTIVATED", "MESSAGE_EMPTY", "MESSAGE_TOO_LONG", "PEER_ID_INVALID", "REPLY_MARKUP_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT", "YOU_BLOCKED_USER"], "messages.sendScreenshotNotification": ["PEER_ID_INVALID"], "messages.setBotCallbackAnswer": ["QUERY_ID_INVALID", "URL_INVALID"], "messages.setBotPrecheckoutResults": ["ERROR_TEXT_EMPTY"], "messages.setBotShippingResults": ["QUERY_ID_INVALID"], "messages.setEncryptedTyping": ["CHAT_ID_INVALID"], "messages.setGameScore": ["PEER_ID_INVALID", "USER_BOT_REQUIRED"], "messages.setInlineBotResults": ["ARTICLE_TITLE_EMPTY", "BUTTON_DATA_INVALID", "BUTTON_TYPE_INVALID", "BUTTON_URL_INVALID", "MESSAGE_EMPTY", "QUERY_ID_INVALID", "REPLY_MARKUP_INVALID", "RESULT_TYPE_INVALID", "SEND_MESSAGE_TYPE_INVALID", "START_PARAM_INVALID"], "messages.setInlineGameScore": ["MESSAGE_ID_INVALID", "USER_BOT_REQUIRED"], "messages.setTyping": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "CHAT_ID_INVALID", "PEER_ID_INVALID", "USER_BANNED_IN_CHANNEL", "USER_IS_BLOCKED", "USER_IS_BOT"], "messages.startBot": ["BOT_INVALID", "PEER_ID_INVALID", "START_PARAM_EMPTY", "START_PARAM_INVALID"], "messages.toggleChatAdmins": ["CHAT_ID_INVALID", "CHAT_NOT_MODIFIED"], "messages.toggleDialogPin": ["PEER_ID_INVALID"], "messages.uninstallStickerSet": ["STICKERSET_INVALID"], "messages.uploadMedia": ["BOT_MISSING", "MEDIA_INVALID", "PEER_ID_INVALID"], "payments.getPaymentForm": ["MESSAGE_ID_INVALID"], "payments.getPaymentReceipt": ["MESSAGE_ID_INVALID"], "payments.sendPaymentForm": ["MESSAGE_ID_INVALID"], "payments.validateRequestedInfo": ["MESSAGE_ID_INVALID"], "phone.acceptCall": ["CALL_ALREADY_ACCEPTED", "CALL_ALREADY_DECLINED", "CALL_PEER_INVALID", "CALL_PROTOCOL_FLAGS_INVALID"], "phone.confirmCall": ["CALL_ALREADY_DECLINED", "CALL_PEER_INVALID"], "phone.discardCall": ["CALL_ALREADY_ACCEPTED", "CALL_PEER_INVALID"], "phone.receivedCall": ["CALL_ALREADY_DECLINED", "CALL_PEER_INVALID"], "phone.requestCall": ["CALL_PROTOCOL_FLAGS_INVALID", "PARTICIPANT_VERSION_OUTDATED", "USER_ID_INVALID"], "phone.saveCallDebug": ["CALL_PEER_INVALID", "DATA_JSON_INVALID"], "phone.setCallRating": ["CALL_PEER_INVALID"], "photos.getUserPhotos": ["MAX_ID_INVALID", "USER_ID_INVALID"], "photos.uploadProfilePhoto": ["FILE_PARTS_INVALID", "IMAGE_PROCESS_FAILED", "PHOTO_CROP_SIZE_SMALL", "PHOTO_EXT_INVALID"], "stickers.addStickerToSet": ["BOT_MISSING", "STICKERSET_INVALID"], "stickers.changeStickerPosition": ["BOT_MISSING", "STICKER_INVALID"], "stickers.createStickerSet": ["BOT_MISSING", "PACK_SHORT_NAME_INVALID", "PACK_SHORT_NAME_OCCUPIED", "PEER_ID_INVALID", "STICKER_EMOJI_INVALID", "STICKER_FILE_INVALID", "STICKER_PNG_DIMENSIONS", "STICKERS_EMPTY", "USER_ID_INVALID"], "stickers.removeStickerFromSet": ["BOT_MISSING", "STICKER_INVALID"], "updates.getChannelDifference": ["CHANNEL_INVALID", "CHANNEL_PRIVATE", "PERSISTENT_TIMESTAMP_EMPTY", "PERSISTENT_TIMESTAMP_INVALID", "RANGES_INVALID"], "updates.getDifference": ["CDN_METHOD_INVALID", "DATE_EMPTY", "PERSISTENT_TIMESTAMP_EMPTY", "PERSISTENT_TIMESTAMP_INVALID"], "upload.getCdnFileHashes": ["CDN_METHOD_INVALID", "RSA_DECRYPT_FAILED"], "upload.getFile": ["FILE_ID_INVALID", "INPUT_FETCH_FAIL", "LIMIT_INVALID", "LOCATION_INVALID", "OFFSET_INVALID"], "upload.getWebFile": ["LOCATION_INVALID"], "upload.reuploadCdnFile": ["RSA_DECRYPT_FAILED"], "upload.saveBigFilePart": ["FILE_PART_EMPTY", "FILE_PART_INVALID", "FILE_PART_SIZE_INVALID", "FILE_PARTS_INVALID"], "upload.saveFilePart": ["FILE_PART_EMPTY", "FILE_PART_INVALID", "INPUT_FETCH_FAIL"], "users.getFullUser": ["USER_ID_INVALID"], "{}": ["INVITE_HASH_EXPIRED"]}, "401": {"account.updateStatus": ["SESSION_PASSWORD_NEEDED"], "auth.signIn": ["SESSION_PASSWORD_NEEDED"], "contacts.resolveUsername": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"], "help.getCdnConfig": ["AUTH_KEY_PERM_EMPTY"], "messages.getDialogs": ["SESSION_PASSWORD_NEEDED"], "messages.getHistory": ["AUTH_KEY_PERM_EMPTY"], "messages.importChatInvite": ["SESSION_PASSWORD_NEEDED"], "updates.getDifference": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"], "updates.getState": ["SESSION_PASSWORD_NEEDED"], "upload.getFile": ["AUTH_KEY_PERM_EMPTY"], "upload.saveFilePart": ["SESSION_PASSWORD_NEEDED"], "users.getUsers": ["AUTH_KEY_PERM_EMPTY", "SESSION_PASSWORD_NEEDED"]}, "403": {"channels.createChannel": ["USER_RESTRICTED"], "channels.deleteMessages": ["MESSAGE_DELETE_FORBIDDEN"], "channels.editAdmin": ["CHAT_ADMIN_INVITE_REQUIRED", "RIGHT_FORBIDDEN", "USER_PRIVACY_RESTRICTED"], "channels.getFullChannel": ["CHANNEL_PUBLIC_GROUP_NA"], "channels.inviteToChannel": ["CHAT_WRITE_FORBIDDEN", "USER_CHANNELS_TOO_MUCH", "USER_PRIVACY_RESTRICTED"], "channels.leaveChannel": ["CHANNEL_PUBLIC_GROUP_NA"], "invokeWithLayer": ["CHAT_WRITE_FORBIDDEN"], "messages.addChatUser": ["USER_NOT_MUTUAL_CONTACT", "USER_PRIVACY_RESTRICTED"], "messages.createChat": ["USER_RESTRICTED"], "messages.deleteMessages": ["MESSAGE_DELETE_FORBIDDEN"], "messages.editMessage": ["CHAT_WRITE_FORBIDDEN", "MESSAGE_AUTHOR_REQUIRED"], "messages.forwardMessages": ["CHAT_SEND_GIFS_FORBIDDEN", "CHAT_SEND_MEDIA_FORBIDDEN", "CHAT_SEND_STICKERS_FORBIDDEN", "CHAT_WRITE_FORBIDDEN"], "messages.getMessageEditData": ["MESSAGE_AUTHOR_REQUIRED"], "messages.migrateChat": ["CHAT_ADMIN_REQUIRED"], "messages.sendEncryptedService": ["USER_IS_BLOCKED"], "messages.sendInlineBotResult": ["CHAT_WRITE_FORBIDDEN"], "messages.sendMedia": ["CHAT_SEND_MEDIA_FORBIDDEN", "CHAT_WRITE_FORBIDDEN"], "messages.sendMessage": ["CHAT_WRITE_FORBIDDEN"], "messages.setInlineBotResults": ["USER_BOT_INVALID"], "messages.setTyping": ["CHAT_WRITE_FORBIDDEN"], "phone.requestCall": ["USER_IS_BLOCKED", "USER_PRIVACY_RESTRICTED"], "updates.getChannelDifference": ["CHANNEL_PUBLIC_GROUP_NA"]}, "406": {"auth.checkPhone": ["PHONE_NUMBER_INVALID"], "auth.sendCode": ["PHONE_NUMBER_INVALID", "PHONE_PASSWORD_FLOOD"], "help.getConfig": ["AUTH_KEY_DUPLICATED"], "invokeWithLayer": ["AUTH_KEY_DUPLICATED"], "messages.getHistory": ["AUTH_KEY_DUPLICATED"], "messages.sendMessage": ["AUTH_KEY_DUPLICATED"], "updates.getState": ["AUTH_KEY_DUPLICATED"]}, "500": {"auth.sendCode": ["AUTH_RESTART"], "auth.signUp": ["MEMBER_OCCUPY_PRIMARY_LOC_FAILED", "REG_ID_GENERATE_FAILED"], "channels.getChannels": ["NEED_CHAT_INVALID"], "contacts.deleteContacts": ["NEED_MEMBER_INVALID"], "contacts.importCard": ["NEED_MEMBER_INVALID"], "invokeWithLayer": ["NEED_MEMBER_INVALID"], "messages.acceptEncryption": ["ENCRYPTION_OCCUPY_FAILED"], "messages.editChatTitle": ["NEED_CHAT_INVALID"], "messages.forwardMessages": ["PTS_CHANGE_EMPTY", "RANDOM_ID_DUPLICATE"], "messages.sendMedia": ["RANDOM_ID_DUPLICATE", "STORAGE_CHECK_FAILED"], "messages.sendMessage": ["RANDOM_ID_DUPLICATE"], "phone.acceptCall": ["CALL_OCCUPY_FAILED"], "phone.requestCall": ["PARTICIPANT_CALL_FAILED"], "updates.getChannelDifference": ["HISTORY_GET_FAILED", "PERSISTENT_TIMESTAMP_OUTDATED"], "updates.getDifference": ["NEED_MEMBER_INVALID", "STORE_INVALID_SCALAR_TYPE"], "upload.getCdnFile": ["UNKNOWN_METHOD"], "users.getUsers": ["MEMBER_NO_LOCATION", "NEED_MEMBER_INVALID"]}}} \ No newline at end of file From 2d7c8908eb2e797462bdbcd04c316bdde70680f2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 15 Jul 2018 11:31:14 +0200 Subject: [PATCH 23/70] Create events.InlineQuery --- telethon/client/buttons.py | 6 +- telethon/client/uploads.py | 91 ++-------- telethon/events/__init__.py | 1 + telethon/events/common.py | 4 +- telethon/events/inlinequery.py | 190 +++++++++++++++++++++ telethon/tl/custom/__init__.py | 1 + telethon/tl/custom/inline.py | 302 +++++++++++++++++++++++++++++++++ telethon/utils.py | 81 ++++++++- 8 files changed, 593 insertions(+), 83 deletions(-) create mode 100644 telethon/events/inlinequery.py create mode 100644 telethon/tl/custom/inline.py diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py index 4741cbda..7e64c164 100644 --- a/telethon/client/buttons.py +++ b/telethon/client/buttons.py @@ -4,7 +4,7 @@ from .. import utils, events class ButtonMethods(UpdateMethods): - def _build_reply_markup(self, buttons): + def _build_reply_markup(self, buttons, inline_only=False): if buttons is None: return None @@ -45,7 +45,9 @@ class ButtonMethods(UpdateMethods): if current: rows.append(types.KeyboardButtonRow(current)) - if is_inline == is_normal and is_normal: + if inline_only and is_normal: + raise ValueError('You cannot use non-inline buttons here') + elif is_inline == is_normal and is_normal: raise ValueError('You cannot mix inline with normal buttons') elif is_inline: return types.ReplyInlineMarkup(rows) diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index f7f4171d..f7daaf7b 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -13,13 +13,6 @@ from .buttons import ButtonMethods from .. import utils, helpers from ..tl import types, functions, custom -try: - import hachoir - import hachoir.metadata - import hachoir.parser -except ImportError: - hachoir = None - __log__ = logging.getLogger(__name__) @@ -224,8 +217,11 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): caption, msg_entities = captions.pop() else: caption, msg_entities = '', None - media.append(types.InputSingleMedia(types.InputMediaPhoto(fh), message=caption, - entities=msg_entities)) + media.append(types.InputSingleMedia( + types.InputMediaPhoto(fh), + message=caption, + entities=msg_entities + )) # Now we can construct the multi-media request result = await self(functions.messages.SendMultiMediaRequest( @@ -420,74 +416,13 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): elif as_image: media = types.InputMediaUploadedPhoto(file_handle) else: - mime_type = None - if isinstance(file, str): - # Determine mime-type and attributes - # Take the first element by using [0] since it returns a tuple - mime_type = guess_type(file)[0] - attr_dict = { - types.DocumentAttributeFilename: - types.DocumentAttributeFilename( - os.path.basename(file)) - } - if utils.is_audio(file) and hachoir: - with hachoir.parser.createParser(file) as parser: - m = hachoir.metadata.extractMetadata(parser) - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio( - voice=voice_note, - title=m.get('title') if m.has( - 'title') else None, - performer=m.get('author') if m.has( - 'author') else None, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - - if not force_document and utils.is_video(file): - if hachoir: - with hachoir.parser.createParser(file) as parser: - m = hachoir.metadata.extractMetadata(parser) - doc = types.DocumentAttributeVideo( - round_message=video_note, - w=m.get('width') if m.has('width') else 0, - h=m.get('height') if m.has('height') else 0, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - else: - doc = types.DocumentAttributeVideo( - 0, 1, 1, round_message=video_note) - - attr_dict[types.DocumentAttributeVideo] = doc - else: - attr_dict = { - types.DocumentAttributeFilename: - types.DocumentAttributeFilename( - os.path.basename( - getattr(file, 'name', - None) or 'unnamed')) - } - - if voice_note: - if types.DocumentAttributeAudio in attr_dict: - attr_dict[types.DocumentAttributeAudio].voice = True - else: - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio(0, voice=True) - - # Now override the attributes if any. As we have a dict of - # {cls: instance}, we can override any class with the list - # of attributes provided by the user easily. - if attributes: - for a in attributes: - attr_dict[type(a)] = a - - # Ensure we have a mime type, any; but it cannot be None - # 'The "octet-stream" subtype is used to indicate that a body - # contains arbitrary binary data.' - if not mime_type: - mime_type = 'application/octet-stream' + attributes, mime_type = utils.get_attributes( + file, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note + ) input_kw = {} if thumb: @@ -496,7 +431,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): media = types.InputMediaUploadedDocument( file=file_handle, mime_type=mime_type, - attributes=list(attr_dict.values()), + attributes=attributes, **input_kw ) return file_handle, media diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py index d0faad3e..0ff7c606 100644 --- a/telethon/events/__init__.py +++ b/telethon/events/__init__.py @@ -6,6 +6,7 @@ from .messageread import MessageRead from .newmessage import NewMessage from .userupdate import UserUpdate from .callbackquery import CallbackQuery +from .inlinequery import InlineQuery class StopPropagation(Exception): diff --git a/telethon/events/common.py b/telethon/events/common.py index aca7f892..6af00a63 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -43,8 +43,8 @@ class EventBuilder(abc.ABC): Args: chats (`entity`, optional): - May be one or more entities (username/peer/etc.). By default, - only matching chats will be handled. + May be one or more entities (username/peer/etc.), preferably IDs. + By default, only matching chats will be handled. blacklist_chats (`bool`, optional): Whether to treat the chats as a blacklist instead of diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py new file mode 100644 index 00000000..ea6f45c2 --- /dev/null +++ b/telethon/events/inlinequery.py @@ -0,0 +1,190 @@ +import inspect +import re + +import asyncio + +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types, functions, custom +from ..tl.custom.sendergetter import SenderGetter + + +@name_inner_event +class InlineQuery(EventBuilder): + """ + Represents an inline query event (when someone writes ``'@my_bot query'``). + + Args: + users (`entity`, optional): + May be one or more entities (username/peer/etc.), preferably IDs. + By default, only inline queries from these users will be handled. + + blacklist_users (`bool`, optional): + Whether to treat the users as a blacklist instead of + as a whitelist (default). This means that every chat + will be handled *except* those specified in ``users`` + which will be ignored if ``blacklist_users=True``. + + pattern (`str`, `callable`, `Pattern`, optional): + If set, only queries matching this pattern will be handled. + You can specify a regex-like string which will be matched + against the message, a callable function that returns ``True`` + if a message is acceptable, or a compiled regex pattern. + """ + def __init__(self, users=None, *, blacklist_users=False, pattern=None): + super().__init__(chats=users, blacklist_chats=blacklist_users) + + if isinstance(pattern, str): + self.pattern = re.compile(pattern).match + elif not pattern or callable(pattern): + self.pattern = pattern + elif hasattr(pattern, 'match') and callable(pattern.match): + self.pattern = pattern.match + else: + raise TypeError('Invalid pattern type given') + + @staticmethod + def build(update): + if isinstance(update, types.UpdateBotInlineQuery): + event = InlineQuery.Event(update) + else: + return + + event._entities = update._entities + return event + + def filter(self, event): + if self.pattern: + match = self.pattern(event.text) + if not match: + return + event.pattern_match = match + + return super().filter(event) + + class Event(EventCommon, SenderGetter): + """ + Represents the event of a new callback query. + + Members: + query (:tl:`UpdateBotCallbackQuery`): + The original :tl:`UpdateBotCallbackQuery`. + + pattern_match (`obj`, optional): + The resulting object from calling the passed ``pattern`` + function, which is ``re.compile(...).match`` by default. + """ + def __init__(self, query): + super().__init__(chat_peer=types.PeerUser(query.user_id)) + self.query = query + self.pattern_match = None + self._answered = False + self._sender_id = query.user_id + self._input_sender = None + self._sender = None + + @property + def id(self): + """ + Returns the unique identifier for the query ID. + """ + return self.query.query_id + + @property + def text(self): + """ + Returns the text the user used to make the inline query. + """ + return self.query.query + + @property + def offset(self): + """ + ??? + """ + return self.query.offset + + @property + def geo(self): + """ + If the user location is requested when using inline mode + and the user's device is able to send it, this will return + the :tl:`GeoPoint` with the position of the user. + """ + return + + @property + def builder(self): + """ + Returns a new `inline result builder + `. + """ + return custom.InlineBuilder(self._client) + + async def answer( + self, results=None, cache_time=0, *, + gallery=False, private=False, + switch_pm=None, switch_pm_param=''): + """ + Answers the inline query with the given results. + + Args: + results (`list`, optional): + A list of :tl:`InputBotInlineResult` to use. + You should use `builder` to create these: + + .. code-block: python + + builder = inline.builder + r1 = builder.article('Be nice', text='Have a nice day') + r2 = builder.article('Be bad', text="I don't like you") + await inline.answer([r1, r2]) + + cache_time (`int`, optional): + For how long this result should be cached on + the user's client. Defaults to 0 for no cache. + + gallery (`bool`, optional): + Whether the results should show as a gallery (grid) or not. + + private (`bool`, optional): + Whether the results should be cached by Telegram + (not private) or by the user's client (private). + + switch_pm (`str`, optional): + If set, this text will be shown in the results + to allow the user to switch to private messages. + + switch_pm_param (`str`, optional): + Optional parameter to start the bot with if + `switch_pm` was used. + """ + if self._answered: + return + + results = [self._as_awaitable(x) for x in results] + done, _ = await asyncio.wait(results) + results = [x.result() for x in done] + + if switch_pm: + switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param) + + return await self._client( + functions.messages.SetInlineBotResultsRequest( + query_id=self.query.query_id, + results=results, + cache_time=cache_time, + gallery=gallery, + private=private, + switch_pm=switch_pm + ) + ) + + @staticmethod + def _as_awaitable(obj): + if inspect.isawaitable(obj): + return obj + + f = asyncio.Future() + f.set_result(obj) + return f diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index d54c27b8..6f15892c 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -5,3 +5,4 @@ from .messagebutton import MessageButton from .forward import Forward from .message import Message from .button import Button +from .inline import InlineBuilder diff --git a/telethon/tl/custom/inline.py b/telethon/tl/custom/inline.py new file mode 100644 index 00000000..c953ad32 --- /dev/null +++ b/telethon/tl/custom/inline.py @@ -0,0 +1,302 @@ +import hashlib + +from .. import functions, types +from ... import utils + + +class InlineBuilder: + """ + Helper class to allow defining inline queries ``results``. + + Common arguments to all methods are + explained here to avoid repetition: + + text (`str`, optional): + If present, the user will send a text + message with this text upon being clicked. + + link_preview (`bool`, optional): + Whether to show a link preview in the sent + text message or not. + + geo (:tl:`InputGeoPoint`, :tl:`GeoPoint`, + :tl:`InputMediaVenue`, :tl:`MessageMediaVenue`, + optional): + If present, it may either be a geo point or a venue. + + period (int, optional): + The period in seconds to be used for geo points. + + contact (:tl:`InputMediaContact`, :tl:`MessageMediaContact`, + optional): + If present, it must be the contact information to send. + + game (`bool`, optional): + May be ``True`` to indicate that the game will be sent. + + buttons (`list`, `custom.Button `, + :tl:`KeyboardButton`, optional): + Same as ``buttons`` for `client.send_message + `. + + parse_mode (`str`, optional): + Same as ``parse_mode`` for `client.send_message + `. + + id (`str`, optional): + The string ID to use for this result. If not present, it + will be the SHA256 hexadecimal digest of converting the + request with empty ID to ``bytes()``, so that the ID will + be deterministic for the same input. + """ + def __init__(self, client): + self._client = client + + async def article( + self, title, description=None, + *, url=None, thumb=None, content=None, + id=None, text=None, parse_mode=utils.Default, link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None, + ): + """ + Creates new inline result of article type. + + Args: + title (`str`): + The title to be shown for this result. + + description (`str`, optional): + Further explanation of what this result means. + + url (`str`, optional): + The URL to be shown for this result. + + thumb (:tl:`InputWebDocument`, optional): + The thumbnail to be shown for this result. + For now it has to be a :tl:`InputWebDocument` if present. + + content (:tl:`InputWebDocument`, optional): + The content to be shown for this result. + For now it has to be a :tl:`InputWebDocument` if present. + """ + # TODO Does 'article' work always? + # article, photo, gif, mpeg4_gif, video, audio, + # voice, document, location, venue, contact, game + result = types.InputBotInlineResult( + id=id or '', + type='article', + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ), + title=title, + description=description, + url=url, + thumb=thumb, + content=content + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + async def photo( + self, file, *, id=None, + text=None, parse_mode=utils.Default, link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates a new inline result of photo type. + + Args: + file (`obj`, optional): + Same as ``file`` for `client.send_file + `. + """ + fh = self._client.upload_file(file, use_cache=types.InputPhoto) + if not isinstance(fh, types.InputPhoto): + r = await self._client(functions.messages.UploadMediaRequest( + types.InputPeerEmpty(), media=types.InputMediaUploadedPhoto(fh) + )) + fh = utils.get_input_photo(r.photo) + + result = types.InputBotInlineResultPhoto( + id=id or '', + type='photo', + photo=fh, + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ) + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + async def document( + self, file, title=None, *, description=None, type=None, + mime_type=None, attributes=None, force_document=False, + voice_note=False, video_note=False, use_cache=True, id=None, + text=None, parse_mode=utils.Default, link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates a new inline result of document type. + + `use_cache`, `mime_type`, `attributes`, `force_document`, + `voice_note` and `video_note` are described in `client.send_file + `. + + Args: + file (`obj`): + Same as ``file`` for ` + telethon.client.uploads.UploadMethods.send_file`. + + title (`str`, optional): + The title to be shown for this result. + + description (`str`, optional): + Further explanation of what this result means. + + type (`str`, optional): + The type of the document. May be one of: photo, gif, + mpeg4_gif, video, audio, voice, document, sticker. + + See "Type of the result" in https://core.telegram.org/bots/api. + """ + if type is None: + if voice_note: + type = 'voice' + else: + type = 'document' + + use_cache = types.InputDocument if use_cache else None + fh = self._client.upload_file(file, use_cache=use_cache) + if not isinstance(fh, types.InputDocument): + attributes, mime_type = utils.get_attributes( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note + ) + r = await self._client(functions.messages.UploadMediaRequest( + types.InputPeerEmpty(), media=types.InputMediaUploadedDocument( + fh, + mime_type=mime_type, + attributes=attributes, + nosound_video=None, + thumb=None + ))) + fh = utils.get_input_document(r.document) + + result = types.InputBotInlineResultDocument( + id=id or '', + type=type, + document=fh, + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ), + title=title, + description=description + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + async def game( + self, short_name, *, id=None, + text=None, parse_mode=utils.Default, link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates a new inline result of game type. + + Args: + short_name (`str`): + The short name of the game to use. + """ + result = types.InputBotInlineResultGame( + id=id or '', + short_name=short_name, + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ) + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + async def _message( + self, *, + text=None, parse_mode=utils.Default, link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + if sum(1 for x in (text, geo, contact, game) if x) != 1: + raise ValueError('Can only use one of text, geo, contact or game') + + markup = self._client._build_reply_markup(buttons, inline_only=True) + if text: + text, msg_entities = await self._client._parse_message_text( + text, parse_mode + ) + return types.InputBotInlineMessageText( + message=text, + no_webpage=not link_preview, + entities=msg_entities, + reply_markup=markup + ) + elif isinstance(geo, (types.InputGeoPoint, types.GeoPoint)): + return types.InputBotInlineMessageMediaGeo( + geo_point=utils.get_input_geo(geo), + period=period, + reply_markup=markup + ) + elif isinstance(geo, (types.InputMediaVenue, types.MessageMediaVenue)): + if isinstance(geo, types.InputMediaVenue): + geo_point = geo.geo_point + else: + geo_point = geo.geo + + return types.InputBotInlineMessageMediaVenue( + geo_point=geo_point, + title=geo.title, + address=geo.address, + provider=geo.provider, + venue_id=geo.venue_id, + venue_type=geo.venue_type, + reply_markup=markup + ) + elif isinstance(contact, ( + types.InputMediaContact, types.MessageMediaContact)): + return types.InputBotInlineMessageMediaContact( + phone_number=contact.phone_number, + first_name=contact.first_name, + last_name=contact.last_name, + vcard=contact.vcard, + reply_markup=markup + ) + elif game: + return types.InputBotInlineMessageGame( + reply_markup=markup + ) + else: + raise ValueError('No text, game or valid geo or contact given') diff --git a/telethon/utils.py b/telethon/utils.py index 18aab14d..e5bdb614 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -29,10 +29,18 @@ from .tl.types import ( FileLocationUnavailable, InputMediaUploadedDocument, ChannelFull, InputMediaUploadedPhoto, DocumentAttributeFilename, photos, TopPeer, InputNotifyPeer, InputMessageID, InputFileLocation, - InputDocumentFileLocation, PhotoSizeEmpty, InputDialogPeer + InputDocumentFileLocation, PhotoSizeEmpty, InputDialogPeer, + DocumentAttributeAudio, DocumentAttributeVideo ) from .tl.types.contacts import ResolvedPeer +try: + import hachoir + import hachoir.metadata + import hachoir.parser +except ImportError: + hachoir = None + USERNAME_RE = re.compile( r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?' ) @@ -424,6 +432,77 @@ def get_message_id(message): raise TypeError('Invalid message type: {}'.format(type(message))) +def get_attributes(file, *, attributes=None, mime_type=None, + force_document=False, voice_note=False, video_note=False): + """ + Get a list of attributes for the given file and + the mime type as a tuple ([attribute], mime_type). + """ + if isinstance(file, str): + # Determine mime-type and attributes + # Take the first element by using [0] since it returns a tuple + if mime_type is None: + mime_type = mimetypes.guess_type(file)[0] + + attr_dict = {DocumentAttributeFilename: + DocumentAttributeFilename(os.path.basename(file))} + + if is_audio(file) and hachoir is not None: + with hachoir.parser.createParser(file) as parser: + m = hachoir.metadata.extractMetadata(parser) + attr_dict[DocumentAttributeAudio] = \ + DocumentAttributeAudio( + voice=voice_note, + title=m.get('title') if m.has('title') else None, + performer=m.get('author') if m.has('author') else None, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + + if not force_document and is_video(file): + if hachoir: + with hachoir.parser.createParser(file) as parser: + m = hachoir.metadata.extractMetadata(parser) + doc = DocumentAttributeVideo( + round_message=video_note, + w=m.get('width') if m.has('width') else 0, + h=m.get('height') if m.has('height') else 0, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + else: + doc = DocumentAttributeVideo( + 0, 1, 1, round_message=video_note) + + attr_dict[DocumentAttributeVideo] = doc + else: + attr_dict = {DocumentAttributeFilename: + DocumentAttributeFilename( + os.path.basename(getattr(file, 'name', None) or 'unnamed'))} + + if voice_note: + if DocumentAttributeAudio in attr_dict: + attr_dict[DocumentAttributeAudio].voice = True + else: + attr_dict[DocumentAttributeAudio] = \ + DocumentAttributeAudio(0, voice=True) + + # Now override the attributes if any. As we have a dict of + # {cls: instance}, we can override any class with the list + # of attributes provided by the user easily. + if attributes: + for a in attributes: + attr_dict[type(a)] = a + + # Ensure we have a mime type, any; but it cannot be None + # 'The "octet-stream" subtype is used to indicate that a body + # contains arbitrary binary data.' + if not mime_type: + mime_type = 'application/octet-stream' + + return list(attr_dict.values()), mime_type + + def sanitize_parse_mode(mode): """ Converts the given parse mode into an object with From 7f78d7ed2f356da48461eb88b1cc569582297eac Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 19 Jul 2018 01:47:32 +0200 Subject: [PATCH 24/70] Use classmethod for all Event.build --- telethon/events/callbackquery.py | 6 +-- telethon/events/chataction.py | 78 +++++++++++++++---------------- telethon/events/common.py | 4 +- telethon/events/inlinequery.py | 6 +-- telethon/events/messagedeleted.py | 8 ++-- telethon/events/messageedited.py | 6 +-- telethon/events/messageread.py | 24 +++++----- telethon/events/newmessage.py | 14 +++--- telethon/events/raw.py | 4 +- telethon/events/userupdate.py | 8 ++-- 10 files changed, 79 insertions(+), 79 deletions(-) diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index d9051347..ef01a341 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -40,11 +40,11 @@ class CallbackQuery(EventBuilder): else: raise TypeError('Invalid data type given') - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, (types.UpdateBotCallbackQuery, types.UpdateInlineBotCallbackQuery)): - event = CallbackQuery.Event(update) + event = cls.Event(update) else: return diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index 1a36b931..59300317 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -8,24 +8,24 @@ class ChatAction(EventBuilder): """ Represents an action in a chat (such as user joined, left, or new pin). """ - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, types.UpdateChannelPinnedMessage) and update.id == 0: # Telegram does not always send # UpdateChannelPinnedMessage for new pins # but always for unpin, with update.id = 0 - event = ChatAction.Event(types.PeerChannel(update.channel_id), - unpin=True) + event = cls.Event(types.PeerChannel(update.channel_id), + unpin=True) elif isinstance(update, types.UpdateChatParticipantAdd): - event = ChatAction.Event(types.PeerChat(update.chat_id), - added_by=update.inviter_id or True, - users=update.user_id) + event = cls.Event(types.PeerChat(update.chat_id), + added_by=update.inviter_id or True, + users=update.user_id) elif isinstance(update, types.UpdateChatParticipantDelete): - event = ChatAction.Event(types.PeerChat(update.chat_id), - kicked_by=True, - users=update.user_id) + event = cls.Event(types.PeerChat(update.chat_id), + kicked_by=True, + users=update.user_id) elif (isinstance(update, ( types.UpdateNewMessage, types.UpdateNewChannelMessage)) @@ -33,46 +33,46 @@ class ChatAction(EventBuilder): msg = update.message action = update.message.action if isinstance(action, types.MessageActionChatJoinedByLink): - event = ChatAction.Event(msg, - added_by=True, - users=msg.from_id) + event = cls.Event(msg, + added_by=True, + users=msg.from_id) elif isinstance(action, types.MessageActionChatAddUser): # If an user adds itself, it means they joined added_by = ([msg.from_id] == action.users) or msg.from_id - event = ChatAction.Event(msg, - added_by=added_by, - users=action.users) + event = cls.Event(msg, + added_by=added_by, + users=action.users) elif isinstance(action, types.MessageActionChatDeleteUser): - event = ChatAction.Event(msg, - kicked_by=msg.from_id or True, - users=action.user_id) + event = cls.Event(msg, + kicked_by=msg.from_id or True, + users=action.user_id) elif isinstance(action, types.MessageActionChatCreate): - event = ChatAction.Event(msg, - users=action.users, - created=True, - new_title=action.title) + event = cls.Event(msg, + users=action.users, + created=True, + new_title=action.title) elif isinstance(action, types.MessageActionChannelCreate): - event = ChatAction.Event(msg, - created=True, - users=msg.from_id, - new_title=action.title) + event = cls.Event(msg, + created=True, + users=msg.from_id, + new_title=action.title) elif isinstance(action, types.MessageActionChatEditTitle): - event = ChatAction.Event(msg, - users=msg.from_id, - new_title=action.title) + event = cls.Event(msg, + users=msg.from_id, + new_title=action.title) elif isinstance(action, types.MessageActionChatEditPhoto): - event = ChatAction.Event(msg, - users=msg.from_id, - new_photo=action.photo) + event = cls.Event(msg, + users=msg.from_id, + new_photo=action.photo) elif isinstance(action, types.MessageActionChatDeletePhoto): - event = ChatAction.Event(msg, - users=msg.from_id, - new_photo=True) + event = cls.Event(msg, + users=msg.from_id, + new_photo=True) elif isinstance(action, types.MessageActionPinMessage): # Telegram always sends this service message for new pins - event = ChatAction.Event(msg, - users=msg.from_id, - new_pin=msg.reply_to_msg_id) + event = cls.Event(msg, + users=msg.from_id, + new_pin=msg.reply_to_msg_id) else: return else: diff --git a/telethon/events/common.py b/telethon/events/common.py index 6af00a63..23b018e1 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -59,9 +59,9 @@ class EventBuilder(abc.ABC): self.blacklist_chats = blacklist_chats self._self_id = None - @staticmethod + @classmethod @abc.abstractmethod - def build(update): + def build(cls, update): """Builds an event for the given update if possible, or returns None""" async def resolve(self, client): diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py index ea6f45c2..3c163137 100644 --- a/telethon/events/inlinequery.py +++ b/telethon/events/inlinequery.py @@ -43,10 +43,10 @@ class InlineQuery(EventBuilder): else: raise TypeError('Invalid pattern type given') - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, types.UpdateBotInlineQuery): - event = InlineQuery.Event(update) + event = cls.Event(update) else: return diff --git a/telethon/events/messagedeleted.py b/telethon/events/messagedeleted.py index ffcb5c23..f13ed6ee 100644 --- a/telethon/events/messagedeleted.py +++ b/telethon/events/messagedeleted.py @@ -7,15 +7,15 @@ class MessageDeleted(EventBuilder): """ Event fired when one or more messages are deleted. """ - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, types.UpdateDeleteMessages): - event = MessageDeleted.Event( + event = cls.Event( deleted_ids=update.messages, peer=None ) elif isinstance(update, types.UpdateDeleteChannelMessages): - event = MessageDeleted.Event( + event = cls.Event( deleted_ids=update.messages, peer=types.PeerChannel(update.channel_id) ) diff --git a/telethon/events/messageedited.py b/telethon/events/messageedited.py index 8bb121d3..3694251f 100644 --- a/telethon/events/messageedited.py +++ b/telethon/events/messageedited.py @@ -8,11 +8,11 @@ class MessageEdited(NewMessage): """ Event fired when a message has been edited. """ - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, (types.UpdateEditMessage, types.UpdateEditChannelMessage)): - event = MessageEdited.Event(update.message) + event = cls.Event(update.message) else: return diff --git a/telethon/events/messageread.py b/telethon/events/messageread.py index 91496c40..1788add0 100644 --- a/telethon/events/messageread.py +++ b/telethon/events/messageread.py @@ -18,25 +18,25 @@ class MessageRead(EventBuilder): super().__init__(chats, blacklist_chats) self.inbox = inbox - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, types.UpdateReadHistoryInbox): - event = MessageRead.Event(update.peer, update.max_id, False) + event = cls.Event(update.peer, update.max_id, False) elif isinstance(update, types.UpdateReadHistoryOutbox): - event = MessageRead.Event(update.peer, update.max_id, True) + event = cls.Event(update.peer, update.max_id, True) elif isinstance(update, types.UpdateReadChannelInbox): - event = MessageRead.Event(types.PeerChannel(update.channel_id), + event = cls.Event(types.PeerChannel(update.channel_id), update.max_id, False) elif isinstance(update, types.UpdateReadChannelOutbox): - event = MessageRead.Event(types.PeerChannel(update.channel_id), - update.max_id, True) + event = cls.Event(types.PeerChannel(update.channel_id), + update.max_id, True) elif isinstance(update, types.UpdateReadMessagesContents): - event = MessageRead.Event(message_ids=update.messages, - contents=True) + event = cls.Event(message_ids=update.messages, + contents=True) elif isinstance(update, types.UpdateChannelReadMessagesContents): - event = MessageRead.Event(types.PeerChannel(update.channel_id), - message_ids=update.messages, - contents=True) + event = cls.Event(types.PeerChannel(update.channel_id), + message_ids=update.messages, + contents=True) else: return diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index 02034824..44b688d6 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -75,15 +75,15 @@ class NewMessage(EventBuilder): await super().resolve(client) self.from_users = await _into_id_set(client, self.from_users) - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, (types.UpdateNewMessage, types.UpdateNewChannelMessage)): if not isinstance(update.message, types.Message): return # We don't care about MessageService's here - event = NewMessage.Event(update.message) + event = cls.Event(update.message) elif isinstance(update, types.UpdateShortMessage): - event = NewMessage.Event(types.Message( + event = cls.Event(types.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, @@ -92,9 +92,9 @@ class NewMessage(EventBuilder): # Note that to_id/from_id complement each other in private # messages, depending on whether the message was outgoing. to_id=types.PeerUser( - update.user_id if update.out else EventBuilder.self_id + update.user_id if update.out else cls.self_id ), - from_id=EventBuilder.self_id if update.out else update.user_id, + from_id=cls.self_id if update.out else update.user_id, message=update.message, date=update.date, fwd_from=update.fwd_from, @@ -103,7 +103,7 @@ class NewMessage(EventBuilder): entities=update.entities )) elif isinstance(update, types.UpdateShortChatMessage): - event = NewMessage.Event(types.Message( + event = cls.Event(types.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, diff --git a/telethon/events/raw.py b/telethon/events/raw.py index befce28f..229fdd53 100644 --- a/telethon/events/raw.py +++ b/telethon/events/raw.py @@ -25,8 +25,8 @@ class Raw(EventBuilder): async def resolve(self, client): pass - @staticmethod - def build(update): + @classmethod + def build(cls, update): return update def filter(self, event): diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py index 5a67d40b..925b353b 100644 --- a/telethon/events/userupdate.py +++ b/telethon/events/userupdate.py @@ -9,11 +9,11 @@ class UserUpdate(EventBuilder): """ Represents an user update (gone online, offline, joined Telegram). """ - @staticmethod - def build(update): + @classmethod + def build(cls, update): if isinstance(update, types.UpdateUserStatus): - event = UserUpdate.Event(update.user_id, - status=update.status) + event = cls.Event(update.user_id, + status=update.status) else: return From 4027ac6a6ff3bbe7be86409a2d8179f3eab4a0a7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 19 Jul 2018 20:38:53 +0200 Subject: [PATCH 25/70] Wrap socket with SSL after connecting See https://github.com/Anorov/PySocks/issues/29 --- telethon/extensions/tcpclient.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/telethon/extensions/tcpclient.py b/telethon/extensions/tcpclient.py index 2b9ee03d..a15d7ca6 100644 --- a/telethon/extensions/tcpclient.py +++ b/telethon/extensions/tcpclient.py @@ -92,14 +92,18 @@ class TcpClient: try: if self._socket is None: self._socket = self._create_socket(mode, self.proxy) - if self.ssl and port == SSL_PORT: - self._socket = ssl.wrap_socket(self._socket, **self.ssl) + wrap_ssl = self.ssl and port == SSL_PORT + else: + wrap_ssl = False await asyncio.wait_for( self._loop.sock_connect(self._socket, address), timeout=self.timeout, loop=self._loop ) + if wrap_ssl: + self._socket = ssl.wrap_socket(self._socket, **self.ssl) + self._closed.clear() except OSError as e: if e.errno in CONN_RESET_ERRNOS: From aa67f107af219def76c842e970c8e97d9bad8b9e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 19 Jul 2018 20:56:45 +0200 Subject: [PATCH 26/70] Temporarily use blocking SSL sockets on connect --- telethon/extensions/tcpclient.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/telethon/extensions/tcpclient.py b/telethon/extensions/tcpclient.py index a15d7ca6..716c4066 100644 --- a/telethon/extensions/tcpclient.py +++ b/telethon/extensions/tcpclient.py @@ -102,7 +102,12 @@ class TcpClient: loop=self._loop ) if wrap_ssl: - self._socket = ssl.wrap_socket(self._socket, **self.ssl) + # Temporarily set the socket to blocking + # (timeout) until connection is established. + self._socket.settimeout(self.timeout) + self._socket = ssl.wrap_socket( + self._socket, do_handshake_on_connect=True, **self.ssl) + self._socket.setblocking(False) self._closed.clear() except OSError as e: From 13437cc3f2a58eddd2c36a885467eb6df7d2ce58 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 20 Jul 2018 12:24:48 +0200 Subject: [PATCH 27/70] Fix infinite recursion for custom.Message.message --- telethon/tl/custom/message.py | 41 ++++++++++++++++------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index e40f210f..0274c850 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -105,24 +105,23 @@ class Message(ChatGetter, SenderGetter): The message text, formatted using the client's default parse mode. Will be ``None`` for :tl:`MessageService`. """ - if self._text is None\ - and isinstance(self.original_message, types.Message): + if self._text is None and 'message' in self.__dict__: if not self._client.parse_mode: - return self.original_message.message + return self.__dict__['message'] self._text = self._client.parse_mode.unparse( - self.original_message.message, self.original_message.entities) + self.__dict__['message'], self.__dict__.get('entities')) + return self._text @text.setter def text(self, value): - if isinstance(self.original_message, types.Message): - if self._client.parse_mode: - msg, ent = self._client.parse_mode.parse(value) - else: - msg, ent = value, [] - self.original_message.message = msg - self.original_message.entities = ent - self._text = value + if self._client.parse_mode: + msg, ent = self._client.parse_mode.parse(value) + else: + msg, ent = value, [] + self.__dict__['message'] = msg + self.__dict__['entities'] = ent + self._text = value @property def raw_text(self): @@ -130,15 +129,13 @@ class Message(ChatGetter, SenderGetter): The raw message text, ignoring any formatting. Will be ``None`` for :tl:`MessageService`. """ - if isinstance(self.original_message, types.Message): - return self.original_message.message + return self.__dict__.get('message') @raw_text.setter def raw_text(self, value): - if isinstance(self.original_message, types.Message): - self.original_message.message = value - self.original_message.entities = [] - self._text = None + self.__dict__['message'] = value + self.__dict__['entities'] = [] + self._text = None @property def message(self): @@ -515,14 +512,14 @@ class Message(ChatGetter, SenderGetter): >>> for _, inner_text in m.get_entities_text(MessageEntityCode): >>> print(inner_text) """ - if not self.original_message.entities: + ent = self.__dict__.get('entities') + if not ent: return [] - ent = self.original_message.entities - if cls and ent: + if cls: ent = [c for c in ent if isinstance(c, cls)] - texts = get_inner_text(self.original_message.message, ent) + texts = get_inner_text(self.__dict__.get('message'), ent) return list(zip(ent, texts)) async def click(self, i=None, j=None, From 5a9a00e7ae968104c26eee501f9ec966c11ce617 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 11:24:20 +0200 Subject: [PATCH 28/70] Assume exported auths last forever This implies that export senders will NOT be deleted from memory once all borrows are returned, thus their auth_key remains as well. When borrowing them if they existed they will be connect()ed if it's the first borrow. This probably fixes #901. --- telethon/client/telegrambaseclient.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 700054f4..8f9854a5 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -433,6 +433,9 @@ class TelegramBaseClient(abc.ABC): if not sender: sender = await self._create_exported_sender(dc_id) sender.dc_id = dc_id + elif not n: + dc = await self._get_dc(dc_id) + await sender.connect(dc.ip_address, dc.port) self._borrowed_senders[dc_id] = (n + 1, sender) @@ -447,12 +450,10 @@ class TelegramBaseClient(abc.ABC): dc_id = sender.dc_id n, _ = self._borrowed_senders[dc_id] n -= 1 - if n > 0: - self._borrowed_senders[dc_id] = (n, sender) - else: + self._borrowed_senders[dc_id] = (n, sender) + if not n: __log__.info('Disconnecting borrowed sender for DC %d', dc_id) await sender.disconnect() - del self._borrowed_senders[dc_id] async def _get_cdn_client(self, cdn_redirect): """Similar to ._borrow_exported_client, but for CDNs""" From a9cc35e604aad58954bf61b425a408e4203109c3 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 11:59:44 +0200 Subject: [PATCH 29/70] Avoid triggering FileMigrateError when possible --- telethon/client/downloads.py | 11 +++++++---- telethon/sessions/memory.py | 4 ++++ telethon/utils.py | 17 +++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index ad2611b3..e2f1a382 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -200,10 +200,13 @@ class DownloadMethods(UserMethods): else: f = file - # The used sender will change if ``FileMigrateError`` occurs - sender = self._sender - exported = False - input_location = utils.get_input_location(input_location) + dc_id, input_location = utils.get_input_location(input_location) + exported = dc_id and self.session.dc_id != dc_id + if exported: + sender = await self._borrow_exported_sender(dc_id) + else: + # The used sender will also change if ``FileMigrateError`` occurs + sender = self._sender __log__.info('Downloading file in chunks of %d bytes', part_size) try: diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index acd09a77..a6c70b97 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -42,6 +42,10 @@ class MemorySession(Session): self._server_address = server_address self._port = port + @property + def dc_id(self): + return self._dc_id + @property def server_address(self): return self._server_address diff --git a/telethon/utils.py b/telethon/utils.py index e5bdb614..b5affe9b 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -537,10 +537,15 @@ def sanitize_parse_mode(mode): def get_input_location(location): - """Similar to :meth:`get_input_peer`, but for input messages.""" + """ + Similar to :meth:`get_input_peer`, but for input messages. + + Note that this returns a tuple ``(dc_id, location)``, the + ``dc_id`` being present if known. + """ try: if location.SUBCLASS_OF_ID == 0x1523d462: - return location # crc32(b'InputFileLocation'): + return None, location # crc32(b'InputFileLocation'): except AttributeError: _raise_cast_fail(location, 'InputFileLocation') @@ -553,8 +558,8 @@ def get_input_location(location): location = location.photo if isinstance(location, Document): - return InputDocumentFileLocation( - location.id, location.access_hash, location.version) + return (location.dc_id, InputDocumentFileLocation( + location.id, location.access_hash, location.version)) elif isinstance(location, Photo): try: location = next(x for x in reversed(location.sizes) @@ -563,8 +568,8 @@ def get_input_location(location): pass if isinstance(location, (FileLocation, FileLocationUnavailable)): - return InputFileLocation( - location.volume_id, location.local_id, location.secret) + return (getattr(location, 'dc_id', None), InputFileLocation( + location.volume_id, location.local_id, location.secret)) _raise_cast_fail(location, 'InputFileLocation') From 24758b82ecb0b423bb06d842bc6681fa80e7d4de Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 12:25:20 +0200 Subject: [PATCH 30/70] Don't make a request at all if it will trigger flood wait --- telethon/client/telegrambaseclient.py | 3 +++ telethon/client/users.py | 21 +++++++++++++++++++++ telethon/tl/tlobject.py | 3 +++ 3 files changed, 27 insertions(+) diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 8f9854a5..2d24a5d7 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -226,6 +226,9 @@ class TelegramBaseClient(abc.ABC): auto_reconnect_callback=self._handle_auto_reconnect ) + # Remember flood-waited requests to avoid making them again + self._flood_waited_requests = {} + # Cache ``{dc_id: (n, MTProtoSender)}`` for all borrowed senders, # being ``n`` the amount of borrows a given sender has; once ``n`` # reaches ``0`` it should be disconnected and removed. diff --git a/telethon/client/users.py b/telethon/client/users.py index 413657b2..f72eac1b 100644 --- a/telethon/client/users.py +++ b/telethon/client/users.py @@ -18,6 +18,20 @@ class UserMethods(TelegramBaseClient): raise _NOT_A_REQUEST await r.resolve(self, utils) + # Avoid making the request if it's already in a flood wait + if r.CONSTRUCTOR_ID in self._flood_waited_requests: + due = self._flood_waited_requests[r.CONSTRUCTOR_ID] + diff = round(due - time.time()) + if diff <= 3: # Flood waits below 3 seconds are "ignored" + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + elif diff <= self.flood_sleep_threshold: + __log__.info('Sleeping early for %ds on flood wait', diff) + await asyncio.sleep(diff, loop=self._loop) + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + else: + raise errors.FloodWaitError(capture=diff) + + request_index = 0 self._last_request = time.time() for _ in range(self._request_retries): try: @@ -28,6 +42,7 @@ class UserMethods(TelegramBaseClient): result = await f self.session.process_entities(result) results.append(result) + request_index += 1 return results else: result = await future @@ -37,6 +52,12 @@ class UserMethods(TelegramBaseClient): __log__.warning('Telegram is having internal issues %s: %s', e.__class__.__name__, e) except (errors.FloodWaitError, errors.FloodTestPhoneWaitError) as e: + if utils.is_list_like(request): + request = request[request_index] + + self._flood_waited_requests\ + [request.CONSTRUCTOR_ID] = time.time() + e.seconds + if e.seconds <= self.flood_sleep_threshold: __log__.info('Sleeping for %ds on flood wait', e.seconds) await asyncio.sleep(e.seconds, loop=self._loop) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index cc0d9ab3..16bf4221 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -3,6 +3,9 @@ from datetime import datetime, date, timedelta class TLObject: + CONSTRUCTOR_ID = None + SUBCLASS_OF_ID = None + @staticmethod def pretty_format(obj, indent=None): """ From 7750c9ff2f1e09083d6c11600143b8a825ae1b70 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 13:24:32 +0200 Subject: [PATCH 31/70] Make sure to not add callbacks from buttons= twice --- telethon/client/buttons.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py index 7e64c164..210c17f5 100644 --- a/telethon/client/buttons.py +++ b/telethon/client/buttons.py @@ -31,6 +31,9 @@ class ButtonMethods(UpdateMethods): is_normal |= not inline if isinstance(button, custom.Button): if button.callback: + self.remove_event_handler( + button.callback, events.CallbackQuery) + self.add_event_handler( button.callback, events.CallbackQuery(data=button.data) From 3bdfd4b32cf211e7a708e42b55dd8768388806c9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 13:54:36 +0200 Subject: [PATCH 32/70] Make build_reply_markup public --- telethon/client/buttons.py | 13 ++++++++++++- telethon/client/messages.py | 6 +++--- telethon/client/uploads.py | 2 +- telethon/tl/custom/inline.py | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py index 210c17f5..49947dbf 100644 --- a/telethon/client/buttons.py +++ b/telethon/client/buttons.py @@ -4,7 +4,18 @@ from .. import utils, events class ButtonMethods(UpdateMethods): - def _build_reply_markup(self, buttons, inline_only=False): + def build_reply_markup(self, buttons, inline_only=False): + """ + Builds a :tl`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for + the given buttons, or does nothing if either no buttons are + provided or the provided argument is already a reply markup. + + This will add any event handlers defined in the + buttons and delete old ones not to call them twice, + so you should probably call this method manually for + serious bots instead re-adding handlers every time you + send a message. Magic can only go so far. + """ if buttons is None: return None diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 62b6ef10..eaf57f25 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -435,7 +435,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): if buttons is None: markup = message.reply_markup else: - markup = self._build_reply_markup(buttons) + markup = self.build_reply_markup(buttons) if silent is None: silent = message.silent @@ -463,7 +463,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): reply_to_msg_id=utils.get_message_id(reply_to), clear_draft=clear_draft, silent=silent, - reply_markup=self._build_reply_markup(buttons) + reply_markup=self.build_reply_markup(buttons) ) result = await self(request) @@ -630,7 +630,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): no_webpage=not link_preview, entities=msg_entities, media=media, - reply_markup=self._build_reply_markup(buttons) + reply_markup=self.build_reply_markup(buttons) ) msg = self._get_response_message(request, await self(request), entity) self._cache_media(msg, file, file_handle) diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index f7daaf7b..09577ecb 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -167,7 +167,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): voice_note=voice_note, video_note=video_note ) - markup = self._build_reply_markup(buttons) + markup = self.build_reply_markup(buttons) request = functions.messages.SendMediaRequest( entity, media, reply_to_msg_id=reply_to, message=caption, entities=msg_entities, reply_markup=markup, silent=silent diff --git a/telethon/tl/custom/inline.py b/telethon/tl/custom/inline.py index c953ad32..9ee244b9 100644 --- a/telethon/tl/custom/inline.py +++ b/telethon/tl/custom/inline.py @@ -253,7 +253,7 @@ class InlineBuilder: if sum(1 for x in (text, geo, contact, game) if x) != 1: raise ValueError('Can only use one of text, geo, contact or game') - markup = self._client._build_reply_markup(buttons, inline_only=True) + markup = self._client.build_reply_markup(buttons, inline_only=True) if text: text, msg_entities = await self._client._parse_message_text( text, parse_mode From 3d7bff64c216fa3d100fef17cc6bc53a81dae13f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 15:29:09 +0200 Subject: [PATCH 33/70] Update to v1.1 --- readthedocs/extra/changelog.rst | 109 ++++++++++++++++++ .../extra/examples/telegram-client.rst | 3 + readthedocs/telethon.events.rst | 13 +++ telethon/version.py | 2 +- 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/readthedocs/extra/changelog.rst b/readthedocs/extra/changelog.rst index fe0b50a2..41dee711 100644 --- a/readthedocs/extra/changelog.rst +++ b/readthedocs/extra/changelog.rst @@ -14,6 +14,115 @@ it can take advantage of new goodies! .. contents:: List of All Versions +Bot Friendly (v1.1) +=================== + +*Published at 2018/07/21* + +Two new event handlers to ease creating normal bots with the library, +namely `events.InlineQuery ` +and `events.CallbackQuery ` +for handling ``@InlineBot queries`` or reacting to a button click. For +this second option, there is an even better way: + +.. code-block:: python + + from telethon.tl.custom import Button + + async def callback(event): + await event.edit('Thank you!') + + bot.send_message(chat, 'Hello!', + buttons=Button.inline('Click me', callback)) + + +You can directly pass the callback when creating the button. + +This is fine for small bots but it will add the callback every time +you send a message, so you probably should do this instead once you +are done testing: + +.. code-block:: python + + markup = bot.build_reply_markup(Button.inline('Click me', callback)) + bot.send_message(chat, 'Hello!', buttons=markup) + + +And yes, you can create more complex button layouts with lists: + +.. code-block:: python + + from telethon import events + + global phone = '' + + @bot.on(events.CallbackQuery) + async def handler(event): + global phone + if event.data == b'<': + phone = phone[:-1] + else: + phone += event.data.decode('utf-8') + + await event.answer('Phone is now {}'.format(phone)) + + markup = bot.build_reply_markup([ + [Button.inline('1'), Button.inline('2'), Button.inline('3')], + [Button.inline('4'), Button.inline('5'), Button.inline('6')], + [Button.inline('7'), Button.inline('8'), Button.inline('9')], + [Button.inline('+'), Button.inline('0'), Button.inline('<')], + ]) + bot.send_message(chat, 'Enter a phone', buttons=markup) + + +(Yes, there are better ways to do this). Now for the rest of things: + + +Additions +~~~~~~~~~ + +- New `custom.Button ` class + to help you create inline (or normal) reply keyboards. You + must sign in as a bot to use the ``buttons=`` parameters. +- New events usable if you sign in as a bot: `events.InlineQuery + ` and `events.CallbackQuery + `. +- New ``silent`` parameter when sending messages, usable in broadcast channels. +- Documentation now has an entire section dedicate to how to use + the client's friendly methods at :ref:`telegram-client-example`. + +Bug fixes +~~~~~~~~~ + +- Empty ``except`` are no longer used which means + sending a keyboard interrupt should now work properly. +- The ``pts`` of incoming updates could be ``None``. +- UTC timezone information is properly set for read ``datetime``. +- Some infinite recursion bugs in the custom message class. +- :tl:`Updates` was being dispatched to raw handlers when it shouldn't. +- Using proxies and HTTPS connection mode may now work properly. +- Less flood waits when downloading media from different data centers, + and the library will now detect them even before sending requests. + +Enhancements +~~~~~~~~~~~~ + +- Interactive sign in now supports signing in with a bot token. +- ``timedelta`` is now supported where a date is expected, which + means you can e.g. ban someone for ``timedelta(minutes=5)``. +- Events are only built once and reused many times, which should + save quite a few CPU cycles if you have a lot of the same type. +- You can now click inline buttons directly if you know their data. + +Internal changes +~~~~~~~~~~~~~~~~ + +- When downloading media, the right sender is directly + used without previously triggering migrate errors. +- Code reusing for getting the chat and the sender, + which easily enables this feature for new types. + + New HTTP(S) Connection Mode (v1.0.4) ==================================== diff --git a/readthedocs/extra/examples/telegram-client.rst b/readthedocs/extra/examples/telegram-client.rst index 64303fce..bba7066c 100644 --- a/readthedocs/extra/examples/telegram-client.rst +++ b/readthedocs/extra/examples/telegram-client.rst @@ -1,3 +1,6 @@ +.. _telegram-client-example: + + ======================== Examples with the Client ======================== diff --git a/readthedocs/telethon.events.rst b/readthedocs/telethon.events.rst index 0d18415a..deb83e96 100644 --- a/readthedocs/telethon.events.rst +++ b/readthedocs/telethon.events.rst @@ -48,6 +48,19 @@ so all the methods in it can be used from any event builder/event instance. :show-inheritance: +.. automodule:: telethon.events.callbackquery + :members: + :undoc-members: + :show-inheritance: + + + +.. automodule:: telethon.events.inlinequery + :members: + :undoc-members: + :show-inheritance: + + .. automodule:: telethon.events.raw :members: :undoc-members: diff --git a/telethon/version.py b/telethon/version.py index 85dfd0b3..7bf9af2a 100644 --- a/telethon/version.py +++ b/telethon/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '1.0.4' +__version__ = '1.1' From 46b2d910d7ca3e53666515c10086c401827754f9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 21 Jul 2018 17:52:42 +0200 Subject: [PATCH 34/70] Fix logging of functools.partial() callbacks --- telethon/client/updates.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/telethon/client/updates.py b/telethon/client/updates.py index 9092a13b..f568cf27 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -283,16 +283,15 @@ class UpdateMethods(UserMethods): try: await callback(event) except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) __log__.debug( - "Event handler '{}' stopped chain of " - "propagation for event {}." - .format(callback.__name__, - type(event).__name__) + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ ) break except Exception: - __log__.exception('Unhandled exception on {}' - .format(callback.__name__)) + name = getattr(callback, '__name__', repr(callback)) + __log__.exception('Unhandled exception on %s', name) async def _handle_auto_reconnect(self): # Upon reconnection, we want to send getState From a332d29c4c7efff98ca890c2ba3d818f361a5040 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 00:40:39 +0200 Subject: [PATCH 35/70] Fix-up 5a9a00e to handle exporting senders to the same DC --- telethon/client/downloads.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index e2f1a382..e0197241 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -203,7 +203,20 @@ class DownloadMethods(UserMethods): dc_id, input_location = utils.get_input_location(input_location) exported = dc_id and self.session.dc_id != dc_id if exported: - sender = await self._borrow_exported_sender(dc_id) + try: + sender = await self._borrow_exported_sender(dc_id) + except errors.DcIdInvalidError: + # Can't export a sender for the ID we are currently in + config = await self(functions.help.GetConfigRequest()) + for option in config.dc_options: + if option.ip_address == self.session.server_address: + self.session.set_dc( + option.id, option.ip_address, option.port) + self.session.save() + break + + # TODO Figure out why the session may have the wrong DC ID + sender = self._sender else: # The used sender will also change if ``FileMigrateError`` occurs sender = self._sender From e4963237dcfbd2082a8c5e1e88f7d320f2204f0a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 01:08:03 +0200 Subject: [PATCH 36/70] Fix-up a332d29 should not be exported on invalid DC --- telethon/client/downloads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index e0197241..abf7f7c9 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -217,6 +217,7 @@ class DownloadMethods(UserMethods): # TODO Figure out why the session may have the wrong DC ID sender = self._sender + exported = False else: # The used sender will also change if ``FileMigrateError`` occurs sender = self._sender From bc03c29216b5d1ddc401fa4020eb7bd4a459b289 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 11:33:29 +0200 Subject: [PATCH 37/70] Fix logical bugs when getting input peers in custom.Message Such as incorrectly handling InputPeerSelf/InputPeerChat and using self._input_sender when self._input_chat was expected. --- telethon/tl/custom/message.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 0274c850..da07edb5 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -46,7 +46,7 @@ class Message(ChatGetter, SenderGetter): self._sender = entities.get(self._sender_id) if self._sender: self._input_sender = get_input_peer(self._sender) - if not getattr(self._input_sender, 'access_hash', None): + if not getattr(self._input_sender, 'access_hash', True): self._input_sender = None else: self._input_sender = None @@ -64,8 +64,10 @@ class Message(ChatGetter, SenderGetter): self._input_chat = input_chat if not self._input_chat and self._chat: self._input_chat = get_input_peer(self._chat) - if not getattr(self._input_sender, 'access_hash', None): + if not getattr(self._input_chat, 'access_hash', True): # Telegram may omit the hash in updates -> invalid peer + # However things like InputPeerSelf() or InputPeerChat(id) + # are still valid so default to getting "True" on not found self._input_chat = None if getattr(self.original_message, 'fwd_from', None): From 5df46f9ed8d503dae575e9d6247c71408b72bd4f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 12:27:08 +0200 Subject: [PATCH 38/70] (Try to) fix infinite recursion in custom.Message again --- telethon/tl/custom/message.py | 65 ++++++++++++++++------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index da07edb5..9e95a3d8 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -33,8 +33,8 @@ class Message(ChatGetter, SenderGetter): # This way there's no need to worry about get/setattr. self.__dict__ = original.__dict__ self.original_message = original - self.stringify = self.original_message.stringify - self.to_dict = self.original_message.to_dict + self.stringify = original.stringify + self.to_dict = original.to_dict self._client = client self._text = None self._reply_message = None @@ -42,7 +42,7 @@ class Message(ChatGetter, SenderGetter): self._buttons_flat = None self._buttons_count = None - self._sender_id = self.original_message.from_id + self._sender_id = original.from_id self._sender = entities.get(self._sender_id) if self._sender: self._input_sender = get_input_peer(self._sender) @@ -53,13 +53,12 @@ class Message(ChatGetter, SenderGetter): # Determine the right chat where the message # was sent, not *to which ID* it was sent. - if not self.original_message.out \ - and isinstance(self.original_message.to_id, types.PeerUser): + if not original.out and isinstance(original.to_id, types.PeerUser): self._chat_peer = types.PeerUser(self._sender_id) else: - self._chat_peer = self.original_message.to_id + self._chat_peer = original.to_id - self._broadcast = bool(self.original_message.post) + self._broadcast = bool(original.post) self._chat = entities.get(self.chat_id) self._input_chat = input_chat if not self._input_chat and self._chat: @@ -70,9 +69,8 @@ class Message(ChatGetter, SenderGetter): # are still valid so default to getting "True" on not found self._input_chat = None - if getattr(self.original_message, 'fwd_from', None): - self._forward = Forward( - self._client, self.original_message.fwd_from, entities) + if getattr(original, 'fwd_from', None): + self._forward = Forward(self._client, original.fwd_from, entities) else: self._forward = None @@ -122,7 +120,7 @@ class Message(ChatGetter, SenderGetter): else: msg, ent = value, [] self.__dict__['message'] = msg - self.__dict__['entities'] = ent + self.entities = ent self._text = value @property @@ -136,7 +134,7 @@ class Message(ChatGetter, SenderGetter): @raw_text.setter def raw_text(self, value): self.__dict__['message'] = value - self.__dict__['entities'] = [] + self.entities = [] self._text = None @property @@ -157,8 +155,7 @@ class Message(ChatGetter, SenderGetter): The :tl:`MessageAction` for the :tl:`MessageService`. Will be ``None`` for :tl:`Message`. """ - if isinstance(self.original_message, types.MessageService): - return self.original_message.action + return self.__dict__.get('action') # TODO Make a property for via_bot and via_input_bot, as well as get_* async def _reload_message(self): @@ -168,8 +165,7 @@ class Message(ChatGetter, SenderGetter): """ try: chat = await self.get_input_chat() if self.is_channel else None - msg = await self._client.get_messages( - chat, ids=self.original_message.id) + msg = await self._client.get_messages(chat, ids=self.id) except ValueError: return # We may not have the input chat/get message failed if not msg: @@ -186,7 +182,7 @@ class Message(ChatGetter, SenderGetter): @property def is_reply(self): """True if the message is a reply to some other or not.""" - return bool(self.original_message.reply_to_msg_id) + return bool(self.reply_to_msg_id) @property def forward(self): @@ -200,13 +196,12 @@ class Message(ChatGetter, SenderGetter): """ Helper methods to set the buttons given the input sender and chat. """ - if isinstance(self.original_message.reply_markup, ( + if isinstance(self.reply_markup, ( types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): self._buttons = [[ - MessageButton(self._client, button, chat, bot, - self.original_message.id) + MessageButton(self._client, button, chat, bot, self.id) for button in row.buttons - ] for row in self.original_message.reply_markup.rows] + ] for row in self.reply_markup.rows] self._buttons_flat = [x for row in self._buttons for x in row] def _needed_markup_bot(self): @@ -217,7 +212,7 @@ class Message(ChatGetter, SenderGetter): to know what bot we want to start. Raises ``ValueError`` if the bot cannot be found but is needed. Returns ``None`` if it's not needed. """ - for row in self.original_message.reply_markup.rows: + for row in self.reply_markup.rows: for button in row.buttons: if isinstance(button, types.KeyboardButtonSwitchInline): if button.same_peer: @@ -226,7 +221,7 @@ class Message(ChatGetter, SenderGetter): raise ValueError('No input sender') else: return self._client.session.get_input_entity( - self.original_message.via_bot_id) + self.via_bot_id) @property def buttons(self): @@ -237,7 +232,7 @@ class Message(ChatGetter, SenderGetter): if not isinstance(self.original_message, types.Message): return # MessageService and MessageEmpty have no markup - if self._buttons is None and self.original_message.reply_markup: + if self._buttons is None and self.reply_markup: if not self.input_chat: return try: @@ -278,12 +273,12 @@ class Message(ChatGetter, SenderGetter): return 0 if self._buttons_count is None and isinstance( - self.original_message.reply_markup, ( + self.reply_markup, ( types.ReplyInlineMarkup, types.ReplyKeyboardMarkup )): self._buttons_count = sum( 1 - for row in self.original_message.reply_markup.rows + for row in self.reply_markup.rows for _ in row.buttons ) @@ -295,9 +290,9 @@ class Message(ChatGetter, SenderGetter): Returns the media of the message. """ if isinstance(self.original_message, types.Message): - return self.original_message.media + return self.__dict__['media'] elif isinstance(self.original_message, types.MessageService): - action = self.original_message.action + action = self.__dict__['action'] if isinstance(action, types.MessageActionChatEditPhoto): return types.MessageMediaPhoto(action.photo) @@ -407,11 +402,11 @@ class Message(ChatGetter, SenderGetter): will later be cached. """ if self._reply_message is None: - if not self.original_message.reply_to_msg_id: + if not self.reply_to_msg_id: return None self._reply_message = await self._client.get_messages( await self.get_input_chat() if self.is_channel else None, - ids=self.original_message.reply_to_msg_id + ids=self.reply_to_msg_id ) return self._reply_message @@ -431,7 +426,7 @@ class Message(ChatGetter, SenderGetter): `telethon.telegram_client.TelegramClient.send_message` with both ``entity`` and ``reply_to`` already set. """ - kwargs['reply_to'] = self.original_message.id + kwargs['reply_to'] = self.id return await self._client.send_message( await self.get_input_chat(), *args, **kwargs) @@ -445,7 +440,7 @@ class Message(ChatGetter, SenderGetter): this `forward_to` method. Use a `telethon.telegram_client.TelegramClient` instance directly. """ - kwargs['messages'] = self.original_message.id + kwargs['messages'] = self.id kwargs['from_peer'] = await self.get_input_chat() return await self._client.forward_messages(*args, **kwargs) @@ -458,9 +453,9 @@ class Message(ChatGetter, SenderGetter): Returns ``None`` if the message was incoming, or the edited :tl:`Message` otherwise. """ - if self.original_message.fwd_from: + if self.fwd_from: return None - if not self.original_message.out: + if not self.__dict__['out']: if not isinstance(self._chat_peer, types.PeerUser): return None me = await self._client.get_me(input_peer=True) @@ -581,7 +576,7 @@ class Message(ChatGetter, SenderGetter): return await self._client( functions.messages.GetBotCallbackAnswerRequest( peer=self._input_chat, - msg_id=self.original_message.id, + msg_id=self.id, data=data ) ) From a3ac6d164526cff6157e879b3862c600c4d736a2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 13:26:12 +0200 Subject: [PATCH 39/70] Create a common message base class --- telethon/tl/custom/messagebase.py | 149 ++++++++++++++++++++++++++++++ telethon/tl/tlobject.py | 8 +- 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 telethon/tl/custom/messagebase.py diff --git a/telethon/tl/custom/messagebase.py b/telethon/tl/custom/messagebase.py new file mode 100644 index 00000000..94e65d46 --- /dev/null +++ b/telethon/tl/custom/messagebase.py @@ -0,0 +1,149 @@ +import abc +from ..tlobject import TLObject + +# TODO Figure out a way to have the generator error on missing fields +# Maybe parsing the init function alone if that's possible. +class MessageBase(abc.ABC, TLObject): + """ + This custom class aggregates both :tl:`Message` and + :tl:`MessageService` to ease accessing their members. + + Members: + id (`int`): + The ID of this message. This field is *always* present. + Any other member is optional and may be ``None``. + + out (`bool`): + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + + Note that messages in your own chat are always incoming, + but this member will be ``True`` if you send a message + to your own chat. Messages you forward to your chat are + *not* considered outgoing, just like official clients + display them. + + mentioned (`bool`): + Whether you were mentioned in this message or not. + Note that replies to your own messages also count + as mentions. + + media_unread (`bool`): + Whether you have read the media in this message + or not, e.g. listened to the voice note media. + + silent (`bool`): + Whether this message should notify or not, + used in channels. + + post (`bool`): + Whether this message is a post in a broadcast + channel or not. + + to_id (:tl:`Peer`): + The peer to which this message was sent, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This + will always be present except for empty messages. + + date (`datetime`): + The UTC+0 `datetime` object indicating when this message + was sent. This will always be present except for empty + messages. + + message (`str`): + The string text of the message for :tl:`Message` instances, + which will be ``None`` for other types of messages. + + action (:tl:`MessageAction`): + The message action object of the message for :tl:`MessageService` + instances, which will be ``None`` for other types of messages. + + from_id (`int`): + The ID of the user who sent this message. This will be + ``None`` if the message was sent in a broadcast channel. + + reply_to_msg_id (`int`): + The ID to which this message is replying to, if any. + + fwd_from (:tl:`MessageFwdHeader`): + The original forward header if this message is a forward. + You should probably use the `forward` property instead. + + via_bot_id (`int`): + The ID of the bot used to send this message + through its inline mode (e.g. "via @like"). + + media (:tl:`MessageMedia`): + The media sent with this message if any (such as + photos, videos, documents, gifs, stickers, etc.). + + You may want to access the `photo`, `document` + etc. properties instead. + + reply_markup (:tl:`ReplyMarkup`): + The reply markup for this message (which was sent + either via a bot or by a bot). You probably want + to access `buttons` instead. + + entities (List[:tl:`MessageEntity`]): + The list of markup entities in this message, + such as bold, italics, code, hyperlinks, etc. + + views (`int`): + The number of views this message from a broadcast + channel has. This is also present in forwards. + + edit_date (`datetime`): + The date when this message was last edited. + + post_author (`str`): + The display name of the message sender to + show in messages sent to broadcast channels. + + grouped_id (`int`): + If this message belongs to a group of messages + (photo albums or video albums), all of them will + have the same value here. + """ + def __init__( + # Common to all + self, id, + + # Common to Message and MessageService (mandatory) + to_id=None, date=None, + + # Common to Message and MessageService (flags) + out=None, mentioned=None, media_unread=None, silent=None, + post=None, from_id=None, reply_to_msg_id=None, + + # For Message (mandatory) + message=None, + + # For Message (flags) + fwd_from=None, via_bot_id=None, media=None, reply_markup=None, + entities=None, views=None, edit_date=None, post_author=None, + grouped_id=None, + + # For MessageAction (mandatory) + action=None): + self.id = id + self.to_id = to_id + self.date = date + self.out = out + self.mentioned = mentioned + self.media_unread = media_unread + self.silent = silent + self.post = post + self.from_id = from_id + self.reply_to_msg_id = reply_to_msg_id + self.message = message + self.fwd_from = fwd_from + self.via_bot_id = via_bot_id + self.media = media + self.reply_markup = reply_markup + self.entities = entities + self.views = views + self.edit_date = edit_date + self.post_author = post_author + self.grouped_id = grouped_id + self.action = action diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 16bf4221..772a9ea3 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -1,8 +1,9 @@ +import abc import struct from datetime import datetime, date, timedelta -class TLObject: +class TLObject(abc.ABC): CONSTRUCTOR_ID = None SUBCLASS_OF_ID = None @@ -140,18 +141,21 @@ class TLObject: def stringify(self): return TLObject.pretty_format(self, indent=0) + @abc.abstractmethod def to_dict(self): raise NotImplementedError + @abc.abstractmethod def __bytes__(self): raise NotImplementedError @classmethod + @abc.abstractmethod def from_reader(cls, reader): raise NotImplementedError -class TLRequest(TLObject): +class TLRequest(abc.ABC, TLObject): """ Represents a content-related `TLObject` (a request that can be sent). """ From c4e94abcf0dfc8f1a5f1c518d08ac06be5c55104 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 16:49:42 +0200 Subject: [PATCH 40/70] Complete moving properties to the new message base --- telethon/tl/custom/message.py | 622 ------------------------------ telethon/tl/custom/messagebase.py | 556 +++++++++++++++++++++++++- 2 files changed, 551 insertions(+), 627 deletions(-) delete mode 100644 telethon/tl/custom/message.py diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py deleted file mode 100644 index 9e95a3d8..00000000 --- a/telethon/tl/custom/message.py +++ /dev/null @@ -1,622 +0,0 @@ -from .. import types, functions -from ...errors import BotTimeout -from ...utils import get_input_peer, get_inner_text -from .chatgetter import ChatGetter -from .sendergetter import SenderGetter -from .messagebutton import MessageButton -from .forward import Forward - - -class Message(ChatGetter, SenderGetter): - """ - Custom class that encapsulates a message providing an abstraction to - easily access some commonly needed features (such as the markdown text - or the text for a given message entity). - - Remember that this class implements `ChatGetter - ` and `SenderGetter - ` which means you - have access to all their sender and chat properties and methods. - - Attributes: - - original_message (:tl:`Message`): - The original :tl:`Message` object. - - Any other attribute: - Attributes not described here are the same as those available - in the original :tl:`Message`. - """ - def __init__(self, client, original, entities, input_chat): - # Share the original dictionary. Modifications to this - # object should also be reflected in the original one. - # This way there's no need to worry about get/setattr. - self.__dict__ = original.__dict__ - self.original_message = original - self.stringify = original.stringify - self.to_dict = original.to_dict - self._client = client - self._text = None - self._reply_message = None - self._buttons = None - self._buttons_flat = None - self._buttons_count = None - - self._sender_id = original.from_id - self._sender = entities.get(self._sender_id) - if self._sender: - self._input_sender = get_input_peer(self._sender) - if not getattr(self._input_sender, 'access_hash', True): - self._input_sender = None - else: - self._input_sender = None - - # Determine the right chat where the message - # was sent, not *to which ID* it was sent. - if not original.out and isinstance(original.to_id, types.PeerUser): - self._chat_peer = types.PeerUser(self._sender_id) - else: - self._chat_peer = original.to_id - - self._broadcast = bool(original.post) - self._chat = entities.get(self.chat_id) - self._input_chat = input_chat - if not self._input_chat and self._chat: - self._input_chat = get_input_peer(self._chat) - if not getattr(self._input_chat, 'access_hash', True): - # Telegram may omit the hash in updates -> invalid peer - # However things like InputPeerSelf() or InputPeerChat(id) - # are still valid so default to getting "True" on not found - self._input_chat = None - - if getattr(original, 'fwd_from', None): - self._forward = Forward(self._client, original.fwd_from, entities) - else: - self._forward = None - - def __new__(cls, client, original, entities, input_chat): - if isinstance(original, types.Message): - return super().__new__(_CustomMessage) - elif isinstance(original, types.MessageService): - return super().__new__(_CustomMessageService) - else: - return cls - - def __str__(self): - return str(self.original_message) - - def __repr__(self): - return repr(self.original_message) - - def __bytes__(self): - return bytes(self.original_message) - - @property - def client(self): - """ - Returns the `telethon.telegram_client.TelegramClient` instance that - created this instance. - """ - return self._client - - @property - def text(self): - """ - The message text, formatted using the client's default parse mode. - Will be ``None`` for :tl:`MessageService`. - """ - if self._text is None and 'message' in self.__dict__: - if not self._client.parse_mode: - return self.__dict__['message'] - self._text = self._client.parse_mode.unparse( - self.__dict__['message'], self.__dict__.get('entities')) - - return self._text - - @text.setter - def text(self, value): - if self._client.parse_mode: - msg, ent = self._client.parse_mode.parse(value) - else: - msg, ent = value, [] - self.__dict__['message'] = msg - self.entities = ent - self._text = value - - @property - def raw_text(self): - """ - The raw message text, ignoring any formatting. - Will be ``None`` for :tl:`MessageService`. - """ - return self.__dict__.get('message') - - @raw_text.setter - def raw_text(self, value): - self.__dict__['message'] = value - self.entities = [] - self._text = None - - @property - def message(self): - """ - The raw message text, ignoring any formatting. - Will be ``None`` for :tl:`MessageService`. - """ - return self.raw_text - - @message.setter - def message(self, value): - self.raw_text = value - - @property - def action(self): - """ - The :tl:`MessageAction` for the :tl:`MessageService`. - Will be ``None`` for :tl:`Message`. - """ - return self.__dict__.get('action') - - # TODO Make a property for via_bot and via_input_bot, as well as get_* - async def _reload_message(self): - """ - Re-fetches this message to reload the sender and chat entities, - along with their input versions. - """ - try: - chat = await self.get_input_chat() if self.is_channel else None - msg = await self._client.get_messages(chat, ids=self.id) - except ValueError: - return # We may not have the input chat/get message failed - if not msg: - return # The message may be deleted and it will be None - - self._sender = msg._sender - self._input_sender = msg._input_sender - self._chat = msg._chat - self._input_chat = msg._input_chat - - async def _refetch_sender(self): - await self._reload_message() - - @property - def is_reply(self): - """True if the message is a reply to some other or not.""" - return bool(self.reply_to_msg_id) - - @property - def forward(self): - """ - Returns `telethon.tl.custom.forward.Forward` if the message - has been forwarded from somewhere else. - """ - return self._forward - - def _set_buttons(self, chat, bot): - """ - Helper methods to set the buttons given the input sender and chat. - """ - if isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): - self._buttons = [[ - MessageButton(self._client, button, chat, bot, self.id) - for button in row.buttons - ] for row in self.reply_markup.rows] - self._buttons_flat = [x for row in self._buttons for x in row] - - def _needed_markup_bot(self): - """ - Returns the input peer of the bot that's needed for the reply markup. - - This is necessary for :tl:`KeyboardButtonSwitchInline` since we need - to know what bot we want to start. Raises ``ValueError`` if the bot - cannot be found but is needed. Returns ``None`` if it's not needed. - """ - for row in self.reply_markup.rows: - for button in row.buttons: - if isinstance(button, types.KeyboardButtonSwitchInline): - if button.same_peer: - bot = self.input_sender - if not bot: - raise ValueError('No input sender') - else: - return self._client.session.get_input_entity( - self.via_bot_id) - - @property - def buttons(self): - """ - Returns a matrix (list of lists) containing all buttons of the message - as `telethon.tl.custom.messagebutton.MessageButton` instances. - """ - if not isinstance(self.original_message, types.Message): - return # MessageService and MessageEmpty have no markup - - if self._buttons is None and self.reply_markup: - if not self.input_chat: - return - try: - bot = self._needed_markup_bot() - except ValueError: - return - else: - self._set_buttons(self._input_chat, bot) - - return self._buttons - - async def get_buttons(self): - """ - Returns `buttons`, but will make an API call to find the - input chat (needed for the buttons) unless it's already cached. - """ - if not self.buttons and isinstance( - self.original_message, types.Message): - chat = await self.get_input_chat() - if not chat: - return - try: - bot = self._needed_markup_bot() - except ValueError: - await self._reload_message() - bot = self._needed_markup_bot() # TODO use via_input_bot - - self._set_buttons(chat, bot) - - return self._buttons - - @property - def button_count(self): - """ - Returns the total button count. - """ - if not isinstance(self.original_message, types.Message): - return 0 - - if self._buttons_count is None and isinstance( - self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup - )): - self._buttons_count = sum( - 1 - for row in self.reply_markup.rows - for _ in row.buttons - ) - - return self._buttons_count or 0 - - @property - def media(self): - """ - Returns the media of the message. - """ - if isinstance(self.original_message, types.Message): - return self.__dict__['media'] - elif isinstance(self.original_message, types.MessageService): - action = self.__dict__['action'] - if isinstance(action, types.MessageActionChatEditPhoto): - return types.MessageMediaPhoto(action.photo) - - @property - def photo(self): - """ - If the message media is a photo, - this returns the :tl:`Photo` object. - """ - if isinstance(self.media, types.MessageMediaPhoto): - if isinstance(self.media.photo, types.Photo): - return self.media.photo - - @property - def document(self): - """ - If the message media is a document, - this returns the :tl:`Document` object. - """ - if isinstance(self.media, types.MessageMediaDocument): - if isinstance(self.media.document, types.Document): - return self.media.document - - 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 :tl:`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 :tl:`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 :tl:`Document` object. - """ - 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 :tl:`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 :tl:`Document` object. - """ - return self._document_by_attribute(types.DocumentAttributeAnimated) - - @property - def sticker(self): - """ - If the message media is a document with a Sticker attribute, - this returns the :tl:`Document` object. - """ - return self._document_by_attribute(types.DocumentAttributeSticker) - - @property - def out(self): - """ - Whether the message is outgoing (i.e. you sent it from - another session) or incoming (i.e. someone else sent it). - - Note that messages in your own chat are always incoming, - but this property will be ``True`` if you send a message - to your own chat. Messages you forward to your chat are - *not* considered outgoing, just like official clients - display them. - """ - return self.__dict__['out'] - - async def get_reply_message(self): - """ - The `telethon.tl.custom.message.Message` that this message is replying - to, or ``None``. - - Note that this will make a network call to fetch the message and - will later be cached. - """ - if self._reply_message is None: - if not self.reply_to_msg_id: - return None - self._reply_message = await self._client.get_messages( - await self.get_input_chat() if self.is_channel else None, - ids=self.reply_to_msg_id - ) - - return self._reply_message - - async def respond(self, *args, **kwargs): - """ - Responds to the message (not as a reply). Shorthand for - `telethon.telegram_client.TelegramClient.send_message` with - ``entity`` already set. - """ - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) - - async def reply(self, *args, **kwargs): - """ - Replies to the message (as a reply). Shorthand for - `telethon.telegram_client.TelegramClient.send_message` with - both ``entity`` and ``reply_to`` already set. - """ - kwargs['reply_to'] = self.id - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) - - async def forward_to(self, *args, **kwargs): - """ - Forwards the message. Shorthand for - `telethon.telegram_client.TelegramClient.forward_messages` with - both ``messages`` and ``from_peer`` already set. - - If you need to forward more than one message at once, don't use - this `forward_to` method. Use a - `telethon.telegram_client.TelegramClient` instance directly. - """ - kwargs['messages'] = self.id - kwargs['from_peer'] = await self.get_input_chat() - return await self._client.forward_messages(*args, **kwargs) - - async def edit(self, *args, **kwargs): - """ - Edits the message iff it's outgoing. Shorthand for - `telethon.telegram_client.TelegramClient.edit_message` with - both ``entity`` and ``message`` already set. - - Returns ``None`` if the message was incoming, or the edited - :tl:`Message` otherwise. - """ - if self.fwd_from: - return None - if not self.__dict__['out']: - if not isinstance(self._chat_peer, types.PeerUser): - return None - me = await self._client.get_me(input_peer=True) - if self._chat_peer.user_id != me.user_id: - return None - - return await self._client.edit_message( - await self.get_input_chat(), self.original_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. - Shorthand for - `telethon.telegram_client.TelegramClient.delete_messages` with - ``entity`` and ``message_ids`` already set. - - If you need to delete more than one message at once, don't use - this `delete` method. Use a - `telethon.telegram_client.TelegramClient` instance directly. - """ - return await self._client.delete_messages( - await self.get_input_chat(), [self.original_message], - *args, **kwargs - ) - - async def download_media(self, *args, **kwargs): - """ - Downloads the media contained in the message, if any. - `telethon.telegram_client.TelegramClient.download_media` with - the ``message`` already set. - """ - return await self._client.download_media( - self.original_message, *args, **kwargs) - - def get_entities_text(self, cls=None): - """ - Returns a list of tuples [(:tl:`MessageEntity`, `str`)], the string - being the inner text of the message entity (like bold, italics, etc). - - Args: - cls (`type`): - Returns entities matching this type only. For example, - the following will print the text for all ``code`` entities: - - >>> from telethon.tl.types import MessageEntityCode - >>> - >>> m = Message(...) - >>> for _, inner_text in m.get_entities_text(MessageEntityCode): - >>> print(inner_text) - """ - ent = self.__dict__.get('entities') - if not ent: - return [] - - if cls: - ent = [c for c in ent if isinstance(c, cls)] - - texts = get_inner_text(self.__dict__.get('message'), ent) - return list(zip(ent, texts)) - - async def click(self, i=None, j=None, - *, text=None, filter=None, data=None): - """ - Calls `telethon.tl.custom.messagebutton.MessageButton.click` - for the specified button. - - Does nothing if the message has no buttons. - - Args: - i (`int`): - Clicks the i'th button (starting from the index 0). - Will ``raise IndexError`` if out of bounds. Example: - - >>> message = Message(...) - >>> # Clicking the 3rd button - >>> # [button1] [button2] - >>> # [ button3 ] - >>> # [button4] [button5] - >>> message.click(2) # index - - j (`int`): - Clicks the button at position (i, j), these being the - indices for the (row, column) respectively. Example: - - >>> # Clicking the 2nd button on the 1st row. - >>> # [button1] [button2] - >>> # [ button3 ] - >>> # [button4] [button5] - >>> message.click(0, 1) # (row, column) - - This is equivalent to ``message.buttons[0][1].click()``. - - text (`str` | `callable`): - Clicks the first button with the text "text". This may - also be a callable, like a ``re.compile(...).match``, - and the text will be passed to it. - - filter (`callable`): - Clicks the first button for which the callable - returns ``True``. The callable should accept a single - `telethon.tl.custom.messagebutton.MessageButton` argument. - - data (`bytes`): - This argument overrides the rest and will not search any - buttons. Instead, it will directly send the request to - behave as if it clicked a button with said data. Note - that if the message does not have this data, it will - ``raise DataInvalidError``. - """ - if data: - if not await self.get_input_chat(): - return None - - try: - return await self._client( - functions.messages.GetBotCallbackAnswerRequest( - peer=self._input_chat, - msg_id=self.id, - data=data - ) - ) - except BotTimeout: - return None - - if sum(int(x is not None) for x in (i, text, filter)) >= 2: - raise ValueError('You can only set either of i, text or filter') - - if not await self.get_buttons(): - return # Accessing the property sets self._buttons[_flat] - - if text is not None: - if callable(text): - for button in self._buttons_flat: - if text(button.text): - return await button.click() - else: - for button in self._buttons_flat: - if button.text == text: - return await button.click() - return - - if filter is not None: - for button in self._buttons_flat: - if filter(button): - return await button.click() - return - - if i is None: - i = 0 - if j is None: - return await self._buttons_flat[i].click() - else: - return await self._buttons[i][j].click() - - -class _CustomMessage(Message, types.Message): - pass - - -class _CustomMessageService(Message, types.MessageService): - pass diff --git a/telethon/tl/custom/messagebase.py b/telethon/tl/custom/messagebase.py index 94e65d46..f580440f 100644 --- a/telethon/tl/custom/messagebase.py +++ b/telethon/tl/custom/messagebase.py @@ -1,13 +1,23 @@ import abc -from ..tlobject import TLObject +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from .messagebutton import MessageButton +from .forward import Forward +from .. import TLObject, types, functions +from ... import utils, errors -# TODO Figure out a way to have the generator error on missing fields +# TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class MessageBase(abc.ABC, TLObject): +class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): """ - This custom class aggregates both :tl:`Message` and + This custom class aggregates both `MessageBase` and :tl:`MessageService` to ease accessing their members. + Remember that this class implements `ChatGetter + ` and `SenderGetter + ` which means you + have access to all their sender and chat properties and methods. + Members: id (`int`): The ID of this message. This field is *always* present. @@ -51,7 +61,7 @@ class MessageBase(abc.ABC, TLObject): messages. message (`str`): - The string text of the message for :tl:`Message` instances, + The string text of the message for `MessageBase` instances, which will be ``None`` for other types of messages. action (:tl:`MessageAction`): @@ -105,6 +115,9 @@ class MessageBase(abc.ABC, TLObject): (photo albums or video albums), all of them will have the same value here. """ + + # region Initialization + def __init__( # Common to all self, id, @@ -126,6 +139,7 @@ class MessageBase(abc.ABC, TLObject): # For MessageAction (mandatory) action=None): + # Common properties to all messages self.id = id self.to_id = to_id self.date = date @@ -147,3 +161,535 @@ class MessageBase(abc.ABC, TLObject): self.post_author = post_author self.grouped_id = grouped_id self.action = action + + # Convenient storage for custom functions + self._client = None + self._text = None + self._reply_message = None + self._buttons = None + self._buttons_flat = None + self._buttons_count = None + self._sender_id = from_id + self._sender = None + self._input_sender = None + + if not out and isinstance(to_id, types.PeerUser): + self._chat_peer = types.PeerUser(from_id) + if from_id == to_id.user_id: + self.out = not self.fwd_from # Patch out in our chat + else: + self._chat_peer = to_id + + self._broadcast = post + self._chat = None + self._input_chat = None + self._forward = None + + def _finish_init(self, client, entities, input_chat): + """ + Finishes the initialization of this message by setting + the client that sent the message and making use of the + known entities. + """ + self._client = client + self._sender = entities.get(self._sender_id) + if self._sender: + self._input_sender = utils.get_input_peer(self._sender) + if not getattr(self._input_sender, 'access_hash', True): + self._input_sender = None + + self._chat = entities.get(self.chat_id) + self._input_chat = input_chat + if not self._input_chat and self._chat: + self._input_chat = utils.get_input_peer(self._chat) + if not getattr(self._input_chat, 'access_hash', True): + # Telegram may omit the hash in updates -> invalid peer + # However things like InputPeerSelf() or InputPeerChat(id) + # are still valid so default to getting "True" on not found + self._input_chat = None + + if self.fwd_from: + self._forward = Forward(self._client, self.fwd_from, entities) + + # endregion Initialization + + # region Public Properties + + @property + def client(self): + """ + Returns the `telethon.client.telegramclient.TelegramClient` + that patched this message. This will only be present if you + **use the friendly methods**, it won't be there if you invoke + raw API methods manually, in which case you should only access + members, not properties. + """ + return self._client + + @property + def text(self): + """ + The message text, formatted using the client's default + parse mode. Will be ``None`` for :tl:`MessageService`. + """ + if self._text is None and self._client: + self._text = self._client.parse_mode.unparse( + self.message, self.entities) + + return self._text + + @text.setter + def text(self, value): + self._text = value + if self._client and self._client.parse_mode: + self.message, self.entities = self._client.parse_mode.parse(value) + else: + self.message, self.entities = value, [] + + @property + def raw_text(self): + """ + The raw message text, ignoring any formatting. + Will be ``None`` for :tl:`MessageService`. + + Setting a value to this field will erase the + `entities`, unlike changing the `message` member. + """ + return self.message + + @raw_text.setter + def raw_text(self, value): + self.message = value + self.entities = [] + self._text = None + + @property + def is_reply(self): + """ + True if the message is a reply to some other. + + Remember that you can access the ID of the message + this one is replying to through `reply_to_msg_id`, + and the `MessageBase` object with `get_reply_message()`. + """ + return bool(self.reply_to_msg_id) + + @property + def forward(self): + """ + Returns `Forward ` + if the message has been forwarded from somewhere else. + """ + return self._forward + + @property + def buttons(self): + """ + Returns a matrix (list of lists) containing all buttons of the message + as `MessageButton ` + instances. + """ + if self._buttons is None and self.reply_markup: + if not self.input_chat: + return + try: + bot = self._needed_markup_bot() + except ValueError: + return + else: + self._set_buttons(self._input_chat, bot) + + return self._buttons + + async def get_buttons(self): + """ + Returns `buttons`, but will make an API call to find the + input chat (needed for the buttons) unless it's already cached. + """ + if not self.buttons and self.reply_markup: + chat = await self.get_input_chat() + if not chat: + return + try: + bot = self._needed_markup_bot() + except ValueError: + await self._reload_message() + bot = self._needed_markup_bot() # TODO use via_input_bot + + self._set_buttons(chat, bot) + + return self._buttons + + @property + def button_count(self): + """ + Returns the total button count. + """ + if self._buttons_count is None: + if isinstance(self.reply_markup, ( + types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + self._buttons_count = sum( + len(row.buttons) for row in self.reply_markup.rows) + else: + self._buttons_count = 0 + + return self._buttons_count + + @property + def photo(self): + """ + If the message media is a photo, this returns the :tl:`Photo` object. + This will also return the photo for :tl:`MessageService` if their + action is :tl:`MessageActionChatEditPhoto`. + """ + if isinstance(self.media, types.MessageMediaPhoto): + if isinstance(self.media.photo, types.Photo): + return self.media.photo + elif isinstance(self.action, types.MessageActionChatEditPhoto): + return self.action.photo + + @property + def document(self): + """ + If the message media is a document, + this returns the :tl:`Document` object. + """ + if isinstance(self.media, types.MessageMediaDocument): + if isinstance(self.media.document, types.Document): + return self.media.document + + @property + def audio(self): + """ + If the message media is a document with an Audio attribute, + this returns the :tl:`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 :tl:`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 :tl:`Document` object. + """ + 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 :tl:`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 :tl:`Document` object. + """ + return self._document_by_attribute(types.DocumentAttributeAnimated) + + @property + def sticker(self): + """ + If the message media is a document with a Sticker attribute, + this returns the :tl:`Document` object. + """ + return self._document_by_attribute(types.DocumentAttributeSticker) + + # endregion Public Properties + + # region Public Methods + + def get_entities_text(self, cls=None): + """ + Returns a list of tuples [(:tl:`MessageEntity`, `str`)], the string + being the inner text of the message entity (like bold, italics, etc). + + Args: + cls (`type`): + Returns entities matching this type only. For example, + the following will print the text for all ``code`` entities: + + >>> from telethon.tl.types import MessageEntityCode + >>> + >>> m = ... # get the message + >>> for _, inner_text in m.get_entities_text(MessageEntityCode): + >>> print(inner_text) + """ + ent = self.entities + if not ent: + return [] + + if cls: + ent = [c for c in ent if isinstance(c, cls)] + + texts = utils.get_inner_text(self.message, ent) + return list(zip(ent, texts)) + + async def get_reply_message(self): + """ + The `MessageBase` that this message is replying to, or ``None``. + + The result will be cached after its first use. + """ + if self._reply_message is None: + if not self.reply_to_msg_id: + return None + + self._reply_message = await self._client.get_messages( + await self.get_input_chat() if self.is_channel else None, + ids=self.reply_to_msg_id + ) + + return self._reply_message + + async def respond(self, *args, **kwargs): + """ + Responds to the message (not as a reply). Shorthand for + `telethon.client.telegramclient.TelegramClient.send_message` + with ``entity`` already set. + """ + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the message (as a reply). Shorthand for + `telethon.client.telegramclient.TelegramClient.send_message` + with both ``entity`` and ``reply_to`` already set. + """ + kwargs['reply_to'] = self.id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def forward_to(self, *args, **kwargs): + """ + Forwards the message. Shorthand for + `telethon.client.telegramclient.TelegramClient.forward_messages` + with both ``messages`` and ``from_peer`` already set. + + If you need to forward more than one message at once, don't use + this `forward_to` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + """ + kwargs['messages'] = self.id + kwargs['from_peer'] = await self.get_input_chat() + return await self._client.forward_messages(*args, **kwargs) + + async def edit(self, *args, **kwargs): + """ + Edits the message iff it's outgoing. Shorthand for + `telethon.client.telegramclient.TelegramClient.edit_message` + with both ``entity`` and ``message`` already set. + + Returns ``None`` if the message was incoming, + or the edited `MessageBase` otherwise. + """ + if self.fwd_from or not self.out: + return None # We assume self.out was patched for our chat + + return await self._client.edit_message( + await self.get_input_chat(), self.id, + *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. + Shorthand for + `telethon.client.telegramclient.TelegramClient.delete_messages` with + ``entity`` and ``message_ids`` already set. + + If you need to delete more than one message at once, don't use + this `delete` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + """ + return await self._client.delete_messages( + await self.get_input_chat(), [self.id], + *args, **kwargs + ) + + async def download_media(self, *args, **kwargs): + """ + Downloads the media contained in the message, if any. Shorthand + for `telethon.client.telegramclient.TelegramClient.download_media` + with the ``message`` already set. + """ + return await self._client.download_media(self, *args, **kwargs) + + async def click(self, i=None, j=None, + *, text=None, filter=None, data=None): + """ + Calls `telethon.tl.custom.messagebutton.MessageButton.click` + for the specified button. + + Does nothing if the message has no buttons. + + Args: + i (`int`): + Clicks the i'th button (starting from the index 0). + Will ``raise IndexError`` if out of bounds. Example: + + >>> message = ... # get the message somehow + >>> # Clicking the 3rd button + >>> # [button1] [button2] + >>> # [ button3 ] + >>> # [button4] [button5] + >>> message.click(2) # index + + j (`int`): + Clicks the button at position (i, j), these being the + indices for the (row, column) respectively. Example: + + >>> # Clicking the 2nd button on the 1st row. + >>> # [button1] [button2] + >>> # [ button3 ] + >>> # [button4] [button5] + >>> message.click(0, 1) # (row, column) + + This is equivalent to ``message.buttons[0][1].click()``. + + text (`str` | `callable`): + Clicks the first button with the text "text". This may + also be a callable, like a ``re.compile(...).match``, + and the text will be passed to it. + + filter (`callable`): + Clicks the first button for which the callable + returns ``True``. The callable should accept a single + `telethon.tl.custom.messagebutton.MessageButton` argument. + + data (`bytes`): + This argument overrides the rest and will not search any + buttons. Instead, it will directly send the request to + behave as if it clicked a button with said data. Note + that if the message does not have this data, it will + ``raise DataInvalidError``. + """ + if data: + if not await self.get_input_chat(): + return None + + try: + return await self._client( + functions.messages.GetBotCallbackAnswerRequest( + peer=self._input_chat, + msg_id=self.id, + data=data + ) + ) + except errors.BotTimeout: + return None + + if sum(int(x is not None) for x in (i, text, filter)) >= 2: + raise ValueError('You can only set either of i, text or filter') + + if not await self.get_buttons(): + return # Accessing the property sets self._buttons[_flat] + + if text is not None: + if callable(text): + for button in self._buttons_flat: + if text(button.text): + return await button.click() + else: + for button in self._buttons_flat: + if button.text == text: + return await button.click() + return + + if filter is not None: + for button in self._buttons_flat: + if filter(button): + return await button.click() + return + + if i is None: + i = 0 + if j is None: + return await self._buttons_flat[i].click() + else: + return await self._buttons[i][j].click() + + # endregion Public Methods + + # region Private Methods + + # TODO Make a property for via_bot and via_input_bot, as well as get_* + async def _reload_message(self): + """ + Re-fetches this message to reload the sender and chat entities, + along with their input versions. + """ + try: + chat = await self.get_input_chat() if self.is_channel else None + msg = await self._client.get_messages(chat, ids=self.id) + except ValueError: + return # We may not have the input chat/get message failed + if not msg: + return # The message may be deleted and it will be None + + self._sender = msg._sender + self._input_sender = msg._input_sender + self._chat = msg._chat + self._input_chat = msg._input_chat + + async def _refetch_sender(self): + await self._reload_message() + + def _set_buttons(self, chat, bot): + """ + Helper methods to set the buttons given the input sender and chat. + """ + if isinstance(self.reply_markup, ( + types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + self._buttons = [[ + MessageButton(self._client, button, chat, bot, self.id) + for button in row.buttons + ] for row in self.reply_markup.rows] + self._buttons_flat = [x for row in self._buttons for x in row] + + def _needed_markup_bot(self): + """ + Returns the input peer of the bot that's needed for the reply markup. + + This is necessary for :tl:`KeyboardButtonSwitchInline` since we need + to know what bot we want to start. Raises ``ValueError`` if the bot + cannot be found but is needed. Returns ``None`` if it's not needed. + """ + for row in self.reply_markup.rows: + for button in row.buttons: + if isinstance(button, types.KeyboardButtonSwitchInline): + if button.same_peer: + bot = self.input_sender + if not bot: + raise ValueError('No input sender') + else: + return self._client.session.get_input_entity( + self.via_bot_id) + + 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. + """ + for attr in self.document.attributes: + if isinstance(attr, kind): + if not condition or condition(self.document): + return self.document + + # endregion Private Methods From fd170984478cf7dd435794c6fc93271b17913ab3 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 17:22:52 +0200 Subject: [PATCH 41/70] Rename MessageBase for Message --- .../tl/custom/{messagebase.py => message.py} | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename telethon/tl/custom/{messagebase.py => message.py} (96%) diff --git a/telethon/tl/custom/messagebase.py b/telethon/tl/custom/message.py similarity index 96% rename from telethon/tl/custom/messagebase.py rename to telethon/tl/custom/message.py index f580440f..4dcde78a 100644 --- a/telethon/tl/custom/messagebase.py +++ b/telethon/tl/custom/message.py @@ -8,9 +8,9 @@ from ... import utils, errors # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): +class Message(abc.ABC, TLObject, ChatGetter, SenderGetter): """ - This custom class aggregates both `MessageBase` and + This custom class aggregates both :tl:`Message` and :tl:`MessageService` to ease accessing their members. Remember that this class implements `ChatGetter @@ -61,7 +61,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): messages. message (`str`): - The string text of the message for `MessageBase` instances, + The string text of the message for :tl:`Message` instances, which will be ``None`` for other types of messages. action (:tl:`MessageAction`): @@ -270,7 +270,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): Remember that you can access the ID of the message this one is replying to through `reply_to_msg_id`, - and the `MessageBase` object with `get_reply_message()`. + and the `Message` object with `get_reply_message()`. """ return bool(self.reply_to_msg_id) @@ -441,7 +441,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def get_reply_message(self): """ - The `MessageBase` that this message is replying to, or ``None``. + The `Message` that this message is replying to, or ``None``. The result will be cached after its first use. """ @@ -459,7 +459,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def respond(self, *args, **kwargs): """ Responds to the message (not as a reply). Shorthand for - `telethon.client.telegramclient.TelegramClient.send_message` + `telethon.client.messages.MessageMethods.send_message` with ``entity`` already set. """ return await self._client.send_message( @@ -468,7 +468,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def reply(self, *args, **kwargs): """ Replies to the message (as a reply). Shorthand for - `telethon.client.telegramclient.TelegramClient.send_message` + `telethon.client.messages.MessageMethods.send_message` with both ``entity`` and ``reply_to`` already set. """ kwargs['reply_to'] = self.id @@ -478,7 +478,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def forward_to(self, *args, **kwargs): """ Forwards the message. Shorthand for - `telethon.client.telegramclient.TelegramClient.forward_messages` + `telethon.client.messages.MessageMethods.forward_messages` with both ``messages`` and ``from_peer`` already set. If you need to forward more than one message at once, don't use @@ -492,11 +492,11 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def edit(self, *args, **kwargs): """ Edits the message iff it's outgoing. Shorthand for - `telethon.client.telegramclient.TelegramClient.edit_message` + `telethon.client.messages.MessageMethods.edit_message` with both ``entity`` and ``message`` already set. Returns ``None`` if the message was incoming, - or the edited `MessageBase` otherwise. + or the edited `Message` otherwise. """ if self.fwd_from or not self.out: return None # We assume self.out was patched for our chat @@ -511,7 +511,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): Deletes the message. You're responsible for checking whether you have the permission to do so, or to except the error otherwise. Shorthand for - `telethon.client.telegramclient.TelegramClient.delete_messages` with + `telethon.client.messages.MessageMethods.delete_messages` with ``entity`` and ``message_ids`` already set. If you need to delete more than one message at once, don't use @@ -526,7 +526,7 @@ class MessageBase(abc.ABC, TLObject, ChatGetter, SenderGetter): async def download_media(self, *args, **kwargs): """ Downloads the media contained in the message, if any. Shorthand - for `telethon.client.telegramclient.TelegramClient.download_media` + for `telethon.client.downloads.DownloadMethods.download_media` with the ``message`` already set. """ return await self._client.download_media(self, *args, **kwargs) From 61a9f1e61c09a7386d99972e6c3c19a616e8962f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 19:11:40 +0200 Subject: [PATCH 42/70] Create a third module to store patched objects --- .gitignore | 1 + telethon_generator/generators/tlobject.py | 63 +++++++++++++++++++---- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 028bcce6..09700873 100755 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs/ # Generated code telethon/tl/functions/ telethon/tl/types/ +telethon/tl/patched/ telethon/tl/alltlobjects.py telethon/errors/rpcerrorlist.py diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 5190c6f8..4fcf2875 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -36,6 +36,13 @@ NAMED_AUTO_CASTS = { BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128', 'int256', 'double', 'Bool', 'true', 'date') +# Patched types {fullname: custom.ns.Name} +PATCHED_TYPES = { + 'messageEmpty': 'message.Message', + 'message': 'message.Message', + 'messageService': 'message.Message' +} + def _write_modules( out_dir, depth, kind, namespace_tlobjects, type_constructors): @@ -130,11 +137,14 @@ def _write_modules( # Generate the class for every TLObject for t in tlobjects: - _write_source_code(t, kind, builder, type_constructors) - builder.current_indent = 0 + if t.fullname in PATCHED_TYPES: + builder.writeln('{} = None # Patched', t.class_name) + else: + _write_source_code(t, kind, builder, type_constructors) + builder.current_indent = 0 # Write the type definitions generated earlier. - builder.writeln('') + builder.writeln() for line in type_defs: builder.writeln(line) @@ -618,11 +628,38 @@ def _write_arg_read_code(builder, arg, args, name): arg.is_flag = True +def _write_patched(out_dir, namespace_tlobjects): + os.makedirs(out_dir, exist_ok=True) + for ns, tlobjects in namespace_tlobjects.items(): + file = os.path.join(out_dir, '{}.py'.format(ns or '__init__')) + with open(file, 'w', encoding='utf-8') as f,\ + SourceBuilder(f) as builder: + builder.writeln(AUTO_GEN_NOTICE) + + builder.writeln('import struct') + builder.writeln('from .. import types, custom') + builder.writeln() + for t in tlobjects: + builder.writeln('class {}(custom.{}):', t.class_name, + PATCHED_TYPES[t.fullname]) + + _write_to_dict(t, builder) + _write_to_bytes(t, builder) + _write_from_reader(t, builder) + builder.current_indent = 0 + builder.writeln() + builder.writeln( + 'types.{1}{0} = {0}', t.class_name, + '{}.'.format(t.namespace) if t.namespace else '' + ) + builder.writeln() + + def _write_all_tlobjects(tlobjects, layer, builder): builder.writeln(AUTO_GEN_NOTICE) builder.writeln() - builder.writeln('from . import types, functions') + builder.writeln('from . import types, functions, patched') builder.writeln() # Create a constant variable to indicate which layer this is @@ -636,9 +673,14 @@ def _write_all_tlobjects(tlobjects, layer, builder): # Fill the dictionary (0x1a2b3c4f: tl.full.type.path.Class) for tlobject in tlobjects: builder.write('{:#010x}: ', tlobject.id) - builder.write('functions' if tlobject.is_function else 'types') + # TODO Probably circular dependency + if tlobject.fullname in PATCHED_TYPES: + builder.write('patched') + else: + builder.write('functions' if tlobject.is_function else 'types') + if tlobject.namespace: - builder.write('.' + tlobject.namespace) + builder.write('.{}', tlobject.namespace) builder.writeln('.{},', tlobject.class_name) @@ -647,13 +689,10 @@ def _write_all_tlobjects(tlobjects, layer, builder): def generate_tlobjects(tlobjects, layer, import_depth, output_dir): - get_file = functools.partial(os.path.join, output_dir) - os.makedirs(get_file('functions'), exist_ok=True) - os.makedirs(get_file('types'), exist_ok=True) - # Group everything by {namespace: [tlobjects]} to generate __init__.py namespace_functions = defaultdict(list) namespace_types = defaultdict(list) + namespace_patched = defaultdict(list) # Group {type: [constructors]} to generate the documentation type_constructors = defaultdict(list) @@ -663,11 +702,15 @@ def generate_tlobjects(tlobjects, layer, import_depth, output_dir): else: namespace_types[tlobject.namespace].append(tlobject) type_constructors[tlobject.result].append(tlobject) + if tlobject.fullname in PATCHED_TYPES: + namespace_patched[tlobject.namespace].append(tlobject) + get_file = functools.partial(os.path.join, output_dir) _write_modules(get_file('functions'), import_depth, 'TLRequest', namespace_functions, type_constructors) _write_modules(get_file('types'), import_depth, 'TLObject', namespace_types, type_constructors) + _write_patched(get_file('patched'), namespace_patched) filename = os.path.join(get_file('alltlobjects.py')) with open(filename, 'w', encoding='utf-8') as file: From 1c0d595205001e02bbbb90808d1ef2ad8acd4bfe Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 19:20:55 +0200 Subject: [PATCH 43/70] Replace custom.Message creation with ._finish_init --- telethon/client/dialogs.py | 8 ++++++-- telethon/client/messageparse.py | 3 ++- telethon/client/messages.py | 16 ++++++++++------ telethon/events/chataction.py | 3 +-- telethon/events/newmessage.py | 3 +-- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/telethon/client/dialogs.py b/telethon/client/dialogs.py index 1fea2da9..f04dbc01 100644 --- a/telethon/client/dialogs.py +++ b/telethon/client/dialogs.py @@ -80,10 +80,14 @@ class DialogMethods(UserMethods): if _total: _total[0] = getattr(r, 'count', len(r.dialogs)) + entities = {utils.get_peer_id(x): x for x in itertools.chain(r.users, r.chats)} - messages = {m.id: custom.Message(self, m, entities, None) - for m in r.messages} + + messages = {} + for m in r.messages: + m._finish_init(self, entities, None) + messages[m.id] = m # Happens when there are pinned dialogs if len(r.dialogs) > limit: diff --git a/telethon/client/messageparse.py b/telethon/client/messageparse.py index 9c75f480..1e71605b 100644 --- a/telethon/client/messageparse.py +++ b/telethon/client/messageparse.py @@ -133,6 +133,7 @@ class MessageParseMethods(UserMethods): break if found: - return custom.Message(self, found, entities, input_chat) + found._finish_init(self, entities, input_chat) + return found # endregion diff --git a/telethon/client/messages.py b/telethon/client/messages.py index eaf57f25..4f44a78b 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -256,7 +256,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): # IDs are returned in descending order (or asc if reverse). last_id = message.id - await yield_(custom.Message(self, message, entities, entity)) + message._finish_init(self, entities, entity) + await yield_(message) have += 1 if len(r.messages) < request.limit: @@ -469,7 +470,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): result = await self(request) if isinstance(result, types.UpdateShortSentMessage): to_id, cls = utils.resolve_id(utils.get_peer_id(entity)) - return custom.Message(self, types.Message( + message = types.Message( id=result.id, to_id=cls(to_id), message=message, @@ -477,7 +478,9 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): out=result.out, media=result.media, entities=result.entities - ), {}, input_chat=entity) + ) + message._finish_init(self, {}, entity) + return message return self._get_response_message(request, result, entity) @@ -547,8 +550,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): random_to_id[update.random_id] = update.id elif isinstance(update, ( types.UpdateNewMessage, types.UpdateNewChannelMessage)): - id_to_message[update.message.id] = custom.Message( - self, update.message, entities, input_chat=entity) + update.message._finish_init(self, entities, entity) + id_to_message[update.message.id] = update.message result = [id_to_message[random_to_id[rnd]] for rnd in req.random_id] return result[0] if single else result @@ -774,6 +777,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): from_id and utils.get_peer_id(message.to_id) != from_id): await yield_(None) else: - await yield_(custom.Message(self, message, entities, entity)) + message._finish_init(self, entities, entity) + await yield_(message) # endregion diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index 59300317..3bf92b1b 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -164,8 +164,7 @@ class ChatAction(EventBuilder): def _set_client(self, client): super()._set_client(client) if self.action_message: - self.action_message = custom.Message( - client, self.action_message, self._entities, None) + self.action_message._finish_init(client, self._entities, None) async def respond(self, *args, **kwargs): """ diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index 44b688d6..ed8f8ecf 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -204,8 +204,7 @@ class NewMessage(EventBuilder): def _set_client(self, client): super()._set_client(client) - self.message = custom.Message( - client, self.message, self._entities, None) + self.message._finish_init(client, self._entities, None) self.__dict__['_init'] = True # No new attributes can be set def __getattr__(self, item): From ace7254344ef25ab5f7caabe8f72817aec2cf947 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 19:26:34 +0200 Subject: [PATCH 44/70] Fix classes MRO and abstractmethod usage Furthermore utils needs to access the message by reference through types.Message because it is patched and replaced. --- telethon/tl/custom/message.py | 2 +- telethon/tl/tlobject.py | 7 ++----- telethon/utils.py | 15 ++++++++------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 4dcde78a..f089d061 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -8,7 +8,7 @@ from ... import utils, errors # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class Message(abc.ABC, TLObject, ChatGetter, SenderGetter): +class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): """ This custom class aggregates both :tl:`Message` and :tl:`MessageService` to ease accessing their members. diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 772a9ea3..234b33e0 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -3,7 +3,7 @@ import struct from datetime import datetime, date, timedelta -class TLObject(abc.ABC): +class TLObject: CONSTRUCTOR_ID = None SUBCLASS_OF_ID = None @@ -141,21 +141,18 @@ class TLObject(abc.ABC): def stringify(self): return TLObject.pretty_format(self, indent=0) - @abc.abstractmethod def to_dict(self): raise NotImplementedError - @abc.abstractmethod def __bytes__(self): raise NotImplementedError @classmethod - @abc.abstractmethod def from_reader(cls, reader): raise NotImplementedError -class TLRequest(abc.ABC, TLObject): +class TLRequest(TLObject): """ Represents a content-related `TLObject` (a request that can be sent). """ diff --git a/telethon/utils.py b/telethon/utils.py index b5affe9b..8e280739 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -7,12 +7,13 @@ import math import mimetypes import os import re -import types from collections import UserList from mimetypes import guess_extension +from types import GeneratorType from .extensions import markdown, html from .helpers import add_surrogate, del_surrogate +from .tl import types from .tl.types import ( Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty, @@ -23,7 +24,7 @@ from .tl.types import ( MessageMediaUnsupported, MessageMediaVenue, InputMediaContact, InputMediaDocument, InputMediaEmpty, InputMediaGame, InputMediaGeoPoint, InputMediaPhoto, InputMediaVenue, InputDocument, - DocumentEmpty, InputDocumentEmpty, Message, GeoPoint, InputGeoPoint, + DocumentEmpty, InputDocumentEmpty, GeoPoint, InputGeoPoint, GeoPointEmpty, InputGeoPointEmpty, Photo, InputPhoto, PhotoEmpty, InputPhotoEmpty, FileLocation, ChatPhotoEmpty, UserProfilePhotoEmpty, FileLocationUnavailable, InputMediaUploadedDocument, ChannelFull, @@ -256,7 +257,7 @@ def get_input_document(document): if isinstance(document, MessageMediaDocument): return get_input_document(document.document) - if isinstance(document, Message): + if isinstance(document, types.Message): return get_input_document(document.media) _raise_cast_fail(document, 'InputDocument') @@ -299,7 +300,7 @@ def get_input_geo(geo): if isinstance(geo, MessageMediaGeo): return get_input_geo(geo.geo) - if isinstance(geo, Message): + if isinstance(geo, types.Message): return get_input_geo(geo.media) _raise_cast_fail(geo, 'InputGeoPoint') @@ -390,7 +391,7 @@ def get_input_media(media, is_photo=False): ChatPhotoEmpty, UserProfilePhotoEmpty, FileLocationUnavailable)): return InputMediaEmpty() - if isinstance(media, Message): + if isinstance(media, types.Message): return get_input_media(media.media, is_photo=is_photo) _raise_cast_fail(media, 'InputMedia') @@ -549,7 +550,7 @@ def get_input_location(location): except AttributeError: _raise_cast_fail(location, 'InputFileLocation') - if isinstance(location, Message): + if isinstance(location, types.Message): location = location.media if isinstance(location, MessageMediaDocument): @@ -622,7 +623,7 @@ def is_list_like(obj): other things), so just support the commonly known list-like objects. """ return isinstance(obj, (list, tuple, set, dict, - UserList, types.GeneratorType)) + UserList, GeneratorType)) def parse_phone(phone): From 52292d77fb4dd3a3f654b3173104f10d5da34421 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 22 Jul 2018 19:40:00 +0200 Subject: [PATCH 45/70] Use types. namespace in utils --- telethon/utils.py | 259 ++++++++++++++++++++++------------------------ 1 file changed, 123 insertions(+), 136 deletions(-) diff --git a/telethon/utils.py b/telethon/utils.py index 8e280739..05fcb620 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -14,26 +14,6 @@ from types import GeneratorType from .extensions import markdown, html from .helpers import add_surrogate, del_surrogate from .tl import types -from .tl.types import ( - Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, - ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty, - MessageMediaDocument, MessageMediaPhoto, PeerChannel, InputChannel, - UserEmpty, InputUser, InputUserEmpty, InputUserSelf, InputPeerSelf, - PeerChat, PeerUser, User, UserFull, UserProfilePhoto, Document, - MessageMediaContact, MessageMediaEmpty, MessageMediaGame, MessageMediaGeo, - MessageMediaUnsupported, MessageMediaVenue, InputMediaContact, - InputMediaDocument, InputMediaEmpty, InputMediaGame, - InputMediaGeoPoint, InputMediaPhoto, InputMediaVenue, InputDocument, - DocumentEmpty, InputDocumentEmpty, GeoPoint, InputGeoPoint, - GeoPointEmpty, InputGeoPointEmpty, Photo, InputPhoto, PhotoEmpty, - InputPhotoEmpty, FileLocation, ChatPhotoEmpty, UserProfilePhotoEmpty, - FileLocationUnavailable, InputMediaUploadedDocument, ChannelFull, - InputMediaUploadedPhoto, DocumentAttributeFilename, photos, - TopPeer, InputNotifyPeer, InputMessageID, InputFileLocation, - InputDocumentFileLocation, PhotoSizeEmpty, InputDialogPeer, - DocumentAttributeAudio, DocumentAttributeVideo -) -from .tl.types.contacts import ResolvedPeer try: import hachoir @@ -82,7 +62,7 @@ def get_display_name(entity): Gets the display name for the given entity, if it's an :tl:`User`, :tl:`Chat` or :tl:`Channel`. Returns an empty string otherwise. """ - if isinstance(entity, User): + if isinstance(entity, types.User): if entity.last_name and entity.first_name: return '{} {}'.format(entity.first_name, entity.last_name) elif entity.first_name: @@ -92,7 +72,7 @@ def get_display_name(entity): else: return '' - elif isinstance(entity, (Chat, Channel)): + elif isinstance(entity, (types.Chat, types.Channel)): return entity.title return '' @@ -102,13 +82,14 @@ def get_extension(media): """Gets the corresponding extension for any Telegram media.""" # Photos are always compressed as .jpg by Telegram - if isinstance(media, (UserProfilePhoto, ChatPhoto, MessageMediaPhoto)): + if isinstance(media, (types.UserProfilePhoto, + types.ChatPhoto, types.MessageMediaPhoto)): return '.jpg' # Documents will come with a mime type - if isinstance(media, MessageMediaDocument): + if isinstance(media, types.MessageMediaDocument): media = media.document - if isinstance(media, Document): + if isinstance(media, types.Document): if media.mime_type == 'application/octet-stream': # Octet stream are just bytes, which have no default extension return '' @@ -140,38 +121,38 @@ def get_input_peer(entity, allow_self=True): else: _raise_cast_fail(entity, 'InputPeer') - if isinstance(entity, User): + if isinstance(entity, types.User): if entity.is_self and allow_self: - return InputPeerSelf() + return types.InputPeerSelf() else: - return InputPeerUser(entity.id, entity.access_hash or 0) + return types.InputPeerUser(entity.id, entity.access_hash or 0) - if isinstance(entity, (Chat, ChatEmpty, ChatForbidden)): - return InputPeerChat(entity.id) + if isinstance(entity, (types.Chat, types.ChatEmpty, types.ChatForbidden)): + return types.InputPeerChat(entity.id) - if isinstance(entity, (Channel, ChannelForbidden)): - return InputPeerChannel(entity.id, entity.access_hash or 0) + if isinstance(entity, (types.Channel, types.ChannelForbidden)): + return types.InputPeerChannel(entity.id, entity.access_hash or 0) - if isinstance(entity, InputUser): - return InputPeerUser(entity.user_id, entity.access_hash) + if isinstance(entity, types.InputUser): + return types.InputPeerUser(entity.user_id, entity.access_hash) - if isinstance(entity, InputChannel): - return InputPeerChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, types.InputChannel): + return types.InputPeerChannel(entity.channel_id, entity.access_hash) - if isinstance(entity, InputUserSelf): - return InputPeerSelf() + if isinstance(entity, types.InputUserSelf): + return types.InputPeerSelf() - if isinstance(entity, UserEmpty): - return InputPeerEmpty() + if isinstance(entity, types.UserEmpty): + return types.InputPeerEmpty() - if isinstance(entity, UserFull): + if isinstance(entity, types.UserFull): return get_input_peer(entity.user) - if isinstance(entity, ChatFull): - return InputPeerChat(entity.id) + if isinstance(entity, types.ChatFull): + return types.InputPeerChat(entity.id) - if isinstance(entity, PeerChat): - return InputPeerChat(entity.chat_id) + if isinstance(entity, types.PeerChat): + return types.InputPeerChat(entity.chat_id) _raise_cast_fail(entity, 'InputPeer') @@ -184,11 +165,11 @@ def get_input_channel(entity): except AttributeError: _raise_cast_fail(entity, 'InputChannel') - if isinstance(entity, (Channel, ChannelForbidden)): - return InputChannel(entity.id, entity.access_hash or 0) + if isinstance(entity, (types.Channel, types.ChannelForbidden)): + return types.InputChannel(entity.id, entity.access_hash or 0) - if isinstance(entity, InputPeerChannel): - return InputChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, types.InputPeerChannel): + return types.InputChannel(entity.channel_id, entity.access_hash) _raise_cast_fail(entity, 'InputChannel') @@ -201,23 +182,23 @@ def get_input_user(entity): except AttributeError: _raise_cast_fail(entity, 'InputUser') - if isinstance(entity, User): + if isinstance(entity, types.User): if entity.is_self: - return InputUserSelf() + return types.InputUserSelf() else: - return InputUser(entity.id, entity.access_hash or 0) + return types.InputUser(entity.id, entity.access_hash or 0) - if isinstance(entity, InputPeerSelf): - return InputUserSelf() + if isinstance(entity, types.InputPeerSelf): + return types.InputUserSelf() - if isinstance(entity, (UserEmpty, InputPeerEmpty)): - return InputUserEmpty() + if isinstance(entity, (types.UserEmpty, types.InputPeerEmpty)): + return types.InputUserEmpty() - if isinstance(entity, UserFull): + if isinstance(entity, types.UserFull): return get_input_user(entity.user) - if isinstance(entity, InputPeerUser): - return InputUser(entity.user_id, entity.access_hash) + if isinstance(entity, types.InputPeerUser): + return types.InputUser(entity.user_id, entity.access_hash) _raise_cast_fail(entity, 'InputUser') @@ -228,12 +209,12 @@ def get_input_dialog(dialog): if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') return dialog if dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return InputDialogPeer(dialog) + return types.InputDialogPeer(dialog) except AttributeError: _raise_cast_fail(dialog, 'InputDialogPeer') try: - return InputDialogPeer(get_input_peer(dialog)) + return types.InputDialogPeer(get_input_peer(dialog)) except TypeError: pass @@ -248,13 +229,13 @@ def get_input_document(document): except AttributeError: _raise_cast_fail(document, 'InputDocument') - if isinstance(document, Document): - return InputDocument(id=document.id, access_hash=document.access_hash) + if isinstance(document, types.Document): + return types.InputDocument(id=document.id, access_hash=document.access_hash) - if isinstance(document, DocumentEmpty): - return InputDocumentEmpty() + if isinstance(document, types.DocumentEmpty): + return types.InputDocumentEmpty() - if isinstance(document, MessageMediaDocument): + if isinstance(document, types.MessageMediaDocument): return get_input_document(document.document) if isinstance(document, types.Message): @@ -271,14 +252,14 @@ def get_input_photo(photo): except AttributeError: _raise_cast_fail(photo, 'InputPhoto') - if isinstance(photo, photos.Photo): + if isinstance(photo, types.photos.Photo): photo = photo.photo - if isinstance(photo, Photo): - return InputPhoto(id=photo.id, access_hash=photo.access_hash) + if isinstance(photo, types.Photo): + return types.InputPhoto(id=photo.id, access_hash=photo.access_hash) - if isinstance(photo, PhotoEmpty): - return InputPhotoEmpty() + if isinstance(photo, types.PhotoEmpty): + return types.InputPhotoEmpty() _raise_cast_fail(photo, 'InputPhoto') @@ -291,13 +272,13 @@ def get_input_geo(geo): except AttributeError: _raise_cast_fail(geo, 'InputGeoPoint') - if isinstance(geo, GeoPoint): - return InputGeoPoint(lat=geo.lat, long=geo.long) + if isinstance(geo, types.GeoPoint): + return types.InputGeoPoint(lat=geo.lat, long=geo.long) - if isinstance(geo, GeoPointEmpty): - return InputGeoPointEmpty() + if isinstance(geo, types.GeoPointEmpty): + return types.InputGeoPointEmpty() - if isinstance(geo, MessageMediaGeo): + if isinstance(geo, types.MessageMediaGeo): return get_input_geo(geo.geo) if isinstance(geo, types.Message): @@ -317,67 +298,67 @@ def get_input_media(media, is_photo=False): if media.SUBCLASS_OF_ID == 0xfaf846f4: # crc32(b'InputMedia') return media elif media.SUBCLASS_OF_ID == 0x846363e0: # crc32(b'InputPhoto') - return InputMediaPhoto(media) + return types.InputMediaPhoto(media) elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument') - return InputMediaDocument(media) + return types.InputMediaDocument(media) except AttributeError: _raise_cast_fail(media, 'InputMedia') - if isinstance(media, MessageMediaPhoto): - return InputMediaPhoto( + if isinstance(media, types.MessageMediaPhoto): + return types.InputMediaPhoto( id=get_input_photo(media.photo), ttl_seconds=media.ttl_seconds ) - if isinstance(media, (Photo, photos.Photo, PhotoEmpty)): - return InputMediaPhoto( + if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)): + return types.InputMediaPhoto( id=get_input_photo(media) ) - if isinstance(media, MessageMediaDocument): - return InputMediaDocument( + if isinstance(media, types.MessageMediaDocument): + return types.InputMediaDocument( id=get_input_document(media.document), ttl_seconds=media.ttl_seconds ) - if isinstance(media, (Document, DocumentEmpty)): - return InputMediaDocument( + if isinstance(media, (types.Document, types.DocumentEmpty)): + return types.InputMediaDocument( id=get_input_document(media) ) - if isinstance(media, FileLocation): + if isinstance(media, types.FileLocation): if is_photo: - return InputMediaUploadedPhoto(file=media) + return types.InputMediaUploadedPhoto(file=media) else: - return InputMediaUploadedDocument( + return types.InputMediaUploadedDocument( file=media, mime_type='application/octet-stream', # unknown, assume bytes - attributes=[DocumentAttributeFilename('unnamed')] + attributes=[types.DocumentAttributeFilename('unnamed')] ) - if isinstance(media, MessageMediaGame): - return InputMediaGame(id=media.game.id) + if isinstance(media, types.MessageMediaGame): + return types.InputMediaGame(id=media.game.id) - if isinstance(media, (ChatPhoto, UserProfilePhoto)): - if isinstance(media.photo_big, FileLocationUnavailable): + if isinstance(media, (types.ChatPhoto, types.UserProfilePhoto)): + if isinstance(media.photo_big, types.FileLocationUnavailable): media = media.photo_small else: media = media.photo_big return get_input_media(media, is_photo=True) - if isinstance(media, MessageMediaContact): - return InputMediaContact( + if isinstance(media, types.MessageMediaContact): + return types.InputMediaContact( phone_number=media.phone_number, first_name=media.first_name, last_name=media.last_name, vcard='' ) - if isinstance(media, MessageMediaGeo): - return InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) + if isinstance(media, types.MessageMediaGeo): + return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) - if isinstance(media, MessageMediaVenue): - return InputMediaVenue( + if isinstance(media, types.MessageMediaVenue): + return types.InputMediaVenue( geo_point=get_input_geo(media.geo), title=media.title, address=media.address, @@ -387,9 +368,10 @@ def get_input_media(media, is_photo=False): ) if isinstance(media, ( - MessageMediaEmpty, MessageMediaUnsupported, - ChatPhotoEmpty, UserProfilePhotoEmpty, FileLocationUnavailable)): - return InputMediaEmpty() + types.MessageMediaEmpty, types.MessageMediaUnsupported, + types.ChatPhotoEmpty, types.UserProfilePhotoEmpty, + types.FileLocationUnavailable)): + return types.InputMediaEmpty() if isinstance(media, types.Message): return get_input_media(media.media, is_photo=is_photo) @@ -401,11 +383,11 @@ def get_input_message(message): """Similar to :meth:`get_input_peer`, but for input messages.""" try: if isinstance(message, int): # This case is really common too - return InputMessageID(message) + return types.InputMessageID(message) elif message.SUBCLASS_OF_ID == 0x54b6bcc5: # crc32(b'InputMessage'): return message elif message.SUBCLASS_OF_ID == 0x790009e3: # crc32(b'Message'): - return InputMessageID(message.id) + return types.InputMessageID(message.id) except AttributeError: pass @@ -445,14 +427,14 @@ def get_attributes(file, *, attributes=None, mime_type=None, if mime_type is None: mime_type = mimetypes.guess_type(file)[0] - attr_dict = {DocumentAttributeFilename: - DocumentAttributeFilename(os.path.basename(file))} + attr_dict = {types.DocumentAttributeFilename: + types.DocumentAttributeFilename(os.path.basename(file))} if is_audio(file) and hachoir is not None: with hachoir.parser.createParser(file) as parser: m = hachoir.metadata.extractMetadata(parser) - attr_dict[DocumentAttributeAudio] = \ - DocumentAttributeAudio( + attr_dict[types.DocumentAttributeAudio] = \ + types.DocumentAttributeAudio( voice=voice_note, title=m.get('title') if m.has('title') else None, performer=m.get('author') if m.has('author') else None, @@ -464,7 +446,7 @@ def get_attributes(file, *, attributes=None, mime_type=None, if hachoir: with hachoir.parser.createParser(file) as parser: m = hachoir.metadata.extractMetadata(parser) - doc = DocumentAttributeVideo( + doc = types.DocumentAttributeVideo( round_message=video_note, w=m.get('width') if m.has('width') else 0, h=m.get('height') if m.has('height') else 0, @@ -472,21 +454,21 @@ def get_attributes(file, *, attributes=None, mime_type=None, if m.has('duration') else 0) ) else: - doc = DocumentAttributeVideo( + doc = types.DocumentAttributeVideo( 0, 1, 1, round_message=video_note) - attr_dict[DocumentAttributeVideo] = doc + attr_dict[types.DocumentAttributeVideo] = doc else: - attr_dict = {DocumentAttributeFilename: - DocumentAttributeFilename( + attr_dict = {types.DocumentAttributeFilename: + types.DocumentAttributeFilename( os.path.basename(getattr(file, 'name', None) or 'unnamed'))} if voice_note: - if DocumentAttributeAudio in attr_dict: - attr_dict[DocumentAttributeAudio].voice = True + if types.DocumentAttributeAudio in attr_dict: + attr_dict[types.DocumentAttributeAudio].voice = True else: - attr_dict[DocumentAttributeAudio] = \ - DocumentAttributeAudio(0, voice=True) + attr_dict[types.DocumentAttributeAudio] = \ + types.DocumentAttributeAudio(0, voice=True) # Now override the attributes if any. As we have a dict of # {cls: instance}, we can override any class with the list @@ -553,23 +535,26 @@ def get_input_location(location): if isinstance(location, types.Message): location = location.media - if isinstance(location, MessageMediaDocument): + if isinstance(location, types.MessageMediaDocument): location = location.document - elif isinstance(location, MessageMediaPhoto): + elif isinstance(location, types.MessageMediaPhoto): location = location.photo - if isinstance(location, Document): - return (location.dc_id, InputDocumentFileLocation( + if isinstance(location, types.Document): + return (location.dc_id, types.InputDocumentFileLocation( location.id, location.access_hash, location.version)) - elif isinstance(location, Photo): + elif isinstance(location, types.Photo): try: - location = next(x for x in reversed(location.sizes) - if not isinstance(x, PhotoSizeEmpty)).location + location = next( + x for x in reversed(location.sizes) + if not isinstance(x, types.PhotoSizeEmpty) + ).location except StopIteration: pass - if isinstance(location, (FileLocation, FileLocationUnavailable)): - return (getattr(location, 'dc_id', None), InputFileLocation( + if isinstance(location, ( + types.FileLocation, types.FileLocationUnavailable)): + return (getattr(location, 'dc_id', None), types.InputFileLocation( location.volume_id, location.local_id, location.secret)) _raise_cast_fail(location, 'InputFileLocation') @@ -694,7 +679,8 @@ def get_peer_id(peer, add_mark=True): try: if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): - if isinstance(peer, (ResolvedPeer, InputNotifyPeer, TopPeer)): + if isinstance(peer, ( + types.ResolvedPeer, types.InputNotifyPeer, types.TopPeer)): peer = peer.peer else: # Not a Peer or an InputPeer, so first get its Input version @@ -703,16 +689,17 @@ def get_peer_id(peer, add_mark=True): _raise_cast_fail(peer, 'int') # Set the right ID/kind, or raise if the TLObject is not recognised - if isinstance(peer, (PeerUser, InputPeerUser)): + if isinstance(peer, (types.PeerUser, types.InputPeerUser)): return peer.user_id - elif isinstance(peer, (PeerChat, InputPeerChat)): + elif isinstance(peer, (types.PeerChat, types.InputPeerChat)): # Check in case the user mixed things up to avoid blowing up if not (0 < peer.chat_id <= 0x7fffffff): peer.chat_id = resolve_id(peer.chat_id)[0] return -peer.chat_id if add_mark else peer.chat_id - elif isinstance(peer, (PeerChannel, InputPeerChannel, ChannelFull)): - if isinstance(peer, ChannelFull): + elif isinstance(peer, ( + types.PeerChannel, types.InputPeerChannel, types.ChannelFull)): + if isinstance(peer, types.ChannelFull): # Special case: .get_input_peer can't return InputChannel from # ChannelFull since it doesn't have an .access_hash attribute. i = peer.id @@ -722,7 +709,7 @@ def get_peer_id(peer, add_mark=True): # Check in case the user mixed things up to avoid blowing up if not (0 < i <= 0x7fffffff): i = resolve_id(i)[0] - if isinstance(peer, ChannelFull): + if isinstance(peer, types.ChannelFull): peer.id = i else: peer.channel_id = i @@ -740,7 +727,7 @@ def get_peer_id(peer, add_mark=True): def resolve_id(marked_id): """Given a marked ID, returns the original ID and its :tl:`Peer` type.""" if marked_id >= 0: - return marked_id, PeerUser + return marked_id, types.PeerUser # There have been report of chat IDs being 10000xyz, which means their # marked version is -10000xyz, which in turn looks like a channel but @@ -748,9 +735,9 @@ def resolve_id(marked_id): # two zeroes. m = re.match(r'-100([^0]\d*)', str(marked_id)) if m: - return int(m.group(1)), PeerChannel + return int(m.group(1)), types.PeerChannel - return -marked_id, PeerChat + return -marked_id, types.PeerChat def get_appropriated_part_size(file_size): From 056842d1a04ffb6217699d17bf771adf27c4567b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 23 Jul 2018 12:18:35 +0200 Subject: [PATCH 46/70] Fix trailing comma breaking Python 3.5.2 compat --- telethon/tl/custom/inline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/tl/custom/inline.py b/telethon/tl/custom/inline.py index 9ee244b9..75a81b94 100644 --- a/telethon/tl/custom/inline.py +++ b/telethon/tl/custom/inline.py @@ -56,7 +56,7 @@ class InlineBuilder: self, title, description=None, *, url=None, thumb=None, content=None, id=None, text=None, parse_mode=utils.Default, link_preview=True, - geo=None, period=60, contact=None, game=False, buttons=None, + geo=None, period=60, contact=None, game=False, buttons=None ): """ Creates new inline result of article type. From d8fa0c81f68232ae276610a5c64dde1a0aaee28b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 23 Jul 2018 12:19:41 +0200 Subject: [PATCH 47/70] Fix-up 52292d7 accessing types under the wrong module --- telethon/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/telethon/utils.py b/telethon/utils.py index 05fcb620..00976a8d 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -230,7 +230,8 @@ def get_input_document(document): _raise_cast_fail(document, 'InputDocument') if isinstance(document, types.Document): - return types.InputDocument(id=document.id, access_hash=document.access_hash) + return types.InputDocument( + id=document.id, access_hash=document.access_hash) if isinstance(document, types.DocumentEmpty): return types.InputDocumentEmpty() @@ -680,7 +681,8 @@ def get_peer_id(peer, add_mark=True): try: if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): if isinstance(peer, ( - types.ResolvedPeer, types.InputNotifyPeer, types.TopPeer)): + types.contacts.ResolvedPeer, types.InputNotifyPeer, + types.TopPeer)): peer = peer.peer else: # Not a Peer or an InputPeer, so first get its Input version From 7778b665db562c8e1d25839065e62e104c5cd608 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 23 Jul 2018 12:44:46 +0200 Subject: [PATCH 48/70] Update to v1.1.1 --- readthedocs/extra/changelog.rst | 23 +++++++++++++++++++++++ telethon/version.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/readthedocs/extra/changelog.rst b/readthedocs/extra/changelog.rst index 41dee711..aef9865b 100644 --- a/readthedocs/extra/changelog.rst +++ b/readthedocs/extra/changelog.rst @@ -14,6 +14,29 @@ it can take advantage of new goodies! .. contents:: List of All Versions +Better Custom Message(v1.1.1) +============================= + +*Published at 2018/07/23* + +The `custom.Message ` class has been +rewritten in a cleaner way and overall feels less hacky in the library. +This should perform better than the previous way in which it was patched. + +The release is primarily intended to test this big change, but also fixes +**Python 3.5.2 compatibility** which was broken due to a trailing comma. + + +Bug fixes +~~~~~~~~~ + +- Using ``functools.partial`` on event handlers broke updates + if they had uncaught exceptions. +- A bug under some session files where the sender would export + authorization for the same data center, which is unsupported. +- Some logical bugs in the custom message class. + + Bot Friendly (v1.1) =================== diff --git a/telethon/version.py b/telethon/version.py index 7bf9af2a..bf03cd53 100644 --- a/telethon/version.py +++ b/telethon/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '1.1' +__version__ = '1.1.1' From 6c51c35ccfd413b434652a8d2b3b7f0d3efaae41 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 24 Jul 2018 18:20:34 +0200 Subject: [PATCH 49/70] Fix _iter_ids not expecting InputChannel --- telethon/client/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 4f44a78b..fc6112db 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -751,7 +751,7 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): total[0] = len(ids) from_id = None # By default, no need to validate from_id - if isinstance(entity, types.InputPeerChannel): + if isinstance(entity, (types.InputChannel, types.InputPeerChannel)): r = await self(functions.channels.GetMessagesRequest(entity, ids)) else: r = await self(functions.messages.GetMessagesRequest(ids)) From 7b22c72c3ebd386caa6ce4e71e27a061082a46f9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 24 Jul 2018 20:38:38 +0200 Subject: [PATCH 50/70] Use UTC timezone for events.UserUpdate --- telethon/events/userupdate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py index 925b353b..75c18728 100644 --- a/telethon/events/userupdate.py +++ b/telethon/events/userupdate.py @@ -96,7 +96,8 @@ class UserUpdate(EventBuilder): isinstance(status, types.UserStatusOnline) else None if self.last_seen: - diff = datetime.datetime.now() - self.last_seen + now = datetime.datetime.now(tz=datetime.timezone.utc) + diff = now - self.last_seen if diff < datetime.timedelta(days=30): self.within_months = True if diff < datetime.timedelta(days=7): From 200a4e47b8260f1bc2491c3d04e4044c8e4d1a26 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 25 Jul 2018 02:21:44 +0200 Subject: [PATCH 51/70] Clarify some strings --- telethon/extensions/tcpclient.py | 1 + telethon_generator/data/error_descriptions | 1 + telethon_generator/data/html/core.html | 2 +- telethon_generator/generators/docs.py | 8 +++++--- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/telethon/extensions/tcpclient.py b/telethon/extensions/tcpclient.py index 716c4066..f24dec40 100644 --- a/telethon/extensions/tcpclient.py +++ b/telethon/extensions/tcpclient.py @@ -67,6 +67,7 @@ class TcpClient: if proxy is None: s = socket.socket(mode, socket.SOCK_STREAM) else: + __log__.info('Connection will be made through proxy %s', proxy) import socks s = socks.socksocket(mode, socket.SOCK_STREAM) if isinstance(proxy, dict): diff --git a/telethon_generator/data/error_descriptions b/telethon_generator/data/error_descriptions index 20c35c1b..0558e800 100644 --- a/telethon_generator/data/error_descriptions +++ b/telethon_generator/data/error_descriptions @@ -67,3 +67,4 @@ FLOOD_TEST_PHONE_WAIT_X=A wait of {} seconds is required in the test servers TAKEOUT_INIT_DELAY_X=A wait of {} seconds is required before being able to initiate the takeout CHAT_NOT_MODIFIED=The chat or channel wasn't modified (title, invites, username, admins, etc. are the same) URL_INVALID=The URL used was invalid (e.g. when answering a callback with an URL that's not t.me/yourbot or your game's URL) +USER_NOT_PARTICIPANT=The target user is not a member of the specified megagroup or channel diff --git a/telethon_generator/data/html/core.html b/telethon_generator/data/html/core.html index aed00864..b55bb7de 100644 --- a/telethon_generator/data/html/core.html +++ b/telethon_generator/data/html/core.html @@ -149,7 +149,7 @@ users.getUsers#0d91a548 id:Vector<InputUser> = Vector<User>
  • Bool: Either True or False.
  • -
  • true: +
  • flag: These arguments aren't actually sent but rather encoded as flags. Any truthy value (True, 7) will enable this flag, although it's recommended to use True or diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index 98d10b73..ecfdee66 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -188,7 +188,8 @@ def _get_description(arg): desc.append('If left unspecified, it will be inferred automatically.') otherwise = True elif arg.is_flag: - desc.append('This argument can be omitted.') + desc.append('This argument defaults to ' + 'None and can be omitted.') otherwise = True if arg.type in {'InputPeer', 'InputUser', 'InputChannel'}: @@ -370,11 +371,12 @@ def _write_html_pages(tlobjects, errors, layer, input_res, output_dir): bold=True) # Type row + friendly_type = 'flag' if arg.type == 'true' else arg.type if arg.is_generic: - docs.add_row('!' + arg.type, align='center') + docs.add_row('!' + friendly_type, align='center') else: docs.add_row( - arg.type, align='center', link= + friendly_type, align='center', link= path_for_type(arg.type, relative_to=filename) ) From 7b4cd92066da779b9ae000b05d167961f36e962b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 25 Jul 2018 12:11:58 +0200 Subject: [PATCH 52/70] Revert 030f292 (use libssl if available) --- telethon/crypto/aes.py | 29 +++++++++++++--- telethon/crypto/libssl.py | 69 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 telethon/crypto/libssl.py diff --git a/telethon/crypto/aes.py b/telethon/crypto/aes.py index 8f13b5f0..3cfcc1af 100644 --- a/telethon/crypto/aes.py +++ b/telethon/crypto/aes.py @@ -1,13 +1,29 @@ """ -AES IGE implementation in Python. This module may use libssl if available. +AES IGE implementation in Python. + +If available, cryptg will be used instead, otherwise +if available, libssl will be used instead, otherwise +the Python implementation will be used. """ import os import pyaes +import logging +from . import libssl + + +__log__ = logging.getLogger(__name__) + try: import cryptg + __log__.info('cryptg detected, it will be used for encryption') except ImportError: cryptg = None + if libssl.encrypt_ige and libssl.decrypt_ige: + __log__.info('libssl detected, it will be used for encryption') + else: + __log__.info('cryptg module not installed and libssl not found, ' + 'falling back to (slower) Python encryption') class AES: @@ -23,6 +39,8 @@ class AES: """ if cryptg: return cryptg.decrypt_ige(cipher_text, key, iv) + if libssl.decrypt_ige: + return libssl.decrypt_ige(cipher_text, key, iv) iv1 = iv[:len(iv) // 2] iv2 = iv[len(iv) // 2:] @@ -56,13 +74,14 @@ class AES: 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) + padding = len(plain_text) % 16 + if padding: + plain_text += os.urandom(16 - padding) if cryptg: return cryptg.encrypt_ige(plain_text, key, iv) + if libssl.encrypt_ige: + return libssl.encrypt_ige(plain_text, key, iv) iv1 = iv[:len(iv) // 2] iv2 = iv[len(iv) // 2:] diff --git a/telethon/crypto/libssl.py b/telethon/crypto/libssl.py new file mode 100644 index 00000000..33e04eb3 --- /dev/null +++ b/telethon/crypto/libssl.py @@ -0,0 +1,69 @@ +""" +Helper module around the system's libssl library if available for IGE mode. +""" +import ctypes +import ctypes.util + + +lib = ctypes.util.find_library('ssl') +if not lib: + decrypt_ige = None + encrypt_ige = None +else: + _libssl = ctypes.cdll.LoadLibrary(lib) + + # https://github.com/openssl/openssl/blob/master/include/openssl/aes.h + AES_ENCRYPT = ctypes.c_int(1) + AES_DECRYPT = ctypes.c_int(0) + AES_MAXNR = 14 + + 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), + ] + + def decrypt_ige(cipher_text, key, iv): + aes_key = AES_KEY() + key_len = ctypes.c_int(8 * len(key)) + key = (ctypes.c_ubyte * len(key))(*key) + iv = (ctypes.c_ubyte * len(iv))(*iv) + + in_len = ctypes.c_size_t(len(cipher_text)) + in_ptr = (ctypes.c_ubyte * len(cipher_text))(*cipher_text) + out_ptr = (ctypes.c_ubyte * len(cipher_text))() + + _libssl.AES_set_decrypt_key(key, key_len, ctypes.byref(aes_key)) + _libssl.AES_ige_encrypt( + ctypes.byref(in_ptr), + ctypes.byref(out_ptr), + in_len, + ctypes.byref(aes_key), + ctypes.byref(iv), + AES_DECRYPT + ) + + return bytes(out_ptr) + + def encrypt_ige(plain_text, key, iv): + aes_key = AES_KEY() + key_len = ctypes.c_int(8 * len(key)) + key = (ctypes.c_ubyte * len(key))(*key) + iv = (ctypes.c_ubyte * len(iv))(*iv) + + in_len = ctypes.c_size_t(len(plain_text)) + in_ptr = (ctypes.c_ubyte * len(plain_text))(*plain_text) + out_ptr = (ctypes.c_ubyte * len(plain_text))() + + _libssl.AES_set_encrypt_key(key, key_len, ctypes.byref(aes_key)) + _libssl.AES_ige_encrypt( + ctypes.byref(in_ptr), + ctypes.byref(out_ptr), + in_len, + ctypes.byref(aes_key), + ctypes.byref(iv), + AES_ENCRYPT + ) + + return bytes(out_ptr) From b3990546eb1bc950d8f17d100887c9ff359d65b0 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 25 Jul 2018 12:19:28 +0200 Subject: [PATCH 53/70] Fix RPCError may occur for no parent message (#908) --- telethon/network/mtprotosender.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/telethon/network/mtprotosender.py b/telethon/network/mtprotosender.py index 481dd974..4a3a38b7 100644 --- a/telethon/network/mtprotosender.py +++ b/telethon/network/mtprotosender.py @@ -512,6 +512,12 @@ class MTProtoSender: __log__.debug('Handling RPC result for message %d', rpc_result.req_msg_id) + if not message: + # TODO We should not get responses to things we never sent + __log__.info('Received response without parent request: {}' + .format(rpc_result.body)) + return + if rpc_result.error: error = rpc_message_to_error(rpc_result.error) self._send_queue.put_nowait(self.state.create_message( @@ -520,8 +526,7 @@ class MTProtoSender: if not message.future.cancelled(): message.future.set_exception(error) - return - elif message: + else: # TODO Would be nice to avoid accessing a per-obj read_result # Instead have a variable that indicated how the result should # be read (an enum) and dispatch to read the result, mostly @@ -531,11 +536,6 @@ class MTProtoSender: if not message.future.cancelled(): message.future.set_result(result) - return - else: - # TODO We should not get responses to things we never sent - __log__.info('Received response without parent request: {}' - .format(rpc_result.body)) async def _handle_container(self, message): """ From 7729a2a78faa087d2953bc33eda5a6897c88319b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 25 Jul 2018 12:33:12 +0200 Subject: [PATCH 54/70] More logging for bad messages (#907) --- telethon/network/mtprotosender.py | 2 +- telethon/network/mtprotostate.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/telethon/network/mtprotosender.py b/telethon/network/mtprotosender.py index 4a3a38b7..cf19ecb4 100644 --- a/telethon/network/mtprotosender.py +++ b/telethon/network/mtprotosender.py @@ -611,7 +611,7 @@ class MTProtoSender: bad_msg = message.obj msg = self._pending_messages.get(bad_msg.bad_msg_id) - __log__.debug('Handling bad msg for message %d', bad_msg.bad_msg_id) + __log__.debug('Handling bad msg %s', bad_msg) if bad_msg.error_code in (16, 17): # Sent msg_id too low or too high (respectively). # Use the current msg_id to determine the right time offset. diff --git a/telethon/network/mtprotostate.py b/telethon/network/mtprotostate.py index 6ea13ae2..e7bde1de 100644 --- a/telethon/network/mtprotostate.py +++ b/telethon/network/mtprotostate.py @@ -157,10 +157,20 @@ class MTProtoState: Updates the time offset to the correct one given a known valid message ID. """ + bad = self._get_new_msg_id() + old = self.time_offset + now = int(time.time()) correct = correct_msg_id >> 32 self.time_offset = correct - now - self._last_msg_id = 0 + + if self.time_offset != old: + self._last_msg_id = 0 + __log__.debug( + 'Updated time offset (old offset %d, bad %d, good %d, new %d)', + old, bad, correct_msg_id, self.time_offset + ) + return self.time_offset def _get_seq_no(self, content_related): From 26f121060d114add6083e48d0ba0b90e293d6d0a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 25 Jul 2018 12:40:49 +0200 Subject: [PATCH 55/70] Always support aggressive in iter_participants (#904) --- telethon/client/chats.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/telethon/client/chats.py b/telethon/client/chats.py index a80bc3f4..39d90617 100644 --- a/telethon/client/chats.py +++ b/telethon/client/chats.py @@ -34,14 +34,14 @@ class ChatMethods(UserMethods): This has no effect for normal chats or users. aggressive (`bool`, optional): - Aggressively looks for all participants in the chat in - order to get more than 10,000 members (a hard limit - imposed by Telegram). Note that this might take a long - time (over 5 minutes), but is able to return over 90,000 - participants on groups with 100,000 members. + Aggressively looks for all participants in the chat. - This has no effect for groups or channels with less than - 10,000 members, or if a ``filter`` is given. + This is useful for channels since 20 July 2018, + Telegram added a server-side limit where only the + first 200 members can be retrieved. With this flag + set, more than 200 will be often be retrieved. + + This has no effect if a ``filter`` is given. _total (`list`, optional): A single-item list to pass the total parameter by reference. @@ -76,20 +76,16 @@ class ChatMethods(UserMethods): limit = float('inf') if limit is None else int(limit) if isinstance(entity, types.InputPeerChannel): - if _total or (aggressive and not filter): - total = (await self(functions.channels.GetFullChannelRequest( - entity - ))).full_chat.participants_count - if _total: - _total[0] = total - else: - total = 0 + if _total: + _total[0] = (await self( + functions.channels.GetFullChannelRequest(entity) + )).full_chat.participants_count if limit == 0: return seen = set() - if total > 10000 and aggressive and not filter: + if aggressive and not filter: requests = [functions.channels.GetParticipantsRequest( channel=entity, filter=types.ChannelParticipantsSearch(search + chr(x)), From f2c8663266dddd5d08024fead5b01c9d8e50bba6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 28 Jul 2018 11:28:02 +0200 Subject: [PATCH 56/70] Fix copy pasted docs and snake_case files --- telethon/client/uploads.py | 2 +- telethon/tl/custom/__init__.py | 2 +- .../tl/custom/{input_sized_file.py => inputsizedfile.py} | 0 telethon/tl/custom/messagebutton.py | 6 +++--- 4 files changed, 5 insertions(+), 5 deletions(-) rename telethon/tl/custom/{input_sized_file.py => inputsizedfile.py} (100%) diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index 09577ecb..60529eba 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -274,7 +274,7 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): Returns: :tl:`InputFileBig` if the file size is larger than 10MB, - `telethon.tl.custom.input_sized_file.InputSizedFile` + `telethon.tl.custom.inputsizedfile.InputSizedFile` (subclass of :tl:`InputFile`) otherwise. """ if isinstance(file, (types.InputFile, types.InputFileBig)): diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index 6f15892c..72f8e95e 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -1,6 +1,6 @@ from .draft import Draft from .dialog import Dialog -from .input_sized_file import InputSizedFile +from .inputsizedfile import InputSizedFile from .messagebutton import MessageButton from .forward import Forward from .message import Message diff --git a/telethon/tl/custom/input_sized_file.py b/telethon/tl/custom/inputsizedfile.py similarity index 100% rename from telethon/tl/custom/input_sized_file.py rename to telethon/tl/custom/inputsizedfile.py diff --git a/telethon/tl/custom/messagebutton.py b/telethon/tl/custom/messagebutton.py index 1bfc53a4..6145d507 100644 --- a/telethon/tl/custom/messagebutton.py +++ b/telethon/tl/custom/messagebutton.py @@ -5,9 +5,9 @@ import webbrowser class MessageButton: """ - Custom class that encapsulates a message providing an abstraction to - easily access some commonly needed features (such as the markdown text - or the text for a given message entity). + Custom class that encapsulates a message button providing + an abstraction to easily access some commonly needed features + (such as clicking the button itself). Attributes: From cc4c6202615322c9a240202672662e1335f9d1c0 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 29 Jul 2018 12:40:40 +0200 Subject: [PATCH 57/70] Show more information for bare RPCError (#919) --- telethon/errors/__init__.py | 7 +++++-- telethon/errors/rpcbaseerrors.py | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index a88459eb..38696ada 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -65,5 +65,8 @@ def rpc_message_to_error(rpc_error, report_method=None): capture = int(m.group(1)) if m.groups() else None return cls(capture=capture) - cls = base_errors.get(rpc_error.error_code, RPCError) - return cls(rpc_error.error_message) + cls = base_errors.get(rpc_error.error_code) + if cls: + return cls(rpc_error.error_message) + + return RPCError(rpc_error.error_code, rpc_error.error_message) diff --git a/telethon/errors/rpcbaseerrors.py b/telethon/errors/rpcbaseerrors.py index 061740d8..78547017 100644 --- a/telethon/errors/rpcbaseerrors.py +++ b/telethon/errors/rpcbaseerrors.py @@ -3,8 +3,13 @@ class RPCError(Exception): code = None message = None + def __init__(self, code, message): + super().__init__('RPCError {}: {}'.format(code, message)) + self.code = code + self.message = message + def __reduce__(self): - return type(self), () + return type(self), (self.code, self.message) class InvalidDCError(RPCError): From 72a04a877f89e65de1121fcf53c28f98e456de02 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 29 Jul 2018 12:48:20 +0200 Subject: [PATCH 58/70] Document editBanned.until_date --- readthedocs/extra/examples/chats-and-channels.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/readthedocs/extra/examples/chats-and-channels.rst b/readthedocs/extra/examples/chats-and-channels.rst index 3b928e8c..fc0d1d5d 100644 --- a/readthedocs/extra/examples/chats-and-channels.rst +++ b/readthedocs/extra/examples/chats-and-channels.rst @@ -237,7 +237,7 @@ banned rights of an user through :tl:`EditBannedRequest` and its parameter # Note that it's "reversed". You must set to ``True`` the permissions # you want to REMOVE, and leave as ``None`` those you want to KEEP. rights = ChannelBannedRights( - until_date=datetime.now() + timedelta(days=7), + until_date=timedelta(days=7), view_messages=None, send_messages=None, send_media=True, @@ -262,6 +262,13 @@ banned rights of an user through :tl:`EditBannedRequest` and its parameter client(EditBannedRequest(channel, user, rights)) +You can also use a ``datetime`` object for ``until_date=``, or even a +Unix timestamp. Note that if you ban someone for less than 30 seconds +or for more than 366 days, Telegram will consider the ban to actually +last forever. This is officially documented under +https://core.telegram.org/bots/api#restrictchatmember. + + Kicking a member **************** From 682e65018707b5a07290e21a0ae8d80d462d5d9c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 29 Jul 2018 12:56:11 +0200 Subject: [PATCH 59/70] Create a basic InlineResult class --- telethon/tl/custom/inlineresult.py | 135 +++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 telethon/tl/custom/inlineresult.py diff --git a/telethon/tl/custom/inlineresult.py b/telethon/tl/custom/inlineresult.py new file mode 100644 index 00000000..f0b1bb9a --- /dev/null +++ b/telethon/tl/custom/inlineresult.py @@ -0,0 +1,135 @@ +from .. import types, functions +from ... import utils + + +class InlineResult: + """ + Custom class that encapsulates a bot inline result providing + an abstraction to easily access some commonly needed features + (such as clicking a result to select it). + + Attributes: + + result (:tl:`BotInlineResult`): + The original :tl:`BotInlineResult` object. + """ + ARTICLE = 'article' + PHOTO = 'photo' + GIF = 'gif' + VIDEO = 'video' + VIDEO_GIF = 'mpeg4_gif' + AUDIO = 'audio' + DOCUMENT = 'document' + LOCATION = 'location' + VENUE = 'venue' + CONTACT = 'contact' + GAME = 'game' + + def __init__(self, client, original, query_id=None): + self._client = client + self.result = original + self._query_id = query_id + + @property + def type(self): + """ + The always-present type of this result. It will be one of: + ``'article'``, ``'photo'``, ``'gif'``, ``'mpeg4_gif'``, ``'video'``, + ``'audio'``, ``'voice'``, ``'document'``, ``'location'``, ``'venue'``, + ``'contact'``, ``'game'``. + + You can access all of these constants through `InlineResult`, + such as `InlineResult.ARTICLE`, `InlineResult.VIDEO_GIF`, etc. + """ + return self.result.type + + @property + def message(self): + """ + The always-present :tl:`BotInlineMessage` that + will be sent if `click` is called on this result. + """ + return self.result.send_message + + @property + def title(self): + """ + The title for this inline result. It may be ``None``. + """ + return self.result.title + + @property + def description(self): + """ + The description for this inline result. It may be ``None``. + """ + return self.result.description + + @property + def url(self): + """ + The URL present in this inline results. If you want to "click" + this URL to open it in your browser, you should use Python's + `webbrowser.open(url)` for such task. + """ + if isinstance(self.result, types.BotInlineResult): + return self.result.url + + @property + def photo(self): + # TODO Document - how to deal with web media vs. normal? + if isinstance(self.result, types.BotInlineResult): + return self.result.thumb + elif isinstance(self.result, types.BotInlineMediaResult): + return self.result.photo + + @property + def document(self): + # TODO Document - how to deal with web media vs. normal? + if isinstance(self.result, types.BotInlineResult): + return self.result.content + elif isinstance(self.result, types.BotInlineMediaResult): + return self.result.document + + async def click(self, entity, reply_to=None, + silent=False, clear_draft=False): + """ + Clicks this result and sends the associated `message`. + + Args: + entity (`entity`): + The entity to which the message of this result should be sent. + + reply_to (`int` | :tl:`Message`, optional): + If present, the sent message will reply to this ID or message. + + silent (`bool`, optional): + If ``True``, the sent message will not notify the user(s). + + clear_draft (`bool`, optional): + Whether the draft should be removed after sending the + message from this result or not. Defaults to ``False``. + """ + entity = await self._client.get_input_entity(entity) + reply_id = None if reply_to is None else utils.get_message_id(reply_to) + req = self._client(functions.messages.SendInlineBotResultRequest( + peer=entity, + query_id=self._query_id, + id=self.result.id, + silent=silent, + clear_draft=clear_draft, + reply_to_msg_id=reply_id + )) + return self._client._get_response_message(req, await req, entity) + + async def download_photo(self): + """ + Downloads the media in `photo` if any and returns the download path. + """ + pass + + async def download_document(self): + """ + Downloads the media in `document` if any and returns the download path. + """ + pass From 96742334a442915d2c1013bef3250c42f2a5607d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 29 Jul 2018 13:03:10 +0200 Subject: [PATCH 60/70] Fix incoming = outgoing = True not working --- telethon/events/newmessage.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index ed8f8ecf..e270c094 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -40,13 +40,12 @@ class NewMessage(EventBuilder): def __init__(self, chats=None, *, blacklist_chats=False, incoming=None, outgoing=None, from_users=None, forwards=None, pattern=None): - if incoming is not None and outgoing is None: + if incoming and outgoing: + incoming = outgoing = None # Same as no filter + elif incoming is not None and outgoing is None: outgoing = not incoming elif outgoing is not None and incoming is None: incoming = not outgoing - - if incoming and outgoing: - self.incoming = self.outgoing = None # Same as no filter elif all(x is not None and not x for x in (incoming, outgoing)): raise ValueError("Don't create an event handler if you " "don't want neither incoming or outgoing!") From 223b007a5523bd9f5d1865f92a3ab848b4ededa4 Mon Sep 17 00:00:00 2001 From: Lonami Date: Sun, 29 Jul 2018 15:49:12 +0200 Subject: [PATCH 61/70] Fix get_message_id after custom message patch --- telethon/utils.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/telethon/utils.py b/telethon/utils.py index 00976a8d..cb09f613 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -396,16 +396,13 @@ def get_input_message(message): def get_message_id(message): - """Sanitizes the 'reply_to' parameter a user may send""" + """Similar to :meth:`get_input_peer`, but for message IDs.""" if message is None: return None if isinstance(message, int): return message - if hasattr(message, 'original_message'): - return message.original_message.id - try: if message.SUBCLASS_OF_ID == 0x790009e3: # hex(crc32(b'Message')) = 0x790009e3 From f0a26d7c765dee09b3a2ab85c7804923568cd17a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 29 Jul 2018 23:16:01 +0200 Subject: [PATCH 62/70] Implement global search (closes #920) --- telethon/client/messages.py | 45 +++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/telethon/client/messages.py b/telethon/client/messages.py index fc6112db..c3410f2b 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -37,6 +37,14 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): entity (`entity`): The entity from whom to retrieve the message history. + It may be ``None`` to perform a global search, or + to get messages by their ID from no particular chat. + Note that some of the offsets will not work if this + is the case. + + Note that if you want to perform a global search, + you **must** set a non-empty `search` string. + limit (`int` | `None`, optional): Number of messages to be retrieved. Due to limitations with the API retrieving more than 3000 messages will take longer @@ -104,6 +112,8 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): instead of being `max_id` as well since messages are returned in ascending order. + You cannot use this if both `entity` and `ids` are ``None``. + _total (`list`, optional): A single-item list to pass the total parameter by reference. @@ -117,9 +127,9 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): an higher limit, so you're free to set the ``batch_size`` that you think may be good. """ - # It's possible to get messages by ID without their entity, so only - # fetch the input version if we're not using IDs or if it was given. - if not ids or entity: + # Note that entity being ``None`` is intended to get messages by + # ID under no specific chat, and also to request a global search. + if entity: entity = await self.get_input_entity(entity) if ids: @@ -158,7 +168,19 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): from_id = None limit = float('inf') if limit is None else int(limit) - if search is not None or filter or from_user: + if not entity: + if reverse: + raise ValueError('Cannot reverse global search') + + reverse = None + request = functions.messages.SearchGlobalRequest( + q=search or '', + offset_date=offset_date, + offset_peer=types.InputPeerEmpty(), + offset_id=offset_id, + limit=1 + ) + elif search is not None or filter or from_user: if filter is None: filter = types.InputMessagesFilterEmpty() request = functions.messages.SearchRequest( @@ -243,7 +265,9 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): or from_id and message.from_id != from_id): continue - if reverse: + if reverse is None: + pass + elif reverse: if message.id <= last_id or message.id >= max_id: return else: @@ -282,12 +306,15 @@ class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods): break else: request.offset_id = last_message.id - if isinstance(request, functions.messages.GetHistoryRequest): - request.offset_date = last_message.date - else: + if isinstance(request, functions.messages.SearchRequest): request.max_date = last_message.date + else: + # getHistory and searchGlobal call it offset_date + request.offset_date = last_message.date - if reverse: + if isinstance(request, functions.messages.SearchGlobalRequest): + request.offset_peer = last_message.input_chat + elif reverse: # We want to skip the one we already have request.add_offset -= 1 From 638eeb3c825a591db9d2407da260861ef3156de6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 31 Jul 2018 12:14:13 +0200 Subject: [PATCH 63/70] Fix attributes not being inferred for open()ed files --- telethon/utils.py | 64 +++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/telethon/utils.py b/telethon/utils.py index cb09f613..586812b9 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -419,47 +419,41 @@ def get_attributes(file, *, attributes=None, mime_type=None, Get a list of attributes for the given file and the mime type as a tuple ([attribute], mime_type). """ - if isinstance(file, str): - # Determine mime-type and attributes - # Take the first element by using [0] since it returns a tuple - if mime_type is None: - mime_type = mimetypes.guess_type(file)[0] + name = file if isinstance(file, str) else getattr(file, 'name', 'unnamed') + if mime_type is None: + mime_type = mimetypes.guess_type(name)[0] - attr_dict = {types.DocumentAttributeFilename: - types.DocumentAttributeFilename(os.path.basename(file))} + attr_dict = {types.DocumentAttributeFilename: + types.DocumentAttributeFilename(os.path.basename(name))} - if is_audio(file) and hachoir is not None: + if is_audio(file) and hachoir is not None: + with hachoir.parser.createParser(file) as parser: + m = hachoir.metadata.extractMetadata(parser) + attr_dict[types.DocumentAttributeAudio] = \ + types.DocumentAttributeAudio( + voice=voice_note, + title=m.get('title') if m.has('title') else None, + performer=m.get('author') if m.has('author') else None, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + + if not force_document and is_video(file): + if hachoir: with hachoir.parser.createParser(file) as parser: m = hachoir.metadata.extractMetadata(parser) - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio( - voice=voice_note, - title=m.get('title') if m.has('title') else None, - performer=m.get('author') if m.has('author') else None, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - - if not force_document and is_video(file): - if hachoir: - with hachoir.parser.createParser(file) as parser: - m = hachoir.metadata.extractMetadata(parser) - doc = types.DocumentAttributeVideo( - round_message=video_note, - w=m.get('width') if m.has('width') else 0, - h=m.get('height') if m.has('height') else 0, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - else: doc = types.DocumentAttributeVideo( - 0, 1, 1, round_message=video_note) + round_message=video_note, + w=m.get('width') if m.has('width') else 0, + h=m.get('height') if m.has('height') else 0, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + else: + doc = types.DocumentAttributeVideo( + 0, 1, 1, round_message=video_note) - attr_dict[types.DocumentAttributeVideo] = doc - else: - attr_dict = {types.DocumentAttributeFilename: - types.DocumentAttributeFilename( - os.path.basename(getattr(file, 'name', None) or 'unnamed'))} + attr_dict[types.DocumentAttributeVideo] = doc if voice_note: if types.DocumentAttributeAudio in attr_dict: From 972950fc2edb24920c661c7edb5a66f56bc5ec5b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 31 Jul 2018 23:23:52 +0200 Subject: [PATCH 64/70] Create utils.resolve_bot_file_id --- telethon/utils.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/telethon/utils.py b/telethon/utils.py index 586812b9..ee123b32 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -2,11 +2,14 @@ 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 base64 +import binascii import itertools import math import mimetypes import os import re +import struct from collections import UserList from mimetypes import guess_extension from types import GeneratorType @@ -733,6 +736,102 @@ def resolve_id(marked_id): return -marked_id, types.PeerChat +def _rle_decode(data): + """ + Decodes run-length-encoded `data`. + """ + new = b'' + last = b'' + for cur in data: + cur = bytes([cur]) + if last == b'\0': + new += last * ord(cur) + last = b'' + else: + new += last + last = cur + + return new + last + + +def resolve_bot_file_id(file_id): + """ + Given a Bot API-style `file_id`, returns the media it represents. + If the `file_id` is not valid, ``None`` is returned instead. + + Note that the `file_id` does not have information such as image + dimensions or file size, so these will be zero if present. + + For thumbnails, the photo ID and hash will always be zero. + """ + try: + data = file_id.encode('ascii') + except (UnicodeEncodeError, AttributeError): + return None + + data.replace(b'-', b'+').replace(b'_', b'/') + b'=' * (len(data) % 4) + try: + data = base64.b64decode(data) + except binascii.Error: + return None + + data = _rle_decode(data) + if data[-1] == b'\x02': + return None + + data = data[:-1] + if len(data) == 24: + file_type, dc_id, media_id, access_hash = struct.unpack(' Date: Tue, 31 Jul 2018 23:35:22 +0200 Subject: [PATCH 65/70] Support bot API file_id on send_file --- telethon/client/uploads.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index 60529eba..8bf3c48e 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -41,7 +41,8 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): Furthermore the file may be any media (a message, document, photo or similar) so that it can be resent without the need - to download and re-upload it again. + to download and re-upload it again. Bot API ``file_id`` + format is also supported. If a list or similar is provided, the files in it will be sent as an album in the order in which they appear, sliced @@ -389,10 +390,15 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): return None, None # Can't turn whatever was given into media media = None + file_handle = None as_image = utils.is_image(file) and not force_document use_cache = types.InputPhoto if as_image else types.InputDocument - if isinstance(file, str) and re.match('https?://', file): - file_handle = None + if not isinstance(file, str): + file_handle = await self.upload_file( + file, progress_callback=progress_callback, + use_cache=use_cache if allow_cache else None + ) + elif re.match('https?://', file): if as_image: media = types.InputMediaPhotoExternal(file) elif not force_document and utils.is_gif(file): @@ -400,10 +406,9 @@ class UploadMethods(ButtonMethods, MessageParseMethods, UserMethods): else: media = types.InputMediaDocumentExternal(file) else: - file_handle = await self.upload_file( - file, progress_callback=progress_callback, - use_cache=use_cache if allow_cache else None - ) + bot_file = utils.resolve_bot_file_id(file) + if bot_file: + media = utils.get_input_media(bot_file) if media: pass # Already have media, don't check the rest From 7d880a856e314ac8530bc188a64e7b98fcce7bf6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 1 Aug 2018 00:15:23 +0200 Subject: [PATCH 66/70] Implement InlineResult.download_media --- telethon/tl/custom/inlineresult.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/telethon/tl/custom/inlineresult.py b/telethon/tl/custom/inlineresult.py index f0b1bb9a..491970e8 100644 --- a/telethon/tl/custom/inlineresult.py +++ b/telethon/tl/custom/inlineresult.py @@ -77,7 +77,10 @@ class InlineResult: @property def photo(self): - # TODO Document - how to deal with web media vs. normal? + """ + Returns either the :tl:`WebDocument` thumbnail for + normal results or the :tl:`Photo` for media results. + """ if isinstance(self.result, types.BotInlineResult): return self.result.thumb elif isinstance(self.result, types.BotInlineMediaResult): @@ -85,7 +88,10 @@ class InlineResult: @property def document(self): - # TODO Document - how to deal with web media vs. normal? + """ + Returns either the :tl:`WebDocument` content for + normal results or the :tl:`Document` for media results. + """ if isinstance(self.result, types.BotInlineResult): return self.result.content elif isinstance(self.result, types.BotInlineMediaResult): @@ -122,14 +128,14 @@ class InlineResult: )) return self._client._get_response_message(req, await req, entity) - async def download_photo(self): + async def download_media(self, *args, **kwargs): """ - Downloads the media in `photo` if any and returns the download path. - """ - pass + Downloads the media in this result (if there is a document, the + document will be downloaded; otherwise, the photo will if present). - async def download_document(self): + This is a wrapper around `client.download_media + `. """ - Downloads the media in `document` if any and returns the download path. - """ - pass + if self.document or self.photo: + return await self._client.download_media( + self.document or self.photo, *args, **kwargs) From 76c7217000cd9d7e9adaed1ca90ed36ab1966703 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 1 Aug 2018 00:37:25 +0200 Subject: [PATCH 67/70] Support downloading web documents --- telethon/client/downloads.py | 74 ++++++++++++++++++++++++++++++------ telethon/utils.py | 3 +- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index abf7f7c9..85d3375c 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -8,6 +8,12 @@ from .users import UserMethods from .. import utils, helpers, errors from ..tl import TLObject, types, functions +try: + import aiohttp +except ImportError: + aiohttp = None + + __log__ = logging.getLogger(__name__) @@ -140,6 +146,10 @@ class DownloadMethods(UserMethods): return self._download_contact( media, file ) + elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)): + return await self._download_web_document( + media, file, progress_callback + ) async def download_file( self, input_location, file=None, *, part_size_kb=None, @@ -298,19 +308,12 @@ class DownloadMethods(UserMethods): progress_callback=progress_callback) return file - async def _download_document( - self, document, file, date, progress_callback): - """Specialized version of .download_media() for documents.""" - if isinstance(document, types.MessageMediaDocument): - document = document.document - if not isinstance(document, types.Document): - return - - file_size = document.size - + @staticmethod + def _get_kind_and_names(attributes): + """Gets kind and possible names for :tl:`DocumentAttribute`.""" kind = 'document' possible_names = [] - for attr in document.attributes: + for attr in attributes: if isinstance(attr, types.DocumentAttributeFilename): possible_names.insert(0, attr.file_name) @@ -327,13 +330,24 @@ class DownloadMethods(UserMethods): elif attr.voice: kind = 'voice' + return kind, possible_names + + async def _download_document( + self, document, file, date, progress_callback): + """Specialized version of .download_media() for documents.""" + if isinstance(document, types.MessageMediaDocument): + document = document.document + if not isinstance(document, types.Document): + return + + kind, possible_names = self._get_kind_and_names(document.attributes) file = self._get_proper_filename( file, kind, utils.get_extension(document), date=date, possible_names=possible_names ) await self.download_file( - document, file, file_size=file_size, + document, file, file_size=document.size, progress_callback=progress_callback) return file @@ -373,6 +387,42 @@ class DownloadMethods(UserMethods): return file + @classmethod + async def _download_web_document(cls, web, file, progress_callback): + """ + Specialized version of .download_media() for web documents. + """ + if not aiohttp: + raise ValueError( + 'Cannot download web documents without the aiohttp ' + 'dependency install it (pip install aiohttp)' + ) + + # TODO Better way to get opened handles of files and auto-close + if isinstance(file, str): + kind, possible_names = cls._get_kind_and_names(web.attributes) + file = cls._get_proper_filename( + file, kind, utils.get_extension(web), + possible_names=possible_names + ) + f = open(file, 'wb') + else: + f = file + + try: + with aiohttp.ClientSession() as session: + # TODO Use progress_callback; get content length from response + # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 + async with session.get(web.url) as response: + while True: + chunk = await response.content.read(128 * 1024) + if not chunk: + break + f.write(chunk) + finally: + if isinstance(file, str): + f.close() + @staticmethod def _get_proper_filename(file, kind, extension, date=None, possible_names=None): diff --git a/telethon/utils.py b/telethon/utils.py index ee123b32..81bbe11d 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -92,7 +92,8 @@ def get_extension(media): # Documents will come with a mime type if isinstance(media, types.MessageMediaDocument): media = media.document - if isinstance(media, types.Document): + if isinstance(media, ( + types.Document, types.WebDocument, types.WebDocumentNoProxy)): if media.mime_type == 'application/octet-stream': # Octet stream are just bytes, which have no default extension return '' From 49a6cb4ef8cc803d89b318c1bc20772d12f18113 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 1 Aug 2018 01:06:08 +0200 Subject: [PATCH 68/70] Fix InlineResult.click() --- telethon/tl/custom/inlineresult.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/telethon/tl/custom/inlineresult.py b/telethon/tl/custom/inlineresult.py index 491970e8..67ac0131 100644 --- a/telethon/tl/custom/inlineresult.py +++ b/telethon/tl/custom/inlineresult.py @@ -118,15 +118,16 @@ class InlineResult: """ entity = await self._client.get_input_entity(entity) reply_id = None if reply_to is None else utils.get_message_id(reply_to) - req = self._client(functions.messages.SendInlineBotResultRequest( + req = functions.messages.SendInlineBotResultRequest( peer=entity, query_id=self._query_id, id=self.result.id, silent=silent, clear_draft=clear_draft, reply_to_msg_id=reply_id - )) - return self._client._get_response_message(req, await req, entity) + ) + return self._client._get_response_message( + req, await self._client(req), entity) async def download_media(self, *args, **kwargs): """ From 7a2d7d98ade3e762193e002b423ade8536060bf5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 1 Aug 2018 01:06:47 +0200 Subject: [PATCH 69/70] Implement client.inline_query() --- readthedocs/telethon.tl.custom.rst | 8 ++++++ telethon/client/__init__.py | 1 + telethon/client/bots.py | 45 ++++++++++++++++++++++++++++++ telethon/client/telegramclient.py | 4 +-- telethon/sync.py | 6 ++-- telethon/tl/custom/__init__.py | 1 + 6 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 telethon/client/bots.py diff --git a/readthedocs/telethon.tl.custom.rst b/readthedocs/telethon.tl.custom.rst index 1099fa5e..ccc3f14c 100644 --- a/readthedocs/telethon.tl.custom.rst +++ b/readthedocs/telethon.tl.custom.rst @@ -54,6 +54,14 @@ telethon\.tl\.custom\.button module :undoc-members: :show-inheritance: +telethon\.tl\.custom\.inlineresult module +----------------------------------------- + +.. automodule:: telethon.tl.custom.inlineresult + :members: + :undoc-members: + :show-inheritance: + telethon\.tl\.custom\.chatgetter module --------------------------------------- diff --git a/telethon/client/__init__.py b/telethon/client/__init__.py index 0e8d2377..2ea98295 100644 --- a/telethon/client/__init__.py +++ b/telethon/client/__init__.py @@ -20,4 +20,5 @@ from .chats import ChatMethods from .dialogs import DialogMethods from .downloads import DownloadMethods from .auth import AuthMethods +from .bots import BotMethods from .telegramclient import TelegramClient diff --git a/telethon/client/bots.py b/telethon/client/bots.py new file mode 100644 index 00000000..8bef4c25 --- /dev/null +++ b/telethon/client/bots.py @@ -0,0 +1,45 @@ +from .users import UserMethods +from ..tl import types, functions, custom + + +class BotMethods(UserMethods): + async def inline_query(self, bot, query, *, offset=None, geo_point=None): + """ + Makes the given inline query to the specified bot + i.e. ``@vote My New Poll`` would be as follows: + + >>> client = ... + >>> client.inline_query('vote', 'My New Poll') + + Args: + bot (`entity`): + The bot entity to which the inline query should be made. + + query (`str`): + The query that should be made to the bot. + + offset (`str`, optional): + The string offset to use for the bot. + + geo_point (:tl:`GeoPoint`, optional) + The geo point location information to send to the bot + for localised results. Available under some bots. + + Returns: + A list of `custom.InlineResult + `. + """ + bot = await self.get_input_entity(bot) + result = await self(functions.messages.GetInlineBotResultsRequest( + bot=bot, + peer=types.InputPeerEmpty(), + query=query, + offset=offset or '', + geo_point=geo_point + )) + + # TODO Custom InlineResults(UserList) class with more information + return [ + custom.InlineResult(self, x, query_id=result.query_id) + for x in result.results + ] diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index e10dc43f..955765fb 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -1,12 +1,12 @@ from . import ( - AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + AuthMethods, DownloadMethods, DialogMethods, ChatMethods, BotMethods, MessageMethods, ButtonMethods, UpdateMethods, UploadMethods, MessageParseMethods, UserMethods ) class TelegramClient( - AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + AuthMethods, DownloadMethods, DialogMethods, ChatMethods, BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, MessageParseMethods, UserMethods ): diff --git a/telethon/sync.py b/telethon/sync.py index 9e79a112..5395f8ad 100644 --- a/telethon/sync.py +++ b/telethon/sync.py @@ -17,7 +17,9 @@ import inspect from async_generator import isasyncgenfunction from .client.telegramclient import TelegramClient -from .tl.custom import Draft, Dialog, MessageButton, Forward, Message +from .tl.custom import ( + Draft, Dialog, MessageButton, Forward, Message, InlineResult +) from .tl.custom.chatgetter import ChatGetter from .tl.custom.sendergetter import SenderGetter @@ -81,4 +83,4 @@ def syncify(*types): syncify(TelegramClient, Draft, Dialog, MessageButton, - ChatGetter, SenderGetter, Forward, Message) + ChatGetter, SenderGetter, Forward, Message, InlineResult) diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index 72f8e95e..e3b6f39b 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -6,3 +6,4 @@ from .forward import Forward from .message import Message from .button import Button from .inline import InlineBuilder +from .inlineresult import InlineResult From 80a5e709cb2dc17c193e99c11ca25d2b0e99ba11 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 1 Aug 2018 13:39:34 +0200 Subject: [PATCH 70/70] Support .download_media with bot API file_id --- telethon/client/downloads.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index 85d3375c..4c0e1239 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -129,6 +129,9 @@ class DownloadMethods(UserMethods): date = datetime.datetime.now() media = message + if isinstance(media, str): + media = utils.resolve_bot_file_id(media) + if isinstance(media, types.MessageMediaWebPage): if isinstance(media.webpage, types.WebPage): media = media.webpage.document or media.webpage.photo