diff --git a/readthedocs/basic/quick-start.rst b/readthedocs/basic/quick-start.rst index 91481983..b3168701 100644 --- a/readthedocs/basic/quick-start.rst +++ b/readthedocs/basic/quick-start.rst @@ -41,7 +41,7 @@ use these if possible. # ...to your contacts await client.send_message('+34600123123', 'Hello, friend!') # ...or even to any username - await client.send_message('TelethonChat', 'Hello, Telethon!') + await client.send_message('username', 'Testing Telethon!') # You can, of course, use markdown in your messages: message = await client.send_message( diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index 453d54fa..562d6b14 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -99,7 +99,7 @@ You will still need an API ID and hash, but the process is very similar: api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - bot_token = '12345:0123456789abcdef0123456789abcdef + bot_token = '12345:0123456789abcdef0123456789abcdef' # We have to manually call "start" if we want an explicit bot token bot = TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) diff --git a/readthedocs/concepts/asyncio.rst b/readthedocs/concepts/asyncio.rst index 03ffd198..ef7c3cd3 100644 --- a/readthedocs/concepts/asyncio.rst +++ b/readthedocs/concepts/asyncio.rst @@ -219,19 +219,21 @@ Can I use threads? ================== Yes, you can, but you must understand that the loops themselves are -not thread safe. and you must be sure to know what is happening. You -may want to create a loop in a new thread and make sure to pass it to -the client: +not thread safe. and you must be sure to know what is happening. The +easiest and cleanest option is to use `asyncio.run` to create and manage +the new event loop for you: .. code-block:: python import asyncio import threading - def go(): - loop = asyncio.new_event_loop() + async def actual_work(): client = TelegramClient(..., loop=loop) - ... + ... # can use `await` here + + def go(): + asyncio.run(actual_work()) threading.Thread(target=go).start() @@ -308,27 +310,26 @@ you can run requests in parallel: async def main(): last, sent, download_path = await asyncio.gather( - client.get_messages('TelethonChat', 10), - client.send_message('TelethonOfftopic', 'Hey guys!'), - client.download_profile_photo('TelethonChat') + client.get_messages('telegram', 10), + client.send_message('me', 'Using asyncio!'), + client.download_profile_photo('telegram') ) loop.run_until_complete(main()) -This code will get the 10 last messages from `@TelethonChat -`_, send one to `@TelethonOfftopic -`_, and also download the profile -photo of the main group. `asyncio` will run all these three tasks -at the same time. You can run all the tasks you want this way. +This code will get the 10 last messages from `@telegram +`_, send one to the chat with yourself, and also +download the profile photo of the channel. `asyncio` will run all these +three tasks at the same time. You can run all the tasks you want this way. A different way would be: .. code-block:: python - loop.create_task(client.get_messages('TelethonChat', 10)) - loop.create_task(client.send_message('TelethonOfftopic', 'Hey guys!')) - loop.create_task(client.download_profile_photo('TelethonChat')) + loop.create_task(client.get_messages('telegram', 10)) + loop.create_task(client.send_message('me', 'Using asyncio!')) + loop.create_task(client.download_profile_photo('telegram')) They will run in the background as long as the loop is running too. diff --git a/readthedocs/concepts/entities.rst b/readthedocs/concepts/entities.rst index 491c8c1e..4ee55d48 100644 --- a/readthedocs/concepts/entities.rst +++ b/readthedocs/concepts/entities.rst @@ -178,7 +178,7 @@ exist, which just have the ID. You cannot get the hash out of them since you should not be needing it. The library probably has cached it before. Peers are enough to identify an entity, but they are not enough to make -a request with them use them. You need to know their hash before you can +a request with them. You need to know their hash before you can "use them", and to know the hash you need to "encounter" them, let it be in your dialogs, participants, message forwards, etc. @@ -296,10 +296,10 @@ applications"? Now do the same with the library. Use what applies: await client.get_dialogs() # Are they participant of some group? Get them. - await client.get_participants('TelethonChat') + await client.get_participants('username') # Is the entity the original sender of a forwarded message? Get it. - await client.get_messages('TelethonChat', 100) + await client.get_messages('username', 100) # NOW you can use the ID, anywhere! await client.send_message(123456, 'Hi!') diff --git a/readthedocs/concepts/sessions.rst b/readthedocs/concepts/sessions.rst index 03c3cbf0..a94bc773 100644 --- a/readthedocs/concepts/sessions.rst +++ b/readthedocs/concepts/sessions.rst @@ -100,6 +100,9 @@ There are other community-maintained implementations available: * `Redis `_: stores all sessions in a single Redis data store. +* `MongoDB `_: + stores the current session in a MongoDB database. + Creating your Own Storage ========================= diff --git a/readthedocs/developing/telegram-api-in-other-languages.rst b/readthedocs/developing/telegram-api-in-other-languages.rst index 3db1ec9f..943a4a1c 100644 --- a/readthedocs/developing/telegram-api-in-other-languages.rst +++ b/readthedocs/developing/telegram-api-in-other-languages.rst @@ -10,7 +10,7 @@ understand the official Telegram documentation) on several languages (even more Python too), listed below: C -* += Possibly the most well-known unofficial open source implementation out there by `@vysheng `__, @@ -29,10 +29,10 @@ published `here `__. JavaScript ========== -`@zerobias `__ is working on -`telegram-mtproto `__, -a work-in-progress JavaScript library installable via -`npm `__. +`Ali Gasymov `__ made the `@mtproto/core `__ library for the browser and nodejs installable via `npm `__. + +`painor `__ is the primary author of `gramjs `__, +a Telegram client implementation in JavaScript. Kotlin ====== @@ -45,6 +45,11 @@ languages for `@badoualy `__, currently as a beta– yet working. +Language-Agnostic +================= + +`Taas `__ is a service that lets you use Telegram API with any HTTP client via API. Using tdlib under the hood, Taas is commercial service, but allows free access if you use under 5000 requests per month. + PHP === diff --git a/readthedocs/examples/projects-using-telethon.rst b/readthedocs/examples/projects-using-telethon.rst deleted file mode 100644 index 7ba4f998..00000000 --- a/readthedocs/examples/projects-using-telethon.rst +++ /dev/null @@ -1,98 +0,0 @@ -.. _telethon_projects: - -======================= -Projects using Telethon -======================= - -This page lists some **interesting and useful** real world -examples showcasing what can be built with the library. - -.. note:: - - Do you have an interesting project that uses the library or know of any - that's not listed here? Feel free to leave a comment at - `issue 744 `_ - so it can be included in the next revision of the documentation! - - You can also advertise your bot and its features, in the issue, although - it should be a big project which can be useful for others before being - included here, so please don't feel offended if it can't be here! - - -.. _projects-telegram-export: - -telethon_examples/ -================== - -`telethon_examples `_ / -`Lonami's site `_ - -This documentation is not the only place where you can find useful code -snippets using the library. The main repository also has a folder with -some cool examples (even a Tkinter GUI!) which you can download, edit -and run to learn and play with them. - -@TelethonSnippets -================= - -`@TelethonSnippets `_ - -You can find useful short snippets for Telethon here. - -telegram-export -=============== - -`telegram-export `_ / -`expectocode's GitHub `_ - -A tool to download Telegram data (users, chats, messages, and media) -into a database (and display the saved data). - -.. _projects-mautrix-telegram: - -mautrix-telegram -================ - -`mautrix-telegram `_ / -`maunium's site `_ - -A Matrix-Telegram hybrid puppeting/relaybot bridge. - -.. _projects-telegramtui: - -TelegramTUI -=========== - -`TelegramTUI `_ / -`bad-day's GitHub `_ - -A Telegram client on your terminal. - -tgcloud -======= - -`tgcloud `_ / -`tgcloud's site `_ - -Opensource Telegram based cloud storage. - -tgmount -======= - -`tgmount `_ / -`nktknshn's GitHub `_ - -Mount Telegram dialogs and channels as a Virtual File System. - -garnet -====== - -`garnet `_ / -`uwinx's GitHub `_ - -Pomegranate (or ``garnet`` for short) is a small telethon add-on which -features persistent conversations based on Finite State Machines (FSM), -a new ``Filter`` to define handlers more conveniently and utilities to -run code on start and finish of the client. Be sure to check the project -to learn about its latest features, since this description may be out of -date. diff --git a/readthedocs/examples/word-of-warning.rst b/readthedocs/examples/word-of-warning.rst index 5501325f..de91741f 100644 --- a/readthedocs/examples/word-of-warning.rst +++ b/readthedocs/examples/word-of-warning.rst @@ -13,4 +13,5 @@ Full API **will** break between different minor versions of the library, since Telegram changes very often. The friendly methods will be kept compatible between major versions. -If you need to see real-world examples, please refer to :ref:`telethon_projects`. +If you need to see real-world examples, please refer to the +`wiki page of projects using Telethon `__. diff --git a/readthedocs/index.rst b/readthedocs/index.rst index 0776e3f2..eb786f10 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -83,7 +83,6 @@ You can also use the menu on the left to quickly skip over sections. examples/chats-and-channels examples/users examples/working-with-messages - examples/projects-using-telethon .. toctree:: :hidden: diff --git a/readthedocs/misc/changelog.rst b/readthedocs/misc/changelog.rst index f8c687ac..8d6484de 100644 --- a/readthedocs/misc/changelog.rst +++ b/readthedocs/misc/changelog.rst @@ -13,6 +13,198 @@ it can take advantage of new goodies! .. contents:: List of All Versions +Bug Fixes (v1.16.1) +=================== + +The last release added support to ``force_file`` on any media, including +things that were not possible before like ``.webp`` files. However, the +``force_document`` toggle commonly used for photos was applied "twice" +(one told the library to send it as a document, and then to send that +document as file), which prevented Telegram for analyzing the images. Long +story short, sending files to the stickers bot stopped working, but that's +been fixed now, and sending photos as documents include the size attribute +again as long as Telegram adds it. + +Enhancements +~~~~~~~~~~~~ + +* When trying to `client.start() ` to + another account if you were previously logged in, the library will now warn + you because this is probably not intended. To avoid the warning, make sure + you're logging in to the right account or logout from the other first. +* Sending a copy of messages with polls will now work when possible. +* The library now automatically retries on inter-dc call errors (which occur + when Telegram has internal issues). + +Bug Fixes +~~~~~~~~~ + +* The aforementioned issue with ``force_document``. +* Square brackets removed from IPv6 addresses. This may fix IPv6 support. + + +Channel Statistics (v1.16) +========================== + ++------------------------+ +| Scheme layer used: 116 | ++------------------------+ + +The newest Telegram update has a new method to also retrieve megagroup +statistics, which can now be used with `client.get_stats() +`. This way you'll be able +to access the raw data about your channel or megagroup statistics. + +The maximum file size limit has also been increased to 2GB on the server, +so you can send even larger files. + +Breaking Changes +~~~~~~~~~~~~~~~~ + +* Besides the obvious layer change, the ``loop`` argument **is now ignored**. + It has been deprecated since Python 3.8 and will be removed in Python 3.10, + and also caused some annoying warning messages when using certain parts of + the library. If you were (incorrectly) relying on using a different loop + from the one that was set, things may break. + +Enhancements +~~~~~~~~~~~~ + +* `client.upload_file() ` + now works better when streaming files (anything that has a ``.read()``), + instead of reading it all into memory when possible. + + +QR login (v1.15) +================ + +*Published at 2020/07/04* + ++------------------------+ +| Scheme layer used: 114 | ++------------------------+ + +The library now has a friendly method to perform QR-login, as detailed in +https://core.telegram.org/api/qr-login. It won't generate QR images, but it +provides a way for you to easily do so with any other library of your choice. + +Additions +~~~~~~~~~ + +* New `client.qr_login() `. +* `message.click ` now lets you + click on buttons requesting phone or location. + +Enhancements +~~~~~~~~~~~~ + +* Updated documentation and list of known errors. +* `events.Album ` should now handle albums from + different data centers more gracefully. +* `client.download_file() + ` now supports + `pathlib.Path` as the destination. + +Bug fixes +~~~~~~~~~ + +* No longer crash on updates received prior to logging in. +* Server-side changes caused clicking on inline buttons to trigger a different + error, which is now handled correctly. + + +Minor quality of life improvements (v1.14) +========================================== + +*Published at 2020/05/26* + ++------------------------+ +| Scheme layer used: 113 | ++------------------------+ + +Some nice things that were missing, along with the usual bug-fixes. + +Additions +~~~~~~~~~ + +* New `Message.dice ` property. +* The ``func=`` parameter of events can now be an ``async`` function. + +Bug fixes +~~~~~~~~~ + +* Fixed `client.action() ` + having an alias wrong. +* Fixed incorrect formatting of some errors. +* Probably more reliable detection of pin events in small groups. +* Fixed send methods on `client.conversation() + ` were not honoring + cancellation. +* Flood waits of zero seconds are handled better. +* Getting the pinned message in a chat was failing. +* Fixed the return value when forwarding messages if some were missing + and also the return value of albums. + +Enhancements +~~~~~~~~~~~~ + +* ``.tgs`` files are now recognised as animated stickers. +* The service message produced by `Message.pin() + ` is now returned. +* Sending a file with `client.send_file() + ` now works fine when + you pass an existing dice media (e.g. sending a message copy). +* `client.edit_permissions() ` + now has the ``embed_links`` parameter which was missing. + +Bug Fixes (v1.13) +================= + +*Published at 2020/04/25* + ++------------------------+ +| Scheme layer used: 112 | ++------------------------+ + +Bug fixes and layer bump. + +Bug fixes +~~~~~~~~~ + +* Passing ``None`` as the entity to `client.delete_messages() + ` would fail. +* When downloading a thumbnail, the name inferred was wrong. + +Bug Fixes (v1.12) +================= + +*Published at 2020/04/20* + ++------------------------+ +| Scheme layer used: 111 | ++------------------------+ + +Once again nothing major, but a few bug fixes and primarily the new layer +deserves a new minor release. + +Bug fixes +~~~~~~~~~ + +These were already included in the ``v1.11.3`` patch: + +* ``libssl`` check was failing on macOS. +* Getting input users would sometimes fail on `events.ChatAction + `. + +These bug fixes are available in this release and beyond: + +* Avoid another occurrence of `MemoryError`. +* Sending large files in albums would fail because it tried to cache them. +* The ``thumb`` was being ignored when sending files from :tl:`InputFile`. +* Fixed editing inline messages from callback queries in some cases. +* Proxy connection is now blocking which should help avoid some errors. + + Bug Fixes (v1.11) ================= diff --git a/readthedocs/modules/custom.rst b/readthedocs/modules/custom.rst index 1f1329bf..8df74a24 100644 --- a/readthedocs/modules/custom.rst +++ b/readthedocs/modules/custom.rst @@ -136,6 +136,15 @@ MessageButton :show-inheritance: +QRLogin +======= + +.. automodule:: telethon.qrlogin + :members: + :undoc-members: + :show-inheritance: + + SenderGetter ============ diff --git a/readthedocs/quick-references/client-reference.rst b/readthedocs/quick-references/client-reference.rst index 52e7d71a..a148f32c 100644 --- a/readthedocs/quick-references/client-reference.rst +++ b/readthedocs/quick-references/client-reference.rst @@ -31,6 +31,7 @@ Auth start send_code_request sign_in + qr_login sign_up log_out edit_2fa @@ -138,6 +139,7 @@ Chats get_profile_photos edit_admin edit_permissions + get_stats action Parse Mode diff --git a/telethon/client/auth.py b/telethon/client/auth.py index b96ee4dd..b0e5243e 100644 --- a/telethon/client/auth.py +++ b/telethon/client/auth.py @@ -3,9 +3,10 @@ import inspect import os import sys import typing +import warnings from .. import utils, helpers, errors, password as pwd_mod -from ..tl import types, functions +from ..tl import types, functions, custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -138,7 +139,29 @@ class AuthMethods: if not self.is_connected(): await self.connect() - if await self.is_user_authorized(): + # Rather than using `is_user_authorized`, use `get_me`. While this is + # more expensive and needs to retrieve more data from the server, it + # enables the library to warn users trying to login to a different + # account. See #1172. + me = await self.get_me() + if me is not None: + # The warnings here are on a best-effort and may fail. + if bot_token: + # bot_token's first part has the bot ID, but it may be invalid + # so don't try to parse as int (instead cast our ID to string). + if bot_token[:bot_token.find(':')] != str(me.id): + warnings.warn( + 'the session already had an authorized user so it did ' + 'not login to the bot account using the provided ' + 'bot_token (it may not be using the user you expect)' + ) + elif not callable(phone) and phone != me.phone: + warnings.warn( + 'the session already had an authorized user so it did ' + 'not login to the user account using the provided ' + 'phone (it may not be using the user you expect)' + ) + return self if not bot_token: @@ -496,6 +519,43 @@ class AuthMethods: return result + async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: + """ + Initiates the QR login procedure. + + Note that you must be connected before invoking this, as with any + other request. + + It is up to the caller to decide how to present the code to the user, + whether it's the URL, using the token bytes directly, or generating + a QR code and displaying it by other means. + + See the documentation for `QRLogin` to see how to proceed after this. + + Arguments + ignored_ids (List[`int`]): + List of already logged-in user IDs, to prevent logging in + twice with the same user. + + Returns + An instance of `QRLogin`. + + Example + .. code-block:: python + + def display_url_as_qr(url): + pass # do whatever to show url as a qr to the user + + qr_login = await client.qr_login() + display_url_as_qr(qr_login.url) + + # Important! You need to wait for the login to complete! + await qr_login.wait() + """ + qr_login = custom.QRLogin(self, ignored_ids or []) + await qr_login.recreate() + return qr_login + async def log_out(self: 'TelegramClient') -> bool: """ Logs out Telegram and deletes the current ``*.session`` file. diff --git a/telethon/client/chats.py b/telethon/client/chats.py index bad8fc4b..ee8dacfd 100644 --- a/telethon/client/chats.py +++ b/telethon/client/chats.py @@ -4,7 +4,7 @@ import itertools import string import typing -from .. import helpers, utils, hints +from .. import helpers, utils, hints, errors from ..requestiter import RequestIter from ..tl import types, functions, custom @@ -30,13 +30,13 @@ class _ChatAction: 'audio': types.SendMessageUploadAudioAction(1), 'voice': types.SendMessageUploadAudioAction(1), # alias + 'song': types.SendMessageUploadAudioAction(1), # alias 'round': types.SendMessageUploadRoundAction(1), 'video': types.SendMessageUploadVideoAction(1), 'photo': types.SendMessageUploadPhotoAction(1), 'document': types.SendMessageUploadDocumentAction(1), 'file': types.SendMessageUploadDocumentAction(1), # alias - 'song': types.SendMessageUploadDocumentAction(1), # alias 'cancel': types.SendMessageCancelAction() } @@ -337,9 +337,25 @@ class _ProfilePhotoIter(RequestIter): else: self.request.offset += len(result.photos) else: + self.total = getattr(result, 'count', None) + if self.total == 0 and isinstance(result, types.messages.ChannelMessages): + # There are some broadcast channels that have a photo but this + # request doesn't retrieve it for some reason. Work around this + # issue by fetching the channel. + # + # We can't use the normal entity because that gives `ChatPhoto` + # but we want a proper `Photo`, so fetch full channel instead. + channel = await self.client(functions.channels.GetFullChannelRequest(self.request.peer)) + photo = channel.full_chat.chat_photo + if isinstance(photo, types.Photo): + self.buffer = [photo] + self.total = 1 + + self.left = len(self.buffer) + return + self.buffer = [x.action.photo for x in result.messages if isinstance(x.action, types.MessageActionChatEditPhoto)] - self.total = getattr(result, 'count', None) if len(result.messages) < self.request.limit: self.left = len(self.buffer) elif result.messages: @@ -922,6 +938,7 @@ class ChatMethods: send_gifs: bool = True, send_games: bool = True, send_inline: bool = True, + embed_link_previews: bool = True, send_polls: bool = True, change_info: bool = True, invite_users: bool = True, @@ -986,6 +1003,12 @@ class ChatMethods: send_inline (`bool`, optional): Whether the user is able to use inline bots or not. + embed_link_previews (`bool`, optional): + Whether the user is able to enable the link preview in the + messages they send. Note that the user will still be able to + send messages with links if this permission is removed, but + these links won't display a link preview. + send_polls (`bool`, optional): Whether the user is able to send polls or not. @@ -1031,6 +1054,7 @@ class ChatMethods: send_gifs=not send_gifs, send_games=not send_games, send_inline=not send_inline, + embed_links=not embed_link_previews, send_polls=not send_polls, change_info=not change_info, invite_users=not invite_users, @@ -1115,4 +1139,67 @@ class ChatMethods: else: raise ValueError('You must pass either a channel or a chat') + async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + ): + """ + Retrieves statistics from the given megagroup or broadcast channel. + + Note that some restrictions apply before being able to fetch statistics, + in particular the channel must have enough members (for megagroups, this + requires `at least 500 members`_). + + Arguments + entity (`entity`): + The channel from which to get statistics. + + Raises + If the given entity is not a channel (broadcast or megagroup), + a `TypeError` is raised. + + If there are not enough members (poorly named) errors such as + ``telethon.errors.ChatAdminRequiredError`` will appear. + + Returns + Either :tl:`BroadcastStats` or :tl:`MegagroupStats`, depending on + whether the input belonged to a broadcast channel or megagroup. + + Example + .. code-block:: python + + # Some megagroup or channel username or ID to fetch + channel = -100123 + stats = await client.get_stats(channel) + print('Stats from', stats.period.min_date, 'to', stats.period.max_date, ':') + print(stats.stringify()) + + .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more + """ + entity = await self.get_input_entity(entity) + if helpers._entity_type(entity) != helpers._EntityType.CHANNEL: + raise TypeError('You must pass a user entity') + + # Don't bother fetching the Channel entity (costs a request), instead + # try to guess and if it fails we know it's the other one (best case + # no extra request, worst just one). + try: + req = functions.stats.GetBroadcastStatsRequest(entity) + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + except errors.BroadcastRequiredError: + req = functions.stats.GetMegagroupStatsRequest(entity) + try: + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + + sender = await self._borrow_exported_sender(dc) + try: + # req will be resolved to use the right types inside by now + return await sender.send(req) + finally: + await self._return_exported_sender(sender) + # endregion diff --git a/telethon/client/dialogs.py b/telethon/client/dialogs.py index 34173c4c..adb23851 100644 --- a/telethon/client/dialogs.py +++ b/telethon/client/dialogs.py @@ -245,7 +245,7 @@ class DialogMethods: # Getting only archived dialogs (both equivalent) archived = await client.get_dialogs(folder=1) - non_archived = await client.get_dialogs(archived=True) + archived = await client.get_dialogs(archived=True) """ return await self.iter_dialogs(*args, **kwargs).collect() @@ -378,7 +378,7 @@ class DialogMethods: entities = [await self.get_input_entity(entity)] else: entities = await asyncio.gather( - *(self.get_input_entity(x) for x in entity), loop=self.loop) + *(self.get_input_entity(x) for x in entity)) if folder is None: raise ValueError('You must specify a folder') diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index c31be69a..ba612197 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -5,6 +5,8 @@ import pathlib import typing import inspect +from ..crypto import AES + from .. import utils, helpers, errors, hints from ..requestiter import RequestIter from ..tl import TLObject, types, functions @@ -17,7 +19,6 @@ except ImportError: if typing.TYPE_CHECKING: from .telegramclient import TelegramClient - # Chunk sizes for upload.getFile must be multiples of the smallest size MIN_CHUNK_SIZE = 4096 MAX_CHUNK_SIZE = 512 * 1024 @@ -67,7 +68,7 @@ class _DirectDownloadIter(RequestIter): async def _request(self): try: - result = await self._sender.send(self.request) + result = await self.client._call(self._sender, self.request) if isinstance(result, types.upload.FileCdnRedirect): raise NotImplementedError # TODO Implement else: @@ -309,13 +310,20 @@ class DownloadMethods: The parameter should be an integer index between ``0`` and ``len(sizes)``. ``0`` will download the smallest thumbnail, and ``len(sizes) - 1`` will download the largest thumbnail. - You can also use negative indices. + You can also use negative indices, which work the same as + they do in Python's `list`. You can also pass the :tl:`PhotoSize` instance to use. + Alternatively, the thumb size type `str` may be used. In short, use ``thumb=0`` if you want the smallest thumbnail and ``thumb=-1`` if you want the largest thumbnail. + .. note:: + The largest thumbnail may be a video instead of a photo, + as they are available since layer 116 and are bigger than + any of the photos. + Returns `None` if no media was provided, or if it was Empty. On success the file path is returned since it may differ from the one given. @@ -328,6 +336,13 @@ class DownloadMethods: # or path = await message.download_media() await message.download_media(filename) + + # Printing download progress + def callback(current, total): + print('Downloaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.download_media(message, progress_callback=callback) """ # TODO This won't work for messageService if isinstance(message, types.Message): @@ -369,10 +384,17 @@ class DownloadMethods: part_size_kb: float = None, file_size: int = None, progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None) -> typing.Optional[bytes]: + dc_id: int = None, + key: bytes = None, + iv: bytes = None) -> typing.Optional[bytes]: """ Low-level method to download files from their input location. + .. note:: + + Generally, you should instead use `download_media`. + This method is intended to be a bit more low-level. + Arguments input_location (:tl:`InputFileLocation`): The file location from which the file will be downloaded. @@ -403,6 +425,13 @@ class DownloadMethods: The data center the library should connect to in order to download the file. You shouldn't worry about this. + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + Example .. code-block:: python @@ -421,6 +450,9 @@ class DownloadMethods: raise ValueError( 'The part size must be evenly divisible by 4096.') + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + in_memory = file is None or file is bytes if in_memory: f = io.BytesIO() @@ -434,6 +466,8 @@ class DownloadMethods: try: async for chunk in self.iter_download( input_location, request_size=part_size, dc_id=dc_id): + if iv and key: + chunk = AES.decrypt_ige(chunk, key, iv) r = f.write(chunk) if inspect.isawaitable(r): await r @@ -535,25 +569,18 @@ class DownloadMethods: # Streaming `media` to an output file # After the iteration ends, the sender is cleaned up with open('photo.jpg', 'wb') as fd: - async for chunk client.iter_download(media): + async for chunk in client.iter_download(media): fd.write(chunk) # Fetching only the header of a file (32 bytes) # You should manually close the iterator in this case. # - # telethon.sync must be imported for this to work, - # and you must not be inside an "async def". + # "stream" is a common name for asynchronous generators, + # and iter_download will yield `bytes` (chunks of the file). stream = client.iter_download(media, request_size=32) - header = next(stream) - stream.close() + header = await stream.__anext__() # "manual" version of `async for` + await stream.close() assert len(header) == 32 - - # Fetching only the header, inside of an ``async def`` - async def main(): - stream = client.iter_download(media, request_size=32) - header = await stream.__anext__() - await stream.close() - assert len(header) == 32 """ info = utils._get_file_info(file) if info.dc_id is not None: @@ -610,12 +637,32 @@ class DownloadMethods: @staticmethod def _get_thumb(thumbs, thumb): + # Seems Telegram has changed the order and put `PhotoStrippedSize` + # last while this is the smallest (layer 116). Ensure we have the + # sizes sorted correctly with a custom function. + def sort_thumbs(thumb): + if isinstance(thumb, types.PhotoStrippedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoCachedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoSize): + return 1, thumb.size + if isinstance(thumb, types.VideoSize): + return 2, thumb.size + + # Empty size or invalid should go last + return 0, 0 + + thumbs = list(sorted(thumbs, key=sort_thumbs)) + if thumb is None: return thumbs[-1] elif isinstance(thumb, int): return thumbs[thumb] + elif isinstance(thumb, str): + return next((t for t in thumbs if t.type == thumb), None) elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize, - types.PhotoStrippedSize)): + types.PhotoStrippedSize, types.VideoSize)): return thumb else: return None @@ -650,11 +697,16 @@ class DownloadMethods: if not isinstance(photo, types.Photo): return - size = self._get_thumb(photo.sizes, thumb) + # Include video sizes here (but they may be None so provide an empty list) + size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb) if not size or isinstance(size, types.PhotoSizeEmpty): return - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + if isinstance(size, types.VideoSize): + file = self._get_proper_filename(file, 'video', '.mp4', date=date) + else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): return self._download_cached_photo_size(size, file) @@ -703,15 +755,15 @@ class DownloadMethods: 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 - ) - if thumb is None: + 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 + ) size = None else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) size = self._get_thumb(document.thumbs, thumb) if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): return self._download_cached_photo_size(size, file) diff --git a/telethon/client/messageparse.py b/telethon/client/messageparse.py index 7bff5c06..67860a4a 100644 --- a/telethon/client/messageparse.py +++ b/telethon/client/messageparse.py @@ -131,7 +131,18 @@ class MessageParseMethods: elif isinstance(update, ( types.UpdateNewChannelMessage, types.UpdateNewMessage)): update.message._finish_init(self, entities, input_chat) - id_to_message[update.message.id] = update.message + + # Pinning a message with `updatePinnedMessage` seems to + # always produce a service message we can't map so return + # it directly. + # + # It could also be a list (e.g. when sending albums). + # + # TODO this method is getting messier and messier as time goes on + if hasattr(request, 'random_id') or utils.is_list_like(request): + id_to_message[update.message.id] = update.message + else: + return update.message elif (isinstance(update, types.UpdateEditMessage) and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): @@ -204,13 +215,18 @@ class MessageParseMethods: # deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at # Telegram), in which case we get some "missing" message mappings. # Log them with the hope that we can better work around them. + # + # This also happens when trying to forward messages that can't + # be forwarded because they don't exist (0, service, deleted) + # among others which could be (like deleted or existing). self._log[__name__].warning( 'Request %s had missing message mappings %s', request, result) return [ - mapping.get(random_to_id.get(rnd)) - or opposite.get(random_to_id.get(rnd)) - for rnd in random_to_id + (mapping.get(random_to_id[rnd]) or opposite.get(random_to_id[rnd])) + if rnd in random_to_id + else None + for rnd in random_id ] # endregion diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 5c263edc..8237c380 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -1025,14 +1025,25 @@ class MessageMethods: force_document=force_document) if isinstance(entity, types.InputBotInlineMessageID): - return await self(functions.messages.EditInlineBotMessageRequest( + request = functions.messages.EditInlineBotMessageRequest( id=entity, message=text, no_webpage=not link_preview, entities=msg_entities, media=media, reply_markup=self.build_reply_markup(buttons) - )) + ) + # Invoke `messages.editInlineBotMessage` from the right datacenter. + # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. + exported = self.session.dc_id != entity.dc_id + if exported: + try: + sender = await self._borrow_exported_sender(entity.dc_id) + return await self._call(sender, request) + finally: + await self._return_exported_sender(sender) + else: + return await self(request) entity = await self.get_input_entity(entity) request = functions.messages.EditMessageRequest( @@ -1046,7 +1057,6 @@ class MessageMethods: schedule_date=schedule ) msg = self._get_response_message(request, await self(request), entity) - await self._cache_media(msg, file, file_handle, image=image) return msg async def delete_messages( @@ -1108,8 +1118,14 @@ class MessageMethods: else int(m) for m in message_ids ) - entity = await self.get_input_entity(entity) if entity else None - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + if entity: + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + else: + # no entity (None), set a value that's not a channel for private delete + ty = helpers._EntityType.USER + + if ty == helpers._EntityType.CHANNEL: return await self([functions.channels.DeleteMessagesRequest( entity, list(c)) for c in utils.chunks(message_ids)]) else: @@ -1136,6 +1152,10 @@ class MessageMethods: If neither message nor maximum ID are provided, all messages will be marked as read by assuming that ``max_id = 0``. + If a message or maximum ID is provided, all the messages up to and + including such ID will be marked as read (for all messages whose ID + ≤ max_id). + See also `Message.mark_read() `. Arguments @@ -1146,8 +1166,8 @@ class MessageMethods: Either a list of messages or a single message. max_id (`int`): - Overrides messages, until which message should the - acknowledge should be sent. + Until which message should the read acknowledge be sent for. + This has priority over the ``message`` parameter. clear_mentions (`bool`): Whether the mention badge should be cleared (so that @@ -1226,11 +1246,24 @@ class MessageMethods: """ message = utils.get_message_id(message) or 0 entity = await self.get_input_entity(entity) - await self(functions.messages.UpdatePinnedMessageRequest( + request = functions.messages.UpdatePinnedMessageRequest( peer=entity, id=message, silent=not notify - )) + ) + result = await self(request) + + # Unpinning does not produce a service message, and technically + # users can pass negative IDs which seem to behave as unpinning too. + if message <= 0: + return + + # Pinning in User chats (just with yourself really) does not produce a service message + if helpers._entity_type(entity) == helpers._EntityType.USER: + return + + # Pinning a message that doesn't exist would RPC-error earlier + return self._get_response_message(request, result, entity) # endregion diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index eb571ea5..62faecae 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -3,7 +3,6 @@ import asyncio import collections import logging import platform -import sys import time import typing @@ -14,12 +13,12 @@ from ..extensions import markdown from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy from ..sessions import Session, SQLiteSession, MemorySession from ..statecache import StateCache -from ..tl import TLObject, functions, types +from ..tl import functions, types from ..tl.alltlobjects import LAYER DEFAULT_DC_ID = 2 DEFAULT_IPV4_IP = '149.154.167.51' -DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]' +DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a' DEFAULT_PORT = 443 if typing.TYPE_CHECKING: @@ -29,6 +28,41 @@ __default_log__ = logging.getLogger(__base_name__) __default_log__.addHandler(logging.NullHandler()) +# In seconds, how long to wait before disconnecting a exported sender. +_DISCONNECT_EXPORTED_AFTER = 60 + + +class _ExportState: + def __init__(self): + # ``n`` is the amount of borrows a given sender has; + # once ``n`` reaches ``0``, disconnect the sender after a while. + self._n = 0 + self._zero_ts = 0 + self._connected = False + + def add_borrow(self): + self._n += 1 + self._connected = True + + def add_return(self): + self._n -= 1 + assert self._n >= 0, 'returned sender more than it was borrowed' + if self._n == 0: + self._zero_ts = time.time() + + def should_disconnect(self): + return (self._n == 0 + and self._connected + and (time.time() - self._zero_ts) > _DISCONNECT_EXPORTED_AFTER) + + def need_connect(self): + return not self._connected + + def mark_disconnected(self): + assert self.should_disconnect(), 'marked as disconnected when it was borrowed' + self._connected = False + + # TODO How hard would it be to support both `trio` and `asyncio`? class TelegramBaseClient(abc.ABC): """ @@ -146,7 +180,8 @@ class TelegramBaseClient(abc.ABC): Defaults to `lang_code`. loop (`asyncio.AbstractEventLoop`, optional): - Asyncio event loop to use. Defaults to `asyncio.get_event_loop()` + Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`. + This argument is ignored. base_logger (`str` | `logging.Logger`, optional): Base logger name or instance to use. @@ -193,7 +228,7 @@ class TelegramBaseClient(abc.ABC): "Refer to telethon.rtfd.io for more information.") self._use_ipv6 = use_ipv6 - self._loop = loop or asyncio.get_event_loop() + self._loop = asyncio.get_event_loop() if isinstance(base_logger, str): base_logger = logging.getLogger(base_logger) @@ -300,7 +335,7 @@ class TelegramBaseClient(abc.ABC): ) self._sender = MTProtoSender( - self.session.auth_key, self._loop, + self.session.auth_key, loggers=self._log, retries=self._connection_retries, delay=self._retry_delay, @@ -314,19 +349,17 @@ class TelegramBaseClient(abc.ABC): # 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. + # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders self._borrowed_senders = {} - self._borrow_sender_lock = asyncio.Lock(loop=self._loop) + self._borrow_sender_lock = asyncio.Lock() self._updates_handle = None self._last_request = time.time() self._channel_pts = {} if sequential_updates: - self._updates_queue = asyncio.Queue(loop=self._loop) - self._dispatching_updates_queue = asyncio.Event(loop=self._loop) + self._updates_queue = asyncio.Queue() + self._dispatching_updates_queue = asyncio.Event() else: # Use a set of pending instead of a queue so we can properly # terminate all pending updates on disconnect. @@ -346,6 +379,15 @@ class TelegramBaseClient(abc.ABC): # {chat_id: {Conversation}} self._conversations = collections.defaultdict(set) + # Hack to workaround the fact Telegram may send album updates as + # different Updates when being sent from a different data center. + # {grouped_id: AlbumHack} + # + # FIXME: We don't bother cleaning this up because it's not really + # worth it, albums are pretty rare and this only holds them + # for a second at most. + self._albums = {} + # Default parse mode self._parse_mode = markdown @@ -375,7 +417,7 @@ class TelegramBaseClient(abc.ABC): .. code-block:: python # Download media in the background - task = client.loop_create_task(message.download_media()) + task = client.loop.create_task(message.download_media()) # Do some work ... @@ -440,7 +482,6 @@ class TelegramBaseClient(abc.ABC): self.session.server_address, self.session.port, self.session.dc_id, - loop=self._loop, loggers=self._log, proxy=self._proxy )): @@ -500,13 +541,22 @@ class TelegramBaseClient(abc.ABC): async def _disconnect_coro(self: 'TelegramClient'): await self._disconnect() + # Also clean-up all exported senders because we're done with them + async with self._borrow_sender_lock: + for state, sender in self._borrowed_senders.values(): + if state.should_disconnect(): + # disconnect should never raise + await sender.disconnect() + + self._borrowed_senders.clear() + # trio's nurseries would handle this for us, but this is asyncio. # All tasks spawned in the background should properly be terminated. if self._dispatching_updates_queue is None and self._updates_queue: for task in self._updates_queue: task.cancel() - await asyncio.wait(self._updates_queue, loop=self._loop) + await asyncio.wait(self._updates_queue) self._updates_queue.clear() pts, date = self._state_cache[None] @@ -589,17 +639,15 @@ class TelegramBaseClient(abc.ABC): # # If one were to do that, Telegram would reset the connection # with no further clues. - sender = MTProtoSender(None, self._loop, loggers=self._log) + sender = MTProtoSender(None, loggers=self._log) await sender.connect(self._connection( dc.ip_address, dc.port, dc.id, - loop=self._loop, loggers=self._log, proxy=self._proxy )) - self._log[__name__].info('Exporting authorization for data center %s', - dc) + self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) req = self._init_with(functions.auth.ImportAuthorizationRequest( id=auth.id, bytes=auth.bytes @@ -616,24 +664,27 @@ class TelegramBaseClient(abc.ABC): Once its job is over it should be `_return_exported_sender`. """ async with self._borrow_sender_lock: - n, sender = self._borrowed_senders.get(dc_id, (0, None)) - if not sender: + self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id) + state, sender = self._borrowed_senders.get(dc_id, (None, None)) + + if state is None: + state = _ExportState() sender = await self._create_exported_sender(dc_id) sender.dc_id = dc_id - elif not n: + self._borrowed_senders[dc_id] = (state, sender) + + elif state.need_connect(): dc = await self._get_dc(dc_id) await sender.connect(self._connection( dc.ip_address, dc.port, dc.id, - loop=self._loop, loggers=self._log, proxy=self._proxy )) - self._borrowed_senders[dc_id] = (n + 1, sender) - - return sender + state.add_borrow() + return sender async def _return_exported_sender(self: 'TelegramClient', sender): """ @@ -641,14 +692,23 @@ class TelegramBaseClient(abc.ABC): been returned, the sender is cleanly disconnected. """ async with self._borrow_sender_lock: - dc_id = sender.dc_id - n, _ = self._borrowed_senders[dc_id] - n -= 1 - self._borrowed_senders[dc_id] = (n, sender) - if not n: - self._log[__name__].info( - 'Disconnecting borrowed sender for DC %d', dc_id) - await sender.disconnect() + self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id) + state, _ = self._borrowed_senders[sender.dc_id] + state.add_return() + + async def _clean_exported_senders(self: 'TelegramClient'): + """ + Cleans-up all unused exported senders by disconnecting them. + """ + async with self._borrow_sender_lock: + for dc_id, (state, sender) in self._borrowed_senders.items(): + if state.should_disconnect(): + self._log[__name__].info( + 'Disconnecting borrowed sender for DC %d', dc_id) + + # Disconnect should never raise + await sender.disconnect() + state.mark_disconnected() async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): """Similar to ._borrow_exported_client, but for CDNs""" diff --git a/telethon/client/updates.py b/telethon/client/updates.py index fe19eaaf..e609f9ab 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -1,4 +1,5 @@ import asyncio +import inspect import itertools import random import time @@ -325,7 +326,7 @@ class UpdateMethods: while self.is_connected(): try: await asyncio.wait_for( - self.disconnected, timeout=60, loop=self._loop + self.disconnected, timeout=60 ) continue # We actually just want to act upon timeout except asyncio.TimeoutError: @@ -335,6 +336,9 @@ class UpdateMethods: except Exception: continue # Any disconnected exception should be ignored + # Check if we have any exported senders to clean-up periodically + await self._clean_exported_senders() + # Don't bother sending pings until the low-level connection is # ready, otherwise a lot of pings will be batched to be sent upon # reconnect, when we really don't care about that. @@ -388,11 +392,19 @@ class UpdateMethods: await self._get_difference(update, channel_id, pts_date) except OSError: pass # We were disconnected, that's okay + except errors.RPCError: + # There's a high chance the request fails because we lack + # the channel. Because these "happen sporadically" (#1428) + # we should be okay (no flood waits) even if more occur. + pass if not self._self_input_peer: # Some updates require our own ID, so we must make sure # that the event builder has offline access to it. Calling # `get_me()` will cache it under `self._self_input_peer`. + # + # It will return `None` if we haven't logged in yet which is + # fine, we will just retry next time anyway. await self.get_me(input_peer=True) built = EventBuilderDict(self, update, others) @@ -421,7 +433,50 @@ class UpdateMethods: if not builder.resolved: await builder.resolve(self) - if not builder.filter(event): + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: + continue + + try: + await callback(event) + except errors.AlreadyInConversationError: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" already has an open conversation, ' + 'ignoring new one', name) + except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ + ) + break + except Exception as e: + if not isinstance(e, asyncio.CancelledError) or self.is_connected(): + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].exception('Unhandled exception on %s', + name) + + async def _dispatch_event(self: 'TelegramClient', event): + """ + Dispatches a single, out-of-order event. Used by `AlbumHack`. + """ + # We're duplicating a most logic from `_dispatch_update`, but all in + # the name of speed; we don't want to make it worse for all updates + # just because albums may need it. + for builder, callback in self._event_builders: + if not isinstance(event, builder.Event): + continue + + if not builder.resolved: + await builder.resolve(self) + + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: continue try: @@ -559,8 +614,15 @@ class EventBuilderDict: try: return self.__dict__[builder] except KeyError: + # Updates may arrive before login (like updateLoginToken) and we + # won't have our self ID yet (anyway only new messages need it). + self_id = ( + self.client._self_input_peer.user_id + if self.client._self_input_peer + else None + ) event = self.__dict__[builder] = builder.build( - self.update, self.others, self.client._self_input_peer.user_id) + self.update, self.others, self_id) if isinstance(event, EventCommon): event.original_update = self.update diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index 923732e1..c46c2899 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -5,9 +5,10 @@ import os import pathlib import re import typing -import inspect from io import BytesIO +from ..crypto import AES + from .. import utils, helpers, hints from ..tl import types, functions, custom @@ -93,6 +94,7 @@ class UploadMethods: *, caption: typing.Union[str, typing.Sequence[str]] = None, force_document: bool = False, + file_size: int = None, clear_draft: bool = False, progress_callback: 'hints.ProgressCallback' = None, reply_to: 'hints.MessageIDLike' = None, @@ -153,6 +155,10 @@ class UploadMethods: * A handle to an uploaded file (from `upload_file`). + * A :tl:`InputMedia` instance. For example, if you want to + send a dice use :tl:`InputMediaDice`, or if you want to + send a contact use :tl:`InputMediaContact`. + To send an album, you should provide a list in this parameter. If a list or similar is provided, the files in it will be @@ -169,6 +175,13 @@ class UploadMethods: the extension of an image file or a video file, it will be sent as such. Otherwise always as a document. + file_size (`int`, optional): + The size of the file to be uploaded if it needs to be uploaded, + which will be determined automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + clear_draft (`bool`, optional): Whether the existing draft should be cleared or not. @@ -261,6 +274,26 @@ class UploadMethods: '/my/photos/holiday2.jpg', '/my/drawings/portrait.png' ]) + + # Printing upload progress + def callback(current, total): + print('Uploaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.send_file(chat, file, progress_callback=callback) + + # Dices, including dart and other future emoji + from telethon.tl import types + await client.send_file(chat, types.InputMediaDice('')) + await client.send_file(chat, types.InputMediaDice('🎯')) + + # Contacts + await client.send_file(chat, types.InputMediaContact( + phone_number='+34 123 456 789', + first_name='Example', + last_name='', + vcard='' + )) """ # TODO Properly implement allow_cache to reuse the sha256 of the file # i.e. `None` was used @@ -332,6 +365,7 @@ class UploadMethods: file_handle, media, image = await self._file_to_media( file, force_document=force_document, + file_size=file_size, progress_callback=progress_callback, attributes=attributes, allow_cache=allow_cache, thumb=thumb, voice_note=voice_note, video_note=video_note, @@ -348,10 +382,7 @@ class UploadMethods: entities=msg_entities, reply_markup=markup, silent=silent, schedule_date=schedule, clear_draft=clear_draft ) - msg = self._get_response_message(request, await self(request), entity) - await self._cache_media(msg, file, file_handle, image=image) - - return msg + return self._get_response_message(request, await self(request), entity) async def _send_album(self: 'TelegramClient', entity, files, caption='', progress_callback=None, reply_to=None, @@ -390,16 +421,12 @@ class UploadMethods: r = await self(functions.messages.UploadMediaRequest( entity, media=fm )) - self.session.cache_file( - fh.md5, fh.size, utils.get_input_photo(r.photo)) fm = utils.get_input_media(r.photo) elif isinstance(fm, types.InputMediaUploadedDocument): r = await self(functions.messages.UploadMediaRequest( entity, media=fm )) - self.session.cache_file( - fh.md5, fh.size, utils.get_input_document(r.document)) fm = utils.get_input_media( r.document, supports_streaming=supports_streaming) @@ -430,12 +457,19 @@ class UploadMethods: file: 'hints.FileLike', *, part_size_kb: float = None, + file_size: int = None, file_name: str = None, use_cache: type = None, + key: bytes = None, + iv: bytes = None, progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile': """ Uploads a file to Telegram's servers, without sending it. + .. note:: + + Generally, you want to use `send_file` instead. + This method returns a handle (an instance of :tl:`InputFile` or :tl:`InputFileBig`, as required) which can be later used before it expires (they are usable during less than a day). @@ -455,6 +489,13 @@ class UploadMethods: Chunk size when uploading files. The larger, the less requests will be made (up to 512KB maximum). + file_size (`int`, optional): + The size of the file to be uploaded, which will be determined + automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + file_name (`str`, optional): The file name which will be used on the resulting InputFile. If not specified, the name will be taken from the ``file`` @@ -465,6 +506,12 @@ class UploadMethods: backward-compatibility (and it may get its use back in the future). + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + progress_callback (`callable`, optional): A callback function accepting two parameters: ``(sent bytes, total)``. @@ -496,34 +543,42 @@ class UploadMethods: if not file_name and getattr(file, 'name', None): file_name = file.name - if isinstance(file, str): + if file_size is not None: + pass # do nothing as it's already kwown + elif isinstance(file, str): file_size = os.path.getsize(file) + stream = open(file, 'rb') + close_stream = True elif isinstance(file, bytes): file_size = len(file) + stream = io.BytesIO(file) + close_stream = True else: - # `aiofiles` shouldn't base `IOBase` because they change the - # methods' definition. `seekable` would be `async` but since - # we won't get to check that, there's no need to maybe-await. - if isinstance(file, io.IOBase) and file.seekable(): - pos = file.tell() + if not callable(getattr(file, 'read', None)): + raise TypeError('file description should have a `read` method') + + if callable(getattr(file, 'seekable', None)): + seekable = await helpers._maybe_await(file.seekable()) else: - pos = None + seekable = False - # TODO Don't load the entire file in memory always - data = file.read() - if inspect.isawaitable(data): - data = await data + if seekable: + pos = await helpers._maybe_await(file.tell()) + await helpers._maybe_await(file.seek(0, os.SEEK_END)) + file_size = await helpers._maybe_await(file.tell()) + await helpers._maybe_await(file.seek(pos, os.SEEK_SET)) - if pos is not None: - file.seek(pos) + stream = file + close_stream = False + else: + self._log[__name__].warning( + 'Could not determine file size beforehand so the entire ' + 'file will be read in-memory') - if not isinstance(data, bytes): - raise TypeError( - 'file descriptor returned {}, not bytes (you must ' - 'open the file in bytes mode)'.format(type(data))) - - file = data - file_size = len(file) + data = await helpers._maybe_await(file.read()) + stream = io.BytesIO(data) + close_stream = True + file_size = len(data) # File will now either be a string or bytes if not part_size_kb: @@ -553,31 +608,46 @@ class UploadMethods: # Determine whether the file is too big (over 10MB) or not # Telegram does make a distinction between smaller or larger files - is_large = file_size > 10 * 1024 * 1024 + is_big = file_size > 10 * 1024 * 1024 hash_md5 = hashlib.md5() - if not is_large: - # Calculate the MD5 hash before anything else. - # As this needs to be done always for small files, - # might as well do it before anything else and - # check the cache. - if isinstance(file, str): - with open(file, 'rb') as stream: - file = stream.read() - hash_md5.update(file) part_count = (file_size + part_size - 1) // part_size self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d', file_size, part_count, part_size) - with open(file, 'rb') if isinstance(file, str) else BytesIO(file)\ - as stream: + pos = 0 + try: for part_index in range(part_count): # Read the file by in chunks of size part_size - part = stream.read(part_size) + part = await helpers._maybe_await(stream.read(part_size)) + + if not isinstance(part, bytes): + raise TypeError( + 'file descriptor returned {}, not bytes (you must ' + 'open the file in bytes mode)'.format(type(part))) + + # `file_size` could be wrong in which case `part` may not be + # `part_size` before reaching the end. + if len(part) != part_size and part_index < part_count - 1: + raise ValueError( + 'read less than {} before reaching the end; either ' + '`file_size` or `read` are wrong'.format(part_size)) + + pos += len(part) + + if not is_big: + # Bit odd that MD5 is only needed for small files and not + # big ones with more chance for corruption, but that's + # what Telegram wants. + hash_md5.update(part) + + # Encryption part if needed + if key and iv: + part = AES.encrypt_ige(part, key, iv) # The SavePartRequest is different depending on whether # the file is too large or not (over or less than 10MB) - if is_large: + if is_big: request = functions.upload.SaveBigFilePartRequest( file_id, part_index, part_count, part) else: @@ -589,14 +659,15 @@ class UploadMethods: self._log[__name__].debug('Uploaded %d/%d', part_index + 1, part_count) if progress_callback: - r = progress_callback(stream.tell(), file_size) - if inspect.isawaitable(r): - await r + await helpers._maybe_await(progress_callback(pos, file_size)) else: raise RuntimeError( 'Failed to upload file part {}.'.format(part_index)) + finally: + if close_stream: + await helpers._maybe_await(stream.close()) - if is_large: + if is_big: return types.InputFileBig(file_id, part_count, file_name) else: return custom.InputSizedFile( @@ -606,7 +677,7 @@ class UploadMethods: # endregion async def _file_to_media( - self, file, force_document=False, + self, file, force_document=False, file_size=None, progress_callback=None, attributes=None, thumb=None, allow_cache=True, voice_note=False, video_note=False, supports_streaming=False, mime_type=None, as_image=None): @@ -616,12 +687,14 @@ class UploadMethods: if isinstance(file, pathlib.Path): file = str(file.absolute()) + is_image = utils.is_image(file) if as_image is None: - as_image = utils.is_image(file) and not force_document + as_image = is_image and not force_document # `aiofiles` do not base `io.IOBase` but do have `read`, so we # just check for the read attribute to see if it's file-like. - if not isinstance(file, (str, bytes)) and not hasattr(file, 'read'): + if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig))\ + and not hasattr(file, 'read'): # The user may pass a Message containing media (or the media, # or anything similar) that should be treated as a file. Try # getting the input media for whatever they passed and send it. @@ -644,16 +717,18 @@ class UploadMethods: media = None file_handle = None - if not isinstance(file, str) or os.path.isfile(file): + + if isinstance(file, (types.InputFile, types.InputFileBig)): + file_handle = file + elif not isinstance(file, str) or os.path.isfile(file): file_handle = await self.upload_file( _resize_photo_if_needed(file, as_image), + file_size=file_size, progress_callback=progress_callback ) elif re.match('https?://', file): if as_image: media = types.InputMediaPhotoExternal(file) - elif not force_document and utils.is_gif(file): - media = types.InputMediaGifExternal(file, '') else: media = types.InputMediaDocumentExternal(file) else: @@ -675,36 +750,26 @@ class UploadMethods: file, mime_type=mime_type, attributes=attributes, - force_document=force_document, + force_document=force_document and not is_image, voice_note=voice_note, video_note=video_note, supports_streaming=supports_streaming ) - input_kw = {} - if thumb: + if not thumb: + thumb = None + else: if isinstance(thumb, pathlib.Path): thumb = str(thumb.absolute()) - input_kw['thumb'] = await self.upload_file(thumb) + thumb = await self.upload_file(thumb, file_size=file_size) media = types.InputMediaUploadedDocument( file=file_handle, mime_type=mime_type, attributes=attributes, - **input_kw + thumb=thumb, + force_file=force_document and not is_image ) return file_handle, media, as_image - async def _cache_media(self: 'TelegramClient', msg, file, file_handle, image): - if file and msg and isinstance(file_handle, - custom.InputSizedFile): - # There was a response message and we didn't use cached - # version, so cache whatever we just sent to the database. - md5, size = file_handle.md5, file_handle.size - if image: - to_cache = utils.get_input_photo(msg.media.photo) - else: - to_cache = utils.get_input_document(msg.media.document) - self.session.cache_file(md5, size, to_cache) - # endregion diff --git a/telethon/client/users.py b/telethon/client/users.py index 39d68aad..3c6faa6e 100644 --- a/telethon/client/users.py +++ b/telethon/client/users.py @@ -27,6 +27,9 @@ def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): class UserMethods: async def __call__(self: 'TelegramClient', request, ordered=False): + return await self._call(self._sender, request, ordered=ordered) + + async def _call(self: 'TelegramClient', sender, request, ordered=False): requests = (request if utils.is_list_like(request) else (request,)) for r in requests: if not isinstance(r, TLRequest): @@ -41,7 +44,7 @@ class UserMethods: self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) elif diff <= self.flood_sleep_threshold: self._log[__name__].info(*_fmt_flood(diff, r, early=True)) - await asyncio.sleep(diff, loop=self._loop) + await asyncio.sleep(diff) self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) else: raise errors.FloodWaitError(request=r, capture=diff) @@ -50,7 +53,7 @@ class UserMethods: self._last_request = time.time() for attempt in retry_range(self._request_retries): try: - future = self._sender.send(request, ordered=ordered) + future = sender.send(request, ordered=ordered) if isinstance(future, list): results = [] exceptions = [] @@ -76,7 +79,8 @@ class UserMethods: self._entity_cache.add(result) return result except (errors.ServerError, errors.RpcCallFailError, - errors.RpcMcgetFailError) as e: + errors.RpcMcgetFailError, errors.InterdcCallErrorError, + errors.InterdcCallRichErrorError) as e: self._log[__name__].warning( 'Telegram is having internal issues %s: %s', e.__class__.__name__, e) @@ -89,9 +93,14 @@ class UserMethods: self._flood_waited_requests\ [request.CONSTRUCTOR_ID] = time.time() + e.seconds + # In test servers, FLOOD_WAIT_0 has been observed, and sleeping for + # such a short amount will cause retries very fast leading to issues. + if e.seconds == 0: + e.seconds = 1 + if e.seconds <= self.flood_sleep_threshold: self._log[__name__].info(*_fmt_flood(e.seconds, request)) - await asyncio.sleep(e.seconds, loop=self._loop) + await asyncio.sleep(e.seconds) else: raise except (errors.PhoneMigrateError, errors.NetworkMigrateError, diff --git a/telethon/events/album.py b/telethon/events/album.py index 2ba1e61b..473542bd 100644 --- a/telethon/events/album.py +++ b/telethon/events/album.py @@ -1,4 +1,6 @@ +import asyncio import time +import weakref from .common import EventBuilder, EventCommon, name_inner_event from .. import utils @@ -14,6 +16,54 @@ _IGNORE_MAX_AGE = 5 # seconds _IGNORE_DICT = {} +_HACK_DELAY = 0.5 + + +class AlbumHack: + """ + When receiving an album from a different data-center, they will come in + separate `Updates`, so we need to temporarily remember them for a while + and only after produce the event. + + Of course events are not designed for this kind of wizardy, so this is + a dirty hack that gets the job done. + + When cleaning up the code base we may want to figure out a better way + to do this, or just leave the album problem to the users; the update + handling code is bad enough as it is. + """ + def __init__(self, client, event): + # It's probably silly to use a weakref here because this object is + # very short-lived but might as well try to do "the right thing". + self._client = weakref.ref(client) + self._event = event # parent event + self._due = client.loop.time() + _HACK_DELAY + + client.loop.create_task(self.deliver_event()) + + def extend(self, messages): + client = self._client() + if client: # weakref may be dead + self._event.messages.extend(messages) + self._due = client.loop.time() + _HACK_DELAY + + async def deliver_event(self): + while True: + client = self._client() + if client is None: + return # weakref is dead, nothing to deliver + + diff = self._due - client.loop.time() + if diff <= 0: + # We've hit our due time, deliver event. It won't respect + # sequential updates but fixing that would just worsen this. + await client._dispatch_event(self._event) + return + + del client # Clear ref and sleep until our due time + await asyncio.sleep(diff) + + @name_inner_event class Album(EventBuilder): """ @@ -66,6 +116,7 @@ class Album(EventBuilder): return # Check if the ignore list is too big, and if it is clean it + # TODO time could technically go backwards; time is not monotonic now = time.time() if len(_IGNORE_DICT) > _IGNORE_MAX_SIZE: for i in [i for i, t in _IGNORE_DICT.items() if now - t > _IGNORE_MAX_AGE]: @@ -84,6 +135,11 @@ class Album(EventBuilder): and u.message.grouped_id == group) ]) + def filter(self, event): + # Albums with less than two messages require a few hacks to work. + if len(event.messages) > 1: + return super().filter(event) + class Event(EventCommon, SenderGetter): """ Represents the event of a new album. @@ -115,6 +171,14 @@ class Album(EventBuilder): for msg in self.messages: msg._finish_init(client, self._entities, None) + if len(self.messages) == 1: + # This will require hacks to be a proper album event + hack = client._albums.get(self.grouped_id) + if hack is None: + client._albums[self.grouped_id] = AlbumHack(client, self) + else: + hack.extend(self.messages) + @property def grouped_id(self): """ @@ -259,7 +323,7 @@ class Album(EventBuilder): `telethon.client.messages.MessageMethods.pin_message` with both ``entity`` and ``message`` already set. """ - await self.messages[0].pin(notify=notify) + return await self.messages[0].pin(notify=notify) def __len__(self): """ diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index 5a6e1370..d1558d21 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -51,7 +51,7 @@ class CallbackQuery(EventBuilder): # Send a message with buttons users can click async def main(): await client.send_message(user, 'Yes or no?', buttons=[ - Button.inline('Yes!', b'yes') + Button.inline('Yes!', b'yes'), Button.inline('Nope', b'no') ]) """ @@ -118,8 +118,10 @@ class CallbackQuery(EventBuilder): elif event.query.data != self.match: return - if not self.func or self.func(event): - return event + if self.func: + # Return the result of func directly as it may need to be awaited + return self.func(event) + return True class Event(EventCommon, SenderGetter): """ diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index 7f9acb9d..b7143d4f 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -1,6 +1,6 @@ from .common import EventBuilder, EventCommon, name_inner_event from .. import utils -from ..tl import types, functions +from ..tl import types @name_inner_event @@ -38,6 +38,10 @@ class ChatAction(EventBuilder): return cls.Event(types.PeerChannel(update.channel_id), unpin=True) + elif isinstance(update, types.UpdateChatPinnedMessage) and update.id == 0: + return cls.Event(types.PeerChat(update.chat_id), + unpin=True) + elif isinstance(update, types.UpdateChatParticipantAdd): return cls.Event(types.PeerChat(update.chat_id), added_by=update.inviter_id or True, @@ -104,8 +108,9 @@ class ChatAction(EventBuilder): return cls.Event(msg, users=msg.from_id, new_photo=True) - elif isinstance(action, types.MessageActionPinMessage): - # Telegram always sends this service message for new pins + elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to_msg_id: + # Seems to not be reliable on unpins, but when pinning + # we prefer this because we know who caused it. return cls.Event(msg, users=msg.from_id, new_pin=msg.reply_to_msg_id) @@ -256,17 +261,8 @@ class ChatAction(EventBuilder): if isinstance(self._pinned_message, int)\ and await self.get_input_chat(): - r = await self._client(functions.channels.GetMessagesRequest( - self._input_chat, [self._pinned_message] - )) - try: - self._pinned_message = next( - x for x in r.messages - if isinstance(x, types.Message) - and x.id == self._pinned_message - ) - except StopIteration: - pass + self._pinned_message = await self._client.get_messages( + self._input_chat, ids=self._pinned_message) if isinstance(self._pinned_message, types.Message): return self._pinned_message @@ -316,7 +312,7 @@ class ChatAction(EventBuilder): @property def user(self): """ - The first user that takes part in this action (e.g. joined). + The first user that takes part in this action. For example, who joined. Might be `None` if the information can't be retrieved or there is no user taking part. @@ -357,7 +353,7 @@ class ChatAction(EventBuilder): @property def users(self): """ - A list of users that take part in this action (e.g. joined). + A list of users that take part in this action. For example, who joined. Might be empty if the information can't be retrieved or there are no users taking part. @@ -381,7 +377,8 @@ class ChatAction(EventBuilder): if not self._user_ids: return [] - if self._users is None or len(self._users) != len(self._user_ids): + # Note: we access the property first so that it fills if needed + if (self.users is None or len(self._users) != len(self._user_ids)) and self.action_message: await self.action_message._reload_message() self._users = [ u for u in self.action_message.action_entities @@ -397,19 +394,31 @@ class ChatAction(EventBuilder): if self._input_users is None and self._user_ids: self._input_users = [] for user_id in self._user_ids: + # First try to get it from our entities + try: + self._input_users.append(utils.get_input_peer(self._entities[user_id])) + continue + except (KeyError, TypeError): + pass + + # If missing, try from the entity cache try: self._input_users.append(self._client._entity_cache[user_id]) + continue except KeyError: pass + return self._input_users or [] async def get_input_users(self): """ Returns `input_users` but will make an API call if necessary. """ - self._input_users = None - if self._input_users is None: - await self.action_message._reload_message() + if not self._user_ids: + return [] + + # Note: we access the property first so that it fills if needed + if (self.input_users is None or len(self._input_users) != len(self._user_ids)) and self.action_message: self._input_users = [ utils.get_input_peer(u) for u in self.action_message.action_entities diff --git a/telethon/events/common.py b/telethon/events/common.py index 42586608..e8978f31 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -1,10 +1,9 @@ import abc import asyncio -import itertools import warnings from .. import utils -from ..tl import TLObject, types, functions +from ..tl import TLObject, types from ..tl.custom.chatgetter import ChatGetter @@ -55,7 +54,7 @@ class EventBuilder(abc.ABC): which will be ignored if ``blacklist_chats=True``. func (`callable`, optional): - A callable function that should accept the event as input + A callable (async or not) function that should accept the event as input parameter, and return a value indicating whether the event should be dispatched or not (any truthy value will do, it does not need to be a `bool`). It works like a custom filter: @@ -93,7 +92,7 @@ class EventBuilder(abc.ABC): return if not self._resolve_lock: - self._resolve_lock = asyncio.Lock(loop=client.loop) + self._resolve_lock = asyncio.Lock() async with self._resolve_lock: if not self.resolved: @@ -105,13 +104,13 @@ class EventBuilder(abc.ABC): 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. + Returns a truthy value if the event passed the filter and should be + used, or falsy otherwise. The return value may need to be awaited. The events must have been resolved before this can be called. """ if not self.resolved: - return None + return if self.chats is not None: # Note: the `event.chat_id` property checks if it's `None` for us @@ -119,10 +118,13 @@ class EventBuilder(abc.ABC): if inside == self.blacklist_chats: # If this chat matches but it's a blacklist ignore. # If it doesn't match but it's a whitelist ignore. - return None + return - if not self.func or self.func(event): - return event + if not self.func: + return True + + # Return the result of func directly as it may need to be awaited + return self.func(event) class EventCommon(ChatGetter, abc.ABC): diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py index b23e3ac2..7a789669 100644 --- a/telethon/events/inlinequery.py +++ b/telethon/events/inlinequery.py @@ -79,11 +79,11 @@ class InlineQuery(EventBuilder): Represents the event of a new callback query. Members: - query (:tl:`UpdateBotCallbackQuery`): - The original :tl:`UpdateBotCallbackQuery`. + query (:tl:`UpdateBotInlineQuery`): + The original :tl:`UpdateBotInlineQuery`. - Make sure to access the `text` of the query if - that's what you want instead working with this. + Make sure to access the `text` property of the query if + you want the text rather than the actual query object. pattern_match (`obj`, optional): The resulting object from calling the passed ``pattern`` @@ -206,10 +206,9 @@ class InlineQuery(EventBuilder): return if results: - futures = [self._as_future(x, self._client.loop) - for x in results] + futures = [self._as_future(x) for x in results] - await asyncio.wait(futures, loop=self._client.loop) + await asyncio.wait(futures) # All futures will be in the `done` *set* that `wait` returns. # @@ -236,10 +235,10 @@ class InlineQuery(EventBuilder): ) @staticmethod - def _as_future(obj, loop): + def _as_future(obj): if inspect.isawaitable(obj): - return asyncio.ensure_future(obj, loop=loop) + return asyncio.ensure_future(obj) - f = loop.create_future() + f = asyncio.get_event_loop().create_future() f.set_result(obj) return f diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index fc3eaaf2..b30a63c8 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -1,4 +1,3 @@ -import asyncio import re from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set diff --git a/telethon/events/raw.py b/telethon/events/raw.py index f7eebb6a..84910778 100644 --- a/telethon/events/raw.py +++ b/telethon/events/raw.py @@ -29,12 +29,12 @@ class Raw(EventBuilder): self.types = None elif not utils.is_list_like(types): if not isinstance(types, type): - raise TypeError('Invalid input type given %s', types) + raise TypeError('Invalid input type given: {}'.format(types)) self.types = types else: if not all(isinstance(x, type) for x in types): - raise TypeError('Invalid input types given %s', types) + raise TypeError('Invalid input types given: {}'.format(types)) self.types = tuple(types) @@ -46,6 +46,8 @@ class Raw(EventBuilder): return update def filter(self, event): - if ((not self.types or isinstance(event, self.types)) - and (not self.func or self.func(event))): + if not self.types or isinstance(event, self.types): + if self.func: + # Return the result of func directly as it may need to be awaited + return self.func(event) return event diff --git a/telethon/extensions/messagepacker.py b/telethon/extensions/messagepacker.py index 443a5f3e..c0f46f48 100644 --- a/telethon/extensions/messagepacker.py +++ b/telethon/extensions/messagepacker.py @@ -22,11 +22,10 @@ class MessagePacker: point where outgoing requests are put, and where ready-messages are get. """ - def __init__(self, state, loop, loggers): + def __init__(self, state, loggers): self._state = state - self._loop = loop self._deque = collections.deque() - self._ready = asyncio.Event(loop=loop) + self._ready = asyncio.Event() self._log = loggers[__name__] def append(self, state): diff --git a/telethon/helpers.py b/telethon/helpers.py index 1b5f8843..55eb1b79 100644 --- a/telethon/helpers.py +++ b/telethon/helpers.py @@ -3,6 +3,7 @@ import asyncio import enum import os import struct +import inspect from hashlib import sha1 @@ -107,6 +108,13 @@ def retry_range(retries): yield 1 + attempt +async def _maybe_await(value): + if inspect.isawaitable(value): + return await value + else: + return value + + async def _cancel(log, **tasks): """ Helper to cancel one or more tasks gracefully, logging exceptions. @@ -123,6 +131,8 @@ async def _cancel(log, **tasks): except RuntimeError: # Probably: RuntimeError: await wasn't used with future # + # See: https://github.com/python/cpython/blob/12d3061c7819a73d891dcce44327410eaf0e1bc2/Lib/asyncio/futures.py#L265 + # # Happens with _asyncio.Task instances (in "Task cancelling" state) # trying to SIGINT the program right during initial connection, on # _recv_loop coroutine (but we're creating its task explicitly with @@ -131,6 +141,12 @@ async def _cancel(log, **tasks): # Since we're aware of this error there's no point in logging it. # *May* be https://bugs.python.org/issue37172 pass + except AssertionError as e: + # In Python 3.6, the above RuntimeError is an AssertionError + # See https://github.com/python/cpython/blob/7df32f844efed33ca781a016017eab7050263b90/Lib/asyncio/futures.py#L328 + if e.args != ("yield from wasn't used with future",): + log.exception('Unhandled exception from %s after cancelling ' + '%s (%s)', name, type(task), task) except Exception: log.exception('Unhandled exception from %s after cancelling ' '%s (%s)', name, type(task), task) diff --git a/telethon/network/connection/connection.py b/telethon/network/connection/connection.py index d7190c73..800ff02b 100644 --- a/telethon/network/connection/connection.py +++ b/telethon/network/connection/connection.py @@ -28,11 +28,10 @@ class Connection(abc.ABC): # should be one of `PacketCodec` implementations packet_codec = None - def __init__(self, ip, port, dc_id, *, loop, loggers, proxy=None): + def __init__(self, ip, port, dc_id, *, loggers, proxy=None): self._ip = ip self._port = port self._dc_id = dc_id # only for MTProxy, it's an abstraction leak - self._loop = loop self._log = loggers[__name__] self._proxy = proxy self._reader = None @@ -48,9 +47,8 @@ class Connection(abc.ABC): async def _connect(self, timeout=None, ssl=None): if not self._proxy: self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection( - self._ip, self._port, loop=self._loop, ssl=ssl), - loop=self._loop, timeout=timeout + asyncio.open_connection(self._ip, self._port, ssl=ssl), + timeout=timeout ) else: import socks @@ -65,11 +63,10 @@ class Connection(abc.ABC): else: s.set_proxy(*self._proxy) - s.setblocking(False) + s.settimeout(timeout) await asyncio.wait_for( - self._loop.sock_connect(s, address), - timeout=timeout, - loop=self._loop + asyncio.get_event_loop().sock_connect(s, address), + timeout=timeout ) if ssl: if ssl_mod is None: @@ -78,17 +75,16 @@ class Connection(abc.ABC): 'without the SSL module being available' ) - s.settimeout(timeout) s = ssl_mod.wrap_socket( s, do_handshake_on_connect=True, ssl_version=ssl_mod.PROTOCOL_SSLv23, ciphers='ADH-AES256-SHA' ) - s.setblocking(False) + + s.setblocking(False) - self._reader, self._writer = \ - await asyncio.open_connection(sock=s, loop=self._loop) + self._reader, self._writer = await asyncio.open_connection(sock=s) self._codec = self.packet_codec(self) self._init_conn() @@ -101,8 +97,9 @@ class Connection(abc.ABC): await self._connect(timeout=timeout, ssl=ssl) self._connected = True - self._send_task = self._loop.create_task(self._send_loop()) - self._recv_task = self._loop.create_task(self._recv_loop()) + loop = asyncio.get_event_loop() + self._send_task = loop.create_task(self._send_loop()) + self._recv_task = loop.create_task(self._recv_loop()) async def disconnect(self): """ diff --git a/telethon/network/connection/tcpmtproxy.py b/telethon/network/connection/tcpmtproxy.py index 2a9438ab..f034dfbe 100644 --- a/telethon/network/connection/tcpmtproxy.py +++ b/telethon/network/connection/tcpmtproxy.py @@ -95,12 +95,12 @@ class TcpMTProxy(ObfuscatedConnection): obfuscated_io = MTProxyIO # noinspection PyUnusedLocal - def __init__(self, ip, port, dc_id, *, loop, loggers, proxy=None): + def __init__(self, ip, port, dc_id, *, loggers, proxy=None): # connect to proxy's host and port instead of telegram's ones proxy_host, proxy_port = self.address_info(proxy) self._secret = bytes.fromhex(proxy[2]) super().__init__( - proxy_host, proxy_port, dc_id, loop=loop, loggers=loggers) + proxy_host, proxy_port, dc_id, loggers=loggers) async def _connect(self, timeout=None, ssl=None): await super()._connect(timeout=timeout, ssl=ssl) diff --git a/telethon/network/mtprotosender.py b/telethon/network/mtprotosender.py index 5483716a..48f6e238 100644 --- a/telethon/network/mtprotosender.py +++ b/telethon/network/mtprotosender.py @@ -40,12 +40,11 @@ class MTProtoSender: A new authorization key will be generated on connection if no other key exists yet. """ - def __init__(self, auth_key, loop, *, loggers, + def __init__(self, auth_key, *, loggers, retries=5, delay=1, auto_reconnect=True, connect_timeout=None, auth_key_callback=None, update_callback=None, auto_reconnect_callback=None): self._connection = None - self._loop = loop self._loggers = loggers self._log = loggers[__name__] self._retries = retries @@ -55,7 +54,7 @@ class MTProtoSender: self._auth_key_callback = auth_key_callback self._update_callback = update_callback self._auto_reconnect_callback = auto_reconnect_callback - self._connect_lock = asyncio.Lock(loop=loop) + self._connect_lock = asyncio.Lock() # Whether the user has explicitly connected or disconnected. # @@ -65,7 +64,7 @@ class MTProtoSender: # pending futures should be cancelled. self._user_connected = False self._reconnecting = False - self._disconnected = self._loop.create_future() + self._disconnected = asyncio.get_event_loop().create_future() self._disconnected.set_result(None) # We need to join the loops upon disconnection @@ -78,8 +77,7 @@ class MTProtoSender: # Outgoing messages are put in a queue and sent in a batch. # Note that here we're also storing their ``_RequestState``. - self._send_queue = MessagePacker(self._state, self._loop, - loggers=self._loggers) + self._send_queue = MessagePacker(self._state, loggers=self._loggers) # Sent states are remembered until a response is received. self._pending_state = {} @@ -171,7 +169,7 @@ class MTProtoSender: if not utils.is_list_like(request): try: - state = RequestState(request, self._loop) + state = RequestState(request) except struct.error as e: # "struct.error: required argument is not an integer" is not # very helpful; log the request to find out what wasn't int. @@ -186,7 +184,7 @@ class MTProtoSender: state = None for req in request: try: - state = RequestState(req, self._loop, after=ordered and state) + state = RequestState(req, after=ordered and state) except struct.error as e: self._log.error('Request caused struct.error: %s: %s', e, request) raise @@ -206,7 +204,7 @@ class MTProtoSender: Note that it may resolve in either a ``ConnectionError`` or any other unexpected error that could not be handled. """ - return asyncio.shield(self._disconnected, loop=self._loop) + return asyncio.shield(self._disconnected) # Private methods @@ -241,7 +239,7 @@ class MTProtoSender: # reconnect cleanly after. await self._connection.disconnect() connected = False - await asyncio.sleep(self._delay, loop=self._loop) + await asyncio.sleep(self._delay) continue # next iteration we will try to reconnect break # all steps done, break retry loop @@ -253,17 +251,18 @@ class MTProtoSender: await self._disconnect(error=e) raise e + loop = asyncio.get_event_loop() self._log.debug('Starting send loop') - self._send_loop_handle = self._loop.create_task(self._send_loop()) + self._send_loop_handle = loop.create_task(self._send_loop()) self._log.debug('Starting receive loop') - self._recv_loop_handle = self._loop.create_task(self._recv_loop()) + self._recv_loop_handle = loop.create_task(self._recv_loop()) # _disconnected only completes after manual disconnection # or errors after which the sender cannot continue such # as failing to reconnect or any unexpected error. if self._disconnected.done(): - self._disconnected = self._loop.create_future() + self._disconnected = loop.create_future() self._log.info('Connection to %s complete!', self._connection) @@ -378,7 +377,7 @@ class MTProtoSender: self._pending_state.clear() if self._auto_reconnect_callback: - self._loop.create_task(self._auto_reconnect_callback()) + asyncio.get_event_loop().create_task(self._auto_reconnect_callback()) break else: @@ -398,7 +397,7 @@ class MTProtoSender: # gets stuck. # TODO It still gets stuck? Investigate where and why. self._reconnecting = True - self._loop.create_task(self._reconnect(error)) + asyncio.get_event_loop().create_task(self._reconnect(error)) # Loops @@ -411,7 +410,7 @@ class MTProtoSender: """ while self._user_connected and not self._reconnecting: if self._pending_ack: - ack = RequestState(MsgsAck(list(self._pending_ack)), self._loop) + ack = RequestState(MsgsAck(list(self._pending_ack))) self._send_queue.append(ack) self._last_acks.append(ack) self._pending_ack.clear() @@ -564,7 +563,7 @@ class MTProtoSender: if rpc_result.error: error = rpc_message_to_error(rpc_result.error, state.request) self._send_queue.append( - RequestState(MsgsAck([state.msg_id]), loop=self._loop)) + RequestState(MsgsAck([state.msg_id]))) if not state.future.cancelled(): state.future.set_exception(error) @@ -751,8 +750,8 @@ class MTProtoSender: enqueuing a :tl:`MsgsStateInfo` to be sent at a later point. """ self._send_queue.append(RequestState(MsgsStateInfo( - req_msg_id=message.msg_id, info=chr(1) * len(message.obj.msg_ids)), - loop=self._loop)) + req_msg_id=message.msg_id, info=chr(1) * len(message.obj.msg_ids) + ))) async def _handle_msg_all(self, message): """ diff --git a/telethon/network/requeststate.py b/telethon/network/requeststate.py index eb598e24..21b5efd9 100644 --- a/telethon/network/requeststate.py +++ b/telethon/network/requeststate.py @@ -10,10 +10,10 @@ class RequestState: """ __slots__ = ('container_id', 'msg_id', 'request', 'data', 'future', 'after') - def __init__(self, request, loop, after=None): + def __init__(self, request, after=None): self.container_id = None self.msg_id = None self.request = request self.data = bytes(request) - self.future = asyncio.Future(loop=loop) + self.future = asyncio.Future() self.after = after diff --git a/telethon/password.py b/telethon/password.py index 5afb53d1..0f950254 100644 --- a/telethon/password.py +++ b/telethon/password.py @@ -162,7 +162,6 @@ def compute_check(request: types.account.Password, password: str): def generate_and_check_random(): random_size = 256 - import time while True: random = os.urandom(random_size) a = int.from_bytes(random, 'big') diff --git a/telethon/requestiter.py b/telethon/requestiter.py index e51cfafe..fd28419d 100644 --- a/telethon/requestiter.py +++ b/telethon/requestiter.py @@ -65,8 +65,7 @@ class RequestIter(abc.ABC): # asyncio will handle times <= 0 to sleep 0 seconds if self.wait_time: await asyncio.sleep( - self.wait_time - (time.time() - self.last_load), - loop=self.client.loop + self.wait_time - (time.time() - self.last_load) ) self.last_load = time.time() diff --git a/telethon/statecache.py b/telethon/statecache.py index adb5099e..fbb505fa 100644 --- a/telethon/statecache.py +++ b/telethon/statecache.py @@ -1,4 +1,3 @@ -import datetime import inspect from .tl import types diff --git a/telethon/tl/core/gzippacked.py b/telethon/tl/core/gzippacked.py index 906c2c67..fb4094e4 100644 --- a/telethon/tl/core/gzippacked.py +++ b/telethon/tl/core/gzippacked.py @@ -1,7 +1,7 @@ import gzip import struct -from .. import TLObject, TLRequest +from .. import TLObject class GzipPacked(TLObject): diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index 6578fbf7..b5599c53 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -10,3 +10,4 @@ from .inlinebuilder import InlineBuilder from .inlineresult import InlineResult from .inlineresults import InlineResults from .conversation import Conversation +from .qrlogin import QRLogin diff --git a/telethon/tl/custom/conversation.py b/telethon/tl/custom/conversation.py index 6d5023a7..79675b65 100644 --- a/telethon/tl/custom/conversation.py +++ b/telethon/tl/custom/conversation.py @@ -1,4 +1,6 @@ import asyncio +import functools +import inspect import itertools import time @@ -11,6 +13,16 @@ from ... import helpers, utils, errors _EDIT_COLLISION_DELTA = 0.001 +def _checks_cancelled(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if self._cancelled: + raise asyncio.CancelledError('The conversation was cancelled before') + + return f(self, *args, **kwargs) + return wrapper + + class Conversation(ChatGetter): """ Represents a conversation inside an specific chat. @@ -66,6 +78,7 @@ class Conversation(ChatGetter): self._edit_dates = {} + @_checks_cancelled async def send_message(self, *args, **kwargs): """ Sends a message in the context of this conversation. Shorthand @@ -81,6 +94,7 @@ class Conversation(ChatGetter): self._last_outgoing = ms[-1].id return sent + @_checks_cancelled async def send_file(self, *args, **kwargs): """ Sends a file in the context of this conversation. Shorthand @@ -96,6 +110,7 @@ class Conversation(ChatGetter): self._last_outgoing = ms[-1].id return sent + @_checks_cancelled def mark_read(self, message=None): """ Marks as read the latest received message if ``message is None``. @@ -117,7 +132,8 @@ class Conversation(ChatGetter): def get_response(self, message=None, *, timeout=None): """ - Gets the next message that responds to a previous one. + Gets the next message that responds to a previous one. This is + the method you need most of the time, along with `get_edit`. Args: message (`Message ` | `int`, optional): @@ -127,6 +143,16 @@ class Conversation(ChatGetter): timeout (`int` | `float`, optional): If present, this `timeout` (in seconds) will override the per-action timeout defined for the conversation. + + .. code-block:: python + + async with client.conversation(...) as conv: + await conv.send_message('Hey, what is your name?') + + response = await conv.get_response() + name = response.text + + await conv.send_message('Nice to meet you, {}!'.format(name)) """ return self._get_message( message, self._response_indices, self._pending_responses, timeout, @@ -257,23 +283,41 @@ class Conversation(ChatGetter): .. note:: - Only use this if there isn't another method available! + **Only use this if there isn't another method available!** For example, don't use `wait_event` for new messages, since `get_response` already exists, etc. Unless you're certain that your code will run fast enough, generally you should get a "handle" of this special coroutine - before acting. Generally, you should do this: + before acting. In this example you will see how to wait for a user + to join a group with proper use of `wait_event`: - >>> from telethon import TelegramClient, events - >>> - >>> client = TelegramClient(...) - >>> - >>> async def main(): - >>> async with client.conversation(...) as conv: - >>> response = conv.wait_event(events.NewMessage(incoming=True)) - >>> await conv.send_message('Hi') - >>> response = await response + .. code-block:: python + + from telethon import TelegramClient, events + + client = TelegramClient(...) + group_id = ... + + async def main(): + # Could also get the user id from an event; this is just an example + user_id = ... + + async with client.conversation(user_id) as conv: + # Get a handle to the future event we'll wait for + handle = conv.wait_event(events.ChatAction( + group_id, + func=lambda e: e.user_joined and e.user_id == user_id + )) + + # Perform whatever action in between + await conv.send_message('Please join this group before speaking to me!') + + # Wait for the event we registered above to fire + event = await handle + + # Continue with the conversation + await conv.send_message('Thanks!') This way your event can be registered before acting, since the response may arrive before your event was @@ -298,9 +342,15 @@ class Conversation(ChatGetter): for key, (ev, fut) in list(self._custom.items()): ev_type = type(ev) inst = built[ev_type] - if inst and ev.filter(inst): - fut.set_result(inst) - del self._custom[key] + + if inst: + filter = ev.filter(inst) + if inspect.isawaitable(filter): + filter = await filter + + if filter: + fut.set_result(inst) + del self._custom[key] def _on_new_message(self, response): response = response.message @@ -379,10 +429,8 @@ class Conversation(ChatGetter): else: raise ValueError('No message was sent previously') + @_checks_cancelled def _get_result(self, future, start_time, timeout, pending, target_id): - if self._cancelled: - raise asyncio.CancelledError('The conversation was cancelled before') - due = self._total_due if timeout is None: timeout = self._timeout @@ -397,8 +445,7 @@ class Conversation(ChatGetter): # cleared when their futures are set to a result. return asyncio.wait_for( future, - timeout=None if due == float('inf') else due - time.time(), - loop=self._client.loop + timeout=None if due == float('inf') else due - time.time() ) def _cancel_all(self, exception=None): diff --git a/telethon/tl/custom/message.py b/telethon/tl/custom/message.py index 8aa5bcb5..5f286a40 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/tl/custom/message.py @@ -552,6 +552,14 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): if isinstance(self.media, types.MessageMediaVenue): return self.media + @property + def dice(self): + """ + The :tl:`MessageMediaDice` in this message, if it's a dice roll. + """ + if isinstance(self.media, types.MessageMediaDice): + return self.media + @property def action_entities(self): """ @@ -756,7 +764,8 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): return await self._client.download_media(self, *args, **kwargs) async def click(self, i=None, j=None, - *, text=None, filter=None, data=None): + *, text=None, filter=None, data=None, share_phone=None, + share_geo=None): """ Calls `button.click ` on the specified button. @@ -805,6 +814,28 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): that if the message does not have this data, it will ``raise DataInvalidError``. + share_phone (`bool` | `str` | tl:`InputMediaContact`): + When clicking on a keyboard button requesting a phone number + (:tl:`KeyboardButtonRequestPhone`), this argument must be + explicitly set to avoid accidentally sharing the number. + + It can be `True` to automatically share the current user's + phone, a string to share a specific phone number, or a contact + media to specify all details. + + If the button is pressed without this, `ValueError` is raised. + + share_geo (`tuple` | `list` | tl:`InputMediaGeoPoint`): + When clicking on a keyboard button requesting a geo location + (:tl:`KeyboardButtonRequestGeoLocation`), this argument must + be explicitly set to avoid accidentally sharing the location. + + It must be a `tuple` of `float` as ``(longitude, latitude)``, + or a :tl:`InputGeoPoint` instance to avoid accidentally using + the wrong roder. + + If the button is pressed without this, `ValueError` is raised. + Example: .. code-block:: python @@ -820,6 +851,9 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): # Click by data await message.click(data=b'payload') + + # Click on a button requesting a phone + await message.click(0, share_phone=True) """ if not self._client: return @@ -836,7 +870,7 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): data=data ) ) - except errors.BotTimeout: + except errors.BotResponseTimeoutError: return None if sum(int(x is not None) for x in (i, text, filter)) >= 2: @@ -845,29 +879,35 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): if not await self.get_buttons(): return # Accessing the property sets self._buttons[_flat] - if text is not None: - if callable(text): + def find_button(): + nonlocal i + if text is not None: + if callable(text): + for button in self._buttons_flat: + if text(button.text): + return button + else: + for button in self._buttons_flat: + if button.text == text: + return button + return + + if filter is not None: for button in self._buttons_flat: - if text(button.text): - return await button.click() + if filter(button): + return button + return + + if i is None: + i = 0 + if j is None: + return self._buttons_flat[i] else: - for button in self._buttons_flat: - if button.text == text: - return await button.click() - return + return self._buttons[i][j] - 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() + button = find_button() + if button: + return await button.click(share_phone=share_phone, share_geo=share_geo) async def mark_read(self): """ @@ -890,7 +930,7 @@ class Message(ChatGetter, SenderGetter, TLObject, abc.ABC): # maybe just make it illegal to call messages from raw API? # That or figure out a way to always set it directly. if self._client: - await self._client.pin_message( + return await self._client.pin_message( await self.get_input_chat(), self.id, notify=notify) # endregion Public Methods diff --git a/telethon/tl/custom/messagebutton.py b/telethon/tl/custom/messagebutton.py index 5c749ac3..c99b8474 100644 --- a/telethon/tl/custom/messagebutton.py +++ b/telethon/tl/custom/messagebutton.py @@ -1,5 +1,5 @@ from .. import types, functions -from ...errors import BotTimeout +from ...errors import BotResponseTimeoutError import webbrowser @@ -59,7 +59,7 @@ class MessageButton: if isinstance(self.button, types.KeyboardButtonUrl): return self.button.url - async def click(self): + async def click(self, share_phone=None, share_geo=None): """ Emulates the behaviour of clicking this button. @@ -75,6 +75,19 @@ class MessageButton: If it's a :tl:`KeyboardButtonUrl`, the URL of the button will be passed to ``webbrowser.open`` and return `True` on success. + + If it's a :tl:`KeyboardButtonRequestPhone`, you must indicate that you + want to ``share_phone=True`` in order to share it. Sharing it is not a + default because it is a privacy concern and could happen accidentally. + + You may also use ``share_phone=phone`` to share a specific number, in + which case either `str` or :tl:`InputMediaContact` should be used. + + If it's a :tl:`KeyboardButtonRequestGeoLocation`, you must pass a + tuple in ``share_geo=(longitude, latitude)``. Note that Telegram seems + to have some heuristics to determine impossible locations, so changing + this value a lot quickly may not work as expected. You may also pass a + :tl:`InputGeoPoint` if you find the order confusing. """ if isinstance(self.button, types.KeyboardButton): return await self._client.send_message( @@ -85,7 +98,7 @@ class MessageButton: ) try: return await self._client(req) - except BotTimeout: + except BotResponseTimeoutError: return None elif isinstance(self.button, types.KeyboardButtonSwitchInline): return await self._client(functions.messages.StartBotRequest( @@ -99,5 +112,28 @@ class MessageButton: ) try: return await self._client(req) - except BotTimeout: + except BotResponseTimeoutError: return None + elif isinstance(self.button, types.KeyboardButtonRequestPhone): + if not share_phone: + raise ValueError('cannot click on phone buttons unless share_phone=True') + + if share_phone == True or isinstance(share_phone, str): + me = await self._client.get_me() + share_phone = types.InputMediaContact( + phone_number=me.phone if share_phone == True else share_phone, + first_name=me.first_name or '', + last_name=me.last_name or '', + vcard='' + ) + + return await self._client.send_file(self._chat, share_phone) + elif isinstance(self.button, types.KeyboardButtonRequestGeoLocation): + if not share_geo: + raise ValueError('cannot click on geo buttons unless share_geo=(longitude, latitude)') + + if isinstance(share_geo, (tuple, list)): + long, lat = share_geo + share_geo = types.InputMediaGeoPoint(types.InputGeoPoint(lat=lat, long=long)) + + return await self._client.send_file(self._chat, share_geo) diff --git a/telethon/tl/custom/qrlogin.py b/telethon/tl/custom/qrlogin.py new file mode 100644 index 00000000..a92276c1 --- /dev/null +++ b/telethon/tl/custom/qrlogin.py @@ -0,0 +1,119 @@ +import asyncio +import base64 +import datetime + +from .. import types, functions +from ... import events + + +class QRLogin: + """ + QR login information. + + Most of the time, you will present the `url` as a QR code to the user, + and while it's being shown, call `wait`. + """ + def __init__(self, client, ignored_ids): + self._client = client + self._request = functions.auth.ExportLoginTokenRequest( + self._client.api_id, self._client.api_hash, ignored_ids) + self._resp = None + + async def recreate(self): + """ + Generates a new token and URL for a new QR code, useful if the code + has expired before it was imported. + """ + self._resp = await self._client(self._request) + + @property + def token(self) -> bytes: + """ + The binary data representing the token. + + It can be used by a previously-authorized client in a call to + :tl:`auth.importLoginToken` to log the client that originally + requested the QR login. + """ + return self._resp.token + + @property + def url(self) -> str: + """ + The ``tg://login`` URI with the token. When opened by a Telegram + application where the user is logged in, it will import the login + token. + + If you want to display a QR code to the user, this is the URL that + should be launched when the QR code is scanned (the URL that should + be contained in the QR code image you generate). + + Whether you generate the QR code image or not is up to you, and the + library can't do this for you due to the vast ways of generating and + displaying the QR code that exist. + + The URL simply consists of `token` base64-encoded. + """ + return 'tg://login?token={}'.format(base64.b64encode(self._resp.token).decode('utf-8')) + + @property + def expires(self) -> datetime.datetime: + """ + The `datetime` at which the QR code will expire. + + If you want to try again, you will need to call `recreate`. + """ + return self._resp.expires + + async def wait(self, timeout: float = None): + """ + Waits for the token to be imported by a previously-authorized client, + either by scanning the QR, launching the URL directly, or calling the + import method. + + This method **must** be called before the QR code is scanned, and + must be executing while the QR code is being scanned. Otherwise, the + login will not complete. + + Will raise `asyncio.TimeoutError` if the login doesn't complete on + time. + + Arguments + timeout (float): + The timeout, in seconds, to wait before giving up. By default + the library will wait until the token expires, which is often + what you want. + + Returns + On success, an instance of :tl:`User`. On failure it will raise. + """ + if timeout is None: + timeout = (self._resp.expires - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds() + + event = asyncio.Event() + + async def handler(_update): + event.set() + + self._client.add_event_handler(handler, events.Raw(types.UpdateLoginToken)) + + try: + # Will raise timeout error if it doesn't complete quick enough, + # which we want to let propagate + await asyncio.wait_for(event.wait(), timeout=timeout) + finally: + self._client.remove_event_handler(handler) + + # We got here without it raising timeout error, so we can proceed + resp = await self._client(self._request) + if isinstance(resp, types.auth.LoginTokenMigrateTo): + await self._client._switch_dc(resp.dc_id) + resp = await self._client(functions.auth.ImportLoginTokenRequest(resp.token)) + # resp should now be auth.loginTokenSuccess + + if isinstance(resp, types.auth.LoginTokenSuccess): + user = resp.authorization.user + self._client._on_login(user) + return user + + raise TypeError('Login token response was unexpected: {}'.format(resp)) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 451fad17..cd563857 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -186,6 +186,19 @@ class TLObject: return json.dumps(d, default=default, **kwargs) def __bytes__(self): + try: + return self._bytes() + except AttributeError: + # If a type is wrong (e.g. expected `TLObject` but `int` was + # provided) it will try to access `._bytes()`, which will fail + # with `AttributeError`. This occurs in fact because the type + # was wrong, so raise the correct error type. + raise TypeError('a TLObject was expected but found something else') + + # Custom objects will call `(...)._bytes()` and not `bytes(...)` so that + # if the wrong type is used (e.g. `int`) we won't try allocating a huge + # amount of data, which would cause a `MemoryError`. + def _bytes(self): raise NotImplementedError @classmethod diff --git a/telethon/utils.py b/telethon/utils.py index 0c63d143..f74ef880 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -51,6 +51,8 @@ mimetypes.add_type('audio/aac', '.aac') mimetypes.add_type('audio/ogg', '.ogg') mimetypes.add_type('audio/flac', '.flac') +mimetypes.add_type('application/x-tgsticker', '.tgs') + USERNAME_RE = re.compile( r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|joinchat/)?' ) @@ -64,7 +66,7 @@ TG_JOIN_RE = re.compile( # # See https://telegram.org/blog/inline-bots#how-does-it-work VALID_USERNAME_RE = re.compile( - r'^([a-z]((?!__)[\w\d]){3,30}[a-z\d]' + r'^([a-z](?:(?!__)\w){3,30}[a-z\d]' r'|gif|vid|pic|bing|wiki|imdb|bold|vote|like|coub)$', re.IGNORECASE ) @@ -481,7 +483,7 @@ def get_input_media( supports_streaming=supports_streaming ) return types.InputMediaUploadedDocument( - file=media, mime_type=mime, attributes=attrs) + file=media, mime_type=mime, attributes=attrs, force_file=force_document) if isinstance(media, types.MessageMediaGame): return types.InputMediaGame(id=types.InputGameID( @@ -510,6 +512,9 @@ def get_input_media( venue_type='' ) + if isinstance(media, types.MessageMediaDice): + return types.InputMediaDice(media.emoticon) + if isinstance(media, ( types.MessageMediaEmpty, types.MessageMediaUnsupported, types.ChatPhotoEmpty, types.UserProfilePhotoEmpty, @@ -520,6 +525,27 @@ def get_input_media( if isinstance(media, types.Message): return get_input_media(media.media, is_photo=is_photo) + if isinstance(media, types.MessageMediaPoll): + if media.poll.quiz: + if not media.results.results: + # A quiz has correct answers, which we don't know until answered. + # If the quiz hasn't been answered we can't reconstruct it properly. + raise TypeError('Cannot cast unanswered quiz to any kind of InputMedia.') + + correct_answers = [r.option for r in media.results.results if r.correct] + else: + correct_answers = None + + return types.InputMediaPoll( + poll=media.poll, + correct_answers=correct_answers, + solution=media.results.solution, + solution_entities=media.results.solution_entities, + ) + + if isinstance(media, types.Poll): + return types.InputMediaPoll(media) + _raise_cast_fail(media, 'InputMedia') @@ -1230,7 +1256,7 @@ def get_appropriated_part_size(file_size): return 128 if file_size <= 786432000: # 750MB return 256 - if file_size <= 1572864000: # 1500MB + if file_size <= 2097152000: # 2000MB return 512 raise ValueError('File size too large') diff --git a/telethon/version.py b/telethon/version.py index fe6bbf97..f6c0593f 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.11.2' +__version__ = '1.16.2' diff --git a/telethon_examples/README.md b/telethon_examples/README.md index 29af0e4d..1e4d12b0 100644 --- a/telethon_examples/README.md +++ b/telethon_examples/README.md @@ -130,6 +130,18 @@ assumes some [`asyncio`] knowledge, but otherwise is easy to follow. ![Screenshot of the tkinter GUI][tkinter GUI] +### [`payment.py`](https://raw.githubusercontent.com/LonamiWebs/Telethon/master/telethon_examples/payment.py) + +* Usable as: **bot**. +* Difficulty: **medium**. + +This example shows how to make invoices (Telegram's way of requesting payments) via a bot account. The example does not include how to add shipping information, though. + +You'll need to obtain a "provider token" to use this example, so please read [Telegram's guide on payments](https://core.telegram.org/bots/payments) before using this example. + + +It makes use of the ["raw API"](https://tl.telethon.dev) (that is, no friendly `client.` methods), which can be helpful in understanding how it works and how it can be used. + [Telethon]: https://github.com/LonamiWebs/Telethon [CC0 License]: https://github.com/LonamiWebs/Telethon/blob/master/telethon_examples/LICENSE diff --git a/telethon_examples/gui.py b/telethon_examples/gui.py index 949d1eb9..bd241f60 100644 --- a/telethon_examples/gui.py +++ b/telethon_examples/gui.py @@ -341,8 +341,8 @@ class App(tkinter.Tk): self.chat.configure(bg='yellow') -async def main(loop, interval=0.05): - client = TelegramClient(SESSION, API_ID, API_HASH, loop=loop) +async def main(interval=0.05): + client = TelegramClient(SESSION, API_ID, API_HASH) try: await client.connect() except Exception as e: @@ -372,7 +372,7 @@ if __name__ == "__main__": # Some boilerplate code to set up the main method aio_loop = asyncio.get_event_loop() try: - aio_loop.run_until_complete(main(aio_loop)) + aio_loop.run_until_complete(main()) finally: if not aio_loop.is_closed(): aio_loop.close() diff --git a/telethon_examples/payment.py b/telethon_examples/payment.py new file mode 100644 index 00000000..4586536a --- /dev/null +++ b/telethon_examples/payment.py @@ -0,0 +1,183 @@ +from telethon import TelegramClient, events, types, functions + +import asyncio +import logging +import tracemalloc +import os +import time +import sys + +loop = asyncio.get_event_loop() + +""" +Provider token can be obtained via @BotFather. more info at https://core.telegram.org/bots/payments#getting-a-token + +If you are using test token, set test=True in generate_invoice function, +If you are using real token, set test=False +""" +provider_token = '' + +tracemalloc.start() +logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.WARNING) +logger = logging.getLogger(__name__) + + +def get_env(name, message, cast=str): + if name in os.environ: + return os.environ[name] + while True: + value = input(message) + try: + return cast(value) + except ValueError as e: + print(e, file=sys.stderr) + time.sleep(1) + + +bot = TelegramClient( + os.environ.get('TG_SESSION', 'payment'), + get_env('TG_API_ID', 'Enter your API ID: ', int), + get_env('TG_API_HASH', 'Enter your API hash: '), + proxy=None +) + + +# That event is handled when customer enters his card/etc, on final pre-checkout +# If we don't `SetBotPrecheckoutResultsRequest`, money won't be charged from buyer, and nothing will happen next. +@bot.on(events.Raw(types.UpdateBotPrecheckoutQuery)) +async def payment_pre_checkout_handler(event: types.UpdateBotPrecheckoutQuery): + if event.payload.decode('UTF-8') == 'product A': + # so we have to confirm payment + await bot( + functions.messages.SetBotPrecheckoutResultsRequest( + query_id=event.query_id, + success=True, + error=None + ) + ) + elif event.payload.decode('UTF-8') == 'product B': + # same for another + await bot( + functions.messages.SetBotPrecheckoutResultsRequest( + query_id=event.query_id, + success=True, + error=None + ) + ) + else: + # for example, something went wrong (whatever reason). We can tell customer about that: + await bot( + functions.messages.SetBotPrecheckoutResultsRequest( + query_id=event.query_id, + success=False, + error='Something went wrong' + ) + ) + + raise events.StopPropagation + + +# That event is handled at the end, when customer payed. +@bot.on(events.Raw(types.UpdateNewMessage)) +async def payment_received_handler(event): + if isinstance(event.message.action, types.MessageActionPaymentSentMe): + payment: types.MessageActionPaymentSentMe = event.message.action + # do something after payment was recieved + if payment.payload.decode('UTF-8') == 'product A': + await bot.send_message(event.message.from_id, 'Thank you for buying product A!') + elif payment.payload.decode('UTF-8') == 'product B': + await bot.send_message(event.message.from_id, 'Thank you for buying product B!') + raise events.StopPropagation + + +# let's put it in one function for more easier way +def generate_invoice(price_label: str, price_amount: int, currency: str, title: str, + description: str, payload: str, start_param: str) -> types.InputMediaInvoice: + price = types.LabeledPrice(label=price_label, amount=price_amount) # label - just a text, amount=10000 means 100.00 + invoice = types.Invoice( + currency=currency, # currency like USD + prices=[price], # there could be a couple of prices. + test=True, # if you're working with test token, else set test=False. + # More info at https://core.telegram.org/bots/payments + + # params for requesting specific fields + name_requested=False, + phone_requested=False, + email_requested=False, + shipping_address_requested=False, + + # if price changes depending on shipping + flexible=False, + + # send data to provider + phone_to_provider=False, + email_to_provider=False + ) + return types.InputMediaInvoice( + title=title, + description=description, + invoice=invoice, + payload=payload.encode('UTF-8'), # payload, which will be sent to next 2 handlers + provider=provider_token, + + provider_data=types.DataJSON('{}'), + # data about the invoice, which will be shared with the payment provider. A detailed description of + # required fields should be provided by the payment provider. + + start_param=start_param, + # Unique deep-linking parameter. May also be used in UpdateBotPrecheckoutQuery + # see: https://core.telegram.org/bots#deep-linking + # it may be the empty string if not needed + + ) + + +@bot.on(events.NewMessage(pattern='/start')) +async def start_handler(event: events.NewMessage.Event): + await event.respond('/product_a - product A\n/product_b - product B\n/product_c - product, shall cause an error') + + +@bot.on(events.NewMessage(pattern='/product_a')) +async def start_handler(event: events.NewMessage.Event): + await bot.send_message( + event.chat_id, 'Sending invoice A', + file=generate_invoice( + price_label='Pay', price_amount=10000, currency='RUB', title='Title A', description='description A', + payload='product A', start_param='abc' + ) + ) + + +@bot.on(events.NewMessage(pattern='/product_b')) +async def start_handler(event: events.NewMessage.Event): + await bot.send_message( + event.chat_id, 'Sending invoice B', + file=generate_invoice( + price_label='Pay', price_amount=20000, currency='RUB', title='Title B', description='description B', + payload='product B', start_param='abc' + ) + ) + + +@bot.on(events.NewMessage(pattern='/product_c')) +async def start_handler(event: events.NewMessage.Event): + await bot.send_message( + event.chat_id, 'Sending invoice C', + file=generate_invoice( + price_label='Pay', price_amount=50000, currency='RUB', title='Title C', + description='description c - shall cause an error', payload='product C', start_param='abc' + ) + ) + + +async def main(): + await bot.start() + await bot.run_until_disconnected() + + +if __name__ == '__main__': + if not provider_token: + logger.error("No provider token supplied.") + exit(1) + loop.run_until_complete(main()) diff --git a/telethon_generator/data/api.tl b/telethon_generator/data/api.tl index 15e82f30..efe1a79f 100644 --- a/telethon_generator/data/api.tl +++ b/telethon_generator/data/api.tl @@ -62,19 +62,19 @@ inputMediaUploadedPhoto#1e287d04 flags:# file:InputFile stickers:flags.0?Vector< inputMediaPhoto#b3ba0635 flags:# id:InputPhoto ttl_seconds:flags.0?int = InputMedia; inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; -inputMediaUploadedDocument#5b38c6c1 flags:# nosound_video:flags.3?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; +inputMediaUploadedDocument#5b38c6c1 flags:# nosound_video:flags.3?true force_file:flags.4?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; inputMediaDocument#23ab23d2 flags:# id:InputDocument ttl_seconds:flags.0?int = InputMedia; inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; -inputMediaGifExternal#4843b0fd url:string q:string = InputMedia; inputMediaPhotoExternal#e5bbfe1a flags:# url:string ttl_seconds:flags.0?int = InputMedia; inputMediaDocumentExternal#fb52dc99 flags:# url:string ttl_seconds:flags.0?int = InputMedia; inputMediaGame#d33f43f3 id:InputGame = InputMedia; inputMediaInvoice#f4e096c3 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON start_param:string = InputMedia; inputMediaGeoLive#ce4e82fd flags:# stopped:flags.0?true geo_point:InputGeoPoint period:flags.1?int = InputMedia; -inputMediaPoll#abe9ca25 flags:# poll:Poll correct_answers:flags.0?Vector = InputMedia; +inputMediaPoll#f94e5f1 flags:# poll:Poll correct_answers:flags.0?Vector solution:flags.1?string solution_entities:flags.1?Vector = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; -inputChatUploadedPhoto#927c55b4 file:InputFile = InputChatPhoto; +inputChatUploadedPhoto#c642724e flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = InputChatPhoto; inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; inputGeoPointEmpty#e4c123d6 = InputGeoPoint; @@ -109,10 +109,10 @@ storage.fileMp4#b3cea0e4 = storage.FileType; storage.fileWebp#1081464c = storage.FileType; userEmpty#200250ba id:int = User; -user#938458c1 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true id:int access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string = User; +user#938458c1 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true id:int access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string = User; userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; -userProfilePhoto#ecd75d8c photo_id:long photo_small:FileLocation photo_big:FileLocation dc_id:int = UserProfilePhoto; +userProfilePhoto#69d3ab26 flags:# has_video:flags.0?true photo_id:long photo_small:FileLocation photo_big:FileLocation dc_id:int = UserProfilePhoto; userStatusEmpty#9d05049 = UserStatus; userStatusOnline#edb93949 expires:int = UserStatus; @@ -128,7 +128,7 @@ channel#d31a961e flags:# creator:flags.0?true left:flags.2?true broadcast:flags. channelForbidden#289da732 flags:# broadcast:flags.5?true megagroup:flags.8?true id:int access_hash:long title:string until_date:flags.16?int = Chat; chatFull#1b7c9db3 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:int about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int = ChatFull; -channelFull#2d895c74 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_view_stats:flags.12?true can_set_location:flags.16?true has_scheduled:flags.19?true id:int about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?int migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?int location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int pts:int = ChatFull; +channelFull#f0e6672a flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true id:int about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?int migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?int location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int = ChatFull; chatParticipant#c8d7493e user_id:int inviter_id:int date:int = ChatParticipant; chatParticipantCreator#da13538a user_id:int = ChatParticipant; @@ -138,7 +138,7 @@ chatParticipantsForbidden#fc900c2b flags:# chat_id:int self_participant:flags.0? chatParticipants#3f460fed chat_id:int participants:Vector version:int = ChatParticipants; chatPhotoEmpty#37c1011c = ChatPhoto; -chatPhoto#475cdbd5 photo_small:FileLocation photo_big:FileLocation dc_id:int = ChatPhoto; +chatPhoto#d20b9f3c flags:# has_video:flags.0?true photo_small:FileLocation photo_big:FileLocation dc_id:int = ChatPhoto; messageEmpty#83e5de54 id:int = Message; message#452c0e65 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true id:int from_id:flags.8?int to_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?int reply_to_msg_id:flags.3?int date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long restriction_reason:flags.22?Vector = Message; @@ -156,6 +156,7 @@ messageMediaGame#fdb19008 game:Game = MessageMedia; messageMediaInvoice#84551347 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string = MessageMedia; messageMediaGeoLive#7c3c2609 geo:GeoPoint period:int = MessageMedia; messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; +messageMediaDice#3f7ee58b value:int emoticon:string = MessageMedia; messageActionEmpty#b6aef7b0 = MessageAction; messageActionChatCreate#a6638b9a title:string users:Vector = MessageAction; @@ -185,7 +186,7 @@ dialog#2c171f72 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer t dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; photoEmpty#2331b22d id:long = Photo; -photo#d07504a5 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector dc_id:int = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; photoSizeEmpty#e17e23c type:string = PhotoSize; photoSize#77bfb61b type:string location:FileLocation w:int h:int size:int = PhotoSize; @@ -211,7 +212,7 @@ inputPeerNotifySettings#9c3d198e flags:# show_previews:flags.0?Bool silent:flags peerNotifySettings#af509d20 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?string = PeerNotifySettings; -peerSettings#818426cd flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true = PeerSettings; +peerSettings#733f2961 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true geo_distance:flags.6?int = PeerSettings; wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; wallPaperNoFile#8af40b25 flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; @@ -224,7 +225,7 @@ inputReportReasonOther#e1746d0a text:string = ReportReason; inputReportReasonCopyright#9b89f93a = ReportReason; inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; -userFull#edf17c12 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true user:User about:flags.1?string settings:PeerSettings profile_photo:flags.2?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int = UserFull; +userFull#edf17c12 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true user:User about:flags.1?string settings:PeerSettings profile_photo:flags.2?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int = UserFull; contact#f911c994 user_id:int mutual:Bool = Contact; @@ -352,6 +353,11 @@ updateTheme#8216fba3 theme:Theme = Update; updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; updateLoginToken#564fe691 = Update; updateMessagePollVote#42f88f2c poll_id:long user_id:int options:Vector = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelParticipant#65d2b464 flags:# channel_id:int date:int user_id:int prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant qts:int = Update; updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; @@ -389,7 +395,7 @@ help.inviteText#18cb9f78 message:string = help.InviteText; encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; encryptedChatWaiting#3bf703dc id:int access_hash:long date:int admin_id:int participant_id:int = EncryptedChat; -encryptedChatRequested#c878527e id:int access_hash:long date:int admin_id:int participant_id:int g_a:bytes = EncryptedChat; +encryptedChatRequested#62718a82 flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:int participant_id:int g_a:bytes = EncryptedChat; encryptedChat#fa56ce36 id:int access_hash:long date:int admin_id:int participant_id:int g_a_or_b:bytes key_fingerprint:long = EncryptedChat; encryptedChatDiscarded#13d6dd27 id:int = EncryptedChat; @@ -416,7 +422,7 @@ inputDocumentEmpty#72f0eaae = InputDocument; inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; documentEmpty#36f8c871 id:long = Document; -document#9ba29cc1 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:int thumbs:flags.0?Vector dc_id:int attributes:Vector = Document; +document#1e87342b flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:int thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; help.support#17c6b5f6 phone_number:string user:User = help.Support; @@ -502,7 +508,7 @@ messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMess webPageEmpty#eb1477e8 id:long = WebPage; webPagePending#c586da1c id:long date:int = WebPage; webPage#e89c45b2 flags:# id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; -webPageNotModified#85849473 = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; @@ -523,11 +529,13 @@ chatInviteExported#fc2e05bc link:string = ExportedChatInvite; chatInviteAlready#5a686d7c chat:Chat = ChatInvite; chatInvite#dfc2f58e flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true title:string photo:Photo participants_count:int participants:flags.4?Vector = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; inputStickerSetEmpty#ffb62b95 = InputStickerSet; inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; stickerSet#eeb46f27 flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true animated:flags.5?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumb:flags.4?PhotoSize thumb_dc_id:flags.4?int count:int hash:int = StickerSet; @@ -612,11 +620,6 @@ channels.channelParticipant#d0d9b163 participant:ChannelParticipant users:Vector help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; -foundGif#162ecc1f url:string thumb_url:string content_url:string content_type:string w:int h:int = FoundGif; -foundGifCached#9c750409 url:string photo:Photo document:Document = FoundGif; - -messages.foundGifs#450a1c0a next_offset:int results:Vector = messages.FoundGifs; - messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; messages.savedGifs#2e0709a5 hash:int gifs:Vector = messages.SavedGifs; @@ -645,7 +648,7 @@ messages.botResults#947ca848 flags:# gallery:flags.0?true query_id:long next_off exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; -messageFwdHeader#ec338270 flags:# from_id:flags.0?int from_name:flags.5?string date:int channel_id:flags.1?int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int = MessageFwdHeader; +messageFwdHeader#353a686b flags:# from_id:flags.0?int from_name:flags.5?string date:int channel_id:flags.1?int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int psa_type:flags.6?string = MessageFwdHeader; auth.codeTypeSms#72a3158c = auth.CodeType; auth.codeTypeCall#741cd3e3 = auth.CodeType; @@ -686,8 +689,8 @@ contacts.topPeersDisabled#b52c939d = contacts.TopPeers; draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; draftMessage#fd8e711f flags:# no_webpage:flags.1?true reply_to_msg_id:flags.0?int message:string entities:flags.3?Vector date:int = DraftMessage; -messages.featuredStickersNotModified#4ede3cf = messages.FeaturedStickers; -messages.featuredStickers#f89d88e5 hash:int sets:Vector unread:Vector = messages.FeaturedStickers; +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#b6abc341 hash:int count:int sets:Vector unread:Vector = messages.FeaturedStickers; messages.recentStickersNotModified#b17f890 = messages.RecentStickers; messages.recentStickers#22f3afb3 hash:int packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; @@ -815,15 +818,16 @@ inputStickerSetItem#ffa0a496 flags:# document:InputDocument emoji:string mask_co inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; phoneCallEmpty#5366c915 id:long = PhoneCall; -phoneCallWaiting#1b8f4ad1 flags:# video:flags.5?true id:long access_hash:long date:int admin_id:int participant_id:int protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; -phoneCallRequested#87eabb53 flags:# video:flags.5?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; -phoneCallAccepted#997c454a flags:# video:flags.5?true id:long access_hash:long date:int admin_id:int participant_id:int g_b:bytes protocol:PhoneCallProtocol = PhoneCall; -phoneCall#8742ae7f flags:# p2p_allowed:flags.5?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int = PhoneCall; -phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.5?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; +phoneCallWaiting#1b8f4ad1 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#87eabb53 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#997c454a flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#8742ae7f flags:# p2p_allowed:flags.5?true video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; phoneConnection#9d4c17c0 id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; -phoneCallProtocol#a2bb35cb flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int = PhoneCallProtocol; +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; @@ -906,9 +910,6 @@ fileHash#6242c773 offset:int limit:int hash:bytes = FileHash; inputClientProxy#75588b3f address:string port:int = InputClientProxy; -help.proxyDataEmpty#e09e1fb8 expires:int = help.ProxyData; -help.proxyDataPromo#2bf7ee23 expires:int peer:Peer chats:Vector users:Vector = help.ProxyData; - help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; @@ -1009,7 +1010,7 @@ pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageLis pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; -page#ae891bec flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector = Page; +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; help.supportName#8c05f1c9 name:string = help.SupportName; @@ -1018,11 +1019,11 @@ help.userInfo#1eb3758 message:string entities:Vector author:strin pollAnswer#6ca9c2e9 text:string option:bytes = PollAnswer; -poll#d5529d06 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:string answers:Vector = Poll; +poll#86e18161 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:string answers:Vector close_period:flags.4?int close_date:flags.5?int = Poll; pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; -pollResults#c87024a2 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector = PollResults; +pollResults#badcc1a3 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; chatOnlines#f041e250 onlines:int = ChatOnlines; @@ -1116,11 +1117,44 @@ bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; +dialogFilter#7438f7e8 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true id:int title:string emoticon:flags.25?string pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +messageInteractionCounters#ad4fc9bd msg_id:int views:int forwards:int = MessageInteractionCounters; + +stats.broadcastStats#bdf78394 period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph recent_message_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8c39793f flags:# proxy:flags.0?true expires:int peer:Peer chats:Vector users:Vector psa_type:flags.1?string psa_message:flags.2?string = help.PromoData; + +videoSize#e831c556 flags:# type:string location:FileLocation w:int h:int size:int video_start_ts:flags.0?double = VideoSize; + +statsGroupTopPoster#18f3d0f7 user_id:int messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#6014f412 user_id:int deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#31962a4c user_id:int invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#bea2f424 flags:# archive_and_mute_new_noncontact_peers:flags.0?Bool = GlobalPrivacySettings; + ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; -initConnection#785188b8 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; @@ -1210,6 +1244,8 @@ account.getThemes#285946f8 format:string hash:int = account.Themes; account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; account.getContentSettings#8b9b4dae = account.ContentSettings; account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; users.getUsers#d91a548 id:Vector = Vector; users.getFullUser#ca30a5b1 id:InputUser = UserFull; @@ -1285,7 +1321,6 @@ messages.migrateChat#15a3b8e3 chat_id:int = Updates; messages.searchGlobal#bf7225a4 flags:# folder_id:flags.0?int q:string offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; messages.reorderStickerSets#78337739 flags:# masks:flags.0?true order:Vector = Bool; messages.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document; -messages.searchGifs#bf9a776b q:string offset:int = messages.FoundGifs; messages.getSavedGifs#83bf3d52 hash:int = messages.SavedGifs; messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; @@ -1354,13 +1389,18 @@ messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#f19ed96d = Vector; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#5fe7025b offset:int limit:int hash:int = messages.FeaturedStickers; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; -photos.updateProfilePhoto#f0bb5152 id:InputPhoto = UserProfilePhoto; -photos.uploadProfilePhoto#4f32c098 file:InputFile = photos.Photo; +photos.updateProfilePhoto#72d4742c id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#89f30f69 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = photos.Photo; photos.deletePhotos#87cf7f2f id:Vector = Vector; photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; @@ -1382,7 +1422,6 @@ help.getAppChangelog#9010ef6f prev_app_version:string = Updates; help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; help.getCdnConfig#52029342 = CdnConfig; help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; -help.getProxyData#3d7758e1 = help.ProxyData; help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; @@ -1392,6 +1431,9 @@ help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; help.getSupportName#d360e72c = help.SupportName; help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#77fa99f suggestion:string = Bool; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; @@ -1431,6 +1473,7 @@ channels.getInactiveChannels#11e831ee = messages.InactiveChats; bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#805d46f6 commands:Vector = Bool; payments.getPaymentForm#99f09745 msg_id:int = payments.PaymentForm; payments.getPaymentReceipt#a092a980 msg_id:int = payments.PaymentReceipt; @@ -1440,10 +1483,11 @@ payments.getSavedInfo#227d824b = payments.SavedInfo; payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; payments.getBankCardData#2e79d779 number:string = payments.BankCardData; -stickers.createStickerSet#9bd86e6a flags:# masks:flags.0?true user_id:InputUser title:string short_name:string stickers:Vector = messages.StickerSet; +stickers.createStickerSet#f1036780 flags:# masks:flags.0?true animated:flags.1?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector = messages.StickerSet; stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#9a364e30 stickerset:InputStickerSet thumb:InputDocument = messages.StickerSet; phone.getCallConfig#55451fa9 = DataJSON; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; @@ -1453,6 +1497,7 @@ phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; @@ -1463,4 +1508,8 @@ langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLangua folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; folders.deleteFolder#1c295881 folder_id:int = Updates; -// LAYER 110 +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; + +// LAYER 117 diff --git a/telethon_generator/data/errors.csv b/telethon_generator/data/errors.csv index dec72b4a..c1dfa689 100644 --- a/telethon_generator/data/errors.csv +++ b/telethon_generator/data/errors.csv @@ -15,9 +15,13 @@ AUTH_KEY_INVALID,401,The key is invalid AUTH_KEY_PERM_EMPTY,401,"The method is unavailable for temporary authorization key, not bound to permanent" AUTH_KEY_UNREGISTERED,401,The key is not registered in the system AUTH_RESTART,500,Restart the authorization process +AUTH_TOKEN_ALREADY_ACCEPTED,400,The authorization token was already used +AUTH_TOKEN_EXPIRED,400,The provided authorization token has expired and the updated QR-code must be re-scanned +AUTH_TOKEN_INVALID,400,An invalid authorization token was provided BANNED_RIGHTS_INVALID,400,"You cannot use that set of permissions in this request, i.e. restricting view_messages as a default" BOTS_TOO_MUCH,400,There are too many bots in this chat/channel BOT_CHANNELS_NA,400,Bots can't edit admin privileges +BOT_COMMAND_DESCRIPTION_INVALID,400,"The command description was empty, too long or had invalid characters used" BOT_GROUPS_BLOCKED,400,This bot can't be added to groups BOT_INLINE_DISABLED,400,This bot can't be used in inline mode BOT_INVALID,400,This is not a valid bot @@ -25,8 +29,11 @@ BOT_METHOD_INVALID,400,The API access for bot users is restricted. The method yo BOT_MISSING,400,This method can only be run by a bot BOT_PAYMENTS_DISABLED,400,This method can only be run by a bot BOT_POLLS_DISABLED,400,You cannot create polls under a bot account +BOT_RESPONSE_TIMEOUT,400,The bot did not answer to the callback query in time +BROADCAST_FORBIDDEN,403,The request cannot be used in broadcast channels BROADCAST_ID_INVALID,400,The channel is invalid BROADCAST_PUBLIC_VOTERS_FORBIDDEN,400,You cannot broadcast polls where the voters are public +BROADCAST_REQUIRED,400,The request can only be used with a broadcast channel BUTTON_DATA_INVALID,400,The provided button data is invalid BUTTON_TYPE_INVALID,400,The type of one of the buttons you provided is invalid BUTTON_URL_INVALID,400,Button URL invalid @@ -67,7 +74,9 @@ CONNECTION_LANG_PACK_INVALID,400,"The specified language pack is not valid. This CONNECTION_LAYER_INVALID,400,The very first request must always be InvokeWithLayerRequest CONNECTION_NOT_INITED,400,Connection not initialized CONNECTION_SYSTEM_EMPTY,400,Connection system empty +CONNECTION_SYSTEM_LANG_CODE_EMPTY,400,The system language string was empty during connection CONTACT_ID_INVALID,400,The provided contact ID is invalid +CONTACT_NAME_EMPTY,400,The provided contact name cannot be empty DATA_INVALID,400,Encrypted data invalid DATA_JSON_INVALID,400,The provided JSON data is invalid DATE_EMPTY,400,Date empty @@ -77,6 +86,7 @@ EMAIL_HASH_EXPIRED,400,The email hash expired and cannot be used to verify it EMAIL_INVALID,400,The given email is invalid EMAIL_UNCONFIRMED_X,400,"Email unconfirmed, the length of the code must be {code_length}" EMOTICON_EMPTY,400,The emoticon field cannot be empty +EMOTICON_INVALID,400,The specified emoticon cannot be used or was not a emoticon ENCRYPTED_MESSAGE_INVALID,400,Encrypted message invalid ENCRYPTION_ALREADY_ACCEPTED,400,Secret chat already accepted ENCRYPTION_ALREADY_DECLINED,400,The secret chat was already declined @@ -100,6 +110,8 @@ FILE_PART_LENGTH_INVALID,400,The length of a file part is invalid FILE_PART_SIZE_CHANGED,400,The file part size (chunk size) cannot change during upload FILE_PART_SIZE_INVALID,400,The provided file part size is invalid FILE_PART_X_MISSING,400,Part {which} of the file is missing from storage +FILE_REFERENCE_EMPTY,400,The file reference must exist to access the media and it cannot be empty +FILE_REFERENCE_EXPIRED,400,The file reference has expired and is no longer valid or it belongs to self-destructing media and cannot be resent FILEREF_UPGRADE_NEEDED,406,The file reference needs to be refreshed before being used again FIRSTNAME_INVALID,400,The first name is invalid FLOOD_TEST_PHONE_WAIT_X,420,A wait of {seconds} seconds is required in the test servers @@ -107,6 +119,7 @@ FLOOD_WAIT_X,420,A wait of {seconds} seconds is required FOLDER_ID_EMPTY,400,The folder you tried to delete was already empty FOLDER_ID_INVALID,400,The folder you tried to use was not valid FRESH_CHANGE_ADMINS_FORBIDDEN,400,Recently logged-in users cannot add or change admins +FRESH_CHANGE_PHONE_FORBIDDEN,406,Recently logged-in users cannot use this request FRESH_RESET_AUTHORISATION_FORBIDDEN,406,The current session is too new and cannot be used to reset other authorisations yet GAME_BOT_INVALID,400,You cannot send that game with the current bot GIF_ID_INVALID,400,The provided GIF ID is invalid @@ -114,6 +127,7 @@ GROUPED_MEDIA_INVALID,400,Invalid grouped media HASH_INVALID,400,The provided hash is invalid HISTORY_GET_FAILED,500,Fetching of history failed IMAGE_PROCESS_FAILED,400,Failure while processing image +INLINE_BOT_REQUIRED,403,The action must be performed through an inline bot callback INLINE_RESULT_EXPIRED,400,The inline query expired INPUT_CONSTRUCTOR_INVALID,400,The provided constructor is invalid INPUT_FETCH_ERROR,,An error occurred while deserializing TL parameters @@ -142,6 +156,7 @@ MEDIA_NEW_INVALID,400,The new media to edit the message with is invalid (such as MEDIA_PREV_INVALID,400,The old media cannot be edited with anything else (such as stickers or voice notes) MEGAGROUP_ID_INVALID,400,The group is invalid MEGAGROUP_PREHISTORY_HIDDEN,400,You can't set this discussion group because it's history is hidden +MEGAGROUP_REQUIRED,400,The request can only be used with a megagroup channel MEMBER_NO_LOCATION,500,An internal failure occurred while fetching user info (couldn't find location) MEMBER_OCCUPY_PRIMARY_LOC_FAILED,500,Occupation of primary member location failed MESSAGE_AUTHOR_REQUIRED,403,Message author required @@ -153,6 +168,7 @@ MESSAGE_ID_INVALID,400,"The specified message ID is invalid or you can't do that MESSAGE_NOT_MODIFIED,400,Content of the message was not modified MESSAGE_POLL_CLOSED,400,The poll was closed and can no longer be voted on MESSAGE_TOO_LONG,400,Message was too long. Current maximum length is 4096 UTF-8 characters +METHOD_INVALID,400,The API method is invalid and cannot be used MSGID_DECREASE_RETRY,500,The request should be retried with a lower message ID MSG_ID_INVALID,400,The message ID used in the peer was invalid MSG_WAIT_FAILED,400,A waiting call returned an error @@ -173,7 +189,9 @@ PARTICIPANT_CALL_FAILED,500,Failure while making call PARTICIPANT_VERSION_OUTDATED,400,The other participant does not use an up to date telegram client with support for calls PASSWORD_EMPTY,400,The provided password is empty PASSWORD_HASH_INVALID,400,The password (and thus its hash value) you entered is invalid +PASSWORD_MISSING,400,The account must have 2-factor authentication enabled (a password) before this method can be used PASSWORD_REQUIRED,400,The account must have 2-factor authentication enabled (a password) before this method can be used +PASSWORD_TOO_FRESH_X,400,The password was added too recently and {seconds} seconds must pass before using the method PAYMENT_PROVIDER_INVALID,400,The payment provider was not recognised or its token was invalid PEER_FLOOD,,Too many requests PEER_ID_INVALID,400,An invalid Peer was used. Make sure to pass the right peer type @@ -208,6 +226,7 @@ POLL_OPTION_INVALID,400,A poll option used invalid data (the data may be too lon POLL_QUESTION_INVALID,400,The poll question was either empty or too long POLL_UNSUPPORTED,400,This layer does not support polls in the issued method PRIVACY_KEY_INVALID,400,The privacy key is invalid +PRIVACY_TOO_LONG,400,Cannot add that many entities in a single request PTS_CHANGE_EMPTY,500,No PTS change QUERY_ID_EMPTY,400,The query ID is empty QUERY_ID_INVALID,400,The query ID is invalid @@ -234,6 +253,7 @@ RPC_MCGET_FAIL,,"Telegram is having internal issues, please try again later." RSA_DECRYPT_FAILED,400,Internal RSA decryption failed SCHEDULE_BOT_NOT_ALLOWED,400,Bots are not allowed to schedule messages SCHEDULE_DATE_TOO_LATE,400,The date you tried to schedule is too far in the future (last known limit of 1 year and a few hours) +SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes online if their privacy does not show this information SCHEDULE_TOO_MUCH,400,You cannot schedule more messages in this chat (last known limit of 100 per chat) SEARCH_QUERY_EMPTY,400,The search query is empty SECONDS_INVALID,400,"Slow mode only supports certain values (e.g. 0, 10s, 30s, 1m, 5m, 15m and 1h)" @@ -242,11 +262,13 @@ SEND_MESSAGE_TYPE_INVALID,400,The message type is invalid SESSION_EXPIRED,401,The authorization has expired SESSION_PASSWORD_NEEDED,401,Two-steps verification is enabled and a password is required SESSION_REVOKED,401,"The authorization has been invalidated, because of the user terminating all sessions" +SESSION_TOO_FRESH_X,400,The session logged in too recently and {seconds} seconds must pass before calling the method SHA256_HASH_INVALID,400,The provided SHA256 hash is invalid SHORTNAME_OCCUPY_FAILED,400,An error occurred when trying to register the short-name used for the sticker pack. Try a different name SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat START_PARAM_EMPTY,400,The start parameter is empty START_PARAM_INVALID,400,Start parameter invalid +STATS_MIGRATE_X,303,The channel statistics must be fetched from DC {dc} STICKERSET_INVALID,400,The provided sticker set is invalid STICKERS_EMPTY,400,No sticker provided STICKER_EMOJI_INVALID,400,Sticker emoji invalid @@ -298,9 +320,11 @@ USER_NOT_PARTICIPANT,400,The target user is not a member of the specified megagr USER_PRIVACY_RESTRICTED,403,The user's privacy settings do not allow you to do this USER_RESTRICTED,403,"You're spamreported, you can't create channels or chats." VIDEO_CONTENT_TYPE_INVALID,400,The video content type is not supported with the given parameters (i.e. supports_streaming) +VIDEO_FILE_INVALID,400,The given video cannot be used WALLPAPER_FILE_INVALID,400,The given file cannot be used as a wallpaper WALLPAPER_INVALID,400,The input wallpaper was not valid WC_CONVERT_URL_INVALID,400,WC convert URL invalid +WEBDOCUMENT_URL_INVALID,400,The given URL cannot be used WEBPAGE_CURL_FAILED,400,Failure while fetching the webpage with cURL WEBPAGE_MEDIA_EMPTY,400,Webpage media empty WORKER_BUSY_TOO_LONG_RETRY,500,Telegram workers are too busy to respond immediately diff --git a/telethon_generator/data/methods.csv b/telethon_generator/data/methods.csv index e1d04b86..83317861 100644 --- a/telethon_generator/data/methods.csv +++ b/telethon_generator/data/methods.csv @@ -5,24 +5,31 @@ account.changePhone,user,PHONE_NUMBER_INVALID account.checkUsername,user,USERNAME_INVALID account.confirmPasswordEmail,user, account.confirmPhone,user,CODE_HASH_INVALID PHONE_CODE_EMPTY +account.createTheme,user, account.deleteSecureValue,user, account.finishTakeoutSession,user, account.getAccountTTL,user, account.getAllSecureValues,user, account.getAuthorizationForm,user, account.getAuthorizations,user, +account.getAutoDownloadSettings,user, account.getContactSignUpNotification,user, +account.getContentSettings,user, +account.getMultiWallPapers,user, account.getNotifyExceptions,user, account.getNotifySettings,user,PEER_ID_INVALID account.getPassword,user, account.getPasswordSettings,user,PASSWORD_HASH_INVALID account.getPrivacy,user,PRIVACY_KEY_INVALID account.getSecureValue,user, +account.getTheme,user, +account.getThemes,user, account.getTmpPassword,user,PASSWORD_HASH_INVALID TMP_PASSWORD_DISABLED account.getWallPaper,user,WALLPAPER_INVALID account.getWallPapers,user, account.getWebAuthorizations,user, account.initTakeoutSession,user, +account.installTheme,user, account.installWallPaper,user,WALLPAPER_INVALID account.registerDevice,user,TOKEN_INVALID account.reportPeer,user,PEER_ID_INVALID @@ -32,32 +39,40 @@ account.resetNotifySettings,user, account.resetWallPapers,user, account.resetWebAuthorization,user, account.resetWebAuthorizations,user, +account.saveAutoDownloadSettings,user, account.saveSecureValue,user,PASSWORD_REQUIRED +account.saveTheme,user, account.saveWallPaper,user,WALLPAPER_INVALID -account.sendChangePhoneCode,user,PHONE_NUMBER_INVALID +account.sendChangePhoneCode,user,FRESH_CHANGE_PHONE_FORBIDDEN PHONE_NUMBER_INVALID account.sendConfirmPhoneCode,user,HASH_INVALID account.sendVerifyEmailCode,user,EMAIL_INVALID account.sendVerifyPhoneCode,user, account.setAccountTTL,user,TTL_DAYS_INVALID account.setContactSignUpNotification,user, -account.setPrivacy,user,PRIVACY_KEY_INVALID +account.setContentSettings,user, +account.setPrivacy,user,PRIVACY_KEY_INVALID PRIVACY_TOO_LONG account.unregisterDevice,user,TOKEN_INVALID account.updateDeviceLocked,user, account.updateNotifySettings,user,PEER_ID_INVALID account.updatePasswordSettings,user,EMAIL_UNCONFIRMED_X NEW_SALT_INVALID NEW_SETTINGS_INVALID PASSWORD_HASH_INVALID account.updateProfile,user,ABOUT_TOO_LONG FIRSTNAME_INVALID account.updateStatus,user,SESSION_PASSWORD_NEEDED +account.updateTheme,user, account.updateUsername,user,USERNAME_INVALID USERNAME_NOT_MODIFIED USERNAME_OCCUPIED +account.uploadTheme,user, account.uploadWallPaper,user,WALLPAPER_FILE_INVALID account.verifyEmail,user,EMAIL_INVALID account.verifyPhone,user, +auth.acceptLoginToken,user, auth.bindTempAuthKey,both,ENCRYPTED_MESSAGE_INVALID INPUT_REQUEST_TOO_LONG TEMP_AUTH_KEY_EMPTY Timeout auth.cancelCode,user,PHONE_NUMBER_INVALID auth.checkPassword,user,PASSWORD_HASH_INVALID auth.dropTempAuthKeys,both, auth.exportAuthorization,both,DC_ID_INVALID +auth.exportLoginToken,user, auth.importAuthorization,both,AUTH_BYTES_INVALID USER_ID_INVALID auth.importBotAuthorization,both,ACCESS_TOKEN_EXPIRED ACCESS_TOKEN_INVALID API_ID_INVALID +auth.importLoginToken,user,AUTH_TOKEN_ALREADY_ACCEPTED AUTH_TOKEN_EXPIRED AUTH_TOKEN_INVALID auth.logOut,both, auth.recoverPassword,user,CODE_EMPTY auth.requestPasswordRecovery,user,PASSWORD_EMPTY @@ -68,6 +83,7 @@ auth.signIn,user,PHONE_CODE_EMPTY PHONE_CODE_EXPIRED PHONE_CODE_INVALID PHONE_NU auth.signUp,user,FIRSTNAME_INVALID MEMBER_OCCUPY_PRIMARY_LOC_FAILED PHONE_CODE_EMPTY PHONE_CODE_EXPIRED PHONE_CODE_INVALID PHONE_NUMBER_FLOOD PHONE_NUMBER_INVALID PHONE_NUMBER_OCCUPIED REG_ID_GENERATE_FAILED bots.answerWebhookJSONQuery,bot,QUERY_ID_INVALID USER_BOT_INVALID bots.sendCustomRequest,bot,USER_BOT_INVALID +bots.setBotCommands,bot,BOT_COMMAND_DESCRIPTION_INVALID channels.checkUsername,user,CHANNEL_INVALID CHAT_ID_INVALID USERNAME_INVALID channels.createChannel,user,CHAT_TITLE_EMPTY USER_RESTRICTED channels.deleteChannel,user,CHANNEL_INVALID CHANNEL_PRIVATE @@ -76,6 +92,8 @@ channels.deleteMessages,both,CHANNEL_INVALID CHANNEL_PRIVATE MESSAGE_DELETE_FORB channels.deleteUserHistory,user,CHANNEL_INVALID CHAT_ADMIN_REQUIRED channels.editAdmin,both,ADMINS_TOO_MUCH ADMIN_RANK_EMOJI_NOT_ALLOWED ADMIN_RANK_INVALID BOT_CHANNELS_NA CHANNEL_INVALID CHAT_ADMIN_INVITE_REQUIRED CHAT_ADMIN_REQUIRED FRESH_CHANGE_ADMINS_FORBIDDEN RIGHT_FORBIDDEN USER_CREATOR USER_ID_INVALID USER_NOT_MUTUAL_CONTACT USER_PRIVACY_RESTRICTED channels.editBanned,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED USER_ADMIN_INVALID USER_ID_INVALID +channels.editCreator,user,PASSWORD_MISSING PASSWORD_TOO_FRESH_X SESSION_TOO_FRESH_X +channels.editLocation,user, channels.editPhoto,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED PHOTO_INVALID channels.editTitle,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED CHAT_NOT_MODIFIED channels.exportMessageLink,user,CHANNEL_INVALID @@ -83,6 +101,8 @@ channels.getAdminLog,user,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED channels.getAdminedPublicChannels,user, channels.getChannels,both,CHANNEL_INVALID CHANNEL_PRIVATE NEED_CHAT_INVALID channels.getFullChannel,both,CHANNEL_INVALID CHANNEL_PRIVATE CHANNEL_PUBLIC_GROUP_NA Timeout +channels.getGroupsForDiscussion,user, +channels.getInactiveChannels,user, channels.getLeftChannels,user, channels.getMessages,both,CHANNEL_INVALID CHANNEL_PRIVATE MESSAGE_IDS_EMPTY channels.getParticipant,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED USER_ID_INVALID USER_NOT_PARTICIPANT @@ -99,13 +119,15 @@ channels.togglePreHistoryHidden,user,CHAT_LINK_EXISTS channels.toggleSignatures,user,CHANNEL_INVALID channels.toggleSlowMode,user,SECONDS_INVALID channels.updateUsername,user,CHANNELS_ADMIN_PUBLIC_TOO_MUCH CHANNEL_INVALID CHAT_ADMIN_REQUIRED USERNAME_INVALID USERNAME_OCCUPIED +contacts.acceptContact,user, +contacts.addContact,user,CONTACT_NAME_EMPTY contacts.block,user,CONTACT_ID_INVALID contacts.deleteByPhones,user, -contacts.deleteContact,user,CONTACT_ID_INVALID contacts.deleteContacts,user,NEED_MEMBER_INVALID Timeout contacts.getBlocked,user, contacts.getContactIDs,user, contacts.getContacts,user, +contacts.getLocated,user, contacts.getSaved,user,TAKEOUT_REQUIRED contacts.getStatuses,user, contacts.getTopPeers,user,TYPES_EMPTY @@ -116,9 +138,9 @@ contacts.resolveUsername,both,AUTH_KEY_PERM_EMPTY SESSION_PASSWORD_NEEDED USERNA contacts.search,user,QUERY_TOO_SHORT SEARCH_QUERY_EMPTY Timeout contacts.toggleTopPeers,user, contacts.unblock,user,CONTACT_ID_INVALID -contest.saveDeveloperInfo,both, folders.deleteFolder,user,FOLDER_ID_EMPTY folders.editPeerFolders,user,FOLDER_ID_INVALID +getFutureSalts,both, help.acceptTermsOfService,user, help.editUserInfo,user,USER_INVALID help.getAppChangelog,user, @@ -151,6 +173,7 @@ langpack.getLanguage,user, langpack.getLanguages,user,LANG_PACK_INVALID langpack.getStrings,user,LANG_PACK_INVALID messages.acceptEncryption,user,CHAT_ID_INVALID ENCRYPTION_ALREADY_ACCEPTED ENCRYPTION_ALREADY_DECLINED ENCRYPTION_OCCUPY_FAILED +messages.acceptUrlAuth,user, messages.addChatUser,user,CHAT_ADMIN_REQUIRED CHAT_ID_INVALID INPUT_USER_DEACTIVATED PEER_ID_INVALID USERS_TOO_MUCH USER_ALREADY_PARTICIPANT USER_ID_INVALID USER_NOT_MUTUAL_CONTACT USER_PRIVACY_RESTRICTED messages.checkChatInvite,user,INVITE_HASH_EMPTY INVITE_HASH_EXPIRED INVITE_HASH_INVALID messages.clearAllDrafts,user, @@ -159,6 +182,7 @@ messages.createChat,user,USERS_TOO_FEW USER_RESTRICTED messages.deleteChatUser,both,CHAT_ID_INVALID PEER_ID_INVALID USER_NOT_PARTICIPANT messages.deleteHistory,user,PEER_ID_INVALID messages.deleteMessages,both,MESSAGE_DELETE_FORBIDDEN +messages.deleteScheduledMessages,user, messages.discardEncryption,user,CHAT_ID_EMPTY ENCRYPTION_ALREADY_DECLINED ENCRYPTION_ID_INVALID messages.editChatAbout,both, messages.editChatAdmin,user,CHAT_ID_INVALID @@ -166,8 +190,8 @@ messages.editChatDefaultBannedRights,both,BANNED_RIGHTS_INVALID messages.editChatPhoto,both,CHAT_ID_INVALID INPUT_CONSTRUCTOR_INVALID INPUT_FETCH_FAIL PEER_ID_INVALID PHOTO_EXT_INVALID messages.editChatTitle,both,CHAT_ID_INVALID NEED_CHAT_INVALID messages.editInlineBotMessage,both,MESSAGE_ID_INVALID MESSAGE_NOT_MODIFIED -messages.editMessage,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_WRITE_FORBIDDEN INPUT_USER_DEACTIVATED MEDIA_NEW_INVALID MEDIA_PREV_INVALID MESSAGE_AUTHOR_REQUIRED MESSAGE_EDIT_TIME_EXPIRED MESSAGE_EMPTY MESSAGE_ID_INVALID MESSAGE_NOT_MODIFIED PEER_ID_INVALID -messages.exportChatInvite,user,CHAT_ID_INVALID +messages.editMessage,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_WRITE_FORBIDDEN INLINE_BOT_REQUIRED INPUT_USER_DEACTIVATED MEDIA_NEW_INVALID MEDIA_PREV_INVALID MESSAGE_AUTHOR_REQUIRED MESSAGE_EDIT_TIME_EXPIRED MESSAGE_EMPTY MESSAGE_ID_INVALID MESSAGE_NOT_MODIFIED PEER_ID_INVALID +messages.exportChatInvite,both,CHAT_ID_INVALID messages.faveSticker,user,STICKER_ID_INVALID messages.forwardMessages,both,BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_SEND_GIFS_FORBIDDEN CHAT_SEND_MEDIA_FORBIDDEN CHAT_SEND_STICKERS_FORBIDDEN CHAT_WRITE_FORBIDDEN GROUPED_MEDIA_INVALID INPUT_USER_DEACTIVATED MEDIA_EMPTY MESSAGE_IDS_EMPTY MESSAGE_ID_INVALID PEER_ID_INVALID PTS_CHANGE_EMPTY RANDOM_ID_DUPLICATE RANDOM_ID_INVALID SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH Timeout USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER messages.getAllChats,user, @@ -175,13 +199,18 @@ messages.getAllDrafts,user, messages.getAllStickers,user, messages.getArchivedStickers,user, messages.getAttachedStickers,user, -messages.getBotCallbackAnswer,user,CHANNEL_INVALID DATA_INVALID MESSAGE_ID_INVALID PEER_ID_INVALID Timeout +messages.getBotCallbackAnswer,user,BOT_RESPONSE_TIMEOUT CHANNEL_INVALID DATA_INVALID MESSAGE_ID_INVALID PEER_ID_INVALID Timeout messages.getChats,both,CHAT_ID_INVALID PEER_ID_INVALID messages.getCommonChats,user,USER_ID_INVALID messages.getDhConfig,user,RANDOM_LENGTH_INVALID +messages.getDialogFilters,user, messages.getDialogUnreadMarks,user, messages.getDialogs,user,INPUT_CONSTRUCTOR_INVALID OFFSET_PEER_ID_INVALID SESSION_PASSWORD_NEEDED Timeout messages.getDocumentByHash,both,SHA256_HASH_INVALID +messages.getEmojiKeywords,user, +messages.getEmojiKeywordsDifference,user, +messages.getEmojiKeywordsLanguages,user, +messages.getEmojiURL,user, messages.getFavedStickers,user, messages.getFeaturedStickers,user, messages.getFullChat,both,CHAT_ID_INVALID PEER_ID_INVALID @@ -198,17 +227,22 @@ messages.getPeerDialogs,user,CHANNEL_PRIVATE PEER_ID_INVALID messages.getPeerSettings,user,CHANNEL_INVALID PEER_ID_INVALID messages.getPinnedDialogs,user, messages.getPollResults,user, +messages.getPollVotes,user,BROADCAST_FORBIDDEN messages.getRecentLocations,user, messages.getRecentStickers,user, messages.getSavedGifs,user, +messages.getScheduledHistory,user, +messages.getScheduledMessages,user, +messages.getSearchCounters,user, messages.getSplitRanges,user, messages.getStatsURL,user, messages.getStickerSet,both,STICKERSET_INVALID messages.getStickers,user,EMOTICON_EMPTY +messages.getSuggestedDialogFilters,user, messages.getUnreadMentions,user,PEER_ID_INVALID messages.getWebPage,user,WC_CONVERT_URL_INVALID messages.getWebPagePreview,user, -messages.hideReportSpam,user,PEER_ID_INVALID +messages.hidePeerSettingsBar,user, messages.importChatInvite,user,CHANNELS_TOO_MUCH INVITE_HASH_EMPTY INVITE_HASH_EXPIRED INVITE_HASH_INVALID SESSION_PASSWORD_NEEDED USERS_TOO_MUCH USER_ALREADY_PARTICIPANT messages.installStickerSet,user,STICKERSET_INVALID messages.markDialogUnread,user, @@ -226,37 +260,42 @@ messages.report,user, messages.reportEncryptedSpam,user,CHAT_ID_INVALID messages.reportSpam,user,PEER_ID_INVALID messages.requestEncryption,user,DH_G_A_INVALID USER_ID_INVALID +messages.requestUrlAuth,user, messages.saveDraft,user,PEER_ID_INVALID messages.saveGif,user,GIF_ID_INVALID messages.saveRecentSticker,user,STICKER_ID_INVALID messages.search,user,CHAT_ADMIN_REQUIRED INPUT_CONSTRUCTOR_INVALID INPUT_USER_DEACTIVATED PEER_ID_INVALID PEER_ID_NOT_SUPPORTED SEARCH_QUERY_EMPTY USER_ID_INVALID -messages.searchGifs,user,SEARCH_QUERY_EMPTY +messages.searchGifs,user,METHOD_INVALID SEARCH_QUERY_EMPTY messages.searchGlobal,user,SEARCH_QUERY_EMPTY messages.searchStickerSets,user, messages.sendEncrypted,user,CHAT_ID_INVALID DATA_INVALID ENCRYPTION_DECLINED MSG_WAIT_FAILED messages.sendEncryptedFile,user,MSG_WAIT_FAILED messages.sendEncryptedService,user,DATA_INVALID ENCRYPTION_DECLINED MSG_WAIT_FAILED USER_IS_BLOCKED messages.sendInlineBotResult,user,CHAT_SEND_INLINE_FORBIDDEN CHAT_WRITE_FORBIDDEN INLINE_RESULT_EXPIRED PEER_ID_INVALID QUERY_ID_EMPTY SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY -messages.sendMedia,both,BOT_PAYMENTS_DISABLED BOT_POLLS_DISABLED BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_SEND_MEDIA_FORBIDDEN CHAT_WRITE_FORBIDDEN EXTERNAL_URL_INVALID FILE_PARTS_INVALID FILE_PART_LENGTH_INVALID GAME_BOT_INVALID INPUT_USER_DEACTIVATED MEDIA_CAPTION_TOO_LONG MEDIA_EMPTY PAYMENT_PROVIDER_INVALID PEER_ID_INVALID PHOTO_EXT_INVALID PHOTO_INVALID_DIMENSIONS PHOTO_SAVE_FILE_INVALID POLL_ANSWERS_INVALID POLL_OPTION_DUPLICATE POLL_QUESTION_INVALID QUIZ_CORRECT_ANSWERS_EMPTY QUIZ_CORRECT_ANSWERS_TOO_MUCH QUIZ_CORRECT_ANSWER_INVALID QUIZ_MULTIPLE_INVALID RANDOM_ID_DUPLICATE SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH STORAGE_CHECK_FAILED Timeout USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT VIDEO_CONTENT_TYPE_INVALID WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY -messages.sendMessage,both,AUTH_KEY_DUPLICATED BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_RESTRICTED CHAT_WRITE_FORBIDDEN ENTITIES_TOO_LONG ENTITY_MENTION_USER_INVALID INPUT_USER_DEACTIVATED MESSAGE_EMPTY MESSAGE_TOO_LONG MSG_ID_INVALID PEER_ID_INVALID POLL_OPTION_INVALID RANDOM_ID_DUPLICATE REPLY_MARKUP_INVALID REPLY_MARKUP_TOO_LONG SCHEDULE_BOT_NOT_ALLOWED SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH Timeout USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER +messages.sendMedia,both,BOT_PAYMENTS_DISABLED BOT_POLLS_DISABLED BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_SEND_MEDIA_FORBIDDEN CHAT_WRITE_FORBIDDEN EMOTICON_INVALID EXTERNAL_URL_INVALID FILE_PARTS_INVALID FILE_PART_LENGTH_INVALID FILE_REFERENCE_EMPTY FILE_REFERENCE_EXPIRED GAME_BOT_INVALID INPUT_USER_DEACTIVATED MEDIA_CAPTION_TOO_LONG MEDIA_EMPTY PAYMENT_PROVIDER_INVALID PEER_ID_INVALID PHOTO_EXT_INVALID PHOTO_INVALID_DIMENSIONS PHOTO_SAVE_FILE_INVALID POLL_ANSWERS_INVALID POLL_OPTION_DUPLICATE POLL_QUESTION_INVALID QUIZ_CORRECT_ANSWERS_EMPTY QUIZ_CORRECT_ANSWERS_TOO_MUCH QUIZ_CORRECT_ANSWER_INVALID QUIZ_MULTIPLE_INVALID RANDOM_ID_DUPLICATE SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH STORAGE_CHECK_FAILED Timeout USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT VIDEO_CONTENT_TYPE_INVALID WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY +messages.sendMessage,both,AUTH_KEY_DUPLICATED BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_RESTRICTED CHAT_WRITE_FORBIDDEN ENTITIES_TOO_LONG ENTITY_MENTION_USER_INVALID INPUT_USER_DEACTIVATED MESSAGE_EMPTY MESSAGE_TOO_LONG MSG_ID_INVALID PEER_ID_INVALID POLL_OPTION_INVALID RANDOM_ID_DUPLICATE REPLY_MARKUP_INVALID REPLY_MARKUP_TOO_LONG SCHEDULE_BOT_NOT_ALLOWED SCHEDULE_DATE_TOO_LATE SCHEDULE_STATUS_PRIVATE SCHEDULE_TOO_MUCH Timeout USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER messages.sendMultiMedia,both,SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH -messages.sendReaction,User,REACTION_INVALID +messages.sendScheduledMessages,user, messages.sendVote,user,MESSAGE_POLL_CLOSED OPTION_INVALID messages.setBotCallbackAnswer,both,QUERY_ID_INVALID URL_INVALID messages.setBotPrecheckoutResults,both,ERROR_TEXT_EMPTY messages.setBotShippingResults,both,QUERY_ID_INVALID messages.setEncryptedTyping,user,CHAT_ID_INVALID messages.setGameScore,bot,PEER_ID_INVALID USER_BOT_REQUIRED -messages.setInlineBotResults,bot,ARTICLE_TITLE_EMPTY BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID MESSAGE_EMPTY PHOTO_CONTENT_URL_EMPTY PHOTO_THUMB_URL_EMPTY QUERY_ID_INVALID REPLY_MARKUP_INVALID RESULT_TYPE_INVALID SEND_MESSAGE_MEDIA_INVALID SEND_MESSAGE_TYPE_INVALID START_PARAM_INVALID USER_BOT_INVALID +messages.setInlineBotResults,bot,ARTICLE_TITLE_EMPTY BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID MESSAGE_EMPTY PHOTO_CONTENT_URL_EMPTY PHOTO_THUMB_URL_EMPTY QUERY_ID_INVALID REPLY_MARKUP_INVALID RESULT_TYPE_INVALID SEND_MESSAGE_MEDIA_INVALID SEND_MESSAGE_TYPE_INVALID START_PARAM_INVALID USER_BOT_INVALID WEBDOCUMENT_URL_INVALID messages.setInlineGameScore,bot,MESSAGE_ID_INVALID USER_BOT_REQUIRED messages.setTyping,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ID_INVALID CHAT_WRITE_FORBIDDEN PEER_ID_INVALID USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT messages.startBot,user,BOT_INVALID PEER_ID_INVALID START_PARAM_EMPTY START_PARAM_INVALID messages.toggleDialogPin,user,PEER_ID_INVALID +messages.toggleStickerSets,user, messages.uninstallStickerSet,user,STICKERSET_INVALID +messages.updateDialogFilter,user, +messages.updateDialogFiltersOrder,user, messages.updatePinnedMessage,both, messages.uploadEncryptedFile,user, messages.uploadMedia,both,BOT_MISSING MEDIA_INVALID PEER_ID_INVALID payments.clearSavedInfo,user, +payments.getBankCardData,user, payments.getPaymentForm,user,MESSAGE_ID_INVALID payments.getPaymentReceipt,user,MESSAGE_ID_INVALID payments.getSavedInfo,user, @@ -273,17 +312,21 @@ phone.setCallRating,user,CALL_PEER_INVALID photos.deletePhotos,user, photos.getUserPhotos,both,MAX_ID_INVALID USER_ID_INVALID photos.updateProfilePhoto,user, -photos.uploadProfilePhoto,user,FILE_PARTS_INVALID IMAGE_PROCESS_FAILED PHOTO_CROP_SIZE_SMALL PHOTO_EXT_INVALID +photos.uploadProfilePhoto,user,FILE_PARTS_INVALID IMAGE_PROCESS_FAILED PHOTO_CROP_SIZE_SMALL PHOTO_EXT_INVALID VIDEO_FILE_INVALID ping,both, reqDHParams,both, reqPq,both, reqPqMulti,both, rpcDropAnswer,both, setClientDHParams,both, +stats.getBroadcastStats,user,BROADCAST_REQUIRED CHAT_ADMIN_REQUIRED STATS_MIGRATE_X +stats.getMegagroupStats,user,CHAT_ADMIN_REQUIRED MEGAGROUP_REQUIRED STATS_MIGRATE_X +stats.loadAsyncGraph,user, stickers.addStickerToSet,bot,BOT_MISSING STICKERSET_INVALID stickers.changeStickerPosition,bot,BOT_MISSING STICKER_INVALID stickers.createStickerSet,bot,BOT_MISSING PACK_SHORT_NAME_INVALID PACK_SHORT_NAME_OCCUPIED PEER_ID_INVALID SHORTNAME_OCCUPY_FAILED STICKERS_EMPTY STICKER_EMOJI_INVALID STICKER_FILE_INVALID STICKER_PNG_DIMENSIONS STICKER_PNG_NOPNG USER_ID_INVALID stickers.removeStickerFromSet,bot,BOT_MISSING STICKER_INVALID +stickers.setStickerSetThumb,bot, updates.getChannelDifference,both,CHANNEL_INVALID CHANNEL_PRIVATE CHANNEL_PUBLIC_GROUP_NA HISTORY_GET_FAILED PERSISTENT_TIMESTAMP_EMPTY PERSISTENT_TIMESTAMP_INVALID PERSISTENT_TIMESTAMP_OUTDATED RANGES_INVALID Timeout updates.getDifference,both,AUTH_KEY_PERM_EMPTY CDN_METHOD_INVALID DATE_EMPTY NEED_MEMBER_INVALID PERSISTENT_TIMESTAMP_EMPTY PERSISTENT_TIMESTAMP_INVALID SESSION_PASSWORD_NEEDED STORE_INVALID_SCALAR_TYPE Timeout updates.getState,both,AUTH_KEY_DUPLICATED MSGID_DECREASE_RETRY SESSION_PASSWORD_NEEDED Timeout diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index cd2b99d7..9ef23861 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -330,7 +330,7 @@ def _write_to_dict(tlobject, builder): def _write_to_bytes(tlobject, builder): - builder.writeln('def __bytes__(self):') + builder.writeln('def _bytes(self):') # Some objects require more than one flag parameter to be set # at the same time. In this case, add an assertion. @@ -509,7 +509,7 @@ def _write_arg_to_bytes(builder, arg, args, name=None): else: # Else it may be a custom type - builder.write('bytes({})', name) + builder.write('{}._bytes()', name) # If the type is not boxed (i.e. starts with lowercase) we should # not serialize the constructor ID (so remove its first 4 bytes). diff --git a/telethon_generator/parsers/tlobject/tlarg.py b/telethon_generator/parsers/tlobject/tlarg.py index bf33a3f6..b510f075 100644 --- a/telethon_generator/parsers/tlobject/tlarg.py +++ b/telethon_generator/parsers/tlobject/tlarg.py @@ -36,7 +36,8 @@ KNOWN_NAMED_EXAMPLES = { ('lang_pack', 'string'): "''", ('lang_code', 'string'): "'en'", ('chat_id', 'int'): '478614198', - ('client_id', 'long'): 'random.randrange(-2**63, 2**63)' + ('client_id', 'long'): 'random.randrange(-2**63, 2**63)', + ('video', 'InputFile'): "client.upload_file('/path/to/file.mp4')", } KNOWN_TYPED_EXAMPLES = { diff --git a/tests/telethon/crypto/__init__.py b/tests/telethon/crypto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telethon/events/__init__.py b/tests/telethon/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telethon/events/test_chataction.py b/tests/telethon/events/test_chataction.py new file mode 100644 index 00000000..24f60596 --- /dev/null +++ b/tests/telethon/events/test_chataction.py @@ -0,0 +1,67 @@ +import pytest + +from telethon import TelegramClient, events, types, utils + + +def get_client(): + return TelegramClient(None, 1, '1') + + +def get_user_456(): + return types.User( + id=456, + access_hash=789, + first_name='User 123' + ) + + +@pytest.mark.asyncio +async def test_get_input_users_no_action_message_no_entities(): + event = events.ChatAction.build(types.UpdateChatParticipantDelete( + chat_id=123, + user_id=456, + version=1 + )) + event._set_client(get_client()) + + assert await event.get_input_users() == [] + + +@pytest.mark.asyncio +async def test_get_input_users_no_action_message(): + user = get_user_456() + event = events.ChatAction.build(types.UpdateChatParticipantDelete( + chat_id=123, + user_id=456, + version=1 + )) + event._set_client(get_client()) + event._entities[user.id] = user + + assert await event.get_input_users() == [utils.get_input_peer(user)] + + +@pytest.mark.asyncio +async def test_get_users_no_action_message_no_entities(): + event = events.ChatAction.build(types.UpdateChatParticipantDelete( + chat_id=123, + user_id=456, + version=1 + )) + event._set_client(get_client()) + + assert await event.get_users() == [] + + +@pytest.mark.asyncio +async def test_get_users_no_action_message(): + user = get_user_456() + event = events.ChatAction.build(types.UpdateChatParticipantDelete( + chat_id=123, + user_id=456, + version=1 + )) + event._set_client(get_client()) + event._entities[user.id] = user + + assert await event.get_users() == [user] diff --git a/tests/telethon/extensions/__init__.py b/tests/telethon/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telethon/test_utils.py b/tests/telethon/test_utils.py index 89391e36..b0cfb33f 100644 --- a/tests/telethon/test_utils.py +++ b/tests/telethon/test_utils.py @@ -1,6 +1,8 @@ import io import pathlib +import pytest + from telethon import utils from telethon.tl.types import ( MessageMediaGame, Game, PhotoEmpty diff --git a/tests/telethon/tl/__init__.py b/tests/telethon/tl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telethon/tl/test_serialization.py b/tests/telethon/tl/test_serialization.py new file mode 100644 index 00000000..7dbb067d --- /dev/null +++ b/tests/telethon/tl/test_serialization.py @@ -0,0 +1,13 @@ +import pytest + +from telethon.tl import types, functions + + +def test_nested_invalid_serialization(): + large_long = 2**62 + request = functions.account.SetPrivacyRequest( + key=types.InputPrivacyKeyChatInvite(), + rules=[types.InputPrivacyValueDisallowUsers(users=[large_long])] + ) + with pytest.raises(TypeError): + bytes(request) diff --git a/update-docs.sh b/update-docs.sh new file mode 100644 index 00000000..fe72cce7 --- /dev/null +++ b/update-docs.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +python setup.py gen docs +rm -rf /tmp/docs +mv docs/ /tmp/docs +git checkout gh-pages +# there's probably better ways but we know none has spaces +rm -rf $(ls /tmp/docs) +mv /tmp/docs/* . +git add constructors/ types/ methods/ index.html js/search.js css/ img/ +git commit --amend -m "Update documentation" +git push --force +git checkout master