diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index dc7a26c2..1e7ebec6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -14,7 +14,7 @@ assignees: '' **Code that causes the issue** ```python -from telethon.sync import TelegramClient +from telethon import TelegramClient ... ``` diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml deleted file mode 100644 index d3c34a20..00000000 --- a/.github/workflows/python.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Python Library - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.5", "3.6", "3.7", "3.8"] - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Set up env - run: | - python -m pip install --upgrade pip - pip install tox - - name: Lint with flake8 - run: | - tox -e flake - - name: Test with pytest - run: | - # use "py", which is the default python version - tox -e py diff --git a/.gitignore b/.gitignore index 6f2cf6f7..a0864768 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ # Generated code -/telethon/tl/functions/ -/telethon/tl/types/ -/telethon/tl/alltlobjects.py -/telethon/errors/rpcerrorlist.py +/telethon/_tl/fn/ +/telethon/_tl/*.py +/telethon/_tl/alltlobjects.py +/telethon/errors/_generated.py # User session *.session +sessions/ /usermedia/ # Builds and testing @@ -20,4 +21,4 @@ __pycache__/ /docs/ # File used to manually test new changes, contains sensitive data -/example.py +/example*.py diff --git a/README.rst b/README.rst index f1eb902c..15985350 100755 --- a/README.rst +++ b/README.rst @@ -35,15 +35,19 @@ Creating a client .. code-block:: python - from telethon import TelegramClient, events, sync + import asyncio + from telethon import TelegramClient, events # These example values won't work. You must get your own api_id and # api_hash from https://my.telegram.org, under API Development. api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - client = TelegramClient('session_name', api_id, api_hash) - client.start() + async def main(): + client = TelegramClient('session_name', api_id, api_hash) + await client.start() + + asyncio.run(main()) Doing stuff @@ -51,14 +55,14 @@ Doing stuff .. code-block:: python - print(client.get_me().stringify()) + print((await client.get_me()).stringify()) - client.send_message('username', 'Hello! Talking to you from Telethon') - client.send_file('username', '/home/myself/Pictures/holidays.jpg') + await client.send_message('username', 'Hello! Talking to you from Telethon') + await client.send_file('username', '/home/myself/Pictures/holidays.jpg') - client.download_profile_photo('me') - messages = client.get_messages('username') - messages[0].download_media() + await client.download_profile_photo('me') + messages = await client.get_messages('username') + await messages[0].download_media() @client.on(events.NewMessage(pattern='(?i)hi|hello')) async def handler(event): diff --git a/pyproject.toml b/pyproject.toml index daae10fa..ca877203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [tool.tox] legacy_tox_ini = """ [tox] -envlist = py35,py36,py37,py38 +envlist = py37,py38 # run with tox -e py [testenv] diff --git a/readthedocs/basic/quick-start.rst b/readthedocs/basic/quick-start.rst index cd187c81..bd36b048 100644 --- a/readthedocs/basic/quick-start.rst +++ b/readthedocs/basic/quick-start.rst @@ -8,70 +8,70 @@ use these if possible. .. code-block:: python + import asyncio from telethon import TelegramClient # Remember to use your own values from my.telegram.org! api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - client = TelegramClient('anon', api_id, api_hash) async def main(): - # Getting information about yourself - me = await client.get_me() + async with TelegramClient('anon', api_id, api_hash).start() as client: + # Getting information about yourself + me = await client.get_me() - # "me" is a user object. You can pretty-print - # any Telegram object with the "stringify" method: - print(me.stringify()) + # "me" is a user object. You can pretty-print + # any Telegram object with the "stringify" method: + print(me.stringify()) - # When you print something, you see a representation of it. - # You can access all attributes of Telegram objects with - # the dot operator. For example, to get the username: - username = me.username - print(username) - print(me.phone) + # When you print something, you see a representation of it. + # You can access all attributes of Telegram objects with + # the dot operator. For example, to get the username: + username = me.username + print(username) + print(me.phone) - # You can print all the dialogs/conversations that you are part of: - async for dialog in client.iter_dialogs(): - print(dialog.name, 'has ID', dialog.id) + # You can print all the dialogs/conversations that you are part of: + async for dialog in client.iter_dialogs(): + print(dialog.name, 'has ID', dialog.id) - # You can send messages to yourself... - await client.send_message('me', 'Hello, myself!') - # ...to some chat ID - await client.send_message(-100123456, 'Hello, group!') - # ...to your contacts - await client.send_message('+34600123123', 'Hello, friend!') - # ...or even to any username - await client.send_message('username', 'Testing Telethon!') + # You can send messages to yourself... + await client.send_message('me', 'Hello, myself!') + # ...to some chat ID + await client.send_message(-100123456, 'Hello, group!') + # ...to your contacts + await client.send_message('+34600123123', 'Hello, friend!') + # ...or even to any username + await client.send_message('username', 'Testing Telethon!') - # You can, of course, use markdown in your messages: - message = await client.send_message( - 'me', - 'This message has **bold**, `code`, __italics__ and ' - 'a [nice website](https://example.com)!', - link_preview=False - ) + # You can, of course, use markdown in your messages: + message = await client.send_message( + 'me', + 'This message has **bold**, `code`, __italics__ and ' + 'a [nice website](https://example.com)!', + link_preview=False + ) - # Sending a message returns the sent message object, which you can use - print(message.raw_text) + # Sending a message returns the sent message object, which you can use + print(message.raw_text) - # You can reply to messages directly if you have a message object - await message.reply('Cool!') + # You can reply to messages directly if you have a message object + await message.reply('Cool!') - # Or send files, songs, documents, albums... - await client.send_file('me', '/home/me/Pictures/holidays.jpg') + # Or send files, songs, documents, albums... + await client.send_file('me', '/home/me/Pictures/holidays.jpg') - # You can print the message history of any chat: - async for message in client.iter_messages('me'): - print(message.id, message.text) + # You can print the message history of any chat: + async for message in client.iter_messages('me'): + print(message.id, message.text) - # You can download media from messages, too! - # The method will return the path where the file was saved. - if message.photo: - path = await message.download_media() - print('File saved to', path) # printed after download is done + # You can download media from messages, too! + # The method will return the path where the file was saved. + if message.photo: + path = await message.download_media() + print('File saved to', path) # printed after download is done - with client: - client.loop.run_until_complete(main()) + asyncio.run(main()) Here, we show how to sign in, get information about yourself, send @@ -100,12 +100,8 @@ proceeding. We will see all the available methods later on. # Most of your code should go here. # You can of course make and use your own async def (do_something). # They only need to be async if they need to await things. - me = await client.get_me() - await do_something(me) + async with client.start(): + me = await client.get_me() + await do_something(me) - with client: - client.loop.run_until_complete(main()) - - After you understand this, you may use the ``telethon.sync`` hack if you - want do so (see :ref:`compatibility-and-convenience`), but note you may - run into other issues (iPython, Anaconda, etc. have some issues with it). + asyncio.run(main()) diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index 9fb14853..05183f5c 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -49,15 +49,19 @@ We can finally write some code to log into our account! .. code-block:: python + import asyncio from telethon import TelegramClient # Use your own values from my.telegram.org api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - # The first parameter is the .session file name (absolute paths allowed) - with TelegramClient('anon', api_id, api_hash) as client: - client.loop.run_until_complete(client.send_message('me', 'Hello, myself!')) + async def main(): + # The first parameter is the .session file name (absolute paths allowed) + async with TelegramClient('anon', api_id, api_hash).start() as client: + await client.send_message('me', 'Hello, myself!') + + asyncio.run(main()) In the first line, we import the class name so we can create an instance @@ -95,18 +99,19 @@ You will still need an API ID and hash, but the process is very similar: .. code-block:: python - from telethon.sync import TelegramClient + import asyncio + from telethon import TelegramClient api_id = 12345 api_hash = '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) + async def main(): + # But then we can use the client instance as usual + async with TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) as bot: + ... # bot is your client - # But then we can use the client instance as usual - with bot: - ... + asyncio.run(main()) To get a bot account, you need to talk @@ -116,11 +121,9 @@ with `@BotFather `_. Signing In behind a Proxy ========================= -If you need to use a proxy to access Telegram, -you will need to either: +If you need to use a proxy to access Telegram, you will need to: -* For Python >= 3.6 : `install python-socks[asyncio]`__ -* For Python <= 3.5 : `install PySocks`__ +`install python-socks[asyncio]`__ and then change @@ -141,16 +144,9 @@ consisting of parameters described `in PySocks usage`__. The allowed values for the argument ``proxy_type`` are: -* For Python <= 3.5: - * ``socks.SOCKS5`` or ``'socks5'`` - * ``socks.SOCKS4`` or ``'socks4'`` - * ``socks.HTTP`` or ``'http'`` - -* For Python >= 3.6: - * All of the above - * ``python_socks.ProxyType.SOCKS5`` - * ``python_socks.ProxyType.SOCKS4`` - * ``python_socks.ProxyType.HTTP`` +* ``python_socks.ProxyType.SOCKS5`` +* ``python_socks.ProxyType.SOCKS4`` +* ``python_socks.ProxyType.HTTP`` Example: diff --git a/readthedocs/concepts/asyncio.rst b/readthedocs/concepts/asyncio.rst index ef7c3cd3..dd85f957 100644 --- a/readthedocs/concepts/asyncio.rst +++ b/readthedocs/concepts/asyncio.rst @@ -58,84 +58,6 @@ What are asyncio basics? loop.run_until_complete(main()) -What does telethon.sync do? -=========================== - -The moment you import any of these: - -.. code-block:: python - - from telethon import sync, ... - # or - from telethon.sync import ... - # or - import telethon.sync - -The ``sync`` module rewrites most ``async def`` -methods in Telethon to something similar to this: - -.. code-block:: python - - def new_method(): - result = original_method() - if loop.is_running(): - # the loop is already running, return the await-able to the user - return result - else: - # the loop is not running yet, so we can run it for the user - return loop.run_until_complete(result) - - -That means you can do this: - -.. code-block:: python - - print(client.get_me().username) - -Instead of this: - -.. code-block:: python - - me = client.loop.run_until_complete(client.get_me()) - print(me.username) - - # or, using asyncio's default loop (it's the same) - import asyncio - loop = asyncio.get_event_loop() # == client.loop - me = loop.run_until_complete(client.get_me()) - print(me.username) - - -As you can see, it's a lot of boilerplate and noise having to type -``run_until_complete`` all the time, so you can let the magic module -to rewrite it for you. But notice the comment above: it won't run -the loop if it's already running, because it can't. That means this: - -.. code-block:: python - - async def main(): - # 3. the loop is running here - print( - client.get_me() # 4. this will return a coroutine! - .username # 5. this fails, coroutines don't have usernames - ) - - loop.run_until_complete( # 2. run the loop and the ``main()`` coroutine - main() # 1. calling ``async def`` "returns" a coroutine - ) - - -Will fail. So if you're inside an ``async def``, then the loop is -running, and if the loop is running, you must ``await`` things yourself: - -.. code-block:: python - - async def main(): - print((await client.get_me()).username) - - loop.run_until_complete(main()) - - What are async, await and coroutines? ===================================== @@ -275,7 +197,7 @@ in it. So if you want to run *other* code, create tasks for it: loop.create_task(clock()) ... - client.run_until_disconnected() + await client.run_until_disconnected() This creates a task for a clock that prints the time every second. You don't need to use `client.run_until_disconnected() @@ -344,19 +266,6 @@ When you use a library, you're not limited to use only its methods. You can combine all the libraries you want. People seem to forget this simple fact! -Why does client.start() work outside async? -=========================================== - -Because it's so common that it's really convenient to offer said -functionality by default. This means you can set up all your event -handlers and start the client without worrying about loops at all. - -Using the client in a ``with`` block, `start -`, `run_until_disconnected -`, and -`disconnect ` -all support this. - Where can I read more? ====================== diff --git a/readthedocs/concepts/full-api.rst b/readthedocs/concepts/full-api.rst index cce026f1..ac38e3d4 100644 --- a/readthedocs/concepts/full-api.rst +++ b/readthedocs/concepts/full-api.rst @@ -74,7 +74,7 @@ Or we call `client.get_input_entity() async def main(): peer = await client.get_input_entity('someone') - client.loop.run_until_complete(main()) + asyncio.run(main()) .. note:: diff --git a/readthedocs/concepts/sessions.rst b/readthedocs/concepts/sessions.rst index a94bc773..028366b1 100644 --- a/readthedocs/concepts/sessions.rst +++ b/readthedocs/concepts/sessions.rst @@ -73,10 +73,10 @@ You can import these ``from telethon.sessions``. For example, using the .. code-block:: python - from telethon.sync import TelegramClient + from telethon import TelegramClient from telethon.sessions import StringSession - with TelegramClient(StringSession(string), api_id, api_hash) as client: + async with TelegramClient(StringSession(string), api_id, api_hash) as client: ... # use the client # Save the string session as a string; you should decide how @@ -129,10 +129,10 @@ The easiest way to generate a string session is as follows: .. code-block:: python - from telethon.sync import TelegramClient + from telethon import TelegramClient from telethon.sessions import StringSession - with TelegramClient(StringSession(), api_id, api_hash) as client: + async with TelegramClient(StringSession(), api_id, api_hash) as client: print(client.session.save()) @@ -156,8 +156,8 @@ you can save it in a variable directly: .. code-block:: python string = '1aaNk8EX-YRfwoRsebUkugFvht6DUPi_Q25UOCzOAqzc...' - with TelegramClient(StringSession(string), api_id, api_hash) as client: - client.loop.run_until_complete(client.send_message('me', 'Hi')) + async with TelegramClient(StringSession(string), api_id, api_hash).start() as client: + await client.send_message('me', 'Hi') These strings are really convenient for using in places like Heroku since diff --git a/readthedocs/developing/testing.rst b/readthedocs/developing/testing.rst index badb7dc6..dfabe73f 100644 --- a/readthedocs/developing/testing.rst +++ b/readthedocs/developing/testing.rst @@ -71,7 +71,7 @@ version incompatabilities. Tox environments are declared in the ``tox.ini`` file. The default environments, declared at the top, can be simply run with ``tox``. The option -``tox -e py36,flake`` can be used to request specific environments to be run. +``tox -e py37,flake`` can be used to request specific environments to be run. Brief Introduction to Pytest-cov ================================ diff --git a/readthedocs/examples/users.rst b/readthedocs/examples/users.rst index d9c648ae..ea83871d 100644 --- a/readthedocs/examples/users.rst +++ b/readthedocs/examples/users.rst @@ -25,7 +25,7 @@ you should use :tl:`GetFullUser`: # or even full = await client(GetFullUserRequest('username')) - bio = full.about + bio = full.full_user.about See :tl:`UserFull` to know what other fields you can access. diff --git a/readthedocs/index.rst b/readthedocs/index.rst index f4b1d877..91d08e0f 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -4,17 +4,21 @@ Telethon's Documentation .. code-block:: python - from telethon.sync import TelegramClient, events + import asyncio + from telethon import TelegramClient, events - with TelegramClient('name', api_id, api_hash) as client: - client.send_message('me', 'Hello, myself!') - print(client.download_profile_photo('me')) + async def main(): + async with TelegramClient('name', api_id, api_hash) as client: + await client.send_message('me', 'Hello, myself!') + print(await client.download_profile_photo('me')) - @client.on(events.NewMessage(pattern='(?i).*Hello')) - async def handler(event): - await event.reply('Hey!') + @client.on(events.NewMessage(pattern='(?i).*Hello')) + async def handler(event): + await event.reply('Hey!') - client.run_until_disconnected() + await client.run_until_disconnected() + + asyncio.run(main()) * Are you new here? Jump straight into :ref:`installation`! @@ -103,7 +107,7 @@ You can also use the menu on the left to quickly skip over sections. :caption: Miscellaneous misc/changelog - misc/wall-of-shame.rst + misc/v2-migration-guide.rst misc/compatibility-and-convenience .. toctree:: diff --git a/readthedocs/misc/changelog.rst b/readthedocs/misc/changelog.rst index 112aa117..5e85c069 100644 --- a/readthedocs/misc/changelog.rst +++ b/readthedocs/misc/changelog.rst @@ -13,6 +13,14 @@ it can take advantage of new goodies! .. contents:: List of All Versions + +Complete overhaul of the library (v2.0) +======================================= + +(inc and link all of migration guide) +properly-typed enums for filters and actions + + Rushed release to fix login (v1.24) =================================== diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst new file mode 100644 index 00000000..dc751b1a --- /dev/null +++ b/readthedocs/misc/v2-migration-guide.rst @@ -0,0 +1,775 @@ +========================= +Version 2 Migration Guide +========================= + +Version 2 represents the second major version change, breaking compatibility +with old code beyond the usual raw API changes in order to clean up a lot of +the technical debt that has grown on the project. + +This document documents all the things you should be aware of when migrating from Telethon version +1.x to 2.0 onwards. It is sorted roughly from the "likely most impactful changes" to "there's a +good chance you were not relying on this to begin with". + +**Please read this document in full before upgrading your code to Telethon 2.0.** + + +Python 3.5 is no longer supported +--------------------------------- + +The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.7. + +This also means workarounds for 3.6 and below have been dropped. + + +User, chat and channel identifiers are now 64-bit numbers +--------------------------------------------------------- + +`Layer 133 `__ changed *a lot* of identifiers from +``int`` to ``long``, meaning they will no longer fit in 32 bits, and instead require 64 bits. + +If you were storing these identifiers somewhere size did matter (for example, a database), you +will need to migrate that to support the new size requirement of 8 bytes. + +For the full list of types changed, please review the above link. + + +Peer IDs, including chat_id and sender_id, no longer follow bot API conventions +------------------------------------------------------------------------------- + +Both the ``utils.get_peer_id`` and ``client.get_peer_id`` methods no longer have an ``add_mark`` +parameter. Both will always return the original ID as given by Telegram. This should lead to less +confusion. However, it also means that an integer ID on its own no longer embeds the information +about the type (did it belong to a user, chat, or channel?), so ``utils.get_peer`` can no longer +guess the type from just a number. + +Because it's not possible to know what other changes Telegram will do with identifiers, it's +probably best to get used to transparently storing whatever value they send along with the type +separatedly. + +As far as I can tell, user, chat and channel identifiers are globally unique, meaning a channel +and a user cannot share the same identifier. The library currently makes this assumption. However, +this is merely an observation (I have never heard of such a collision exist), and Telegram could +change at any time. If you want to be on the safe side, you're encouraged to save a pair of type +and identifier, rather than just the number. + +// TODO we DEFINITELY need to provide a way to "upgrade" old ids +// TODO and storing type+number by hand is a pain, provide better alternative + + +Synchronous compatibility mode has been removed +----------------------------------------------- + +The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been +removed. This implies: + +* The ``telethon.sync`` module is gone. +* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported. + Most notably, you can no longer do ``with client``. It must be ``async with client`` now. +* The "smart" behaviour of the following methods has been removed and now they no longer work in + a synchronous context when the ``asyncio`` event loop was not running. This means they now need + to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``): + * ``start`` + * ``disconnect`` + * ``run_until_disconnected`` + +// TODO provide standalone alternative for this? + + +Complete overhaul of session files +---------------------------------- + +If you were using third-party libraries to deal with sessions, you will need to wait for those to +be updated. The library will automatically upgrade the SQLite session files to the new version, +and the ``StringSession`` remains backward-compatible. The sessions can now be async. + +In case you were relying on the tables used by SQLite (even though these should have been, and +will still need to be, treated as an implementation detail), here are the changes: + +* The ``sessions`` table is now correctly split into ``datacenter`` and ``session``. + ``datacenter`` contains information about a Telegram datacenter, along with its corresponding + authorization key, and ``session`` contains information about the update state and user. +* The ``entities`` table is now called ``entity`` and stores the ``type`` separatedly. +* The ``update_state`` table is now split into ``session`` and ``channel``, which can contain + a per-channel ``pts``. + +Because **the new version does not cache usernames, phone numbers and display names**, using these +in method calls is now quite expensive. You *should* migrate your code to do the Right Thing and +start using identifiers rather than usernames, phone numbers or invite links. This is both simpler +and more reliable, because while a user identifier won't change, their username could. + +You can use the following snippet to make a JSON backup (alternatively, you could just copy the +``.session`` file and keep it around) in case you want to preserve the cached usernames: + +.. code-block:: python + + import sqlite, json + with sqlite3.connect('your.session') as conn, open('entities.json', 'w', encoding='utf-8') as fp: + json.dump([ + {'id': id, 'hash': hash, 'username': username, 'phone': phone, 'name': name, 'date': date} + for (id, hash, username, phone, name, date) + in conn.execute('select id, hash, username, phone, name, date from entities') + ], fp) + +The following public methods or properties have also been removed from ``SQLiteSession`` because +they no longer make sense: + +* ``list_sessions``. You can ``glob.glob('*.session')`` instead. +* ``clone``. + +And the following, which were inherited from ``MemorySession``: + +* ``delete``. You can ``os.remove`` the file instead (preferably after ``client.log_out()``). + ``client.log_out()`` also no longer deletes the session file (it can't as there's no method). +* ``set_dc``. +* ``dc_id``. +* ``server_address``. +* ``port``. +* ``auth_key``. +* ``takeout_id``. +* ``get_update_state``. +* ``set_update_state``. +* ``process_entities``. +* ``get_entity_rows_by_phone``. +* ``get_entity_rows_by_username``. +* ``get_entity_rows_by_name``. +* ``get_entity_rows_by_id``. +* ``get_input_entity``. +* ``cache_file``. +* ``get_file``. + +You also can no longer set ``client.session.save_entities = False``. The entities must be saved +for the library to work properly. If you still don't want it, you should subclass the session and +override the methods to do nothing. + + +Complete overhaul of errors +--------------------------- + +The following error name have changed to follow a better naming convention (clearer acronyms): + +* ``RPCError`` is now ``RpcError``. +* ``InvalidDCError`` is now ``InvalidDcError`` (lowercase ``c``). + +The base errors no longer have a ``.message`` field at the class-level. Instead, it is now an +attribute at the instance level (meaning you cannot do ``BadRequestError.message``, it must be +``bad_request_err.message`` where ``isinstance(bad_request_err, BadRequestError)``). + +The ``.message`` will gain its value at the time the error is constructed, rather than being +known beforehand. + +The parameter order for ``RpcError`` and all its subclasses are now ``(code, message, request)``, +as opposed to ``(message, request, code)``. + +Because Telegram errors can be added at any time, the library no longer generate a fixed set of +them. This means you can no longer use ``dir`` to get a full list of them. Instead, the errors +are automatically generated depending on the name you use for the error, with the following rules: + +* Numbers are removed from the name. The Telegram error ``FLOOD_WAIT_42`` is transformed into + ``FLOOD_WAIT_``. +* Underscores are removed from the name. ``FLOOD_WAIT_`` becomes ``FLOODWAIT``. +* Everything is lowercased. ``FLOODWAIT`` turns into ``floodwait``. +* While the name ends with ``error``, this suffix is removed. + +The only exception to this rule is ``2FA_CONFIRM_WAIT_0``, which is transformed as +``twofaconfirmwait`` (read as ``TwoFaConfirmWait``). + +What all this means is that, if Telegram raises a ``FLOOD_WAIT_42``, you can write the following: + +.. code-block:: python + + from telethon.errors import FloodWaitError + + try: + await client.send_message(chat, message) + except FloodWaitError as e: + print(f'Flood! wait for {e.seconds} seconds') + +Essentially, old code will keep working, but now you have the freedom to define even yet-to-be +discovered errors. This makes use of `PEP 562 `__ on +Python 3.7 and above and a more-hacky approach below (which your IDE may not love). + +Given the above rules, you could also write ``except errors.FLOOD_WAIT`` if you prefer to match +Telegram's naming conventions. We recommend Camel-Case naming with the "Error" suffix, but that's +up to you. + +All errors will include a list of ``.values`` (the extracted number) and ``.value`` (the first +number extracted, or ``None`` if ``values`` is empty). In addition to that, certain errors have +a more-recognizable alias (such as ``FloodWait`` which has ``.seconds`` for its ``.value``). + +The ``telethon.errors`` module continues to provide certain predefined ``RpcError`` to match on +the *code* of the error and not its message (for instance, match all errors with code 403 with +``ForbiddenError``). Note that a certain error message can appear with different codes too, this +is decided by Telegram. + +The ``telethon.errors`` module continues to provide custom errors used by the library such as +``TypeNotFoundError``. + +// TODO keep RPCError around? eh idk how much it's used +// TODO should RpcError subclass ValueError? technically the values used in the request somehow were wrong… +// TODO provide a way to see which errors are known in the docs or at tl.telethon.dev + + +Changes to the default parse mode +--------------------------------- + +The default markdown parse mode now conforms to the commonmark specification. + +The old markdown parser (which was used as the default ``client.parse_mode``) used to emulate +Telegram Desktop's behaviour. Now ``__ +is used instead, which fixes certain parsing bugs but also means the formatting will be different. + +Most notably, ``__`` will now make text bold. If you want the old behaviour, use a single +underscore instead (such as ``_``). You can also use a single asterisk (``*``) for italics. +Because now there's proper parsing, you also gain: + +* Headings (``# text``) will now be underlined. +* Certain HTML tags will now also be recognized in markdown (including ```` for underlining text). +* Line breaks behave properly now. For a single-line break, end your line with ``\\``. +* Inline links should no longer behave in a strange manner. +* Pre-blocks can now have a language. Official clients don't syntax highlight code yet, though. + +Furthermore, the parse mode is no longer client-dependant. It is now configured through ``Message``. + +// TODO provide a way to get back the old behaviour? + + +The "iter" variant of the client methods have been removed +---------------------------------------------------------- + +Instead, you can now use the result of the ``get_*`` variant. For instance, where before you had: + +.. code-block:: python + + async for message in client.iter_messages(...): + pass + +You would now do: + + .. code-block:: python + + async for message in client.get_messages(...): + pass # ^^^ now it's get, not iter + +You can still use ``await`` on the ``get_`` methods to retrieve the list. + +The removed methods are: + +* iter_messages +* iter_dialogs +* iter_participants +* iter_admin_log +* iter_profile_photos +* iter_drafts + +The only exception to this rule is ``iter_download``. + +Additionally, when using ``await``, if the method was called with a limit of 1 (either through +setting just one value to fetch, or setting the limit to one), either ``None`` or a single item +(outside of a ``list``) will be returned. This used to be the case only for ``get_messages``, +but now all methods behave in the same way for consistency. + +When using ``async for``, the default limit will be ``None``, meaning all items will be fetched. +When using ``await``, the default limit will be ``1``, meaning the latest item will be fetched. +If you want to use ``await`` but still get a list, use the ``.collect()`` method to collect the +results into a list: + +.. code-block:: python + + chat = ... + + # will iterate over all (default limit=None) + async for message in client.get_messages(chat): + ... + + # will return either a single Message or None if there is not any (limit=1) + message = await client.get_messages(chat) + + # will collect all messages into a list (default limit=None). will also take long! + all_messages = await client.get_messages(chat).collect() + + +// TODO keep providing the old ``iter_`` versions? it doesn't really hurt, even if the recommended way changed +// TODO does the download really need to be special? get download is kind of weird though + + +Raw API has been renamed and is now considered private +------------------------------------------------------ + +The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to +signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``). + +Because in Python "we're all adults", you *can* use this private module if you need to. However, +you *are* also acknowledging that this is a private module prone to change (and indeed, it will +change on layer upgrades across minor version bumps). + +The ``Request`` suffix has been removed from the classes inside ``tl.functions``. + +The ``tl.types`` is now simply ``_tl``, and the ``tl.functions`` is now ``_tl.fn``. + +Some examples: + +.. code-block:: python + + # Before + from telethon.tl import types, functions + + await client(functions.messages.SendMessageRequest(...)) + message: types.Message = ... + + # After + from telethon import _tl + await client(_tl.fn.messages.SendMessage(...)) + message: _tl.Message + +This serves multiple goals: + +* It removes redundant parts from the names. The "recommended" way of using the raw API is through + the subpackage namespace, which already contains a mention to "functions" in it. In addition, + some requests were awkward, such as ``SendCustomRequestRequest``. +* It makes it easier to search for code that is using the raw API, so that you can quickly + identify which parts are making use of it. +* The name is shorter, but remains recognizable. + +Because *a lot* of these objects are created, they now define ``__slots__``. This means you can +no longer monkey-patch them to add new attributes at runtime. You have to create a subclass if you +want to define new attributes. + +This also means that the updates from ``events.Raw`` **no longer have** ``update._entities``. + +``tlobject.to_dict()`` has changed and is now generated dynamically based on the ``__slots__`. +This may incur a small performance hit (but you shouldn't really be using ``.to_dict()`` when +you can just use attribute access and ``getattr``). In general, this should handle ill-defined +objects more gracefully (for instance, those where you're using a ``tuple`` and not a ``list`` +or using a list somewhere it shouldn't be), and have no other observable effects. As an extra +benefit, this slightly cuts down on the amount of bloat. + +In ``tlobject.to_dict()``, the special ``_`` key is now also contains the module (so you can +actually distinguish between equally-named classes). If you want the old behaviour, use +``tlobject.__class__.__name__` instead (and add ``Request`` for functions). + +Because the string representation of an object used ``tlobject.to_dict()``, it is now also +affected by these changes. + +// TODO this definitely generated files mapping from the original name to this new one... +// TODO what's the alternative to update._entities? and update._client?? + + +Many subpackages and modules are now private +-------------------------------------------- + +There were a lot of things which were public but should not have been. From now on, you should +only rely on things that are either publicly re-exported or defined. That is, as soon as anything +starts with an underscore (``_``) on its name, you're acknowledging that the functionality may +change even across minor version changes, and thus have your code break. + +The following subpackages are now considered private: + +* ``client`` is now ``_client``. +* ``crypto`` is now ``_crypto``. +* ``extensions`` is now ``_misc``. +* ``tl`` is now ``_tl``. + +The following modules have been moved inside ``_misc``: + +* ``entitycache.py`` +* ``helpers.py`` +* ``hints.py`` +* ``password.py`` +* ``requestiter.py` +* ``statecache.py`` +* ``utils.py`` + +// TODO review telethon/__init__.py isn't exposing more than it should + + +Using the client in a context-manager no longer calls start automatically +------------------------------------------------------------------------- + +The following code no longer automatically calls ``client.start()``: + +.. code-block:: python + + async with TelegramClient(...) as client: + ... + + # or + + async with client: + ... + + +This means the context-manager will only call ``client.connect()`` and ``client.disconnect()``. +The rationale for this change is that it could be strange for this to ask for the login code if +the session ever was invalid. If you want the old behaviour, you now need to be explicit: + + +.. code-block:: python + + async with TelegramClient(...).start() as client: + ... # ++++++++ + + +Note that you do not need to ``await`` the call to ``.start()`` if you are going to use the result +in a context-manager (but it's okay if you put the ``await``). + + +Several methods have been removed from the client +------------------------------------------------- + +``client.download_file`` has been removed. Instead, ``client.download_media`` should be used. +The now-removed ``client.download_file`` method was a lower level implementation which should +have not been exposed at all. + +``client.build_reply_markup`` has been removed. Manually calling this method was purely an +optimization (the buttons won't need to be transformed into a reply markup every time they're +used). This means you can just remove any calls to this method and things will continue to work. + + +Support for bot-API style file_id has been removed +-------------------------------------------------- + +They have been half-broken for a while now, so this is just making an existing reality official. +See `issue #1613 `__ for details. + +An alternative solution to re-use files may be provided in the future. For the time being, you +should either upload the file as needed, or keep a message with the media somewhere you can +later fetch it (by storing the chat and message identifier). + +Additionally, the ``custom.File.id`` property is gone (which used to provide access to this +"bot-API style" file identifier. + +// TODO could probably provide an in-memory cache for uploads to temporarily reuse old InputFile. +// this should lessen the impact of the removal of this feature + + +Removal of several utility methods +---------------------------------- + +The following ``utils`` methods no longer exist or have been made private: + +* ``utils.resolve_bot_file_id``. It was half-broken. +* ``utils.pack_bot_file_id``. It was half-broken. +* ``utils.resolve_invite_link``. It has been broken for a while, so this just makes its removal + official (see `issue #1723 `__). +* ``utils.resolve_id``. Marked IDs are no longer used thorough the library. The removal of this + method also means ``utils.get_peer`` can no longer get a ``Peer`` from just a number, as the + type is no longer embedded inside the ID. + +// TODO provide the new clean utils + + +Changes on how to configure filters for certain client methods +-------------------------------------------------------------- + +Before, ``client.iter_participants`` (and ``get_participants``) would expect a type or instance +of the raw Telegram definition as a ``filter``. Now, this ``filter`` expects a string. +The supported values are: + +* ``'admin'`` +* ``'bot'`` +* ``'kicked'`` +* ``'banned'`` +* ``'contact'`` + +If you prefer to avoid hardcoding strings, you may use ``telethon.enums.Participant``. + +// TODO maintain support for the old way of doing it? +// TODO now that there's a custom filter, filter client-side for small chats? + + +The custom.Message class and the way it is used has changed +----------------------------------------------------------- + +It no longer inherits ``TLObject``, and rather than trying to mimick Telegram's ``Message`` +constructor, it now takes two parameters: a ``TelegramClient`` instance and a ``_tl.Message``. +As a benefit, you can now more easily reconstruct instances of this type from a previously-stored +``_tl.Message`` instance. + +There are no public attributes. Instead, they are now properties which forward the values into and +from the private ``_message`` field. As a benefit, the documentation will now be easier to follow. +However, you can no longer use ``del`` on these. + +The ``_tl.Message.media`` attribute will no longer be ``None`` when using raw API if the media was +``messageMediaEmpty``. As a benefit, you can now actually distinguish between no media and empty +media. The ``Message.media`` property as returned by friendly methods will still be ``None`` on +empty media. + +The ``telethon.tl.patched`` hack has been removed. + +The message sender no longer is the channel when no sender is provided by Telegram. Telethon used +to patch this value for channels to be the same as the chat, but now it will be faithful to +Telegram's value. + +In order to avoid breaking more code than strictly necessary, ``.raw_text`` will remain a synonym +of ``.message``, and ``.text`` will still be the text formatted through the ``client.parse_mode``. +However, you're encouraged to change uses of ``.raw_text`` with ``.message``, and ``.text`` with +either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` and ``.raw_text`` +may disappear in future versions, and their behaviour is not immediately obvious. + +// TODO actually provide the things mentioned here + + +Using a flat list to define buttons will now create rows and not columns +------------------------------------------------------------------------ + +When sending a message with buttons under a bot account, passing a flat list such as the following: + +.. code-block:: python + + bot.send_message(chat, message, buttons=[ + Button.inline('top'), + Button.inline('middle'), + Button.inline('bottom'), + ]) + +Will now send a message with 3 rows of buttons, instead of a message with 3 columns (old behaviour). +If you still want the old behaviour, wrap the list inside another list: + +.. code-block:: python + + bot.send_message(chat, message, buttons=[[ + # + + Button.inline('top'), + Button.inline('middle'), + Button.inline('bottom'), + ]]) + #+ + + +Changes to the string and to_dict representation +------------------------------------------------ + +The string representation of raw API objects will now have its "printing depth" limited, meaning +very large and nested objects will be easier to read. + +If you want to see the full object's representation, you should instead use Python's builtin +``repr`` method. + +The ``.stringify`` method remains unchanged. + +Here's a comparison table for a convenient overview: + ++-------------------+---------------------------------------------+---------------------------------------------+ +| | Telethon v1.x | Telethon v2.x | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| | ``__str__`` | ``__repr__`` | ``.stringify`` | ``__str__`` | ``__repr__`` | ``.stringify`` | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Useful? | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Multiline? | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Shows everything? | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ + +Both of the string representations may still change in the future without warning, as Telegram +adds, changes or removes fields. It should only be used for debugging. If you need a persistent +string representation, it is your job to decide which fields you care about and their format. + +The ``Message`` representation now contains different properties, which should be more useful and +less confusing. + + +Changes on how to configure a different connection mode +------------------------------------------------------- + +The ``connection`` parameter of the ``TelegramClient`` now expects a string, and not a type. +The supported values are: + +* ``'full'`` +* ``'intermediate'`` +* ``'abridged'`` +* ``'obfuscated'`` +* ``'http'`` + +The value chosen by the library is left as an implementation detail which may change. However, +you can force a certain mode by explicitly configuring it. If you don't want to hardcode the +string, you can import these values from the new ``telethon.enums`` module: + +.. code-block:: python + + client = TelegramClient(..., connection='tcp') + + # or + + from telethon.enums import ConnectionMode + client = TelegramClient(..., connection=ConnectionMode.TCP) + +You may have noticed there's currently no alternative for ``TcpMTProxy``. This mode has been +broken for some time now (see `issue #1319 `__) +anyway, so until there's a working solution, the mode is not supported. Pull Requests are welcome! + + +The to_json method on objects has been removed +---------------------------------------------- + +This was not very useful, as most of the time, you'll probably be having other data along with the +object's JSON. It simply saved you an import (and not even always, in case you wanted another +encoder). Use ``json.dumps(obj.to_dict())`` instead. + + +The Conversation API has been removed +------------------------------------- + +This API had certain shortcomings, such as lacking persistence, poor interaction with other event +handlers, and overcomplicated usage for anything beyond the simplest case. + +It is not difficult to write your own code to deal with a conversation's state. A simple +`Finite State Machine `__ inside your handlers will do +just fine This approach can also be easily persisted, and you can adjust it to your needs and +your handlers much more easily. + +// TODO provide standalone alternative for this? + + +Deleting messages now returns a more useful value +------------------------------------------------- + +It used to return a list of :tl:`messages.affectedMessages` which I expect very little people were +actually using. Now it returns an ``int`` value indicating the number of messages that did exist +and were deleted. + + +Changes to the methods to retrieve participants +----------------------------------------------- + +The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now gone. +It was not reliable, and was a cause of flood wait errors. + +The ``search`` parameter is no longer ignored when ``filter`` is specified. + + +The total value when getting participants has changed +----------------------------------------------------- + +Before, it used to always be the total amount of people inside the chat. Now the filter is also +considered. If you were running ``client.get_participants`` with a ``filter`` other than the +default and accessing the ``list.total``, you will now get a different result. You will need to +perform a separate request with no filter to fetch the total without filter (this is what the +library used to do). + + +Changes to editing messages +--------------------------- + +Before, calling ``message.edit()`` would completely ignore your attempt to edit a message if the +message had a forward header or was not outgoing. This is no longer the case. It is now the user's +responsibility to check for this. + +However, most likely, you were already doing the right thing (or else you would've experienced a +"why is this not being edited", which you would most likely consider a bug rather than a feature). + +When using ``client.edit_message``, you now must always specify the chat and the message (or +message identifier). This should be less "magic". As an example, if you were doing this before: + +.. code-block:: python + + await client.edit_message(message, 'new text') + +You now have to do the following: + +.. code-block:: python + + await client.edit_message(message.input_chat, message.id, 'new text') + + # or + + await message.edit('new text') + + +Signing in no longer sends the code +----------------------------------- + +``client.sign_in()`` used to run ``client.send_code_request()`` if you only provided the phone and +not the code. It no longer does this. If you need that convenience, use ``client.start()`` instead. + + +The client.disconnected property has been removed +------------------------------------------------- + +``client.run_until_disconnected()`` should be used instead. + + +The TelegramClient is no longer made out of mixins +-------------------------------------------------- + +If you were relying on any of the individual mixins that made up the client, such as +``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone. +There is a single ``TelegramClient`` class now, containing everything you need. + + +The takeout context-manager has changed +--------------------------------------- + +It no longer has a finalize. All the requests made by the client in the same task will be wrapped, +not only those made through the proxy client returned by the context-manager. + +This cleans up the (rather hacky) implementation, making use of Python's ``contextvar``. If you +still need the takeout session to persist, you should manually use the ``begin_takeout`` and +``end_takeout`` method. + +If you want to ignore the currently-active takeout session in a task, toggle the following context +variable: + +.. code-block:: python + + telethon.ignore_takeout.set(True) + + +CdnDecrypter has been removed +----------------------------- + +It was not really working and was more intended to be an implementation detail than anything else. + + +URL buttons no longer open the web-browser +------------------------------------------ + +Now the URL is returned. You can still use ``webbrowser.open`` to get the old behaviour. + + +--- + +you can no longer pass an attributes list because the constructor is now nice. +use raw api if you really need it. +goal is to hide raw api from high level api. sorry. + +no parsemode. use the correct parameter. it's more convenient than setting two. + +formatting_entities stays because otherwise it's the only feasible way to manually specify it. + +todo update send_message and send_file docs (well review all functions) + +album overhaul. use a list of Message instead. + +size selector for download_profile_photo and download_media is now different + +still thumb because otherwise documents are weird. + +keep support for explicit size instance? + +renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read? + +force sms removed as it was broken anyway and not very reliable + +you can now await client.action for a one-off any action not just cancel + +fwd msg and delete msg now mandate a list rather than a single int or msg +(since there's msg.delete and msg.forward_to this should be no issue). +they are meant to work on lists. + +also mark read only supports single now. a list would just be max anyway. +removed max id since it's not really of much use. + +client loop has been removed. embrace implicit loop as asyncio does now + +renamed some client params, and made other privates + timeout -> connect_timeout + connection_retries -> connect_retries + retry_delay -> connect_retry_delay + +sequential_updates is gone +connection type is gone + +raise_last_call_error is now the default rather than ValueError + +self-produced updates like getmessage now also trigger a handler diff --git a/readthedocs/misc/wall-of-shame.rst b/readthedocs/misc/wall-of-shame.rst deleted file mode 100644 index 87be0464..00000000 --- a/readthedocs/misc/wall-of-shame.rst +++ /dev/null @@ -1,65 +0,0 @@ -============= -Wall of Shame -============= - - -This project has an -`issues `__ section for -you to file **issues** whenever you encounter any when working with the -library. Said section is **not** for issues on *your* program but rather -issues with Telethon itself. - -If you have not made the effort to 1. read through the docs and 2. -`look for the method you need `__, -you will end up on the `Wall of -Shame `__, -i.e. all issues labeled -`"RTFM" `__: - - **rtfm** - Literally "Read The F--king Manual"; a term showing the - frustration of being bothered with questions so trivial that the asker - could have quickly figured out the answer on their own with minimal - effort, usually by reading readily-available documents. People who - say"RTFM!" might be considered rude, but the true rude ones are the - annoying people who take absolutely no self-responibility and expect to - have all the answers handed to them personally. - - *"Damn, that's the twelveth time that somebody posted this question - to the messageboard today! RTFM, already!"* - - *by Bill M. July 27, 2004* - -If you have indeed read the docs, and have tried looking for the method, -and yet you didn't find what you need, **that's fine**. Telegram's API -can have some obscure names at times, and for this reason, there is a -`"question" -label `__ -with questions that are okay to ask. Just state what you've tried so -that we know you've made an effort, or you'll go to the Wall of Shame. - -Of course, if the issue you're going to open is not even a question but -a real issue with the library (thankfully, most of the issues have been -that!), you won't end up here. Don't worry. - -Current winner --------------- - -The current winner is `issue -213 `__: - -**Issue:** - -.. figure:: https://user-images.githubusercontent.com/6297805/29822978-9a9a6ef0-8ccd-11e7-9ec5-934ea0f57681.jpg - -:alt: Winner issue - -Winner issue - -**Answer:** - -.. figure:: https://user-images.githubusercontent.com/6297805/29822983-9d523402-8ccd-11e7-9fb1-5783740ee366.jpg - -:alt: Winner issue answer - -Winner issue answer diff --git a/readthedocs/modules/client.rst b/readthedocs/modules/client.rst index de5502c9..4fc076d8 100644 --- a/readthedocs/modules/client.rst +++ b/readthedocs/modules/client.rst @@ -20,10 +20,10 @@ Each mixin has its own methods, which you all can use. async def main(): # Now you can use all client methods listed below, like for example... - await client.send_message('me', 'Hello to myself!') + async with client.start(): + await client.send_message('me', 'Hello to myself!') - with client: - client.loop.run_until_complete(main()) + asyncio.run(main()) You **don't** need to import these `AuthMethods`, `MessageMethods`, etc. diff --git a/readthedocs/modules/custom.rst b/readthedocs/modules/custom.rst index 074b2161..01284fbb 100644 --- a/readthedocs/modules/custom.rst +++ b/readthedocs/modules/custom.rst @@ -46,15 +46,6 @@ ChatGetter :show-inheritance: -Conversation -============ - -.. automodule:: telethon.tl.custom.conversation - :members: - :undoc-members: - :show-inheritance: - - Dialog ====== diff --git a/readthedocs/quick-references/client-reference.rst b/readthedocs/quick-references/client-reference.rst index 6dd8245c..22517288 100644 --- a/readthedocs/quick-references/client-reference.rst +++ b/readthedocs/quick-references/client-reference.rst @@ -107,7 +107,6 @@ Dialogs iter_drafts get_drafts delete_dialog - conversation Users ----- diff --git a/readthedocs/quick-references/faq.rst b/readthedocs/quick-references/faq.rst index df267b86..0b1e28b0 100644 --- a/readthedocs/quick-references/faq.rst +++ b/readthedocs/quick-references/faq.rst @@ -127,14 +127,7 @@ This is basic Python knowledge. You should use the dot operator: AttributeError: 'coroutine' object has no attribute 'id' ======================================================== -You either forgot to: - -.. code-block:: python - - import telethon.sync - # ^^^^^ import sync - -Or: +Telethon is an asynchronous library. This means you need to ``await`` most methods: .. code-block:: python @@ -218,19 +211,7 @@ Check out `quart_login.py`_ for an example web-application based on Quart. Can I use Anaconda/Spyder/IPython with the library? =================================================== -Yes, but these interpreters run the asyncio event loop implicitly, -which interferes with the ``telethon.sync`` magic module. - -If you use them, you should **not** import ``sync``: - -.. code-block:: python - - # Change any of these...: - from telethon import TelegramClient, sync, ... - from telethon.sync import TelegramClient, ... - - # ...with this: - from telethon import TelegramClient, ... +Yes, but these interpreters run the asyncio event loop implicitly, so be wary of that. You are also more likely to get "sqlite3.OperationalError: database is locked" with them. If they cause too much trouble, just write your code in a ``.py`` diff --git a/readthedocs/quick-references/objects-reference.rst b/readthedocs/quick-references/objects-reference.rst index 51ed4607..41f73033 100644 --- a/readthedocs/quick-references/objects-reference.rst +++ b/readthedocs/quick-references/objects-reference.rst @@ -155,33 +155,6 @@ its name, bot-API style file ID, etc. sticker_set -Conversation -============ - -The `Conversation ` object -is returned by the `client.conversation() -` method to easily -send and receive responses like a normal conversation. - -It bases `ChatGetter `. - -.. currentmodule:: telethon.tl.custom.conversation.Conversation - -.. autosummary:: - :nosignatures: - - send_message - send_file - mark_read - get_response - get_reply - get_edit - wait_read - wait_event - cancel - cancel_all - - AdminLogEvent ============= diff --git a/requirements.txt b/requirements.txt index 2b650ec4..a7d0e620 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -pyaes -rsa +markdown-it-py~=1.1.0 +pyaes~=1.6.1 +rsa~=4.7.2 diff --git a/setup.py b/setup.py index a498980a..caf4a54a 100755 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ GENERATOR_DIR = Path('telethon_generator') LIBRARY_DIR = Path('telethon') ERRORS_IN = GENERATOR_DIR / 'data/errors.csv' -ERRORS_OUT = LIBRARY_DIR / 'errors/rpcerrorlist.py' +ERRORS_OUT = LIBRARY_DIR / 'errors/_generated.py' METHODS_IN = GENERATOR_DIR / 'data/methods.csv' @@ -55,8 +55,8 @@ METHODS_IN = GENERATOR_DIR / 'data/methods.csv' FRIENDLY_IN = GENERATOR_DIR / 'data/friendly.csv' TLOBJECT_IN_TLS = [Path(x) for x in GENERATOR_DIR.glob('data/*.tl')] -TLOBJECT_OUT = LIBRARY_DIR / 'tl' -IMPORT_DEPTH = 2 +TLOBJECT_OUT = LIBRARY_DIR / '_tl' +TLOBJECT_MOD = 'telethon._tl' DOCS_IN_RES = GENERATOR_DIR / 'data/html' DOCS_OUT = Path('docs') @@ -94,7 +94,7 @@ def generate(which, action='gen'): if clean: clean_tlobjects(TLOBJECT_OUT) else: - generate_tlobjects(tlobjects, layer, IMPORT_DEPTH, TLOBJECT_OUT) + generate_tlobjects(tlobjects, layer, TLOBJECT_MOD, TLOBJECT_OUT) if 'errors' in which: which.remove('errors') @@ -208,7 +208,7 @@ def main(argv): # See https://stackoverflow.com/a/40300957/4759433 # -> https://www.python.org/dev/peps/pep-0345/#requires-python # -> http://setuptools.readthedocs.io/en/latest/setuptools.html - python_requires='>=3.5', + python_requires='>=3.7', # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ @@ -223,10 +223,10 @@ def main(argv): 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], keywords='telegram api chat client library messaging mtproto', packages=find_packages(exclude=[ diff --git a/telethon/__init__.py b/telethon/__init__.py index 3a62f1c8..653356d1 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,14 +1,11 @@ -from .client.telegramclient import TelegramClient -from .network import connection -from .tl import types, functions, custom -from .tl.custom import Button -from .tl import patched as _ # import for its side-effects -from . import version, events, utils, errors +# Note: the import order matters +from ._misc import helpers as _ # no dependencies +from . import _tl # no dependencies +from ._misc import utils as _ # depends on helpers and _tl +from ._misc import hints as _ # depends on types/custom +from ._client.account import ignore_takeout + +from ._client.telegramclient import TelegramClient +from . import version, events, errors, enums __version__ = version.__version__ - -__all__ = [ - 'TelegramClient', 'Button', - 'types', 'functions', 'custom', 'errors', - 'events', 'utils', 'connection' -] diff --git a/telethon/_client/__init__.py b/telethon/_client/__init__.py new file mode 100644 index 00000000..5cfe7a83 --- /dev/null +++ b/telethon/_client/__init__.py @@ -0,0 +1,4 @@ +""" +This package defines the main `telethon._client.telegramclient.TelegramClient` instance +which delegates the work to free-standing functions defined in the rest of files. +""" diff --git a/telethon/_client/account.py b/telethon/_client/account.py new file mode 100644 index 00000000..cdf79850 --- /dev/null +++ b/telethon/_client/account.py @@ -0,0 +1,73 @@ +import functools +import inspect +import typing +import dataclasses +from contextvars import ContextVar + +from .._misc import helpers, utils +from .. import _tl + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +ignore_takeout = ContextVar('ignore_takeout', default=False) + + +# TODO Make use of :tl:`InvokeWithMessagesRange` somehow +# For that, we need to use :tl:`GetSplitRanges` first. +class _Takeout: + def __init__(self, client, kwargs): + self._client = client + self._kwargs = kwargs + + async def __aenter__(self): + await self._client.begin_takeout(**kwargs) + return self._client + + async def __aexit__(self, exc_type, exc_value, traceback): + await self._client.end_takeout(success=exc_type is None) + + +def takeout(self: 'TelegramClient', **kwargs): + return _Takeout(self, kwargs) + + +async def begin_takeout( + self: 'TelegramClient', + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None, +) -> 'TelegramClient': + if takeout_active(): + raise ValueError('a previous takeout session was already active') + + await self._replace_session_state(takeout_id=(await client( + contacts=contacts, + message_users=users, + message_chats=chats, + message_megagroups=megagroups, + message_channels=channels, + files=files, + file_max_size=max_file_size + )).id) + + +def takeout_active(self: 'TelegramClient') -> bool: + return self._session_state.takeout_id is not None + + +async def end_takeout(self: 'TelegramClient', success: bool) -> bool: + if not takeout_active(): + raise ValueError('no previous takeout session was active') + + result = await self(_tl.fn.account.FinishTakeoutSession(success)) + if not result: + raise ValueError("could not end the active takeout session") + + await self._replace_session_state(takeout_id=None) diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py new file mode 100644 index 00000000..7d122d8f --- /dev/null +++ b/telethon/_client/auth.py @@ -0,0 +1,431 @@ +import getpass +import inspect +import os +import sys +import typing +import warnings +import functools +import dataclasses + +from .._misc import utils, helpers, password as pwd_mod +from .. import errors, _tl +from ..types import _custom + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class StartingClient: + def __init__(self, client, start_fn): + self.client = client + self.start_fn = start_fn + + async def __aenter__(self): + await self.start_fn() + return self.client + + async def __aexit__(self, *args): + await self.client.__aexit__(*args) + + def __await__(self): + return self.__aenter__().__await__() + + +def start( + self: 'TelegramClient', + phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), + password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), + *, + bot_token: str = None, + code_callback: typing.Callable[[], typing.Union[str, int]] = None, + first_name: str = 'New User', + last_name: str = '', + max_attempts: int = 3) -> 'TelegramClient': + if code_callback is None: + def code_callback(): + return input('Please enter the code you received: ') + elif not callable(code_callback): + raise ValueError( + 'The code_callback parameter needs to be a callable ' + 'function that returns the code you received by Telegram.' + ) + + if not phone and not bot_token: + raise ValueError('No phone number or bot token provided.') + + if phone and bot_token and not callable(phone): + raise ValueError('Both a phone and a bot token provided, ' + 'must only provide one of either') + + return StartingClient(self, functools.partial(_start, + self=self, + phone=phone, + password=password, + bot_token=bot_token, + code_callback=code_callback, + first_name=first_name, + last_name=last_name, + max_attempts=max_attempts + )) + +async def _start( + self: 'TelegramClient', phone, password, bot_token, + code_callback, first_name, last_name, max_attempts): + if not self.is_connected(): + await self.connect() + + # 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 phone and not callable(phone) and utils.parse_phone(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: + # Turn the callable into a valid phone number (or bot token) + while callable(phone): + value = phone() + if inspect.isawaitable(value): + value = await value + + if ':' in value: + # Bot tokens have 'user_id:access_hash' format + bot_token = value + break + + phone = utils.parse_phone(value) or phone + + if bot_token: + await self.sign_in(bot_token=bot_token) + return self + + me = None + attempts = 0 + two_step_detected = False + + await self.send_code_request(phone) + sign_up = False # assume login + while attempts < max_attempts: + try: + value = code_callback() + if inspect.isawaitable(value): + value = await value + + # Since sign-in with no code works (it sends the code) + # we must double-check that here. Else we'll assume we + # logged in, and it will return None as the User. + if not value: + raise errors.PhoneCodeEmptyError(request=None) + + if sign_up: + me = await self.sign_up(value, first_name, last_name) + else: + # Raises SessionPasswordNeededError if 2FA enabled + me = await self.sign_in(phone, code=value) + break + except errors.SessionPasswordNeededError: + two_step_detected = True + break + except errors.PhoneNumberOccupiedError: + sign_up = False + except errors.PhoneNumberUnoccupiedError: + sign_up = True + except (errors.PhoneCodeEmptyError, + errors.PhoneCodeExpiredError, + errors.PhoneCodeHashEmptyError, + errors.PhoneCodeInvalidError): + print('Invalid code. Please try again.', file=sys.stderr) + + attempts += 1 + else: + raise RuntimeError( + '{} consecutive sign-in attempts failed. Aborting' + .format(max_attempts) + ) + + if two_step_detected: + if not password: + raise ValueError( + "Two-step verification is enabled for this account. " + "Please provide the 'password' argument to 'start()'." + ) + + if callable(password): + for _ in range(max_attempts): + try: + value = password() + if inspect.isawaitable(value): + value = await value + + me = await self.sign_in(phone=phone, password=value) + break + except errors.PasswordHashInvalidError: + print('Invalid password. Please try again', + file=sys.stderr) + else: + raise errors.PasswordHashInvalidError(request=None) + else: + me = await self.sign_in(phone=phone, password=password) + + # We won't reach here if any step failed (exit by exception) + signed, name = 'Signed in successfully as', utils.get_display_name(me) + try: + print(signed, name) + except UnicodeEncodeError: + # Some terminals don't support certain characters + print(signed, name.encode('utf-8', errors='ignore') + .decode('ascii', errors='ignore')) + + return self + +def _parse_phone_and_hash(self, phone, phone_hash): + """ + Helper method to both parse and validate phone and its hash. + """ + phone = utils.parse_phone(phone) or self._phone + if not phone: + raise ValueError( + 'Please make sure to call send_code_request first.' + ) + + phone_hash = phone_hash or self._phone_code_hash.get(phone, None) + if not phone_hash: + raise ValueError('You also need to provide a phone_code_hash.') + + return phone, phone_hash + +async def sign_in( + self: 'TelegramClient', + phone: str = None, + code: typing.Union[str, int] = None, + *, + password: str = None, + bot_token: str = None, + phone_code_hash: str = None) -> 'typing.Union[_tl.User, _tl.auth.SentCode]': + me = await self.get_me() + if me: + return me + + if phone and code: + phone, phone_code_hash = \ + _parse_phone_and_hash(self, phone, phone_code_hash) + + # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, + # PhoneCodeHashEmptyError or PhoneCodeInvalidError. + request = _tl.fn.auth.SignIn( + phone, phone_code_hash, str(code) + ) + elif password: + pwd = await self(_tl.fn.account.GetPassword()) + request = _tl.fn.auth.CheckPassword( + pwd_mod.compute_check(pwd, password) + ) + elif bot_token: + request = _tl.fn.auth.ImportBotAuthorization( + flags=0, bot_auth_token=bot_token, + api_id=self._api_id, api_hash=self._api_hash + ) + else: + raise ValueError('You must provide either phone and code, password, or bot_token.') + + result = await self(request) + if isinstance(result, _tl.auth.AuthorizationSignUpRequired): + # Emulate pre-layer 104 behaviour + self._tos = result.terms_of_service + raise errors.PhoneNumberUnoccupiedError(request=request) + + return await _update_session_state(self, result.user) + +async def sign_up( + self: 'TelegramClient', + code: typing.Union[str, int], + first_name: str, + last_name: str = '', + *, + phone: str = None, + phone_code_hash: str = None) -> '_tl.User': + me = await self.get_me() + if me: + return me + + # To prevent abuse, one has to try to sign in before signing up. This + # is the current way in which Telegram validates the code to sign up. + # + # `sign_in` will set `_tos`, so if it's set we don't need to call it + # because the user already tried to sign in. + # + # We're emulating pre-layer 104 behaviour so except the right error: + if not self._tos: + try: + return await self.sign_in( + phone=phone, + code=code, + phone_code_hash=phone_code_hash, + ) + except errors.PhoneNumberUnoccupiedError: + pass # code is correct and was used, now need to sign in + + if self._tos and self._tos.text: + if self.parse_mode: + t = self.parse_mode.unparse(self._tos.text, self._tos.entities) + else: + t = self._tos.text + sys.stderr.write("{}\n".format(t)) + sys.stderr.flush() + + phone, phone_code_hash = \ + _parse_phone_and_hash(self, phone, phone_code_hash) + + result = await self(_tl.fn.auth.SignUp( + phone_number=phone, + phone_code_hash=phone_code_hash, + first_name=first_name, + last_name=last_name + )) + + if self._tos: + await self( + _tl.fn.help.AcceptTermsOfService(self._tos.id)) + + return await _update_session_state(self, result.user) + + +async def _update_session_state(self, user, save=True): + """ + Callback called whenever the login or sign up process completes. + Returns the input user parameter. + """ + state = await self(_tl.fn.updates.GetState()) + await _replace_session_state( + self, + save=save, + user_id=user.id, + bot=user.bot, + pts=state.pts, + qts=state.qts, + date=int(state.date.timestamp()), + seq=state.seq, + ) + + return user + + +async def _replace_session_state(self, *, save=True, **changes): + new = dataclasses.replace(self._session_state, **changes) + await self._session.set_state(new) + self._session_state = new + + if save: + await self._session.save() + + +async def send_code_request( + self: 'TelegramClient', + phone: str) -> '_tl.auth.SentCode': + result = None + phone = utils.parse_phone(phone) or self._phone + phone_hash = self._phone_code_hash.get(phone) + + if phone_hash: + result = await self( + _tl.fn.auth.ResendCode(phone, phone_hash)) + + self._phone_code_hash[phone] = result.phone_code_hash + else: + try: + result = await self(_tl.fn.auth.SendCode( + phone, self._api_id, self._api_hash, _tl.CodeSettings())) + except errors.AuthRestartError: + return await self.send_code_request(phone) + + # phone_code_hash may be empty, if it is, do not save it (#1283) + if result.phone_code_hash: + self._phone_code_hash[phone] = phone_hash = result.phone_code_hash + + self._phone = phone + + return result + +async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QRLogin: + qr_login = _custom.QRLogin(self, ignored_ids or []) + await qr_login.recreate() + return qr_login + +async def log_out(self: 'TelegramClient') -> bool: + try: + await self(_tl.fn.auth.LogOut()) + except errors.RPCError: + return False + + await self.disconnect() + return True + +async def edit_2fa( + self: 'TelegramClient', + current_password: str = None, + new_password: str = None, + *, + hint: str = '', + email: str = None, + email_code_callback: typing.Callable[[int], str] = None) -> bool: + if new_password is None and current_password is None: + return False + + if email and not callable(email_code_callback): + raise ValueError('email present without email_code_callback') + + pwd = await self(_tl.fn.account.GetPassword()) + pwd.new_algo.salt1 += os.urandom(32) + assert isinstance(pwd, _tl.account.Password) + if not pwd.has_password and current_password: + current_password = None + + if current_password: + password = pwd_mod.compute_check(pwd, current_password) + else: + password = _tl.InputCheckPasswordEmpty() + + if new_password: + new_password_hash = pwd_mod.compute_digest( + pwd.new_algo, new_password) + else: + new_password_hash = b'' + + try: + await self(_tl.fn.account.UpdatePasswordSettings( + password=password, + new_settings=_tl.account.PasswordInputSettings( + new_algo=pwd.new_algo, + new_password_hash=new_password_hash, + hint=hint, + email=email, + new_secure_settings=None + ) + )) + except errors.EmailUnconfirmedError as e: + code = email_code_callback(e.code_length) + if inspect.isawaitable(code): + code = await code + + code = str(code) + await self(_tl.fn.account.ConfirmPasswordEmail(code)) + + return True diff --git a/telethon/_client/bots.py b/telethon/_client/bots.py new file mode 100644 index 00000000..2e3ef1b6 --- /dev/null +++ b/telethon/_client/bots.py @@ -0,0 +1,33 @@ +import typing + +from ..types import _custom +from .._misc import hints +from .. import _tl + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +async def inline_query( + self: 'TelegramClient', + bot: 'hints.EntityLike', + query: str, + *, + entity: 'hints.EntityLike' = None, + offset: str = None, + geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults: + bot = await self.get_input_entity(bot) + if entity: + peer = await self.get_input_entity(entity) + else: + peer = _tl.InputPeerEmpty() + + result = await self(_tl.fn.messages.GetInlineBotResults( + bot=bot, + peer=peer, + query=query, + offset=offset or '', + geo_point=geo_point + )) + + return _custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py new file mode 100644 index 00000000..cd6d9726 --- /dev/null +++ b/telethon/_client/chats.py @@ -0,0 +1,700 @@ +import asyncio +import inspect +import itertools +import string +import typing + +from .. import errors, _tl +from .._misc import helpers, utils, requestiter, tlobject, enums, hints +from ..types import _custom + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + +_MAX_PARTICIPANTS_CHUNK_SIZE = 200 +_MAX_ADMIN_LOG_CHUNK_SIZE = 100 +_MAX_PROFILE_PHOTO_CHUNK_SIZE = 100 + + +class _ChatAction: + def __init__(self, client, chat, action, *, delay, auto_cancel): + self._client = client + self._chat = chat + self._action = action + self._delay = delay + self._auto_cancel = auto_cancel + self._request = None + self._task = None + self._running = False + + def __await__(self): + return self._once().__await__() + + async def __aenter__(self): + self._chat = await self._client.get_input_entity(self._chat) + + # Since `self._action` is passed by reference we can avoid + # recreating the request all the time and still modify + # `self._action.progress` directly in `progress`. + self._request = _tl.fn.messages.SetTyping( + self._chat, self._action) + + self._running = True + self._task = asyncio.create_task(self._update()) + return self + + async def __aexit__(self, *args): + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + self._task = None + + async def _once(self): + self._chat = await self._client.get_input_entity(self._chat) + await self._client(_tl.fn.messages.SetTyping(self._chat, self._action)) + + async def _update(self): + try: + while self._running: + await self._client(self._request) + await asyncio.sleep(self._delay) + except ConnectionError: + pass + except asyncio.CancelledError: + if self._auto_cancel: + await self._client(_tl.fn.messages.SetTyping( + self._chat, _tl.SendMessageCancelAction())) + + @staticmethod + def _parse(action): + if isinstance(action, tlobject.TLObject) and action.SUBCLASS_OF_ID != 0x20b2cc21: + return action + + return { + enums.Action.TYPING: _tl.SendMessageTypingAction(), + enums.Action.CONTACT: _tl.SendMessageChooseContactAction(), + enums.Action.GAME: _tl.SendMessageGamePlayAction(), + enums.Action.LOCATION: _tl.SendMessageGeoLocationAction(), + enums.Action.STICKER: _tl.SendMessageChooseStickerAction(), + enums.Action.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(), + enums.Action.RECORD_ROUND: _tl.SendMessageRecordRoundAction(), + enums.Action.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(), + enums.Action.AUDIO: _tl.SendMessageUploadAudioAction(1), + enums.Action.ROUND: _tl.SendMessageUploadRoundAction(1), + enums.Action.VIDEO: _tl.SendMessageUploadVideoAction(1), + enums.Action.PHOTO: _tl.SendMessageUploadPhotoAction(1), + enums.Action.DOCUMENT: _tl.SendMessageUploadDocumentAction(1), + enums.Action.CANCEL: _tl.SendMessageCancelAction(), + }[enums.Action(action)] + + def progress(self, current, total): + if hasattr(self._action, 'progress'): + self._action.progress = 100 * round(current / total) + + +class _ParticipantsIter(requestiter.RequestIter): + async def _init(self, entity, filter, search): + if not filter: + if search: + filter = _tl.ChannelParticipantsSearch(search) + else: + filter = _tl.ChannelParticipantsRecent() + else: + filter = enums.Participant(filter) + if filter == enums.Participant.ADMIN: + filter = _tl.ChannelParticipantsAdmins() + elif filter == enums.Participant.BOT: + filter = _tl.ChannelParticipantsBots() + elif filter == enums.Participant.KICKED: + filter = _tl.ChannelParticipantsKicked(search) + elif filter == enums.Participant.BANNED: + filter = _tl.ChannelParticipantsBanned(search) + elif filter == enums.Participant.CONTACT: + filter = _tl.ChannelParticipantsContacts(search) + else: + raise RuntimeError('unhandled enum variant') + + entity = await self.client.get_input_entity(entity) + ty = helpers._entity_type(entity) + if search and (filter or ty != helpers._EntityType.CHANNEL): + # We need to 'search' ourselves unless we have a PeerChannel + search = search.casefold() + + self.filter_entity = lambda ent: ( + search in utils.get_display_name(ent).casefold() or + search in (getattr(ent, 'username', None) or '').casefold() + ) + else: + self.filter_entity = lambda ent: True + + if ty == helpers._EntityType.CHANNEL: + if self.limit <= 0: + # May not have access to the channel, but getFull can get the .total. + self.total = (await self.client( + _tl.fn.channels.GetFullChannel(entity) + )).full_chat.participants_count + raise StopAsyncIteration + + self.seen = set() + self.request = _tl.fn.channels.GetParticipants( + channel=entity, + filter=filter or _tl.ChannelParticipantsSearch(search), + offset=0, + limit=_MAX_PARTICIPANTS_CHUNK_SIZE, + hash=0 + ) + + elif ty == helpers._EntityType.CHAT: + full = await self.client( + _tl.fn.messages.GetFullChat(entity.chat_id)) + if not isinstance( + full.full_chat.participants, _tl.ChatParticipants): + # ChatParticipantsForbidden won't have ``.participants`` + self.total = 0 + raise StopAsyncIteration + + self.total = len(full.full_chat.participants.participants) + + users = {user.id: user for user in full.users} + for participant in full.full_chat.participants.participants: + if isinstance(participant, _tl.ChannelParticipantBanned): + user_id = participant.peer.user_id + else: + user_id = participant.user_id + user = users[user_id] + if not self.filter_entity(user): + continue + + user = users[user_id] + self.buffer.append(user) + + return True + else: + self.total = 1 + if self.limit != 0: + user = await self.client.get_entity(entity) + if self.filter_entity(user): + self.buffer.append(user) + + return True + + async def _load_next_chunk(self): + # Only care about the limit for the first request + # (small amount of people). + # + # Most people won't care about getting exactly 12,345 + # members so it doesn't really matter not to be 100% + # precise with being out of the offset/limit here. + self.request.limit = min( + self.limit - self.request.offset, _MAX_PARTICIPANTS_CHUNK_SIZE) + + if self.request.offset > self.limit: + return True + + participants = await self.client(self.request) + self.total = participants.count + + self.request.offset += len(participants.participants) + users = {user.id: user for user in participants.users} + for participant in participants.participants: + if isinstance(participant, _tl.ChannelParticipantBanned): + if not isinstance(participant.peer, _tl.PeerUser): + # May have the entire channel banned. See #3105. + continue + user_id = participant.peer.user_id + else: + user_id = participant.user_id + + user = users[user_id] + if not self.filter_entity(user) or user.id in self.seen: + continue + self.seen.add(user_id) + user = users[user_id] + self.buffer.append(user) + + +class _AdminLogIter(requestiter.RequestIter): + async def _init( + self, entity, admins, search, min_id, max_id, + join, leave, invite, restrict, unrestrict, ban, unban, + promote, demote, info, settings, pinned, edit, delete, + group_call + ): + if any((join, leave, invite, restrict, unrestrict, ban, unban, + promote, demote, info, settings, pinned, edit, delete, + group_call)): + events_filter = _tl.ChannelAdminLogEventsFilter( + join=join, leave=leave, invite=invite, ban=restrict, + unban=unrestrict, kick=ban, unkick=unban, promote=promote, + demote=demote, info=info, settings=settings, pinned=pinned, + edit=edit, delete=delete, group_call=group_call + ) + else: + events_filter = None + + self.entity = await self.client.get_input_entity(entity) + + admin_list = [] + if admins: + if not utils.is_list_like(admins): + admins = (admins,) + + for admin in admins: + admin_list.append(await self.client.get_input_entity(admin)) + + self.request = _tl.fn.channels.GetAdminLog( + self.entity, q=search or '', min_id=min_id, max_id=max_id, + limit=0, events_filter=events_filter, admins=admin_list or None + ) + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_ADMIN_LOG_CHUNK_SIZE) + r = await self.client(self.request) + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + self.request.max_id = min((e.id for e in r.events), default=0) + for ev in r.events: + if isinstance(ev.action, + _tl.ChannelAdminLogEventActionEditMessage): + ev.action.prev_message = _custom.Message._new( + self.client, ev.action.prev_message, entities, self.entity) + + ev.action.new_message = _custom.Message._new( + self.client, ev.action.new_message, entities, self.entity) + + elif isinstance(ev.action, + _tl.ChannelAdminLogEventActionDeleteMessage): + ev.action.message = _custom.Message._new( + self.client, ev.action.message, entities, self.entity) + + self.buffer.append(_custom.AdminLogEvent(ev, entities)) + + if len(r.events) < self.request.limit: + return True + + +class _ProfilePhotoIter(requestiter.RequestIter): + async def _init( + self, entity, offset, max_id + ): + entity = await self.client.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.USER: + self.request = _tl.fn.photos.GetUserPhotos( + entity, + offset=offset, + max_id=max_id, + limit=1 + ) + else: + self.request = _tl.fn.messages.Search( + peer=entity, + q='', + filter=_tl.InputMessagesFilterChatPhotos(), + min_date=None, + max_date=None, + offset_id=0, + add_offset=offset, + limit=1, + max_id=max_id, + min_id=0, + hash=0 + ) + + if self.limit == 0: + self.request.limit = 1 + result = await self.client(self.request) + if isinstance(result, _tl.photos.Photos): + self.total = len(result.photos) + elif isinstance(result, _tl.messages.Messages): + self.total = len(result.messages) + else: + # Luckily both photosSlice and messages have a count for total + self.total = getattr(result, 'count', None) + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE) + result = await self.client(self.request) + + if isinstance(result, _tl.photos.Photos): + self.buffer = result.photos + self.left = len(self.buffer) + self.total = len(self.buffer) + elif isinstance(result, _tl.messages.Messages): + self.buffer = [x.action.photo for x in result.messages + if isinstance(x.action, _tl.MessageActionChatEditPhoto)] + + self.left = len(self.buffer) + self.total = len(self.buffer) + elif isinstance(result, _tl.photos.PhotosSlice): + self.buffer = result.photos + self.total = result.count + if len(self.buffer) < self.request.limit: + self.left = len(self.buffer) + else: + self.request.offset += len(result.photos) + else: + # Some broadcast channels have a photo that this request doesn't + # retrieve for whatever random reason the Telegram server feels. + # + # This means the `total` count may be wrong but there's not much + # that can be done around it (perhaps there are too many photos + # and this is only a partial result so it's not possible to just + # use the len of the result). + self.total = getattr(result, 'count', None) + + # Unconditionally fetch the full channel to obtain this photo and + # yield it with the rest (unless it's a duplicate). + seen_id = None + if isinstance(result, _tl.messages.ChannelMessages): + channel = await self.client(_tl.fn.channels.GetFullChannel(self.request.peer)) + photo = channel.full_chat.chat_photo + if isinstance(photo, _tl.Photo): + self.buffer.append(photo) + seen_id = photo.id + + self.buffer.extend( + x.action.photo for x in result.messages + if isinstance(x.action, _tl.MessageActionChatEditPhoto) + and x.action.photo.id != seen_id + ) + + if len(result.messages) < self.request.limit: + self.left = len(self.buffer) + elif result.messages: + self.request.add_offset = 0 + self.request.offset_id = result.messages[-1].id + + +def get_participants( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + search: str = '', + filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter: + return _ParticipantsIter( + self, + limit, + entity=entity, + filter=filter, + search=search + ) + + +def get_admin_log( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.EntitiesLike' = None, + join: bool = None, + leave: bool = None, + invite: bool = None, + restrict: bool = None, + unrestrict: bool = None, + ban: bool = None, + unban: bool = None, + promote: bool = None, + demote: bool = None, + info: bool = None, + settings: bool = None, + pinned: bool = None, + edit: bool = None, + delete: bool = None, + group_call: bool = None) -> _AdminLogIter: + return _AdminLogIter( + self, + limit, + entity=entity, + admins=admins, + search=search, + min_id=min_id, + max_id=max_id, + join=join, + leave=leave, + invite=invite, + restrict=restrict, + unrestrict=unrestrict, + ban=ban, + unban=unban, + promote=promote, + demote=demote, + info=info, + settings=settings, + pinned=pinned, + edit=edit, + delete=delete, + group_call=group_call + ) + + +def get_profile_photos( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: int = (), + *, + offset: int = 0, + max_id: int = 0) -> _ProfilePhotoIter: + return _ProfilePhotoIter( + self, + limit, + entity=entity, + offset=offset, + max_id=max_id + ) + + +def action( + self: 'TelegramClient', + entity: 'hints.EntityLike', + action: 'typing.Union[str, _tl.TypeSendMessageAction]', + *, + delay: float = 4, + auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': + action = _ChatAction._parse(action) + + return _ChatAction( + self, entity, action, delay=delay, auto_cancel=auto_cancel) + +async def edit_admin( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike', + *, + change_info: bool = None, + post_messages: bool = None, + edit_messages: bool = None, + delete_messages: bool = None, + ban_users: bool = None, + invite_users: bool = None, + pin_messages: bool = None, + add_admins: bool = None, + manage_call: bool = None, + anonymous: bool = None, + is_admin: bool = None, + title: str = None) -> _tl.Updates: + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + ty = helpers._entity_type(user) + if ty != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') + + perm_names = ( + 'change_info', 'post_messages', 'edit_messages', 'delete_messages', + 'ban_users', 'invite_users', 'pin_messages', 'add_admins', + 'anonymous', 'manage_call', + ) + + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + # If we try to set these permissions in a megagroup, we + # would get a RIGHT_FORBIDDEN. However, it makes sense + # that an admin can post messages, so we want to avoid the error + if post_messages or edit_messages: + # TODO get rid of this once sessions cache this information + if entity.channel_id not in self._megagroup_cache: + full_entity = await self.get_entity(entity) + self._megagroup_cache[entity.channel_id] = full_entity.megagroup + + if self._megagroup_cache[entity.channel_id]: + post_messages = None + edit_messages = None + + perms = locals() + return await self(_tl.fn.channels.EditAdmin(entity, user, _tl.ChatAdminRights(**{ + # A permission is its explicit (not-None) value or `is_admin`. + # This essentially makes `is_admin` be the default value. + name: perms[name] if perms[name] is not None else is_admin + for name in perm_names + }), rank=title or '')) + + elif ty == helpers._EntityType.CHAT: + # If the user passed any permission in a small + # group chat, they must be a full admin to have it. + if is_admin is None: + is_admin = any(locals()[x] for x in perm_names) + + return await self(_tl.fn.messages.EditChatAdmin( + entity, user, is_admin=is_admin)) + + else: + raise ValueError( + 'You can only edit permissions in groups and channels') + +async def edit_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' = None, + until_date: 'hints.DateLike' = None, + *, + view_messages: bool = True, + send_messages: bool = True, + send_media: bool = True, + send_stickers: bool = True, + 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, + pin_messages: bool = True) -> _tl.Updates: + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty != helpers._EntityType.CHANNEL: + raise ValueError('You must pass either a channel or a supergroup') + + rights = _tl.ChatBannedRights( + until_date=until_date, + view_messages=not view_messages, + send_messages=not send_messages, + send_media=not send_media, + send_stickers=not send_stickers, + 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, + pin_messages=not pin_messages + ) + + if user is None: + return await self(_tl.fn.messages.EditChatDefaultBannedRights( + peer=entity, + banned_rights=rights + )) + + user = await self.get_input_entity(user) + ty = helpers._entity_type(user) + if ty != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') + + if isinstance(user, _tl.InputPeerSelf): + raise ValueError('You cannot restrict yourself') + + return await self(_tl.fn.channels.EditBanned( + channel=entity, + participant=user, + banned_rights=rights + )) + +async def kick_participant( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' +): + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + if helpers._entity_type(user) != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') + + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHAT: + resp = await self(_tl.fn.messages.DeleteChatUser(entity.chat_id, user)) + elif ty == helpers._EntityType.CHANNEL: + if isinstance(user, _tl.InputPeerSelf): + # Despite no longer being in the channel, the account still + # seems to get the service message. + resp = await self(_tl.fn.channels.LeaveChannel(entity)) + else: + resp = await self(_tl.fn.channels.EditBanned( + channel=entity, + participant=user, + banned_rights=_tl.ChatBannedRights( + until_date=None, view_messages=True) + )) + await asyncio.sleep(0.5) + await self(_tl.fn.channels.EditBanned( + channel=entity, + participant=user, + banned_rights=_tl.ChatBannedRights(until_date=None) + )) + else: + raise ValueError('You must pass either a channel or a chat') + + return self._get_response_message(None, resp, entity) + +async def get_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike' = None +) -> 'typing.Optional[custom.ParticipantPermissions]': + entity = await self.get_entity(entity) + + if not user: + if helpers._entity_type(entity) != helpers._EntityType.USER: + return entity.default_banned_rights + + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + if helpers._entity_type(user) != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + participant = await self(_tl.fn.channels.GetParticipant( + entity, + user + )) + return _custom.ParticipantPermissions(participant.participant, False) + elif helpers._entity_type(entity) == helpers._EntityType.CHAT: + chat = await self(_tl.fn.messages.GetFullChat( + entity + )) + if isinstance(user, _tl.InputPeerSelf): + user = await self.get_me(input_peer=True) + for participant in chat.full_chat.participants.participants: + if participant.user_id == user.user_id: + return _custom.ParticipantPermissions(participant, True) + raise errors.USER_NOT_PARTICIPANT(400, 'USER_NOT_PARTICIPANT') + + raise ValueError('You must pass either a channel or a chat') + +async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, _tl.Message]' = None, +): + entity = await self.get_input_entity(entity) + if helpers._entity_type(entity) != helpers._EntityType.CHANNEL: + raise TypeError('You must pass a channel entity') + + message = utils.get_message_id(message) + if message is not None: + try: + req = _tl.fn.stats.GetMessageStats(entity, message) + return await self(req) + except errors.STATS_MIGRATE as e: + dc = e.dc + else: + # 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 = _tl.fn.stats.GetBroadcastStats(entity) + return await self(req) + except errors.STATS_MIGRATE as e: + dc = e.dc + except errors.BROADCAST_REQUIRED: + req = _tl.fn.stats.GetMegagroupStats(entity) + try: + return await self(req) + except errors.STATS_MIGRATE 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) diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py new file mode 100644 index 00000000..e3832ee8 --- /dev/null +++ b/telethon/_client/dialogs.py @@ -0,0 +1,244 @@ +import asyncio +import inspect +import itertools +import typing + +from .. import errors, _tl +from .._misc import helpers, utils, requestiter, hints +from ..types import _custom + +_MAX_CHUNK_SIZE = 100 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +def _dialog_message_key(peer, message_id): + """ + Get the key to get messages from a dialog. + + We cannot just use the message ID because channels share message IDs, + and the peer ID is required to distinguish between them. But it is not + necessary in small group chats and private chats. + """ + return (peer.channel_id if isinstance(peer, _tl.PeerChannel) else None), message_id + + +class _DialogsIter(requestiter.RequestIter): + async def _init( + self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder + ): + self.request = _tl.fn.messages.GetDialogs( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=1, + hash=0, + exclude_pinned=ignore_pinned, + folder_id=folder + ) + + if self.limit <= 0: + # Special case, get a single dialog and determine count + dialogs = await self.client(self.request) + self.total = getattr(dialogs, 'count', len(dialogs.dialogs)) + raise StopAsyncIteration + + self.seen = set() + self.offset_date = offset_date + self.ignore_migrated = ignore_migrated + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_CHUNK_SIZE) + r = await self.client(self.request) + + self.total = getattr(r, 'count', len(r.dialogs)) + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats) + if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))} + + messages = { + _dialog_message_key(m.peer_id, m.id): _custom.Message._new(self.client, m, entities, None) + for m in r.messages + } + + for d in r.dialogs: + # We check the offset date here because Telegram may ignore it + message = messages.get(_dialog_message_key(d.peer, d.top_message)) + if self.offset_date: + date = getattr(message, 'date', None) + if not date or date.timestamp() > self.offset_date.timestamp(): + continue + + peer_id = utils.get_peer_id(d.peer) + if peer_id not in self.seen: + self.seen.add(peer_id) + if peer_id not in entities: + # > In which case can a UserEmpty appear in the list of banned members? + # > In a very rare cases. This is possible but isn't an expected behavior. + # Real world example: https://t.me/TelethonChat/271471 + continue + + cd = _custom.Dialog(self.client, d, entities, message) + if cd.dialog.pts: + self.client._channel_pts[cd.id] = cd.dialog.pts + + if not self.ignore_migrated or getattr( + cd.entity, 'migrated_to', None) is None: + self.buffer.append(cd) + + if len(r.dialogs) < self.request.limit\ + or not isinstance(r, _tl.messages.DialogsSlice): + # Less than we requested means we reached the end, or + # we didn't get a DialogsSlice which means we got all. + return True + + # We can't use `messages[-1]` as the offset ID / date. + # Why? Because pinned dialogs will mess with the order + # in this list. Instead, we find the last dialog which + # has a message, and use it as an offset. + last_message = next(filter(None, ( + messages.get(_dialog_message_key(d.peer, d.top_message)) + for d in reversed(r.dialogs) + )), None) + + self.request.exclude_pinned = True + self.request.offset_id = last_message.id if last_message else 0 + self.request.offset_date = last_message.date if last_message else None + self.request.offset_peer = self.buffer[-1].input_entity + + +class _DraftsIter(requestiter.RequestIter): + async def _init(self, entities, **kwargs): + if not entities: + r = await self.client(_tl.fn.messages.GetAllDrafts()) + items = r.updates + else: + peers = [] + for entity in entities: + peers.append(_tl.InputDialogPeer( + await self.client.get_input_entity(entity))) + + r = await self.client(_tl.fn.messages.GetPeerDialogs(peers)) + items = r.dialogs + + # TODO Maybe there should be a helper method for this? + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + self.buffer.extend( + _custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) + for d in items + ) + + async def _load_next_chunk(self): + return [] + + +def get_dialogs( + self: 'TelegramClient', + limit: float = (), + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + offset_peer: 'hints.EntityLike' = _tl.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, + archived: bool = None +) -> _DialogsIter: + if archived is not None: + folder = 1 if archived else 0 + + return _DialogsIter( + self, + limit, + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + ignore_pinned=ignore_pinned, + ignore_migrated=ignore_migrated, + folder=folder + ) + + +def get_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None +) -> _DraftsIter: + limit = None + if entity: + if not utils.is_list_like(entity): + entity = (entity,) + limit = len(entity) + + return _DraftsIter(self, limit, entities=entity) + + +async def edit_folder( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None, + folder: typing.Union[int, typing.Sequence[int]] = None, + *, + unpack=None +) -> _tl.Updates: + if (entity is None) == (unpack is None): + raise ValueError('You can only set either entities or unpack, not both') + + if unpack is not None: + return await self(_tl.fn.folders.DeleteFolder( + folder_id=unpack + )) + + if not utils.is_list_like(entity): + entities = [await self.get_input_entity(entity)] + else: + entities = await asyncio.gather( + *(self.get_input_entity(x) for x in entity)) + + if folder is None: + raise ValueError('You must specify a folder') + elif not utils.is_list_like(folder): + folder = [folder] * len(entities) + elif len(entities) != len(folder): + raise ValueError('Number of folders does not match number of entities') + + return await self(_tl.fn.folders.EditPeerFolders([ + _tl.InputFolderPeer(x, folder_id=y) + for x, y in zip(entities, folder) + ])) + +async def delete_dialog( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + revoke: bool = False +): + # If we have enough information (`Dialog.delete` gives it to us), + # then we know we don't have to kick ourselves in deactivated chats. + if isinstance(entity, _tl.Chat): + deactivated = entity.deactivated + else: + deactivated = False + + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + return await self(_tl.fn.channels.LeaveChannel(entity)) + + if ty == helpers._EntityType.CHAT and not deactivated: + try: + result = await self(_tl.fn.messages.DeleteChatUser( + entity.chat_id, _tl.InputUserSelf(), revoke_history=revoke + )) + except errors.PEER_ID_INVALID: + # Happens if we didn't have the deactivated information + result = None + else: + result = None + + if not await self.is_bot(): + await self(_tl.fn.messages.DeleteHistory(entity, 0, revoke=revoke)) + + return result diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py new file mode 100644 index 00000000..5bf8f01f --- /dev/null +++ b/telethon/_client/downloads.py @@ -0,0 +1,768 @@ +import datetime +import io +import os +import pathlib +import typing +import inspect +import asyncio + +from .._crypto import AES +from .._misc import utils, helpers, requestiter, tlobject, hints, enums +from .. import errors, _tl + +try: + import aiohttp +except ImportError: + aiohttp = None + +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 + +# 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files. +TIMED_OUT_SLEEP = 1 + +class _DirectDownloadIter(requestiter.RequestIter): + async def _init( + self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data + ): + self.request = _tl.fn.upload.GetFile( + file, offset=offset, limit=request_size) + + self.total = file_size + self._stride = stride + self._chunk_size = chunk_size + self._last_part = None + self._msg_data = msg_data + self._timed_out = False + + self._exported = dc_id and self.client._session_state.dc_id != dc_id + if not self._exported: + # The used sender will also change if ``FileMigrateError`` occurs + self._sender = self.client._sender + else: + # If this raises DcIdInvalidError, it means we tried exporting the same DC we're in. + # This should not happen, but if it does, it's a bug. + self._sender = await self.client._borrow_exported_sender(dc_id) + + async def _load_next_chunk(self): + cur = await self._request() + self.buffer.append(cur) + if len(cur) < self.request.limit: + self.left = len(self.buffer) + await self.close() + else: + self.request.offset += self._stride + + async def _request(self): + try: + result = await self.client._call(self._sender, self.request) + self._timed_out = False + if isinstance(result, _tl.upload.FileCdnRedirect): + raise NotImplementedError # TODO Implement + else: + return result.bytes + + except errors.TimeoutError as e: + if self._timed_out: + self.client._log[__name__].warning('Got two timeouts in a row while downloading file') + raise + + self._timed_out = True + self.client._log[__name__].info('Got timeout while downloading file, retrying once') + await asyncio.sleep(TIMED_OUT_SLEEP) + return await self._request() + + except errors.FileMigrateError as e: + self.client._log[__name__].info('File lives in another DC') + self._sender = await self.client._borrow_exported_sender(e.new_dc) + self._exported = True + return await self._request() + + except errors.FilerefUpgradeNeededError as e: + # Only implemented for documents which are the ones that may take that long to download + if not self._msg_data \ + or not isinstance(self.request.location, _tl.InputDocumentFileLocation) \ + or self.request.location.thumb_size != '': + raise + + self.client._log[__name__].info('File ref expired during download; refetching message') + chat, msg_id = self._msg_data + msg = await self.client.get_messages(chat, ids=msg_id) + + if not isinstance(msg.media, _tl.MessageMediaDocument): + raise + + document = msg.media.document + + # Message media may have been edited for something else + if document.id != self.request.location.id: + raise + + self.request.location.file_reference = document.file_reference + return await self._request() + + async def close(self): + if not self._sender: + return + + try: + if self._exported: + await self.client._return_exported_sender(self._sender) + elif self._sender != self.client._sender: + await self._sender.disconnect() + finally: + self._sender = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + await self.close() + + +class _GenericDownloadIter(_DirectDownloadIter): + async def _load_next_chunk(self): + # 1. Fetch enough for one chunk + data = b'' + + # 1.1. ``bad`` is how much into the data we have we need to offset + bad = self.request.offset % self.request.limit + before = self.request.offset + + # 1.2. We have to fetch from a valid offset, so remove that bad part + self.request.offset -= bad + + done = False + while not done and len(data) - bad < self._chunk_size: + cur = await self._request() + self.request.offset += self.request.limit + + data += cur + done = len(cur) < self.request.limit + + # 1.3 Restore our last desired offset + self.request.offset = before + + # 2. Fill the buffer with the data we have + # 2.1. Slicing `bytes` is expensive, yield `memoryview` instead + mem = memoryview(data) + + # 2.2. The current chunk starts at ``bad`` offset into the data, + # and each new chunk is ``stride`` bytes apart of the other + for i in range(bad, len(data), self._stride): + self.buffer.append(mem[i:i + self._chunk_size]) + + # 2.3. We will yield this offset, so move to the next one + self.request.offset += self._stride + + # 2.4. If we are in the last chunk, we will return the last partial data + if done: + self.left = len(self.buffer) + await self.close() + return + + # 2.5. If we are not done, we can't return incomplete chunks. + if len(self.buffer[-1]) != self._chunk_size: + self._last_part = self.buffer.pop().tobytes() + + # 3. Be careful with the offsets. Re-fetching a bit of data + # is fine, since it greatly simplifies things. + # TODO Try to not re-fetch data + self.request.offset -= self._stride + + +async def download_profile_photo( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'hints.FileLike' = None, + *, + thumb, + progress_callback) -> typing.Optional[str]: + # hex(crc32(x.encode('ascii'))) for x in + # ('User', 'Chat', 'UserFull', 'ChatFull') + ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) + # ('InputPeer', 'InputUser', 'InputChannel') + INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) + if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS: + entity = await self.get_entity(entity) + + possible_names = [] + if entity.SUBCLASS_OF_ID not in ENTITIES: + photo = entity + else: + if not hasattr(entity, 'photo'): + # Special case: may be a ChatFull with photo:Photo + # This is different from a normal UserProfilePhoto and Chat + if not hasattr(entity, 'chat_photo'): + return None + + return await _download_photo( + self, entity.chat_photo, file, date=None, + thumb=thumb, progress_callback=progress_callback + ) + + for attr in ('username', 'first_name', 'title'): + possible_names.append(getattr(entity, attr, None)) + + photo = entity.photo + + if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)): + thumb = enums.Size.ORIGINAL if thumb == () else enums.Size(thumb) + + dc_id = photo.dc_id + loc = _tl.InputPeerPhotoFileLocation( + peer=await self.get_input_entity(entity), + photo_id=photo.photo_id, + big=thumb >= enums.Size.LARGE + ) + else: + # It doesn't make any sense to check if `photo` can be used + # as input location, because then this method would be able + # to "download the profile photo of a message", i.e. its + # media which should be done with `download_media` instead. + return None + + file = _get_proper_filename( + file, 'profile_photo', '.jpg', + possible_names=possible_names + ) + + try: + result = await _download_file( + self=self, + input_location=loc, + file=file, + dc_id=dc_id + ) + return result if file is bytes else file + except errors.LocationInvalidError: + # See issue #500, Android app fails as of v4.6.0 (1155). + # The fix seems to be using the full channel chat photo. + ie = await self.get_input_entity(entity) + ty = helpers._entity_type(ie) + if ty == helpers._EntityType.CHANNEL: + full = await self(_tl.fn.channels.GetFullChannel(ie)) + return await _download_photo( + self, full.full_chat.chat_photo, file, + date=None, progress_callback=progress_callback, + thumb=thumb + ) + else: + # Until there's a report for chats, no need to. + return None + +async def download_media( + self: 'TelegramClient', + message: 'hints.MessageLike', + file: 'hints.FileLike' = None, + *, + size = (), + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: + # Downloading large documents may be slow enough to require a new file reference + # to be obtained mid-download. Store (input chat, message id) so that the message + # can be re-fetched. + msg_data = None + + # TODO This won't work for messageService + if isinstance(message, _tl.Message): + date = message.date + media = message.media + msg_data = (message.input_chat, message.id) if message.input_chat else None + else: + date = datetime.datetime.now() + media = message + + if isinstance(media, _tl.MessageService): + if isinstance(message.action, + _tl.MessageActionChatEditPhoto): + media = media.photo + + if isinstance(media, _tl.MessageMediaWebPage): + if isinstance(media.webpage, _tl.WebPage): + media = media.webpage.document or media.webpage.photo + + if isinstance(media, (_tl.MessageMediaPhoto, _tl.Photo)): + return await _download_photo( + self, media, file, date, thumb, progress_callback + ) + elif isinstance(media, (_tl.MessageMediaDocument, _tl.Document)): + return await _download_document( + self, media, file, date, thumb, progress_callback, msg_data + ) + elif isinstance(media, _tl.MessageMediaContact): + return _download_contact( + self, media, file + ) + elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)): + return await _download_web_document( + self, media, file, progress_callback + ) + +async def _download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None, + msg_data: tuple = None) -> typing.Optional[bytes]: + """ + Low-level method to download files from their input location. + + Arguments + input_location (:tl:`InputFileLocation`): + The file location from which the file will be downloaded. + See `telethon.utils.get_input_location` source for a complete + list of supported _tl. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + If the file path is `None` or `bytes`, then the result + will be saved in memory and returned as `bytes`. + + part_size_kb (`int`, optional): + Chunk size when downloading files. The larger, the less + requests will be made (up to 512KB maximum). + + file_size (`int`, optional): + The file size that is about to be downloaded, if known. + Only used if ``progress_callback`` is specified. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(downloaded bytes, total)``. Note that the + ``total`` is the provided ``file_size``. + + dc_id (`int`, optional): + 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 + """ + + if not part_size_kb: + if not file_size: + part_size_kb = 64 # Reasonable default + else: + part_size_kb = utils.get_appropriated_part_size(file_size) + + part_size = int(part_size_kb * 1024) + if part_size % MIN_CHUNK_SIZE != 0: + 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() + elif isinstance(file, str): + # Ensure that we'll be able to download the media + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + try: + async for chunk in _iter_download( + self, input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): + if iv and key: + chunk = AES.decrypt_ige(chunk, key, iv) + r = f.write(chunk) + if inspect.isawaitable(r): + await r + + if progress_callback: + r = progress_callback(f.tell(), file_size) + if inspect.isawaitable(r): + await r + + # Not all IO objects have flush (see #1227) + if callable(getattr(f, 'flush', None)): + f.flush() + + if in_memory: + return f.getvalue() + finally: + if isinstance(file, str) or in_memory: + f.close() + +def iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = (), + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None +): + return _iter_download( + self, + file, + offset=offset, + stride=stride, + limit=limit, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + dc_id=dc_id, + ) + +def _iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None, + msg_data: tuple = None +): + info = utils._get_file_info(file) + if info.dc_id is not None: + dc_id = info.dc_id + + if file_size is None: + file_size = info.size + + file = info.location + + if chunk_size is None: + chunk_size = request_size + + if limit is None and file_size is not None: + limit = (file_size + chunk_size - 1) // chunk_size + + if stride is None: + stride = chunk_size + elif stride < chunk_size: + raise ValueError('stride must be >= chunk_size') + + request_size -= request_size % MIN_CHUNK_SIZE + if request_size < MIN_CHUNK_SIZE: + request_size = MIN_CHUNK_SIZE + elif request_size > MAX_CHUNK_SIZE: + request_size = MAX_CHUNK_SIZE + + if chunk_size == request_size \ + and offset % MIN_CHUNK_SIZE == 0 \ + and stride % MIN_CHUNK_SIZE == 0 \ + and (limit is None or offset % limit == 0): + cls = _DirectDownloadIter + self._log[__name__].info('Starting direct file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + else: + cls = _GenericDownloadIter + self._log[__name__].info('Starting indirect file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + + return cls( + self, + limit, + file=file, + dc_id=dc_id, + offset=offset, + stride=stride, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + msg_data=msg_data, + ) + + +def _get_thumb(thumbs, thumb): + if isinstance(thumb, tlobject.TLObject): + return thumb + + thumb = enums.Size(thumb) + return min( + thumbs, + default=None, + key=lambda t: abs(thumb - enums.Size(t.type)) + ) + +def _download_cached_photo_size(self: 'TelegramClient', size, file): + # No need to download anything, simply write the bytes + if isinstance(size, _tl.PhotoStrippedSize): + data = utils.stripped_photo_to_jpg(size.bytes) + else: + data = size.bytes + + if file is bytes: + return data + elif isinstance(file, str): + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + try: + f.write(data) + finally: + if isinstance(file, str): + f.close() + return file + +async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback): + """Specialized version of .download_media() for photos""" + # Determine the photo and its largest size + if isinstance(photo, _tl.MessageMediaPhoto): + photo = photo.photo + if not isinstance(photo, _tl.Photo): + return + + # Include video sizes here (but they may be None so provide an empty list) + size = _get_thumb(photo.sizes + (photo.video_sizes or []), thumb) + if not size or isinstance(size, _tl.PhotoSizeEmpty): + return + + if isinstance(size, _tl.VideoSize): + file = _get_proper_filename(file, 'video', '.mp4', date=date) + else: + file = _get_proper_filename(file, 'photo', '.jpg', date=date) + + if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)): + return _download_cached_photo_size(self, size, file) + + if isinstance(size, _tl.PhotoSizeProgressive): + file_size = max(size.sizes) + else: + file_size = size.size + + result = await _download_file( + self=self, + input_location=_tl.InputPhotoFileLocation( + id=photo.id, + access_hash=photo.access_hash, + file_reference=photo.file_reference, + thumb_size=size.type + ), + file=file, + file_size=file_size, + progress_callback=progress_callback + ) + return result if file is bytes else file + +def _get_kind_and_names(attributes): + """Gets kind and possible names for :tl:`DocumentAttribute`.""" + kind = 'document' + possible_names = [] + for attr in attributes: + if isinstance(attr, _tl.DocumentAttributeFilename): + possible_names.insert(0, attr.file_name) + + elif isinstance(attr, _tl.DocumentAttributeAudio): + kind = 'audio' + if attr.performer and attr.title: + possible_names.append('{} - {}'.format( + attr.performer, attr.title + )) + elif attr.performer: + possible_names.append(attr.performer) + elif attr.title: + possible_names.append(attr.title) + elif attr.voice: + kind = 'voice' + + return kind, possible_names + +async def _download_document( + self, document, file, date, thumb, progress_callback, msg_data): + """Specialized version of .download_media() for documents.""" + if isinstance(document, _tl.MessageMediaDocument): + document = document.document + if not isinstance(document, _tl.Document): + return + + if thumb == (): + kind, possible_names = _get_kind_and_names(document.attributes) + file = _get_proper_filename( + file, kind, utils.get_extension(document), + date=date, possible_names=possible_names + ) + size = None + else: + file = _get_proper_filename(file, 'photo', '.jpg', date=date) + size = _get_thumb(document.thumbs, thumb) + if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)): + return _download_cached_photo_size(self, size, file) + + result = await _download_file( + self=self, + input_location=_tl.InputDocumentFileLocation( + id=document.id, + access_hash=document.access_hash, + file_reference=document.file_reference, + thumb_size=size.type if size else '' + ), + file=file, + file_size=size.size if size else document.size, + progress_callback=progress_callback, + msg_data=msg_data, + ) + + return result if file is bytes else file + +def _download_contact(cls, mm_contact, file): + """ + Specialized version of .download_media() for contacts. + Will make use of the vCard 4.0 format. + """ + first_name = mm_contact.first_name + last_name = mm_contact.last_name + phone_number = mm_contact.phone_number + + # Remove these pesky characters + first_name = first_name.replace(';', '') + last_name = (last_name or '').replace(';', '') + result = ( + 'BEGIN:VCARD\n' + 'VERSION:4.0\n' + 'N:{f};{l};;;\n' + 'FN:{f} {l}\n' + 'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n' + 'END:VCARD\n' + ).format(f=first_name, l=last_name, p=phone_number).encode('utf-8') + + if file is bytes: + return result + elif isinstance(file, str): + file = cls._get_proper_filename( + file, 'contact', '.vcard', + possible_names=[first_name, phone_number, last_name] + ) + f = open(file, 'wb') + else: + f = file + + try: + f.write(result) + finally: + # Only close the stream if we opened it + if isinstance(file, str): + f.close() + + return file + +async def _download_web_document(cls, web, file, progress_callback): + """ + Specialized version of .download_media() for web documents. + """ + if not aiohttp: + raise ValueError( + 'Cannot download web documents without the aiohttp ' + 'dependency install it (pip install aiohttp)' + ) + + # TODO Better way to get opened handles of files and auto-close + in_memory = file is bytes + if in_memory: + f = io.BytesIO() + elif isinstance(file, str): + kind, possible_names = cls._get_kind_and_names(web.attributes) + file = cls._get_proper_filename( + file, kind, utils.get_extension(web), + possible_names=possible_names + ) + f = open(file, 'wb') + else: + f = file + + try: + async with aiohttp.ClientSession() as session: + # TODO Use progress_callback; get content length from response + # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 + async with session.get(web.url) as response: + while True: + chunk = await response.content.read(128 * 1024) + if not chunk: + break + f.write(chunk) + finally: + if isinstance(file, str) or file is bytes: + f.close() + + return f.getvalue() if in_memory else file + +def _get_proper_filename(file, kind, extension, + date=None, possible_names=None): + """Gets a proper filename for 'file', if this is a path. + + 'kind' should be the kind of the output file (photo, document...) + 'extension' should be the extension to be added to the file if + the filename doesn't have any yet + 'date' should be when this file was originally sent, if known + 'possible_names' should be an ordered list of possible names + + If no modification is made to the path, any existing file + will be overwritten. + If any modification is made to the path, this method will + ensure that no existing file will be overwritten. + """ + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + if file is not None and not isinstance(file, str): + # Probably a stream-like object, we cannot set a filename here + return file + + if file is None: + file = '' + elif os.path.isfile(file): + # Make no modifications to valid existing paths + return file + + if os.path.isdir(file) or not file: + try: + name = None if possible_names is None else next( + x for x in possible_names if x + ) + except StopIteration: + name = None + + if not name: + if not date: + date = datetime.datetime.now() + name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( + kind, + date.year, date.month, date.day, + date.hour, date.minute, date.second, + ) + file = os.path.join(file, name) + + directory, name = os.path.split(file) + name, ext = os.path.splitext(name) + if not ext: + ext = extension + + result = os.path.join(directory, name + ext) + if not os.path.isfile(result): + return result + + i = 1 + while True: + result = os.path.join(directory, '{} ({}){}'.format(name, i, ext)) + if not os.path.isfile(result): + return result + i += 1 diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py new file mode 100644 index 00000000..f545a2f3 --- /dev/null +++ b/telethon/_client/messageparse.py @@ -0,0 +1,177 @@ +import itertools +import re +import typing + +from .._misc import helpers, utils +from ..types import _custom +from .. import _tl + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +async def _replace_with_mention(self: 'TelegramClient', entities, i, user): + """ + Helper method to replace ``entities[i]`` to mention ``user``, + or do nothing if it can't be found. + """ + try: + entities[i] = _tl.InputMessageEntityMentionName( + entities[i].offset, entities[i].length, + await self.get_input_entity(user) + ) + return True + except (ValueError, TypeError): + return False + +async def _parse_message_text(self: 'TelegramClient', message, parse_mode): + """ + Returns a (parsed message, entities) tuple depending on ``parse_mode``. + """ + if parse_mode == (): + parse_mode = self._parse_mode + else: + parse_mode = utils.sanitize_parse_mode(parse_mode) + + if not parse_mode: + return message, [] + + original_message = message + message, msg_entities = parse_mode.parse(message) + if original_message and not message and not msg_entities: + raise ValueError("Failed to parse message") + + for i in reversed(range(len(msg_entities))): + e = msg_entities[i] + if isinstance(e, _tl.MessageEntityTextUrl): + m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) + if m: + user = int(m.group(1)) if m.group(1) else e.url + is_mention = await _replace_with_mention(self, msg_entities, i, user) + if not is_mention: + del msg_entities[i] + elif isinstance(e, (_tl.MessageEntityMentionName, + _tl.InputMessageEntityMentionName)): + is_mention = await _replace_with_mention(self, msg_entities, i, e.user_id) + if not is_mention: + del msg_entities[i] + + return message, msg_entities + +def _get_response_message(self: 'TelegramClient', request, result, input_chat): + """ + Extracts the response message known a request and Update result. + The request may also be the ID of the message to match. + + If ``request is None`` this method returns ``{id: message}``. + + If ``request.random_id`` is a list, this method returns a list too. + """ + if isinstance(result, _tl.UpdateShort): + updates = [result.update] + entities = {} + elif isinstance(result, (_tl.Updates, _tl.UpdatesCombined)): + updates = result.updates + entities = {utils.get_peer_id(x): x + for x in + itertools.chain(result.users, result.chats)} + else: + return None + + random_to_id = {} + id_to_message = {} + for update in updates: + if isinstance(update, _tl.UpdateMessageID): + random_to_id[update.random_id] = update.id + + elif isinstance(update, ( + _tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)): + update.message = _custom.Message._new(self, update.message, entities, input_chat) + + # Pinning a message with `updatePinnedMessage` seems to + # always produce a service message we can't map so return + # it directly. The same happens for kicking users. + # + # 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, _tl.UpdateEditMessage) + and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): + update.message = _custom.Message._new(self, update.message, entities, input_chat) + + # Live locations use `sendMedia` but Telegram responds with + # `updateEditMessage`, which means we won't have `id` field. + if hasattr(request, 'random_id'): + id_to_message[update.message.id] = update.message + elif request.id == update.message.id: + return update.message + + elif (isinstance(update, _tl.UpdateEditChannelMessage) + and utils.get_peer_id(request.peer) == + utils.get_peer_id(update.message.peer_id)): + if request.id == update.message.id: + return _custom.Message._new(self, update.message, entities, input_chat) + + elif isinstance(update, _tl.UpdateNewScheduledMessage): + # Scheduled IDs may collide with normal IDs. However, for a + # single request there *shouldn't* be a mix between "some + # scheduled and some not". + id_to_message[update.message.id] = _custom.Message._new(self, update.message, entities, input_chat) + + elif isinstance(update, _tl.UpdateMessagePoll): + if request.media.poll.id == update.poll_id: + return _custom.Message._new(self, _tl.Message( + id=request.id, + peer_id=utils.get_peer(request.peer), + media=_tl.MessageMediaPoll( + poll=update.poll, + results=update.results + ), + date=None, + message='' + ), entities, input_chat) + + if request is None: + return id_to_message + + random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None) + if random_id is None: + # Can happen when pinning a message does not actually produce a service message. + self._log[__name__].warning( + 'No random_id in %s to map to, returning None message for %s', request, result) + return None + + if not utils.is_list_like(random_id): + msg = id_to_message.get(random_to_id.get(random_id)) + + if not msg: + self._log[__name__].warning( + 'Request %s had missing message mapping %s', request, result) + + return msg + + try: + return [id_to_message[random_to_id[rnd]] for rnd in random_id] + except KeyError: + # Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets + # 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 [ + id_to_message.get(random_to_id[rnd]) + if rnd in random_to_id + else None + for rnd in random_id + ] diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py new file mode 100644 index 00000000..250582d0 --- /dev/null +++ b/telethon/_client/messages.py @@ -0,0 +1,740 @@ +import inspect +import itertools +import time +import typing +import warnings + +from .._misc import helpers, utils, requestiter, hints +from ..types import _custom +from ..types._custom.inputmessage import InputMessage +from .. import errors, _tl + +_MAX_CHUNK_SIZE = 100 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class _MessagesIter(requestiter.RequestIter): + """ + Common factor for all requests that need to iterate over messages. + """ + async def _init( + self, entity, offset_id, min_id, max_id, + from_user, offset_date, add_offset, filter, search, reply_to, + scheduled + ): + # Note that entity being `None` will perform a global search. + if entity: + self.entity = await self.client.get_input_entity(entity) + else: + self.entity = None + if self.reverse: + raise ValueError('Cannot reverse global search') + + # Telegram doesn't like min_id/max_id. If these IDs are low enough + # (starting from last_id - 100), the request will return nothing. + # + # We can emulate their behaviour locally by setting offset = max_id + # and simply stopping once we hit a message with ID <= min_id. + if self.reverse: + offset_id = max(offset_id, min_id) + if offset_id and max_id: + if max_id - offset_id <= 1: + raise StopAsyncIteration + + if not max_id: + max_id = float('inf') + else: + offset_id = max(offset_id, max_id) + if offset_id and min_id: + if offset_id - min_id <= 1: + raise StopAsyncIteration + + if self.reverse: + if offset_id: + offset_id += 1 + elif not offset_date: + # offset_id has priority over offset_date, so don't + # set offset_id to 1 if we want to offset by date. + offset_id = 1 + + if from_user: + from_user = await self.client.get_input_entity(from_user) + self.from_id = await self.client.get_peer_id(from_user) + else: + self.from_id = None + + # `messages.searchGlobal` only works with text `search` or `filter` queries. + # If we want to perform global a search with `from_user` we have to perform + # a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`. + if not self.entity and from_user: + self.entity = _tl.InputPeerEmpty() + + if filter is None: + filter = _tl.InputMessagesFilterEmpty() + else: + filter = filter() if isinstance(filter, type) else filter + + if not self.entity: + self.request = _tl.fn.messages.SearchGlobal( + q=search or '', + filter=filter, + min_date=None, + max_date=offset_date, + offset_rate=0, + offset_peer=_tl.InputPeerEmpty(), + offset_id=offset_id, + limit=1 + ) + elif scheduled: + self.request = _tl.fn.messages.GetScheduledHistory( + peer=entity, + hash=0 + ) + elif reply_to is not None: + self.request = _tl.fn.messages.GetReplies( + peer=self.entity, + msg_id=reply_to, + offset_id=offset_id, + offset_date=offset_date, + add_offset=add_offset, + limit=1, + max_id=0, + min_id=0, + hash=0 + ) + elif search is not None or not isinstance(filter, _tl.InputMessagesFilterEmpty) or from_user: + # Telegram completely ignores `from_id` in private chats + ty = helpers._entity_type(self.entity) + if ty == helpers._EntityType.USER: + # Don't bother sending `from_user` (it's ignored anyway), + # but keep `from_id` defined above to check it locally. + from_user = None + else: + # Do send `from_user` to do the filtering server-side, + # and set `from_id` to None to avoid checking it locally. + self.from_id = None + + self.request = _tl.fn.messages.Search( + peer=self.entity, + q=search or '', + filter=filter, + min_date=None, + max_date=offset_date, + offset_id=offset_id, + add_offset=add_offset, + limit=0, # Search actually returns 0 items if we ask it to + max_id=0, + min_id=0, + hash=0, + from_id=from_user + ) + + # Workaround issue #1124 until a better solution is found. + # Telegram seemingly ignores `max_date` if `filter` (and + # nothing else) is specified, so we have to rely on doing + # a first request to offset from the ID instead. + # + # Even better, using `filter` and `from_id` seems to always + # trigger `RPC_CALL_FAIL` which is "internal issues"... + if not isinstance(filter, _tl.InputMessagesFilterEmpty) \ + and offset_date and not search and not offset_id: + async for m in self.client.iter_messages( + self.entity, 1, offset_date=offset_date): + self.request.offset_id = m.id + 1 + else: + self.request = _tl.fn.messages.GetHistory( + peer=self.entity, + limit=1, + offset_date=offset_date, + offset_id=offset_id, + min_id=0, + max_id=0, + add_offset=add_offset, + hash=0 + ) + + if self.limit <= 0: + # No messages, but we still need to know the total message count + result = await self.client(self.request) + if isinstance(result, _tl.messages.MessagesNotModified): + self.total = result.count + else: + self.total = getattr(result, 'count', len(result.messages)) + raise StopAsyncIteration + + if self.wait_time is None: + self.wait_time = 1 if self.limit > 3000 else 0 + + # When going in reverse we need an offset of `-limit`, but we + # also want to respect what the user passed, so add them together. + if self.reverse: + self.request.add_offset -= _MAX_CHUNK_SIZE + + self.add_offset = add_offset + self.max_id = max_id + self.min_id = min_id + self.last_id = 0 if self.reverse else float('inf') + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_CHUNK_SIZE) + if self.reverse and self.request.limit != _MAX_CHUNK_SIZE: + # Remember that we need -limit when going in reverse + self.request.add_offset = self.add_offset - self.request.limit + + r = await self.client(self.request) + self.total = getattr(r, 'count', len(r.messages)) + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + messages = reversed(r.messages) if self.reverse else r.messages + for message in messages: + if (isinstance(message, _tl.MessageEmpty) + or self.from_id and message.sender_id != self.from_id): + continue + + if not self._message_in_range(message): + return True + + # There has been reports that on bad connections this method + # was returning duplicated IDs sometimes. Using ``last_id`` + # is an attempt to avoid these duplicates, since the message + # IDs are returned in descending order (or asc if reverse). + self.last_id = message.id + self.buffer.append(_custom.Message._new(self.client, message, entities, self.entity)) + + if len(r.messages) < self.request.limit: + return True + + # Get the last message that's not empty (in some rare cases + # it can happen that the last message is :tl:`MessageEmpty`) + if self.buffer: + self._update_offset(self.buffer[-1], r) + else: + # There are some cases where all the messages we get start + # being empty. This can happen on migrated mega-groups if + # the history was cleared, and we're using search. Telegram + # acts incredibly weird sometimes. Messages are returned but + # only "empty", not their contents. If this is the case we + # should just give up since there won't be any new Message. + return True + + def _message_in_range(self, message): + """ + Determine whether the given message is in the range or + it should be ignored (and avoid loading more chunks). + """ + # No entity means message IDs between chats may vary + if self.entity: + if self.reverse: + if message.id <= self.last_id or message.id >= self.max_id: + return False + else: + if message.id >= self.last_id or message.id <= self.min_id: + return False + + return True + + def _update_offset(self, last_message, response): + """ + After making the request, update its offset with the last message. + """ + self.request.offset_id = last_message.id + if self.reverse: + # We want to skip the one we already have + self.request.offset_id += 1 + + if isinstance(self.request, _tl.fn.messages.Search): + # Unlike getHistory and searchGlobal that use *offset* date, + # this is *max* date. This means that doing a search in reverse + # will break it. Since it's not really needed once we're going + # (only for the first request), it's safe to just clear it off. + self.request.max_date = None + else: + # getHistory, searchGlobal and getReplies call it offset_date + self.request.offset_date = last_message.date + + if isinstance(self.request, _tl.fn.messages.SearchGlobal): + if last_message.input_chat: + self.request.offset_peer = last_message.input_chat + else: + self.request.offset_peer = _tl.InputPeerEmpty() + + self.request.offset_rate = getattr(response, 'next_rate', 0) + + +class _IDsIter(requestiter.RequestIter): + async def _init(self, entity, ids): + self.total = len(ids) + self._ids = list(reversed(ids)) if self.reverse else ids + self._offset = 0 + self._entity = (await self.client.get_input_entity(entity)) if entity else None + self._ty = helpers._entity_type(self._entity) if self._entity else None + + # 30s flood wait every 300 messages (3 requests of 100 each, 30 of 10, etc.) + if self.wait_time is None: + self.wait_time = 10 if self.limit > 300 else 0 + + async def _load_next_chunk(self): + ids = self._ids[self._offset:self._offset + _MAX_CHUNK_SIZE] + if not ids: + raise StopAsyncIteration + + self._offset += _MAX_CHUNK_SIZE + + from_id = None # By default, no need to validate from_id + if self._ty == helpers._EntityType.CHANNEL: + try: + r = await self.client( + _tl.fn.channels.GetMessages(self._entity, ids)) + except errors.MessageIdsEmptyError: + # All IDs were invalid, use a dummy result + r = _tl.messages.MessagesNotModified(len(ids)) + else: + r = await self.client(_tl.fn.messages.GetMessages(ids)) + if self._entity: + from_id = await _get_peer(self.client, self._entity) + + if isinstance(r, _tl.messages.MessagesNotModified): + self.buffer.extend(None for _ in ids) + return + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + # Telegram seems to return the messages in the order in which + # we asked them for, so we don't need to check it ourselves, + # unless some messages were invalid in which case Telegram + # may decide to not send them at all. + # + # The passed message IDs may not belong to the desired entity + # since the user can enter arbitrary numbers which can belong to + # arbitrary chats. Validate these unless ``from_id is None``. + for message in r.messages: + if isinstance(message, _tl.MessageEmpty) or ( + from_id and message.peer_id != from_id): + self.buffer.append(None) + else: + self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity)) + + +async def _get_peer(self: 'TelegramClient', input_peer: 'hints.EntityLike'): + try: + return utils.get_peer(input_peer) + except TypeError: + # Can only be self by now + return _tl.PeerUser(await self.get_peer_id(input_peer)) + + +def get_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + max_id: int = 0, + min_id: int = 0, + add_offset: int = 0, + search: str = None, + filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None, + from_user: 'hints.EntityLike' = None, + wait_time: float = None, + ids: 'typing.Union[int, typing.Sequence[int]]' = None, + reverse: bool = False, + reply_to: int = None, + scheduled: bool = False +) -> 'typing.Union[_MessagesIter, _IDsIter]': + if ids is not None: + if not utils.is_list_like(ids): + ids = [ids] + + return _IDsIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=len(ids), + entity=entity, + ids=ids + ) + + return _MessagesIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=limit, + entity=entity, + offset_id=offset_id, + min_id=min_id, + max_id=max_id, + from_user=from_user, + offset_date=offset_date, + add_offset=add_offset, + filter=filter, + search=search, + reply_to=reply_to, + scheduled=scheduled + ) + + +async def _get_comment_data( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, _tl.Message]' +): + r = await self(_tl.fn.messages.GetDiscussionMessage( + peer=entity, + msg_id=utils.get_message_id(message) + )) + m = r.messages[0] + chat = next(c for c in r.chats if c.id == m.peer_id.channel_id) + return utils.get_input_peer(chat), m.id + +async def send_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageLike' = '', + *, + # - Message contents + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: typing.Optional[hints.FileLike] = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + send_as: 'hints.EntityLike' = None, + clear_draft: bool = False, + background: bool = None, + noforwards: bool = None, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, _tl.Message]' = None, +) -> '_tl.Message': + if isinstance(message, str): + message = InputMessage( + text=message, + markdown=markdown, + html=html, + formatting_entities=formatting_entities, + link_preview=link_preview, + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + silent=silent, + buttons=buttons, + ttl=ttl, + ) + elif isinstance(message, _custom.Message): + message = message._as_input() + elif not isinstance(message, InputMessage): + raise TypeError(f'message must be either str, Message or InputMessage, but got: {message!r}') + + entity = await self.get_input_entity(entity) + if comment_to is not None: + entity, reply_to = await _get_comment_data(self, entity, comment_to) + elif reply_to: + reply_to = utils.get_message_id(reply_to) + + if message._file: + # TODO Properly implement allow_cache to reuse the sha256 of the file + # i.e. `None` was used + + # TODO album + if message._file._should_upload_thumb(): + message._file._set_uploaded_thumb(await self.upload_file(message._file._thumb)) + + if message._file._should_upload_file(): + message._file._set_uploaded_file(await self.upload_file(message._file._file)) + + request = _tl.fn.messages.SendMedia( + entity, message._file._media, reply_to_msg_id=reply_to, message=message._text, + entities=message._fmt_entities, reply_markup=message._reply_markup, silent=message._silent, + schedule_date=schedule, clear_draft=clear_draft, + background=background, noforwards=noforwards, send_as=send_as + ) + else: + request = _tl.fn.messages.SendMessage( + peer=entity, + message=message._text, + entities=formatting_entities, + no_webpage=not link_preview, + reply_to_msg_id=utils.get_message_id(reply_to), + clear_draft=clear_draft, + silent=silent, + background=background, + reply_markup=_custom.button.build_reply_markup(buttons), + schedule_date=schedule, + noforwards=noforwards, + send_as=send_as + ) + + result = await self(request) + if isinstance(result, _tl.UpdateShortSentMessage): + return _custom.Message._new(self, _tl.Message( + id=result.id, + peer_id=await _get_peer(self, entity), + message=message._text, + date=result.date, + out=result.out, + media=result.media, + entities=result.entities, + reply_markup=request.reply_markup, + ttl_period=result.ttl_period + ), {}, entity) + + return self._get_response_message(request, result, entity) + +async def forward_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + from_peer: 'hints.EntityLike' = None, + *, + background: bool = None, + with_my_score: bool = None, + silent: bool = None, + as_album: bool = None, + schedule: 'hints.DateLike' = None, + noforwards: bool = None, + send_as: 'hints.EntityLike' = None +) -> 'typing.Sequence[_tl.Message]': + if as_album is not None: + warnings.warn('the as_album argument is deprecated and no longer has any effect') + + entity = await self.get_input_entity(entity) + + if from_peer: + from_peer = await self.get_input_entity(from_peer) + from_peer_id = await self.get_peer_id(from_peer) + else: + from_peer_id = None + + def get_key(m): + if isinstance(m, int): + if from_peer_id is not None: + return from_peer_id + + raise ValueError('from_peer must be given if integer IDs are used') + elif isinstance(m, _tl.Message): + return m.chat_id + else: + raise TypeError('Cannot forward messages of type {}'.format(type(m))) + + sent = [] + for _chat_id, chunk in itertools.groupby(messages, key=get_key): + chunk = list(chunk) + if isinstance(chunk[0], int): + chat = from_peer + else: + chat = await chunk[0].get_input_chat() + chunk = [m.id for m in chunk] + + req = _tl.fn.messages.ForwardMessages( + from_peer=chat, + id=chunk, + to_peer=entity, + silent=silent, + background=background, + with_my_score=with_my_score, + schedule_date=schedule, + noforwards=noforwards, + send_as=send_as + ) + result = await self(req) + sent.extend(self._get_response_message(req, result, entity)) + + return sent[0] if single else sent + +async def edit_message( + self: 'TelegramClient', + entity: 'typing.Union[hints.EntityLike, _tl.Message]', + message: 'hints.MessageLike' = None, + text: str = None, + *, + parse_mode: str = (), + attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, + formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'hints.FileLike' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + buttons: 'hints.MarkupLike' = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None +) -> '_tl.Message': + if formatting_entities is None: + text, formatting_entities = await self._parse_message_text(text, parse_mode) + file_handle, media, image = await self._file_to_media(file, + supports_streaming=supports_streaming, + thumb=thumb, + attributes=attributes, + force_document=force_document) + + if isinstance(message, _tl.InputBotInlineMessageID): + request = _tl.fn.messages.EditInlineBotMessage( + id=message, + message=text, + no_webpage=not link_preview, + entities=formatting_entities, + media=media, + reply_markup=_custom.button.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_state.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 = _tl.fn.messages.EditMessage( + peer=entity, + id=utils.get_message_id(message), + message=text, + no_webpage=not link_preview, + entities=formatting_entities, + media=media, + reply_markup=_custom.button.build_reply_markup(buttons), + schedule_date=schedule + ) + msg = self._get_response_message(request, await self(request), entity) + return msg + +async def delete_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + *, + revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]': + messages = ( + m.id if isinstance(m, ( + _tl.Message, _tl.MessageService, _tl.MessageEmpty)) + else int(m) for m in messages + ) + + 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: + res = await self([_tl.fn.channels.DeleteMessages( + entity, list(c)) for c in utils.chunks(messages)]) + else: + res = await self([_tl.fn.messages.DeleteMessages( + list(c), revoke) for c in utils.chunks(messages)]) + + return sum(r.pts_count for r in res) + +async def mark_read( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageIDLike' = None, + *, + clear_mentions: bool = False) -> bool: + if not message: + max_id = 0 + elif isinstance(message, int): + max_id = message + else: + max_id = message.id + + entity = await self.get_input_entity(entity) + if clear_mentions: + await self(_tl.fn.messages.ReadMentions(entity)) + + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + return await self(_tl.fn.channels.ReadHistory( + utils.get_input_channel(entity), max_id=max_id)) + else: + return await self(_tl.fn.messages.ReadHistory( + entity, max_id=max_id)) + + return False + +async def pin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False +): + return await _pin(self, entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) + +async def unpin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False +): + return await _pin(self, entity, message, unpin=True, notify=notify) + +async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): + message = utils.get_message_id(message) or 0 + entity = await self.get_input_entity(entity) + if message <= 0: # old behaviour accepted negative IDs to unpin + await self(_tl.fn.messages.UnpinAllMessages(entity)) + return + + request = _tl.fn.messages.UpdatePinnedMessage( + peer=entity, + id=message, + silent=not notify, + unpin=unpin, + pm_oneside=pm_oneside + ) + result = await self(request) + + # Unpinning does not produce a service message. + # Pinning a message that was already pinned also produces no service message. + # Pinning a message in your own chat does not produce a service message, + # but pinning on a private conversation with someone else does. + if unpin or not result.updates: + return + + # Pinning a message that doesn't exist would RPC-error earlier + return self._get_response_message(request, result, entity) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py new file mode 100644 index 00000000..480263e4 --- /dev/null +++ b/telethon/_client/telegrambaseclient.py @@ -0,0 +1,470 @@ +import abc +import re +import asyncio +import collections +import logging +import platform +import time +import typing +import ipaddress +import dataclasses + +from .. import version, __name__ as __base_name__, _tl +from .._crypto import rsa +from .._misc import markdown, enums, helpers +from .._network import MTProtoSender, Connection, transports +from .._sessions import Session, SQLiteSession, MemorySession +from .._sessions.types import DataCenter, SessionState, EntityType, ChannelState +from .._updates import EntityCache, MessageBox + +DEFAULT_DC_ID = 2 +DEFAULT_IPV4_IP = '149.154.167.51' +DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a' +DEFAULT_PORT = 443 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + +_base_log = logging.getLogger(__base_name__) + + +# 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`? + + +def init( + self: 'TelegramClient', + session: 'typing.Union[str, Session]', + api_id: int, + api_hash: str, + *, + # Logging. + base_logger: typing.Union[str, logging.Logger] = None, + # Connection parameters. + use_ipv6: bool = False, + proxy: typing.Union[tuple, dict] = None, + local_addr: typing.Union[str, tuple] = None, + device_model: str = None, + system_version: str = None, + app_version: str = None, + lang_code: str = 'en', + system_lang_code: str = 'en', + # Nice-to-have. + auto_reconnect: bool = True, + connect_timeout: int = 10, + connect_retries: int = 4, + connect_retry_delay: int = 1, + request_retries: int = 4, + flood_sleep_threshold: int = 60, + # Update handling. + catch_up: bool = False, + receive_updates: bool = True, + max_queued_updates: int = 100, +): + # Logging. + if isinstance(base_logger, str): + base_logger = logging.getLogger(base_logger) + elif not isinstance(base_logger, logging.Logger): + base_logger = _base_log + + class _Loggers(dict): + def __missing__(self, key): + if key.startswith("telethon."): + key = key.split('.', maxsplit=1)[1] + + return base_logger.getChild(key) + + self._log = _Loggers() + + # Sessions. + if isinstance(session, str) or session is None: + try: + session = SQLiteSession(session) + except ImportError: + import warnings + warnings.warn( + 'The sqlite3 module is not available under this ' + 'Python installation and no _ session ' + 'instance was given; using MemorySession.\n' + 'You will need to re-login every time unless ' + 'you use another session storage' + ) + session = MemorySession() + elif not isinstance(session, Session): + raise TypeError( + 'The given session must be a str or a Session instance.' + ) + + self._session = session + # In-memory copy of the session's state to avoid a roundtrip as it contains commonly-accessed values. + self._session_state = None + + # Nice-to-have. + self._request_retries = request_retries + self._connect_retries = connect_retries + self._connect_retry_delay = connect_retry_delay or 0 + self._connect_timeout = connect_timeout + self.flood_sleep_threshold = flood_sleep_threshold + self._flood_waited_requests = {} # prevent calls that would floodwait entirely + self._parse_mode = markdown + + # Update handling. + self._catch_up = catch_up + self._no_updates = not receive_updates + self._updates_queue = asyncio.Queue(maxsize=max_queued_updates) + self._updates_handle = None + self._message_box = MessageBox() + self._entity_cache = EntityCache() # required for proper update handling (to know when to getDifference) + + # Connection parameters. + if not api_id or not api_hash: + raise ValueError( + "Your API ID or Hash cannot be empty or None. " + "Refer to telethon.rtfd.io for more information.") + + if local_addr is not None: + if use_ipv6 is False and ':' in local_addr: + raise TypeError('A local IPv6 address must only be used with `use_ipv6=True`.') + elif use_ipv6 is True and ':' not in local_addr: + raise TypeError('`use_ipv6=True` must only be used with a local IPv6 address.') + + self._transport = transports.Full() + self._use_ipv6 = use_ipv6 + self._local_addr = local_addr + self._proxy = proxy + self._auto_reconnect = auto_reconnect + self._api_id = int(api_id) + self._api_hash = api_hash + + # Used on connection. Capture the variables in a lambda since + # exporting clients need to create this InvokeWithLayer. + system = platform.uname() + + if system.machine in ('x86_64', 'AMD64'): + default_device_model = 'PC 64bit' + elif system.machine in ('i386','i686','x86'): + default_device_model = 'PC 32bit' + else: + default_device_model = system.machine + default_system_version = re.sub(r'-.+','',system.release) + + self._init_request = _tl.fn.InitConnection( + api_id=self._api_id, + device_model=device_model or default_device_model or 'Unknown', + system_version=system_version or default_system_version or '1.0', + app_version=app_version or self.__version__, + lang_code=lang_code, + system_lang_code=system_lang_code, + lang_pack='', # "langPacks are for official apps only" + query=None, + proxy=None + ) + + self._sender = MTProtoSender( + loggers=self._log, + retries=self._connect_retries, + delay=self._connect_retry_delay, + auto_reconnect=self._auto_reconnect, + connect_timeout=self._connect_timeout, + updates_queue=self._updates_queue, + ) + + # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders. + self._borrowed_senders = {} + self._borrow_sender_lock = asyncio.Lock() + + +def get_flood_sleep_threshold(self): + return self._flood_sleep_threshold + +def set_flood_sleep_threshold(self, value): + # None -> 0, negative values don't really matter + self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60) + + +async def connect(self: 'TelegramClient') -> None: + all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()} + self._session_state = await self._session.get_state() + + if self._session_state is None: + try_fetch_user = False + self._session_state = SessionState( + user_id=0, + dc_id=DEFAULT_DC_ID, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=None, + ) + else: + try_fetch_user = self._session_state.user_id == 0 + if self._catch_up: + channel_states = await self._session.get_all_channel_states() + self._message_box.load(self._session_state, channel_states) + for state in channel_states: + entity = await self._session.get_entity(EntityType.CHANNEL, state.channel_id) + if entity: + self._entity_cache.put(entity) + + dc = all_dcs.get(self._session_state.dc_id) + if dc is None: + dc = DataCenter( + id=DEFAULT_DC_ID, + ipv4=None if self._use_ipv6 else int(ipaddress.ip_address(DEFAULT_IPV4_IP)), + ipv6=int(ipaddress.ip_address(DEFAULT_IPV6_IP)) if self._use_ipv6 else None, + port=DEFAULT_PORT, + auth=b'', + ) + all_dcs[dc.id] = dc + + # Use known key, if any + self._sender.auth_key.key = dc.auth + + if not await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), + loggers=self._log, + local_addr=self._local_addr, + )): + # We don't want to init or modify anything if we were already connected + return + + if self._sender.auth_key.key != dc.auth: + all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) + + # Need to send invokeWithLayer for things to work out. + # Make the most out of this opportunity by also refreshing our state. + # During the v1 to v2 migration, this also correctly sets the IPv* columns. + self._init_request.query = _tl.fn.help.GetConfig() + + config = await self._sender.send(_tl.fn.InvokeWithLayer( + _tl.LAYER, self._init_request + )) + + for dc in config.dc_options: + if dc.media_only or dc.tcpo_only or dc.cdn: + continue + + ip = int(ipaddress.ip_address(dc.ip_address)) + if dc.id in all_dcs: + if dc.ipv6: + all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv6=ip) + else: + all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv4=ip) + elif dc.ipv6: + all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') + else: + all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') + + for dc in all_dcs.values(): + await self._session.insert_dc(dc) + + if try_fetch_user: + # If there was a previous session state, but the current user ID is 0, it means we've + # migrated and not yet populated the current user (or the client connected but never + # logged in). Attempt to fetch the user now. If it works, also get the update state. + me = await self.get_me() + if me: + await self._update_session_state(me, save=False) + + await self._session.save() + + self._updates_handle = asyncio.create_task(self._update_loop()) + +def is_connected(self: 'TelegramClient') -> bool: + sender = getattr(self, '_sender', None) + return sender and sender.is_connected() + +async def disconnect(self: 'TelegramClient'): + return await _disconnect_coro(self) + +def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + init_proxy = None + + self._init_request.proxy = init_proxy + self._proxy = proxy + + # While `await client.connect()` passes new proxy on each new call, + # auto-reconnect attempts use already set up `_connection` inside + # the `_sender`, so the only way to change proxy between those + # is to directly inject parameters. + + connection = getattr(self._sender, "_connection", None) + if connection: + if isinstance(connection, conns.TcpMTProxy): + connection._ip = proxy[0] + connection._port = proxy[1] + else: + connection._proxy = proxy + +async def _disconnect_coro(self: 'TelegramClient'): + await _disconnect(self) + + # 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(): + # Note that we're not checking for `state.should_disconnect()`. + # If the user wants to disconnect the client, ALL connections + # to Telegram (including exported senders) should be closed. + # + # Disconnect should never raise, so there's no try/except. + await sender.disconnect() + # Can't use `mark_disconnected` because it may be borrowed. + state._connected = False + + # If any was borrowed + self._borrowed_senders.clear() + + +async def _disconnect(self: 'TelegramClient'): + """ + Disconnect only, without closing the session. Used in reconnections + to different data centers, where we don't want to close the session + file; user disconnects however should close it since it means that + their job with the client is complete and we should clean it up all. + """ + await self._sender.disconnect() + await helpers._cancel(self._log[__name__], updates_handle=self._updates_handle) + try: + await self._updates_handle + except asyncio.CancelledError: + pass + + await self._session.insert_entities(self._entity_cache.get_all_entities()) + + session_state, channel_states = self._message_box.session_state() + for channel_id, pts in channel_states.items(): + await self._session.insert_channel_state(ChannelState(channel_id=channel_id, pts=pts)) + + await self._replace_session_state(**session_state) + + +async def _switch_dc(self: 'TelegramClient', new_dc): + """ + Permanently switches the current connection to the new data center. + """ + self._log[__name__].info('Reconnecting to new data center %s', new_dc) + + await self._replace_session_state(dc_id=new_dc) + await _disconnect(self) + return await self.connect() + +async def _create_exported_sender(self: 'TelegramClient', dc_id): + """ + Creates a new exported `MTProtoSender` for the given `dc_id` and + returns it. This method should be used by `_borrow_exported_sender`. + """ + # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt + # for clearly showing how to export the authorization + dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id) + # Can't reuse self._sender._connection as it has its own seqno. + # + # If one were to do that, Telegram would reset the connection + # with no further clues. + sender = MTProtoSender(loggers=self._log) + await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), + loggers=self._log, + local_addr=self._local_addr, + )) + self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) + auth = await self(_tl.fn.auth.ExportAuthorization(dc_id)) + self._init_request.query = _tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes) + req = _tl.fn.InvokeWithLayer(_tl.LAYER, self._init_request) + await sender.send(req) + return sender + +async def _borrow_exported_sender(self: 'TelegramClient', dc_id): + """ + Borrows a connected `MTProtoSender` for the given `dc_id`. + If it's not cached, creates a new one if it doesn't exist yet, + and imports a freshly exported authorization key for it to be usable. + + Once its job is over it should be `_return_exported_sender`. + """ + async with self._borrow_sender_lock: + 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 _create_exported_sender(self, dc_id) + sender.dc_id = dc_id + self._borrowed_senders[dc_id] = (state, sender) + + elif state.need_connect(): + dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id) + + await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), + loggers=self._log, + local_addr=self._local_addr, + )) + + state.add_borrow() + return sender + +async def _return_exported_sender(self: 'TelegramClient', sender): + """ + Returns a borrowed exported sender. If all borrows have + been returned, the sender is cleanly disconnected. + """ + async with self._borrow_sender_lock: + 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() diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py new file mode 100644 index 00000000..d78b0ce6 --- /dev/null +++ b/telethon/_client/telegramclient.py @@ -0,0 +1,3520 @@ +import asyncio +import functools +import inspect +import typing +import logging + +from . import ( + account, auth, bots, chats, dialogs, downloads, messageparse, messages, + telegrambaseclient, updates, uploads, users +) +from .. import version, _tl +from ..types import _custom +from .._events.common import EventBuilder, EventCommon +from .._misc import enums + + +def forward_call(to_func): + def decorator(from_func): + return functools.wraps(from_func)(to_func) + return decorator + + +class TelegramClient: + """ + Arguments + session (`str` | `telethon.sessions.abstract.Session`, `None`): + The file name of the session file to be used if a string is + given (it may be a full path), or the Session instance to be + used otherwise. If it's `None`, the session will not be saved, + and you should call :meth:`.log_out()` when you're done. + + Note that if you pass a string it will be a file in the current + working directory, although you can also pass absolute paths. + + The session file contains enough information for you to login + without re-sending the code, so if you have to enter the code + more than once, maybe you're changing the working directory, + renaming or removing the file, or using random names. + + api_id (`int` | `str`): + The API ID you obtained from https://my.telegram.org. + + api_hash (`str`): + The API hash you obtained from https://my.telegram.org. + + connection (`str`, optional): + The connection mode to be used when creating a new connection + to the servers. The available modes are: + + * ``'full'`` + * ``'intermediate'`` + * ``'abridged'`` + * ``'obfuscated'`` + * ``'http'`` + + Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. + + use_ipv6 (`bool`, optional): + Whether to connect to the servers through IPv6 or not. + By default this is `False` as IPv6 support is not + too widespread yet. + + proxy (`tuple` | `list` | `dict`, optional): + An iterable consisting of the proxy info. If `connection` is + one of `MTProxy`, then it should contain MTProxy credentials: + ``('hostname', port, 'secret')``. Otherwise, it's meant to store + function parameters for PySocks, like ``(type, 'hostname', port)``. + See https://github.com/Anorov/PySocks#usage-1 for more. + + local_addr (`str` | `tuple`, optional): + Local host address (and port, optionally) used to bind the socket to locally. + You only need to use this if you have multiple network cards and + want to use a specific one. + + timeout (`int` | `float`, optional): + The timeout in seconds to be used when connecting. + This is **not** the timeout to be used when ``await``'ing for + invoked requests, and you should use ``asyncio.wait`` or + ``asyncio.wait_for`` for that. + + request_retries (`int` | `None`, optional): + How many times a request should be retried. Request are retried + when Telegram is having internal issues (due to either + ``errors.ServerError`` or ``errors.RpcCallFailError``), + when there is a ``errors.FloodWaitError`` less than + `flood_sleep_threshold`, or when there's a migrate error. + + May take a negative or `None` value for infinite retries, but + this is not recommended, since some requests can always trigger + a call fail (such as searching for messages). + + connection_retries (`int` | `None`, optional): + How many times the reconnection should retry, either on the + initial connection or when Telegram disconnects us. May be + set to a negative or `None` value for infinite retries, but + this is not recommended, since the program can get stuck in an + infinite loop. + + retry_delay (`int` | `float`, optional): + The delay in seconds to sleep between automatic reconnections. + + auto_reconnect (`bool`, optional): + Whether reconnection should be retried `connection_retries` + times automatically if Telegram disconnects us or not. + + sequential_updates (`bool`, optional): + By default every incoming update will create a new task, so + you can handle several updates in parallel. Some scripts need + the order in which updates are processed to be sequential, and + this setting allows them to do so. + + If set to `True`, incoming updates will be put in a queue + and processed sequentially. This means your event handlers + should *not* perform long-running operations since new + updates are put inside of an unbounded queue. + + flood_sleep_threshold (`int` | `float`, optional): + The threshold below which the library should automatically + sleep on flood wait and slow mode wait errors (inclusive). For instance, if a + ``FloodWaitError`` for 17s occurs and `flood_sleep_threshold` + is 20s, the library will ``sleep`` automatically. If the error + was for 21s, it would ``raise FloodWaitError`` instead. Values + larger than a day (like ``float('inf')``) will be changed to a day. + + raise_last_call_error (`bool`, optional): + When API calls fail in a way that causes Telethon to retry + automatically, should the RPC error of the last attempt be raised + instead of a generic ValueError. This is mostly useful for + detecting when Telegram has internal issues. + + device_model (`str`, optional): + "Device model" to be sent when creating the initial connection. + Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown. + + system_version (`str`, optional): + "System version" to be sent when creating the initial connection. + Defaults to ``platform.uname().release`` stripped of everything ahead of -. + + app_version (`str`, optional): + "App version" to be sent when creating the initial connection. + Defaults to `telethon.version.__version__`. + + lang_code (`str`, optional): + "Language code" to be sent when creating the initial connection. + Defaults to ``'en'``. + + system_lang_code (`str`, optional): + "System lang code" to be sent when creating the initial connection. + Defaults to `lang_code`. + + base_logger (`str` | `logging.Logger`, optional): + Base logger name or instance to use. + If a `str` is given, it'll be passed to `logging.getLogger()`. If a + `logging.Logger` is given, it'll be used directly. If something + else or nothing is given, the default logger will be used. + + receive_updates (`bool`, optional): + Whether the client will receive updates or not. By default, updates + will be received from Telegram as they occur. + + Turning this off means that Telegram will not send updates at all + so event handlers and QR login will not work. However, certain + scripts don't need updates, so this will reduce the amount of + bandwidth used. + """ + + # region Account + + @forward_call(account.takeout) + def takeout( + self: 'TelegramClient', + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None) -> 'TelegramClient': + """ + Returns a context-manager which calls `TelegramClient.begin_takeout` + on enter and `TelegramClient.end_takeout` on exit. The same errors + and conditions apply. + + This is useful for the common case of not wanting the takeout to + persist (although it still might if a disconnection occurs before it + can be ended). + + Example + .. code-block:: python + + async with client.takeout(): + async for message in client.iter_messages(chat, wait_time=0): + ... # Do something with the message + """ + + @forward_call(account.begin_takeout) + def begin_takeout( + self: 'TelegramClient', + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None) -> 'TelegramClient': + """ + Begin a takeout session. All subsequent requests made by the client + will be behind a takeout session. The takeout session will persist + in the session file, until `TelegramClient.end_takeout` is used. + + When the takeout session is enabled, some requests will have lower + flood limits. This is useful if you want to export the data from + conversations or mass-download media, since the rate limits will + be lower. Only some requests will be affected, and you will need + to adjust the `wait_time` of methods like `client.iter_messages + `. + + By default, all parameters are `None`, and you need to enable those + you plan to use by setting them to either `True` or `False`. + + You should ``except errors.TakeoutInitDelayError as e``, since this + exception will raise depending on the condition of the session. You + can then access ``e.seconds`` to know how long you should wait for + before calling the method again. + + If you want to ignore the currently-active takeout session in a task, + toggle the following context variable: + + .. code-block:: python + + telethon.ignore_takeout.set(True) + + An error occurs if ``TelegramClient.takeout_active`` was already ``True``. + + Arguments + contacts (`bool`): + Set to `True` if you plan on downloading contacts. + + users (`bool`): + Set to `True` if you plan on downloading information + from users and their private conversations with you. + + chats (`bool`): + Set to `True` if you plan on downloading information + from small group chats, such as messages and media. + + megagroups (`bool`): + Set to `True` if you plan on downloading information + from megagroups (channels), such as messages and media. + + channels (`bool`): + Set to `True` if you plan on downloading information + from broadcast channels, such as messages and media. + + files (`bool`): + Set to `True` if you plan on downloading media and + you don't only wish to export messages. + + max_file_size (`int`): + The maximum file size, in bytes, that you plan + to download for each message with media. + + Example + .. code-block:: python + + from telethon import errors + + try: + await client.begin_takeout() + + await client.get_messages('me') # wrapped through takeout (less limits) + + async for message in client.iter_messages(chat, wait_time=0): + ... # Do something with the message + + await client.end_takeout(success=True) + + except errors.TakeoutInitDelayError as e: + print('Must wait', e.seconds, 'before takeout') + + except Exception: + await client.end_takeout(success=False) + """ + + @property + def takeout_active(self: 'TelegramClient') -> bool: + return account.takeout_active(self) + + @forward_call(account.end_takeout) + async def end_takeout(self: 'TelegramClient', success: bool) -> bool: + """ + Finishes the current takeout session. + + Arguments + success (`bool`): + Whether the takeout completed successfully or not. + + Returns + `True` if the operation was successful, `False` otherwise. + + Example + .. code-block:: python + + await client.end_takeout(success=False) + """ + + # endregion Account + + # region Auth + + @forward_call(auth.start) + def start( + self: 'TelegramClient', + phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), + password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), + *, + bot_token: str = None, + code_callback: typing.Callable[[], typing.Union[str, int]] = None, + first_name: str = 'New User', + last_name: str = '', + max_attempts: int = 3) -> 'TelegramClient': + """ + Starts the client (connects and logs in if necessary). + + By default, this method will be interactive (asking for + user input if needed), and will handle 2FA if enabled too. + + If the phone doesn't belong to an existing account (and will hence + `sign_up` for a new one), **you are agreeing to Telegram's + Terms of Service. This is required and your account + will be banned otherwise.** See https://telegram.org/tos + and https://core.telegram.org/api/terms. + + Even though this method is not marked as ``async``, you still need to + ``await`` its result for it to do anything useful. + + Arguments + phone (`str` | `int` | `callable`): + The phone (or callable without arguments to get it) + to which the code will be sent. If a bot-token-like + string is given, it will be used as such instead. + The argument may be a coroutine. + + password (`str`, `callable`, optional): + The password for 2 Factor Authentication (2FA). + This is only required if it is enabled in your account. + The argument may be a coroutine. + + bot_token (`str`): + Bot Token obtained by `@BotFather `_ + to log in as a bot. Cannot be specified with ``phone`` (only + one of either allowed). + + code_callback (`callable`, optional): + A callable that will be used to retrieve the Telegram + login code. Defaults to `input()`. + The argument may be a coroutine. + + first_name (`str`, optional): + The first name to be used if signing up. This has no + effect if the account already exists and you sign in. + + last_name (`str`, optional): + Similar to the first name, but for the last. Optional. + + max_attempts (`int`, optional): + How many times the code/password callback should be + retried or switching between signing in and signing up. + + Returns + This `TelegramClient`, so initialization + can be chained with ``.start()``. + + Example + .. code-block:: python + + client = TelegramClient('anon', api_id, api_hash) + + # Starting as a bot account + await client.start(bot_token=bot_token) + + # Starting as a user account + await client.start(phone) + # Please enter the code you received: 12345 + # Please enter your password: ******* + # (You are now logged in) + + # Starting using a context manager (note the lack of await): + async with client.start(): + pass + """ + + @forward_call(auth.sign_in) + async def sign_in( + self: 'TelegramClient', + phone: str = None, + code: typing.Union[str, int] = None, + *, + password: str = None, + bot_token: str = None, + phone_code_hash: str = None) -> 'typing.Union[_tl.User, _tl.auth.SentCode]': + """ + Logs in to Telegram to an existing user or bot account. + + You should only use this if you are not authorized yet. + + .. note:: + + In most cases, you should simply use `start()` and not this method. + + Arguments + phone (`str` | `int`): + The phone to send the code to if no code was provided, + or to override the phone that was previously used with + these requests. + + code (`str` | `int`): + The code that Telegram sent. Note that if you have sent this + code through the application itself it will immediately + expire. If you want to send the code, obfuscate it somehow. + If you're not doing any of this you can ignore this note. + + password (`str`): + 2FA password, should be used if a previous call raised + ``SessionPasswordNeededError``. + + bot_token (`str`): + Used to sign in as a bot. Not all requests will be available. + This should be the hash the `@BotFather `_ + gave you. + + phone_code_hash (`str`, optional): + The hash returned by `send_code_request`. This can be left as + `None` to use the last hash known for the phone to be used. + + Returns + The signed in user, or the information about + :meth:`send_code_request`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.sign_in(phone) # send code + + code = input('enter code: ') + await client.sign_in(phone, code) + """ + + @forward_call(auth.sign_up) + async def sign_up( + self: 'TelegramClient', + code: typing.Union[str, int], + first_name: str, + last_name: str = '', + *, + phone: str = None, + phone_code_hash: str = None) -> '_tl.User': + """ + Signs up to Telegram as a new user account. + + Use this if you don't have an account yet. + + You must call `send_code_request` first. + + **By using this method you're agreeing to Telegram's + Terms of Service. This is required and your account + will be banned otherwise.** See https://telegram.org/tos + and https://core.telegram.org/api/terms. + + Arguments + code (`str` | `int`): + The code sent by Telegram + + first_name (`str`): + The first name to be used by the new account. + + last_name (`str`, optional) + Optional last name. + + phone (`str` | `int`, optional): + The phone to sign up. This will be the last phone used by + default (you normally don't need to set this). + + phone_code_hash (`str`, optional): + The hash returned by `send_code_request`. This can be left as + `None` to use the last hash known for the phone to be used. + + Returns + The new created :tl:`User`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.send_code_request(phone) + + code = input('enter code: ') + await client.sign_up(code, first_name='Anna', last_name='Banana') + """ + + @forward_call(auth.send_code_request) + async def send_code_request( + self: 'TelegramClient', + phone: str) -> '_tl.auth.SentCode': + """ + Sends the Telegram code needed to login to the given phone number. + + Arguments + phone (`str` | `int`): + The phone to which the code will be sent. + + Returns + An instance of :tl:`SentCode`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + sent = await client.send_code_request(phone) + print(sent) + """ + + @forward_call(auth.qr_login) + 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() + """ + + @forward_call(auth.log_out) + async def log_out(self: 'TelegramClient') -> bool: + """ + Logs out Telegram and deletes the current ``*.session`` file. + + Returns + `True` if the operation was successful. + + Example + .. code-block:: python + + # Note: you will need to login again! + await client.log_out() + """ + + @forward_call(auth.edit_2fa) + async def edit_2fa( + self: 'TelegramClient', + current_password: str = None, + new_password: str = None, + *, + hint: str = '', + email: str = None, + email_code_callback: typing.Callable[[int], str] = None) -> bool: + """ + Changes the 2FA settings of the logged in user. + + Review carefully the parameter explanations before using this method. + + Note that this method may be *incredibly* slow depending on the + prime numbers that must be used during the process to make sure + that everything is safe. + + Has no effect if both current and new password are omitted. + + Arguments + current_password (`str`, optional): + The current password, to authorize changing to ``new_password``. + Must be set if changing existing 2FA settings. + Must **not** be set if 2FA is currently disabled. + Passing this by itself will remove 2FA (if correct). + + new_password (`str`, optional): + The password to set as 2FA. + If 2FA was already enabled, ``current_password`` **must** be set. + Leaving this blank or `None` will remove the password. + + hint (`str`, optional): + Hint to be displayed by Telegram when it asks for 2FA. + Leaving unspecified is highly discouraged. + Has no effect if ``new_password`` is not set. + + email (`str`, optional): + Recovery and verification email. If present, you must also + set `email_code_callback`, else it raises ``ValueError``. + + email_code_callback (`callable`, optional): + If an email is provided, a callback that returns the code sent + to it must also be set. This callback may be asynchronous. + It should return a string with the code. The length of the + code will be passed to the callback as an input parameter. + + If the callback returns an invalid code, it will raise + ``CodeInvalidError``. + + Returns + `True` if successful, `False` otherwise. + + Example + .. code-block:: python + + # Setting a password for your account which didn't have + await client.edit_2fa(new_password='I_<3_Telethon') + + # Removing the password + await client.edit_2fa(current_password='I_<3_Telethon') + """ + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, *args): + await self.disconnect() + + # endregion Auth + + # region Bots + + @forward_call(bots.inline_query) + async def inline_query( + self: 'TelegramClient', + bot: 'hints.EntityLike', + query: str, + *, + entity: 'hints.EntityLike' = None, + offset: str = None, + geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults: + """ + Makes an inline query to the specified bot (``@vote New Poll``). + + Arguments + bot (`entity`): + The bot entity to which the inline query should be made. + + query (`str`): + The query that should be made to the bot. + + entity (`entity`, optional): + The entity where the inline query is being made from. Certain + bots use this to display different results depending on where + it's used, such as private chats, groups or channels. + + If specified, it will also be the default entity where the + message will be sent after clicked. Otherwise, the "empty + peer" will be used, which some bots may not handle correctly. + + offset (`str`, optional): + The string offset to use for the bot. + + geo_point (:tl:`GeoPoint`, optional) + The geo point location information to send to the bot + for localised results. Available under some bots. + + Returns + A list of `_custom.InlineResult + `. + + Example + .. code-block:: python + + # Make an inline query to @like + results = await client.inline_query('like', 'Do you like Telethon?') + + # Send the first result to some chat + message = await results[0].click('TelethonOffTopic') + """ + + # endregion Bots + + # region Chats + + @forward_call(chats.get_participants) + def get_participants( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + search: str = '', + filter: typing.Union[str, enums.Participant] = ()) -> chats._ParticipantsIter: + """ + Iterator over the participants belonging to the specified chat. + + The order is unspecified. + + Arguments + entity (`entity`): + The entity from which to retrieve the participants list. + + limit (`int`): + Limits amount of participants fetched. + + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns a single user. + + search (`str`, optional): + Look for participants with this string in name/username. + + Note that the search is only compatible with some ``filter`` + when fetching members from a channel or megagroup. This may + change in the future. + + filter (`str`, optional): + The filter to be used, if you want e.g. only admins + Note that you might not have permissions for some filter. + This has no effect for normal chats or users. + + The available filters are: + + * ``'admin'`` + * ``'bot'`` + * ``'kicked'`` + * ``'banned'`` + * ``'contact'`` + + Yields + The :tl:`User` objects returned by :tl:`GetParticipants`. + + Example + .. code-block:: python + + # Show all user IDs in a chat + async for user in client.iter_participants(chat): + print(user.id) + + # Search by name + async for user in client.iter_participants(chat, search='name'): + print(user.username) + + # Filter by admins + from telethon.tl.types import ChannelParticipantsAdmins + async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): + print(user.first_name) + + # Get a list of 0 people but print the total amount of participants in the chat + users = await client.get_participants(chat, limit=0) + print(users.total) + """ + + @forward_call(chats.get_admin_log) + def get_admin_log( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.EntitiesLike' = None, + join: bool = None, + leave: bool = None, + invite: bool = None, + restrict: bool = None, + unrestrict: bool = None, + ban: bool = None, + unban: bool = None, + promote: bool = None, + demote: bool = None, + info: bool = None, + settings: bool = None, + pinned: bool = None, + edit: bool = None, + delete: bool = None, + group_call: bool = None) -> chats._AdminLogIter: + """ + Iterator over the admin log for the specified channel. + + The default order is from the most recent event to to the oldest. + + Note that you must be an administrator of it to use this method. + + If none of the filters are present (i.e. they all are `None`), + *all* event types will be returned. If at least one of them is + `True`, only those that are true will be returned. + + Arguments + entity (`entity`): + The channel entity from which to get its admin log. + + limit (`int` | `None`, optional): + Number of events to be retrieved. + + The limit may also be `None`, which would eventually return + the whole history. + + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last event. + + max_id (`int`): + All the events with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the events with a lower (older) ID or equal to this will + be excluded. + + search (`str`): + The string to be used as a search query. + + admins (`entity` | `list`): + If present, the events will be filtered by these admins + (or single admin) and only those caused by them will be + returned. + + join (`bool`): + If `True`, events for when a user joined will be returned. + + leave (`bool`): + If `True`, events for when a user leaves will be returned. + + invite (`bool`): + If `True`, events for when a user joins through an invite + link will be returned. + + restrict (`bool`): + If `True`, events with partial restrictions will be + returned. This is what the API calls "ban". + + unrestrict (`bool`): + If `True`, events removing restrictions will be returned. + This is what the API calls "unban". + + ban (`bool`): + If `True`, events applying or removing all restrictions will + be returned. This is what the API calls "kick" (restricting + all permissions removed is a ban, which kicks the user). + + unban (`bool`): + If `True`, events removing all restrictions will be + returned. This is what the API calls "unkick". + + promote (`bool`): + If `True`, events with admin promotions will be returned. + + demote (`bool`): + If `True`, events with admin demotions will be returned. + + info (`bool`): + If `True`, events changing the group info will be returned. + + settings (`bool`): + If `True`, events changing the group settings will be + returned. + + pinned (`bool`): + If `True`, events of new pinned messages will be returned. + + edit (`bool`): + If `True`, events of message edits will be returned. + + delete (`bool`): + If `True`, events of message deletions will be returned. + + group_call (`bool`): + If `True`, events related to group calls will be returned. + + Yields + Instances of `AdminLogEvent `. + + Example + .. code-block:: python + + async for event in client.iter_admin_log(channel): + if event.changed_title: + print('The title changed from', event.old, 'to', event.new) + + # Get all events of deleted message events which said "heck" and print the last one + events = await client.get_admin_log(channel, limit=None, search='heck', delete=True) + + # Print the old message before it was deleted + print(events[-1].old) + """ + + @forward_call(chats.get_profile_photos) + def get_profile_photos( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: int = (), + *, + offset: int = 0, + max_id: int = 0) -> chats._ProfilePhotoIter: + """ + Iterator over a user's profile photos or a chat's photos. + + The order is from the most recent photo to the oldest. + + Arguments + entity (`entity`): + The entity from which to get the profile or chat photos. + + limit (`int` | `None`, optional): + Number of photos to be retrieved. + + The limit may also be `None`, which would eventually all + the photos that are still available. + + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last profile photo. + + offset (`int`): + How many photos should be skipped before returning the first one. + + max_id (`int`): + The maximum ID allowed when fetching photos. + + Yields + Instances of :tl:`Photo`. + + Example + .. code-block:: python + + # Download all the profile photos of some user + async for photo in client.iter_profile_photos(user): + await client.download_media(photo) + + # Get all the photos of a channel and download the oldest one + photos = await client.get_profile_photos(channel, limit=None) + await client.download_media(photos[-1]) + """ + + @forward_call(chats.action) + def action( + self: 'TelegramClient', + entity: 'hints.EntityLike', + action: 'typing.Union[str, _tl.TypeSendMessageAction]', + *, + delay: float = 4, + auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': + """ + Returns a context-manager object to represent a "chat action". + + Chat actions indicate things like "user is typing", "user is + uploading a photo", etc. + + If the action is ``'cancel'``, you should just ``await`` the result, + since it makes no sense to use a context-manager for it. + + See the example below for intended usage. + + Arguments + entity (`entity`): + The entity where the action should be showed in. + + action (`str` | :tl:`SendMessageAction`): + The action to show. You can either pass a instance of + :tl:`SendMessageAction` or better, a string used while: + + * ``'typing'``: typing a text message. + * ``'contact'``: choosing a contact. + * ``'game'``: playing a game. + * ``'location'``: choosing a geo location. + * ``'sticker'``: choosing a sticker. + * ``'record-audio'``: recording a voice note. + You may use ``'record-voice'`` as alias. + * ``'record-round'``: recording a round video. + * ``'record-video'``: recording a normal video. + * ``'audio'``: sending an audio file (voice note or song). + You may use ``'voice'`` and ``'song'`` as aliases. + * ``'round'``: uploading a round video. + * ``'video'``: uploading a video file. + * ``'photo'``: uploading a photo. + * ``'document'``: uploading a document file. + You may use ``'file'`` as alias. + * ``'cancel'``: cancel any pending action in this chat. + + Invalid strings will raise a ``ValueError``. + + delay (`int` | `float`): + The delay, in seconds, to wait between sending actions. + For example, if the delay is 5 and it takes 7 seconds to + do something, three requests will be made at 0s, 5s, and + 7s to cancel the action. + + auto_cancel (`bool`): + Whether the action should be cancelled once the context + manager exists or not. The default is `True`, since + you don't want progress to be shown when it has already + completed. + + Returns + Either a context-manager object or a coroutine. + + Example + .. code-block:: python + + # Type for 2 seconds, then send a message + async with client.action(chat, 'typing'): + await asyncio.sleep(2) + await client.send_message(chat, 'Hello world! I type slow ^^') + + # Cancel any previous action + await client.action(chat, 'cancel') + + # Upload a document, showing its progress (most clients ignore this) + async with client.action(chat, 'document') as action: + await client.send_file(chat, zip_file, progress_callback=action.progress) + """ + + @forward_call(chats.edit_admin) + async def edit_admin( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike', + *, + change_info: bool = None, + post_messages: bool = None, + edit_messages: bool = None, + delete_messages: bool = None, + ban_users: bool = None, + invite_users: bool = None, + pin_messages: bool = None, + add_admins: bool = None, + manage_call: bool = None, + anonymous: bool = None, + is_admin: bool = None, + title: str = None) -> _tl.Updates: + """ + Edits admin permissions for someone in a chat. + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to grant one). + + Unless otherwise stated, permissions will work in channels and megagroups. + + Arguments + entity (`entity`): + The channel, megagroup or chat where the promotion should happen. + + user (`entity`): + The user to be promoted. + + change_info (`bool`, optional): + Whether the user will be able to change info. + + post_messages (`bool`, optional): + Whether the user will be able to post in the channel. + This will only work in broadcast channels. + + edit_messages (`bool`, optional): + Whether the user will be able to edit messages in the channel. + This will only work in broadcast channels. + + delete_messages (`bool`, optional): + Whether the user will be able to delete messages. + + ban_users (`bool`, optional): + Whether the user will be able to ban users. + + invite_users (`bool`, optional): + Whether the user will be able to invite users. Needs some testing. + + pin_messages (`bool`, optional): + Whether the user will be able to pin messages. + + add_admins (`bool`, optional): + Whether the user will be able to add admins. + + manage_call (`bool`, optional): + Whether the user will be able to manage group calls. + + anonymous (`bool`, optional): + Whether the user will remain anonymous when sending messages. + The sender of the anonymous messages becomes the group itself. + + .. note:: + + Users may be able to identify the anonymous admin by its + _custom title, so additional care is needed when using both + ``anonymous`` and _custom titles. For example, if multiple + anonymous admins share the same title, users won't be able + to distinguish them. + + is_admin (`bool`, optional): + Whether the user will be an admin in the chat. + This will only work in small group chats. + Whether the user will be an admin in the chat. This is the + only permission available in small group chats, and when + used in megagroups, all non-explicitly set permissions will + have this value. + + Essentially, only passing ``is_admin=True`` will grant all + permissions, but you can still disable those you need. + + title (`str`, optional): + The _custom title (also known as "rank") to show for this admin. + This text will be shown instead of the "admin" badge. + This will only work in channels and megagroups. + + When left unspecified or empty, the default localized "admin" + badge will be shown. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + # Allowing `user` to pin messages in `chat` + await client.edit_admin(chat, user, pin_messages=True) + + # Granting all permissions except for `add_admins` + await client.edit_admin(chat, user, is_admin=True, add_admins=False) + """ + + @forward_call(chats.edit_permissions) + async def edit_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' = None, + until_date: 'hints.DateLike' = None, + *, + view_messages: bool = True, + send_messages: bool = True, + send_media: bool = True, + send_stickers: bool = True, + 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, + pin_messages: bool = True) -> _tl.Updates: + """ + Edits user restrictions in a chat. + + Set an argument to `False` to apply a restriction (i.e. remove + the permission), or omit them to use the default `True` (i.e. + don't apply a restriction). + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to revoke one). + + By default, each boolean argument is `True`, meaning that it + is true that the user has access to the default permission + and may be able to make use of it. + + If you set an argument to `False`, then a restriction is applied + regardless of the default permissions. + + It is important to note that `True` does *not* mean grant, only + "don't restrict", and this is where the default permissions come + in. A user may have not been revoked the ``pin_messages`` permission + (it is `True`) but they won't be able to use it if the default + permissions don't allow it either. + + Arguments + entity (`entity`): + The channel or megagroup where the restriction should happen. + + user (`entity`, optional): + If specified, the permission will be changed for the specific user. + If left as `None`, the default chat permissions will be updated. + + until_date (`DateLike`, optional): + When the user will be unbanned. + + If the due date or duration is longer than 366 days or shorter than + 30 seconds, the ban will be forever. Defaults to ``0`` (ban forever). + + view_messages (`bool`, optional): + Whether the user is able to view messages or not. + Forbidding someone from viewing messages equals to banning them. + This will only work if ``user`` is set. + + send_messages (`bool`, optional): + Whether the user is able to send messages or not. + + send_media (`bool`, optional): + Whether the user is able to send media or not. + + send_stickers (`bool`, optional): + Whether the user is able to send stickers or not. + + send_gifs (`bool`, optional): + Whether the user is able to send animated gifs or not. + + send_games (`bool`, optional): + Whether the user is able to send games or not. + + 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. + + change_info (`bool`, optional): + Whether the user is able to change info or not. + + invite_users (`bool`, optional): + Whether the user is able to invite other users or not. + + pin_messages (`bool`, optional): + Whether the user is able to pin messages or not. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + from datetime import timedelta + + # Banning `user` from `chat` for 1 minute + await client.edit_permissions(chat, user, timedelta(minutes=1), + view_messages=False) + + # Banning `user` from `chat` forever + await client.edit_permissions(chat, user, view_messages=False) + + # Kicking someone (ban + un-ban) + await client.edit_permissions(chat, user, view_messages=False) + await client.edit_permissions(chat, user) + """ + + @forward_call(chats.kick_participant) + async def kick_participant( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' + ): + """ + Kicks a user from a chat. + + Kicking yourself (``'me'``) will result in leaving the chat. + + .. note:: + + Attempting to kick someone who was banned will remove their + restrictions (and thus unbanning them), since kicking is just + ban + unban. + + Arguments + entity (`entity`): + The channel or chat where the user should be kicked from. + + user (`entity`, optional): + The user to kick. + + Returns + Returns the service `Message ` + produced about a user being kicked, if any. + + Example + .. code-block:: python + + # Kick some user from some chat, and deleting the service message + msg = await client.kick_participant(chat, user) + await msg.delete() + + # Leaving chat + await client.kick_participant(chat, 'me') + """ + + @forward_call(chats.get_permissions) + async def get_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike' = None + ) -> 'typing.Optional[_custom.ParticipantPermissions]': + """ + Fetches the permissions of a user in a specific chat or channel or + get Default Restricted Rights of Chat or Channel. + + .. note:: + + This request has to fetch the entire chat for small group chats, + which can get somewhat expensive, so use of a cache is advised. + + Arguments + entity (`entity`): + The channel or chat the user is participant of. + + user (`entity`, optional): + Target user. + + Returns + A `ParticipantPermissions ` + instance. Refer to its documentation to see what properties are + available. + + Example + .. code-block:: python + + permissions = await client.get_permissions(chat, user) + if permissions.is_admin: + # do something + + # Get Banned Permissions of Chat + await client.get_permissions(chat) + """ + + @forward_call(chats.get_stats) + async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, _tl.Message]' = None, + ): + """ + 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. + + message (`int` | ``Message``, optional): + The message ID from which to get statistics, if your goal is + to obtain the statistics of a single message. + + 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 + If both ``entity`` and ``message`` were provided, returns + :tl:`MessageStats`. Otherwise, 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 + """ + + # endregion Chats + + # region Dialogs + + @forward_call(dialogs.get_dialogs) + def get_dialogs( + self: 'TelegramClient', + limit: float = (), + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + offset_peer: 'hints.EntityLike' = _tl.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, + archived: bool = None + ) -> dialogs._DialogsIter: + """ + Iterator over the dialogs (open conversations/subscribed channels). + + The order is the same as the one seen in official applications + (first pinned, them from those with the most recent message to + those with the oldest message). + + Arguments + limit (`int` | `None`): + How many dialogs to be retrieved as maximum. Can be set to + `None` to retrieve all dialogs. Note that this may take + whole minutes if you have hundreds of dialogs, as Telegram + will tell the library to slow down through a + ``FloodWaitError``. + + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the most-recent dialog. + + offset_date (`datetime`, optional): + The offset date to be used. + + offset_id (`int`, optional): + The message ID to be used as an offset. + + offset_peer (:tl:`InputPeer`, optional): + The peer to be used as an offset. + + ignore_pinned (`bool`, optional): + Whether pinned dialogs should be ignored or not. + When set to `True`, these won't be yielded at all. + + ignore_migrated (`bool`, optional): + Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` + should be included or not. By default all the chats in your + dialogs are returned, but setting this to `True` will ignore + (i.e. skip) them in the same way official applications do. + + folder (`int`, optional): + The folder from which the dialogs should be retrieved. + + If left unspecified, all dialogs (including those from + folders) will be returned. + + If set to ``0``, all dialogs that don't belong to any + folder will be returned. + + If set to a folder number like ``1``, only those from + said folder will be returned. + + By default Telegram assigns the folder ID ``1`` to + archived chats, so you should use that if you need + to fetch the archived dialogs. + + archived (`bool`, optional): + Alias for `folder`. If unspecified, all will be returned, + `False` implies ``folder=0`` and `True` implies ``folder=1``. + Yields + Instances of `Dialog `. + + Example + .. code-block:: python + + # Print all dialog IDs and the title, nicely formatted + async for dialog in client.iter_dialogs(): + print('{:>14}: {}'.format(dialog.id, dialog.title)) + + # Get all open conversation, print the title of the first + dialogs = await client.get_dialogs(limit=None) + first = dialogs[0] + print(first.title) + + # Use the dialog somewhere else + await client.send_message(first, 'hi') + + # Getting only non-archived dialogs (both equivalent) + non_archived = await client.get_dialogs(folder=0, limit=None) + non_archived = await client.get_dialogs(archived=False, limit=None) + + # Getting only archived dialogs (both equivalent) + archived = await client.get_dialogs(folder=1, limit=None) + archived = await client.get_dialogs(archived=True, limit=None) + """ + + @forward_call(dialogs.get_drafts) + def get_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None + ) -> dialogs._DraftsIter: + """ + Iterator over draft messages. + + The order is unspecified. + + Arguments + entity (`hints.EntitiesLike`, optional): + The entity or entities for which to fetch the draft messages. + If left unspecified, all draft messages will be returned. + + Yields + Instances of `Draft `. + + Example + .. code-block:: python + + # Clear all drafts + async for draft in client.get_drafts(): + await draft.delete() + + # Getting the drafts with 'bot1' and 'bot2' + async for draft in client.iter_drafts(['bot1', 'bot2']): + print(draft.text) + + # Get the draft in your chat + draft = await client.get_drafts('me') + print(draft.text) + """ + + @forward_call(dialogs.edit_folder) + async def edit_folder( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None, + folder: typing.Union[int, typing.Sequence[int]] = None, + *, + unpack=None + ) -> _tl.Updates: + """ + Edits the folder used by one or more dialogs to archive them. + + Arguments + entity (entities): + The entity or list of entities to move to the desired + archive folder. + + folder (`int`): + The folder to which the dialog should be archived to. + + If you want to "archive" a dialog, use ``folder=1``. + + If you want to "un-archive" it, use ``folder=0``. + + You may also pass a list with the same length as + `entities` if you want to control where each entity + will go. + + unpack (`int`, optional): + If you want to unpack an archived folder, set this + parameter to the folder number that you want to + delete. + + When you unpack a folder, all the dialogs inside are + moved to the folder number 0. + + You can only use this parameter if the other two + are not set. + + Returns + The :tl:`Updates` object that the request produces. + + Example + .. code-block:: python + + # Archiving the first 5 dialogs + dialogs = await client.get_dialogs(5) + await client.edit_folder(dialogs, 1) + + # Un-archiving the third dialog (archiving to folder 0) + await client.edit_folder(dialog[2], 0) + + # Moving the first dialog to folder 0 and the second to 1 + dialogs = await client.get_dialogs(2) + await client.edit_folder(dialogs, [0, 1]) + + # Un-archiving all dialogs + await client.edit_folder(unpack=1) + """ + + @forward_call(dialogs.delete_dialog) + async def delete_dialog( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + revoke: bool = False + ): + """ + Deletes a dialog (leaves a chat or channel). + + This method can be used as a user and as a bot. However, + bots will only be able to use it to leave groups and channels + (trying to delete a private conversation will do nothing). + + See also `Dialog.delete() `. + + Arguments + entity (entities): + The entity of the dialog to delete. If it's a chat or + channel, you will leave it. Note that the chat itself + is not deleted, only the dialog, because you left it. + + revoke (`bool`, optional): + On private chats, you may revoke the messages from + the other peer too. By default, it's `False`. Set + it to `True` to delete the history for both. + + This makes no difference for bot accounts, who can + only leave groups and channels. + + Returns + The :tl:`Updates` object that the request produces, + or nothing for private conversations. + + Example + .. code-block:: python + + # Deleting the first dialog + dialogs = await client.get_dialogs(5) + await client.delete_dialog(dialogs[0]) + + # Leaving a channel by username + await client.delete_dialog('username') + """ + + # endregion Dialogs + + # region Downloads + + @forward_call(downloads.download_profile_photo) + async def download_profile_photo( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'hints.FileLike' = None, + *, + thumb: typing.Union[str, enums.Size] = (), + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[str]: + """ + Downloads the profile photo from the given user, chat or channel. + + Arguments + entity (`entity`): + From who the photo will be downloaded. + + .. note:: + + This method expects the full entity (which has the data + to download the photo), not an input variant. + + It's possible that sometimes you can't fetch the entity + from its input (since you can get errors like + ``ChannelPrivateError``) but you already have it through + another call, like getting a forwarded message from it. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + If file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). + + thumb (optional): + The thumbnail size to download. A different size may be chosen + if the specified size doesn't exist. The category of the size + you choose will be respected when possible (e.g. if you + specify a cropped size, a cropped variant of similar size will + be preferred over a boxed variant of similar size). Cropped + images are considered to be smaller than boxed images. + + By default, the largest size (original) is downloaded. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + + Returns + `None` if no photo was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. + + Example + .. code-block:: python + + # Download your own profile photo + path = await client.download_profile_photo('me') + print(path) + """ + + @forward_call(downloads.download_media) + async def download_media( + self: 'TelegramClient', + message: 'hints.MessageLike', + file: 'hints.FileLike' = None, + *, + thumb: typing.Union[str, enums.Size] = (), + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: + """ + Downloads the given media from a message object. + + Note that if the download is too slow, you should consider installing + ``cryptg`` (through ``pip install cryptg``) so that decrypting the + received data is done in C instead of Python (much faster). + + See also `Message.download_media() `. + + Arguments + message (`Message ` | :tl:`Media`): + The media or message containing the media that will be downloaded. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + If file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + + thumb (optional): + The thumbnail size to download. A different size may be chosen + if the specified size doesn't exist. The category of the size + you choose will be respected when possible (e.g. if you + specify a cropped size, a cropped variant of similar size will + be preferred over a boxed variant of similar size). Cropped + images are considered to be smaller than boxed images. + + By default, the original media is downloaded. + + 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. + + Example + .. code-block:: python + + path = await client.download_media(message) + await client.download_media(message, filename) + # 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) + """ + + @forward_call(downloads.iter_download) + def iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = downloads.MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None + ): + """ + Iterates over a file download, yielding chunks of the file. + + This method can be used to stream files in a more convenient + way, since it offers more control (pausing, resuming, etc.) + + .. note:: + + Using a value for `offset` or `stride` which is not a multiple + of the minimum allowed `request_size`, or if `chunk_size` is + different from `request_size`, the library will need to do a + bit more work to fetch the data in the way you intend it to. + + You normally shouldn't worry about this. + + Arguments + file (`hints.FileLike`): + The file of which contents you want to iterate over. + + offset (`int`, optional): + The offset in bytes into the file from where the + download should start. For example, if a file is + 1024KB long and you just want the last 512KB, you + would use ``offset=512 * 1024``. + + stride (`int`, optional): + The stride of each chunk (how much the offset should + advance between reading each chunk). This parameter + should only be used for more advanced use cases. + + It must be bigger than or equal to the `chunk_size`. + + limit (`int`, optional): + The limit for how many *chunks* will be yielded at most. + + chunk_size (`int`, optional): + The maximum size of the chunks that will be yielded. + Note that the last chunk may be less than this value. + By default, it equals to `request_size`. + + request_size (`int`, optional): + How many bytes will be requested to Telegram when more + data is required. By default, as many bytes as possible + are requested. If you would like to request data in + smaller sizes, adjust this parameter. + + Note that values outside the valid range will be clamped, + and the final value will also be a multiple of the minimum + allowed size. + + file_size (`int`, optional): + If the file size is known beforehand, you should set + this parameter to said value. Depending on the type of + the input file passed, this may be set automatically. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + Yields + + `bytes` objects representing the chunks of the file if the + right conditions are met, or `memoryview` objects instead. + + Example + .. code-block:: python + + # 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 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. + # + # "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 = await stream.__anext__() # "manual" version of `async for` + await stream.close() + assert len(header) == 32 + """ + + # endregion Downloads + + # region Messages + + @forward_call(messages.get_messages) + def get_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = (), + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + max_id: int = 0, + min_id: int = 0, + add_offset: int = 0, + search: str = None, + filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None, + from_user: 'hints.EntityLike' = None, + wait_time: float = None, + ids: 'typing.Union[int, typing.Sequence[int]]' = None, + reverse: bool = False, + reply_to: int = None, + scheduled: bool = False + ) -> 'typing.Union[_MessagesIter, _IDsIter]': + """ + Iterator over the messages for the given chat. + + The default order is from newest to oldest, but this + behaviour can be changed with the `reverse` parameter. + + If either `search`, `filter` or `from_user` are provided, + :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. + + .. note:: + + Telegram's flood wait limit for :tl:`GetHistory` seems to + be around 30 seconds per 10 requests, therefore a sleep of 1 + second is the default for this limit (or above). + + Arguments + entity (`entity`): + The entity from whom to retrieve the message history. + + It may be `None` to perform a global search, or + to get messages by their ID from no particular chat. + Note that some of the offsets will not work if this + is the case. + + Note that if you want to perform a global search, + you **must** set a non-empty `search` string, a `filter`. + or `from_user`. + + limit (`int` | `None`, optional): + Number of messages to be retrieved. Due to limitations with + the API retrieving more than 3000 messages will take longer + than half a minute (or even more based on previous calls). + + The limit may also be `None`, which would eventually return + the whole history. + + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last message. + + offset_date (`datetime`): + Offset date (messages *previous* to this date will be + retrieved). Exclusive. + + offset_id (`int`): + Offset message ID (only messages *previous* to the given + ID will be retrieved). Exclusive. + + max_id (`int`): + All the messages with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the messages with a lower (older) ID or equal to this will + be excluded. + + add_offset (`int`): + Additional message offset (all of the specified offsets + + this offset = older messages). + + search (`str`): + The string to be used as a search query. + + filter (:tl:`MessagesFilter` | `type`): + The filter to use when returning messages. For instance, + :tl:`InputMessagesFilterPhotos` would yield only messages + containing photos. + + from_user (`entity`): + Only messages from this entity will be returned. + + wait_time (`int`): + Wait time (in seconds) between different + :tl:`GetHistory`. Use this parameter to avoid hitting + the ``FloodWaitError`` as needed. If left to `None`, it will + default to 1 second only if the limit is higher than 3000. + + If the ``ids`` parameter is used, this time will default + to 10 seconds only if the amount of IDs is higher than 300. + + ids (`int`, `list`): + A single integer ID (or several IDs) for the message that + should be returned. This parameter takes precedence over + the rest (which will be ignored if this is set). This can + for instance be used to get the message with ID 123 from + a channel. Note that if the message doesn't exist, `None` + will appear in its place, so that zipping the list of IDs + with the messages can match one-to-one. + + .. note:: + + At the time of writing, Telegram will **not** return + :tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that + failed (i.e. the message is not replying to any, or is + replying to a deleted message). This means that it is + **not** possible to match messages one-by-one, so be + careful if you use non-integers in this parameter. + + reverse (`bool`, optional): + If set to `True`, the messages will be returned in reverse + order (from oldest to newest, instead of the default newest + to oldest). This also means that the meaning of `offset_id` + and `offset_date` parameters is reversed, although they will + still be exclusive. `min_id` becomes equivalent to `offset_id` + instead of being `max_id` as well since messages are returned + in ascending order. + + You cannot use this if both `entity` and `ids` are `None`. + + reply_to (`int`, optional): + If set to a message ID, the messages that reply to this ID + will be returned. This feature is also known as comments in + posts of broadcast channels, or viewing threads in groups. + + This feature can only be used in broadcast channels and their + linked megagroups. Using it in a chat or private conversation + will result in ``telethon.errors.PeerIdInvalidError`` to occur. + + When using this parameter, the ``filter`` and ``search`` + parameters have no effect, since Telegram's API doesn't + support searching messages in replies. + + .. note:: + + This feature is used to get replies to a message in the + *discussion* group. If the same broadcast channel sends + a message and replies to it itself, that reply will not + be included in the results. + + scheduled (`bool`, optional): + If set to `True`, messages which are scheduled will be returned. + All other parameter will be ignored for this, except `entity`. + + Yields + Instances of `Message `. + + Example + .. code-block:: python + + # From most-recent to oldest + async for message in client.iter_messages(chat): + print(message.id, message.text) + + # From oldest to most-recent + async for message in client.iter_messages(chat, reverse=True): + print(message.id, message.text) + + # Filter by sender, and limit to 10 + async for message in client.iter_messages(chat, 10, from_user='me'): + print(message.text) + + # Server-side search with fuzzy text + async for message in client.iter_messages(chat, search='hello'): + print(message.id) + + # Filter by message type: + from telethon.tl.types import InputMessagesFilterPhotos + async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): + print(message.photo) + + # Getting comments from a post in a channel: + async for message in client.iter_messages(channel, reply_to=123): + print(message.chat.title, message.text) + + # Get 0 photos and print the total to show how many photos there are + from telethon.tl.types import InputMessagesFilterPhotos + photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) + print(photos.total) + + # Get all the photos in a list + all_photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) + + # Get the last photo or None if none has been sent yet (same as setting limit 1) + photo = await client.get_messages(chat, filter=InputMessagesFilterPhotos) + + # Get a single message given an ID: + message_1337 = await client.get_messages(chat, ids=1337) + """ + + @forward_call(messages.send_message) + async def send_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageLike' = '', + *, + # - Message contents + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: 'typing.Optional[hints.FileLike]' = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + clear_draft: bool = False, + background: bool = None, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, _tl.Message]' = None, + ) -> '_tl.Message': + """ + Sends a Message to the specified user, chat or channel. + + The message can be either a string or a previous Message instance. + If it's a previous Message instance, the rest of parameters will be ignored. + If it's not, a Message instance will be constructed, and send_to used. + + Sending a ``/start`` command with a parameter (like ``?start=data``) + is also done through this method. Simply send ``'/start data'`` to + the bot. + + See also `Message.respond() ` + and `Message.reply() `. + + Arguments + entity (`entity`): + To who will it be sent. + + message (`str` | `Message `): + The message to be sent, or another message object to resend. + + The maximum length for a message is 35,000 bytes or 4,096 + characters. Longer messages will not be sliced automatically, + and you should slice them manually if the text to send is + longer than said length. + + reply_to (`int` | `Message `, optional): + Whether to reply to a message or not. If an integer is provided, + it should be the ID of the message that it should reply to. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`file`, optional): + Sends a message with a file attached (e.g. a photo, + video, audio or document). The ``message`` may be empty. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + clear_draft (`bool`, optional): + Whether the existing draft should be cleared or not. + + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + All the following limits apply together: + + * There can be 100 buttons at most (any more are ignored). + * There can be 8 buttons per row at most (more are ignored). + * The maximum callback data per button is 64 bytes. + * The maximum data that can be embedded in total is just + over 4KB, shared between inline callback data and text. + + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to `False`, which means it will + notify them. Set it to `True` to alter this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + Returns + The sent `_custom.Message `. + + Example + .. code-block:: python + + # Markdown is the default + await client.send_message('me', 'Hello **world**!') + + # Default to another parse mode + client.parse_mode = 'html' + + await client.send_message('me', 'Some bold and italic text') + await client.send_message('me', 'An URL') + # code and pre tags also work, but those break the documentation :) + await client.send_message('me', 'Mentions') + + # Explicit parse mode + # No parse mode by default + client.parse_mode = None + + # ...but here I want markdown + await client.send_message('me', 'Hello, **world**!', parse_mode='md') + + # ...and here I need HTML + await client.send_message('me', 'Hello, world!', parse_mode='html') + + # If you logged in as a bot account, you can send buttons + from telethon import events, Button + + @client.on(events.CallbackQuery) + async def callback(event): + await event.edit('Thank you for clicking {}!'.format(event.data)) + + # Single inline button + await client.send_message(chat, 'A single button, with "clk1" as data', + buttons=Button.inline('Click me', b'clk1')) + + # Matrix of inline buttons + await client.send_message(chat, 'Pick one from this grid', buttons=[ + [Button.inline('Left'), Button.inline('Right')], + [Button.url('Check this site!', 'https://example.com')] + ]) + + # Reply keyboard + await client.send_message(chat, 'Welcome', buttons=[ + Button.text('Thanks!', resize=True, single_use=True), + Button.request_phone('Send phone'), + Button.request_location('Send location') + ]) + + # Forcing replies or clearing buttons. + await client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) + await client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) + + # Scheduling a message to be sent after 5 minutes + from datetime import timedelta + await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) + """ + + @forward_call(messages.forward_messages) + async def forward_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + from_peer: 'hints.EntityLike' = None, + *, + background: bool = None, + with_my_score: bool = None, + silent: bool = None, + as_album: bool = None, + schedule: 'hints.DateLike' = None + ) -> 'typing.Sequence[_tl.Message]': + """ + Forwards the given messages to the specified entity. + + If you want to "forward" a message without the forward header + (the "forwarded from" text), you should use `send_message` with + the original message instead. This will send a copy of it. + + See also `Message.forward_to() `. + + Arguments + entity (`entity`): + To which entity the message(s) will be forwarded. + + messages (`list`): + The messages to forward, or their integer IDs. + + from_peer (`entity`): + If the given messages are integer IDs and not instances + of the ``Message`` class, this *must* be specified in + order for the forward to work. This parameter indicates + the entity from which the messages should be forwarded. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be forwarded in background. + + with_my_score (`bool`, optional): + Whether forwarded should contain your game score. + + as_album (`bool`, optional): + This flag no longer has any effect. + + schedule (`hints.DateLike`, optional): + If set, the message(s) won't forward immediately, and + instead they will be scheduled to be automatically sent + at a later time. + + Returns + The list of forwarded `Message `, + or a single one if a list wasn't provided as input. + + Note that if all messages are invalid (i.e. deleted) the call + will fail with ``MessageIdInvalidError``. If only some are + invalid, the list will have `None` instead of those messages. + + Example + .. code-block:: python + + # a single one + await client.forward_messages(chat, message) + # or + await client.forward_messages(chat, message_id, from_chat) + # or + await message.forward_to(chat) + + # multiple + await client.forward_messages(chat, messages) + # or + await client.forward_messages(chat, message_ids, from_chat) + + # Forwarding as a copy + await client.send_message(chat, message) + """ + + @forward_call(messages.edit_message) + async def edit_message( + self: 'TelegramClient', + entity: 'typing.Union[hints.EntityLike, _tl.Message]', + message: 'hints.MessageLike', + text: str = None, + *, + parse_mode: str = (), + attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, + formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'hints.FileLike' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + buttons: 'hints.MarkupLike' = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None + ) -> '_tl.Message': + """ + Edits the given message to change its text or media. + + See also `Message.edit() `. + + Arguments + entity (`entity` | `Message `): + From which chat to edit the message. This can also be + the message to be edited, and the entity will be inferred + from it, so the next parameter will be assumed to be the + message text. + + You may also pass a :tl:`InputBotInlineMessageID`, + which is the only way to edit messages that were sent + after the user selects an inline query result. + + message (`int` | `Message ` | `str`): + The ID of the message (or `Message + ` itself) to be edited. + If the `entity` was a `Message + `, then this message + will be treated as the new text. + + text (`str`, optional): + The new text of the message. Does nothing if the `entity` + was a `Message `. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`str` | `bytes` | `file` | `media`, optional): + The file object that should replace the existing media + in the message. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't be edited immediately, and instead + it will be scheduled to be automatically edited at a later + time. + + Note that this parameter will have no effect if you are + trying to edit a message that was sent via inline bots. + + Returns + The edited `Message `, + unless `entity` was a :tl:`InputBotInlineMessageID` in which + case this method returns a boolean. + + Raises + ``MessageAuthorRequiredError`` if you're not the author of the + message but tried editing it anyway. + + ``MessageNotModifiedError`` if the contents of the message were + not modified at all. + + ``MessageIdInvalidError`` if the ID of the message is invalid + (the ID itself may be correct, but the message with that ID + cannot be edited). For example, when trying to edit messages + with a reply markup (or clear markup) this error will be raised. + + Example + .. code-block:: python + + message = await client.send_message(chat, 'hello') + + await client.edit_message(chat, message, 'hello!') + # or + await client.edit_message(chat, message.id, 'hello!!') + # or + await message.edit('hello!!!') + """ + + @forward_call(messages.delete_messages) + async def delete_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + *, + revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]': + """ + Deletes the given messages, optionally "for everyone". + + See also `Message.delete() `. + + .. warning:: + + This method does **not** validate that the message IDs belong + to the chat that you passed! It's possible for the method to + delete messages from different private chats and small group + chats at once, so make sure to pass the right IDs. + + Arguments + entity (`entity`): + From who the message will be deleted. This can actually + be `None` for normal chats, but **must** be present + for channels and megagroups. + + messages (`list`): + The messages to delete, or their integer IDs. + + revoke (`bool`, optional): + Whether the message should be deleted for everyone or not. + By default it has the opposite behaviour of official clients, + and it will delete the message for everyone. + + `Since 24 March 2019 + `_, you can + also revoke messages of any age (i.e. messages sent long in + the past) the *other* person sent in private conversations + (and of course your messages too). + + Disabling this has no effect on channels or megagroups, + since it will unconditionally delete the message for everyone. + + Returns + A list of :tl:`AffectedMessages`, each item being the result + for the delete calls of the messages in chunks of 100 each. + + Example + .. code-block:: python + + await client.delete_messages(chat, messages) + """ + + @forward_call(messages.mark_read) + async def mark_read( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageIDLike' = None, + *, + clear_mentions: bool = False) -> bool: + """ + Marks messages as read and optionally clears mentions. + + This effectively marks a message as read (or more than one) in the + given conversation. + + If no message or maximum ID is provided, all messages will be + marked as read. + + 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 + entity (`entity`): + The chat where these messages are located. + + message (`Message `): + The last (most-recent) message which was read, or its ID. + This is only useful if you want to mark a chat as partially read. + + max_id (`int`): + 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 + there are no more mentions) or not for the given entity. + + If no message is provided, this will be the only action + taken. + + Example + .. code-block:: python + + # using a Message object + await client.mark_read(chat, message) + # ...or using the int ID of a Message + await client.mark_read(chat, message_id) + # ...or passing a list of messages to mark as read + await client.mark_read(chat, messages) + """ + + @forward_call(messages.pin_message) + async def pin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False + ): + """ + Pins a message in a chat. + + The default behaviour is to *not* notify members, unlike the + official applications. + + See also `Message.pin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to pin. If it's + `None`, all messages will be unpinned instead. + + notify (`bool`, optional): + Whether the pin should notify people or not. + + pm_oneside (`bool`, optional): + Whether the message should be pinned for everyone or not. + By default it has the opposite behaviour of official clients, + and it will pin the message for both sides, in private chats. + + Example + .. code-block:: python + + # Send and pin a message to annoy everyone + message = await client.send_message(chat, 'Pinotifying is fun!') + await client.pin_message(chat, message, notify=True) + """ + + @forward_call(messages.unpin_message) + async def unpin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False + ): + """ + Unpins a message in a chat. + + If no message ID is specified, all pinned messages will be unpinned. + + See also `Message.unpin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to unpin. If it's + `None`, all messages will be unpinned instead. + + Example + .. code-block:: python + + # Unpin all messages from a chat + await client.unpin_message(chat) + """ + + # endregion Messages + + # region Base + + # Current TelegramClient version + __version__ = version.__version__ + + def __init__( + self: 'TelegramClient', + session: 'typing.Union[str, Session]', + api_id: int, + api_hash: str, + *, + # Logging. + base_logger: typing.Union[str, logging.Logger] = None, + # Connection parameters. + use_ipv6: bool = False, + proxy: typing.Union[tuple, dict] = None, + local_addr: typing.Union[str, tuple] = None, + device_model: str = None, + system_version: str = None, + app_version: str = None, + lang_code: str = 'en', + system_lang_code: str = 'en', + # Nice-to-have. + auto_reconnect: bool = True, + connect_timeout: int = 10, + connect_retries: int = 4, + connect_retry_delay: int = 1, + request_retries: int = 4, + flood_sleep_threshold: int = 60, + # Update handling. + catch_up: bool = False, + receive_updates: bool = True, + max_queued_updates: int = 100, + ): + telegrambaseclient.init(**locals()) + + @property + def flood_sleep_threshold(self): + return telegrambaseclient.get_flood_sleep_threshold(**locals()) + + @flood_sleep_threshold.setter + def flood_sleep_threshold(self, value): + return telegrambaseclient.set_flood_sleep_threshold(**locals()) + + @forward_call(telegrambaseclient.connect) + async def connect(self: 'TelegramClient') -> None: + """ + Connects to Telegram. + + .. note:: + + Connect means connect and nothing else, and only one low-level + request is made to notify Telegram about which layer we will be + using. + + Before Telegram sends you updates, you need to make a high-level + request, like `client.get_me() `, + as described in https://core.telegram.org/api/updates. + + Example + .. code-block:: python + + try: + await client.connect() + except OSError: + print('Failed to connect') + """ + + @forward_call(telegrambaseclient.is_connected) + def is_connected(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user has connected. + + This method is **not** asynchronous (don't use ``await`` on it). + + Example + .. code-block:: python + + while client.is_connected(): + await asyncio.sleep(1) + """ + + @forward_call(telegrambaseclient.disconnect) + def disconnect(self: 'TelegramClient'): + """ + Disconnects from Telegram. + + If the event loop is already running, this method returns a + coroutine that you should await on your own code; otherwise + the loop is ran until said coroutine completes. + + Example + .. code-block:: python + + # You don't need to use this if you used "with client" + await client.disconnect() + """ + + @forward_call(telegrambaseclient.set_proxy) + def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + """ + Changes the proxy which will be used on next (re)connection. + + Method has no immediate effects if the client is currently connected. + + The new proxy will take it's effect on the next reconnection attempt: + - on a call `await client.connect()` (after complete disconnect) + - on auto-reconnect attempt (e.g, after previous connection was lost) + """ + + # endregion Base + + # region Updates + + @forward_call(updates.set_receive_updates) + async def set_receive_updates(self: 'TelegramClient', receive_updates): + """ + Change the value of `receive_updates`. + + This is an `async` method, because in order for Telegram to start + sending updates again, a request must be made. + """ + + @forward_call(updates.run_until_disconnected) + def run_until_disconnected(self: 'TelegramClient'): + """ + Wait until the library is disconnected. + + It also notifies Telegram that we want to receive updates + as described in https://core.telegram.org/api/updates. + + Event handlers will continue to run while the method awaits for a + disconnection to occur. Essentially, this method "blocks" until a + disconnection occurs, and keeps your code running if you have nothing + else to do. + + Manual disconnections can be made by calling `disconnect() + ` + or exiting the context-manager using the client (for example, a + ``KeyboardInterrupt`` by pressing ``Ctrl+C`` on the console window + would propagate the error, exit the ``with`` block and disconnect). + + If a disconnection error occurs (i.e. the library fails to reconnect + automatically), said error will be raised through here, so you have a + chance to ``except`` it on your own code. + + Example + .. code-block:: python + + # Blocks the current task here until a disconnection occurs. + # + # You will still receive updates, since this prevents the + # script from exiting. + await client.run_until_disconnected() + """ + + @forward_call(updates.on) + def on(self: 'TelegramClient', event: EventBuilder): + """ + Decorator used to `add_event_handler` more conveniently. + + + Arguments + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + # Here we use client.on + @client.on(events.NewMessage) + async def handler(event): + ... + """ + + @forward_call(updates.add_event_handler) + def add_event_handler( + self: 'TelegramClient', + callback: updates.Callback, + event: EventBuilder = None): + """ + Registers a new event handler callback. + + The callback will be called when the specified event occurs. + + Arguments + callback (`callable`): + The callable function accepting one parameter to be used. + + Note that if you have used `telethon.events.register` in + the callback, ``event`` will be ignored, and instead the + events you previously registered will be used. + + event (`_EventBuilder` | `type`, optional): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + If left unspecified, `telethon.events.raw.Raw` (the + :tl:`Update` objects with no further processing) will + be passed instead. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + async def handler(event): + ... + + client.add_event_handler(handler, events.NewMessage) + """ + + @forward_call(updates.remove_event_handler) + def remove_event_handler( + self: 'TelegramClient', + callback: updates.Callback, + event: EventBuilder = None) -> int: + """ + Inverse operation of `add_event_handler()`. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + + Example + .. code-block:: python + + @client.on(events.Raw) + @client.on(events.NewMessage) + async def handler(event): + ... + + # Removes only the "Raw" handling + # "handler" will still receive "events.NewMessage" + client.remove_event_handler(handler, events.Raw) + + # "handler" will stop receiving anything + client.remove_event_handler(handler) + """ + + @forward_call(updates.list_event_handlers) + def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + """ + Lists all registered event handlers. + + Returns + A list of pairs consisting of ``(callback, event)``. + + Example + .. code-block:: python + + @client.on(events.NewMessage(pattern='hello')) + async def on_greeting(event): + '''Greets someone''' + await event.reply('Hi') + + for callback, event in client.list_event_handlers(): + print(id(callback), type(event)) + """ + + @forward_call(updates.catch_up) + async def catch_up(self: 'TelegramClient'): + """ + Forces the client to "catch-up" on missed updates. + + The method does not wait for all updates to be received. + + Example + .. code-block:: python + + await client.catch_up() + """ + + # endregion Updates + + # region Uploads + + @forward_call(uploads.send_file) + async def send_file( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', + *, + 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, + attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, + thumb: 'hints.FileLike' = None, + allow_cache: bool = True, + parse_mode: str = (), + formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, + voice_note: bool = False, + video_note: bool = False, + buttons: 'hints.MarkupLike' = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, _tl.Message]' = None, + ttl: int = None, + **kwargs) -> '_tl.Message': + """ + Sends message with the given file to the specified entity. + + .. note:: + + If the ``hachoir3`` package (``hachoir`` module) is installed, + it will be used to determine metadata from audio and video files. + + If the ``pillow`` package is installed and you are sending a photo, + it will be resized to fit within the maximum dimensions allowed + by Telegram to avoid ``errors.PhotoInvalidDimensionsError``. This + cannot be done if you are sending :tl:`InputFile`, however. + + Arguments + entity (`entity`): + Who will receive the file. + + file (`str` | `bytes` | `file` | `media`): + The file to send, which can be one of: + + * A local file path to an in-disk file. The file name + will be the path's base name. + + * A `bytes` byte array with the file's data to send + (for example, by using ``text.encode('utf-8')``). + A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send + (for example, by using ``open(file, 'rb')``). + Its ``.name`` property will be used for the file name, + or a default if it doesn't have one. + + * An external URL to a file over the internet. This will + send the file as "external" media, and Telegram is the + one that will fetch the media and send it. + + * A handle to an existing file (for example, if you sent a + message with media before, you can use its ``message.media`` + as a file here). + + * 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 + sent as an album in the order in which they appear, sliced + in chunks of 10 if more than 10 are given. + + caption (`str`, optional): + Optional caption for the sent media message. When sending an + album, the caption may be a list of strings, which will be + assigned to the files pairwise. + + force_document (`bool`, optional): + If left to `False` and the file is a path that ends with + 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. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + reply_to (`int` | `Message `): + Same as `reply_to` from `send_message`. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + + allow_cache (`bool`, optional): + This parameter currently does nothing, but is kept for + backward-compatibility (and it may get its use back in + the future). + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + voice_note (`bool`, optional): + If `True` the audio will be sent as a voice note. + + video_note (`bool`, optional): + If `True` the video will be sent as a video note, + also known as a round video message. + + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the file won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + ttl (`int`. optional): + The Time-To-Live of the file (also known as "self-destruct timer" + or "self-destructing media"). If set, files can only be viewed for + a short period of time before they disappear from the message + history automatically. + + The value must be at least 1 second, and at most 60 seconds, + otherwise Telegram will ignore this parameter. + + Not all types of media can be used with this parameter, such + as text documents, which will fail with ``TtlMediaInvalidError``. + + Returns + The `Message ` (or messages) + containing the sent file, or messages if a list of them was passed. + + Example + .. code-block:: python + + # Normal files like photos + await client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") + # or + await client.send_message(chat, "It's me!", file='/my/photos/me.jpg') + + # Voice notes or round videos + await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) + await client.send_file(chat, '/my/videos/video.mp4', video_note=True) + + # _custom thumbnails + await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') + + # Only documents + await client.send_file(chat, '/my/photos/photo.png', force_document=True) + + # Albums + await client.send_file(chat, [ + '/my/photos/holiday1.jpg', + '/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 import _tl + await client.send_file(chat, _tl.InputMediaDice('')) + await client.send_file(chat, _tl.InputMediaDice('🎯')) + + # Contacts + await client.send_file(chat, _tl.InputMediaContact( + phone_number='+34 123 456 789', + first_name='Example', + last_name='', + vcard='' + )) + """ + + @forward_call(uploads.upload_file) + async def upload_file( + self: 'TelegramClient', + 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) -> '_tl.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). + + Uploading a file will simply return a "handle" to the file stored + remotely in the Telegram servers, which can be later used on. This + will **not** upload the file to your own chat or any chat at all. + + Arguments + file (`str` | `bytes` | `file`): + The path of the file, byte array, or stream that will be sent. + Note that if a byte array or a stream is given, a filename + or its type won't be inferred, and it will be sent as an + "unnamed application/octet-stream". + + part_size_kb (`int`, optional): + 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`` + and if this is not a `str`, it will be ``"unnamed"``. + + use_cache (`type`, optional): + This parameter currently does nothing, but is kept for + 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)``. + + Returns + :tl:`InputFileBig` if the file size is larger than 10MB, + `InputSizedFile ` + (subclass of :tl:`InputFile`) otherwise. + + Example + .. code-block:: python + + # Photos as photo and document + file = await client.upload_file('photo.jpg') + await client.send_file(chat, file) # sends as photo + await client.send_file(chat, file, force_document=True) # sends as document + + file.name = 'not a photo.jpg' + await client.send_file(chat, file, force_document=True) # document, new name + + # As song or as voice note + file = await client.upload_file('song.ogg') + await client.send_file(chat, file) # sends as song + await client.send_file(chat, file, voice_note=True) # sends as voice note + """ + + # endregion Uploads + + # region Users + + @forward_call(users.call) + async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): + """ + Invokes (sends) one or more MTProtoRequests and returns (receives) + their result. + + Args: + request (`TLObject` | `list`): + The request or requests to be invoked. + + ordered (`bool`, optional): + Whether the requests (if more than one was given) should be + executed sequentially on the server. They run in arbitrary + order by default. + + flood_sleep_threshold (`int` | `None`, optional): + The flood sleep threshold to use for this request. This overrides + the default value stored in + `client.flood_sleep_threshold ` + + Returns: + The result of the request (often a `TLObject`) or a list of + results if more than one request was given. + """ + + @forward_call(users.get_me) + async def get_me(self: 'TelegramClient', input_peer: bool = False) \ + -> 'typing.Union[_tl.User, _tl.InputPeerUser]': + """ + Gets "me", the current :tl:`User` who is logged in. + + If the user has not logged in yet, this method returns `None`. + + Arguments + input_peer (`bool`, optional): + Whether to return the :tl:`InputPeerUser` version or the normal + :tl:`User`. This can be useful if you just need to know the ID + of yourself. + + Returns + Your own :tl:`User`. + + Example + .. code-block:: python + + me = await client.get_me() + print(me.username) + """ + + @forward_call(users.is_bot) + async def is_bot(self: 'TelegramClient') -> bool: + """ + Return `True` if the signed-in user is a bot, `False` otherwise. + + Example + .. code-block:: python + + if await client.is_bot(): + print('Beep') + else: + print('Hello') + """ + + @forward_call(users.is_user_authorized) + async def is_user_authorized(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user is authorized (logged in). + + Example + .. code-block:: python + + if not await client.is_user_authorized(): + await client.send_code_request(phone) + code = input('enter code: ') + await client.sign_in(phone, code) + """ + + @forward_call(users.get_entity) + async def get_entity( + self: 'TelegramClient', + entity: 'hints.EntitiesLike') -> 'hints.Entity': + """ + Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` + or :tl:`Channel`. You can also pass a list or iterable of entities, + and they will be efficiently fetched from the network. + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username is given, **the username will be resolved** making + an API call every time. Resolving usernames is an expensive + operation and will start hitting flood waits around 50 usernames + in a short period of time. + + If you want to get the entity for a *cached* username, you should + first `get_input_entity(username) ` which will + use the cache), and then use `get_entity` with the result of the + previous call. + + Similar limits apply to invite links, and you should use their + ID instead. + + Using phone numbers (from people in your contact list), exact + names, integer IDs or :tl:`Peer` rely on a `get_input_entity` + first, which in turn needs the entity to be in cache, unless + a :tl:`InputPeer` was passed. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` will be raised. + + Returns + :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the + input entity. A list will be returned if more than one was given. + + Example + .. code-block:: python + + from telethon import utils + + me = await client.get_entity('me') + print(utils.get_display_name(me)) + + chat = await client.get_input_entity('username') + async for message in client.iter_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_input_entity if you will reuse it a lot. + async for message in client.iter_messages('username'): + ... + + # Note that for this to work the phone number must be in your contacts + some_id = await client.get_peer_id('+34123456789') + """ + + @forward_call(users.get_input_entity) + async def get_input_entity( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> '_tl.TypeInputPeer': + """ + Turns the given entity into its input entity version. + + Most requests use this kind of :tl:`InputPeer`, so this is the most + suitable call to make for those cases. **Generally you should let the + library do its job** and don't worry about getting the input entity + first, but if you're going to use an entity often, consider making the + call: + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username or invite link is given, **the library will + use the cache**. This means that it's possible to be using + a username that *changed* or an old invite link (this only + happens if an invite link for a small group chat is used + after it was upgraded to a mega-group). + + If the username or ID from the invite link is not found in + the cache, it will be fetched. The same rules apply to phone + numbers (``'+34 123456789'``) from people in your contact list. + + If an exact name is given, it must be in the cache too. This + is not reliable as different people can share the same name + and which entity is returned is arbitrary, and should be used + only for quick tests. + + If a positive integer ID is given, the entity will be searched + in cached users, chats or channels, without making any call. + + If a negative integer ID is given, the entity will be searched + exactly as either a chat (prefixed with ``-``) or as a channel + (prefixed with ``-100``). + + If a :tl:`Peer` is given, it will be searched exactly in the + cache as either a user, chat or channel. + + If the given object can be turned into an input entity directly, + said operation will be done. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` will be raised. + + Returns + :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` + or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. + + If you need to get the ID of yourself, you should use + `get_me` with ``input_peer=True``) instead. + + Example + .. code-block:: python + + # If you're going to use "username" often in your code + # (make a lot of calls), consider getting its input entity + # once, and then using the "user" everywhere instead. + user = await client.get_input_entity('username') + + # The same applies to IDs, chats or channels. + chat = await client.get_input_entity(-123456789) + """ + + @forward_call(users.get_peer_id) + async def get_peer_id( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> int: + """ + Gets the ID for the given entity. + + This method needs to be ``async`` because `peer` supports usernames, + invite-links, phone numbers (from people in your contact list), etc. + + Example + .. code-block:: python + + print(await client.get_peer_id('me')) + """ + + # endregion Users + + # region Private + + @forward_call(users._call) + async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): + pass + + @forward_call(updates._update_loop) + async def _update_loop(self: 'TelegramClient'): + pass + + @forward_call(messageparse._parse_message_text) + async def _parse_message_text(self: 'TelegramClient', message, parse_mode): + pass + + @forward_call(messageparse._get_response_message) + def _get_response_message(self: 'TelegramClient', request, result, input_chat): + pass + + @forward_call(messages._get_comment_data) + async def _get_comment_data( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, _tl.Message]' + ): + pass + + @forward_call(telegrambaseclient._switch_dc) + async def _switch_dc(self: 'TelegramClient', new_dc): + pass + + @forward_call(telegrambaseclient._borrow_exported_sender) + async def _borrow_exported_sender(self: 'TelegramClient', dc_id): + pass + + @forward_call(telegrambaseclient._return_exported_sender) + async def _return_exported_sender(self: 'TelegramClient', sender): + pass + + @forward_call(telegrambaseclient._clean_exported_senders) + async def _clean_exported_senders(self: 'TelegramClient'): + pass + + @forward_call(auth._update_session_state) + async def _update_session_state(self, user, *, save=True): + pass + + @forward_call(auth._replace_session_state) + async def _replace_session_state(self, *, save=True, **changes): + pass + + # endregion Private diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py new file mode 100644 index 00000000..cf26e809 --- /dev/null +++ b/telethon/_client/updates.py @@ -0,0 +1,130 @@ +import asyncio +import inspect +import itertools +import random +import sys +import time +import traceback +import typing +import logging +from collections import deque + +from ..errors._rpcbase import RpcError +from .._events.common import EventBuilder, EventCommon +from .._events.raw import Raw +from .._events.base import StopPropagation, _get_handlers +from .._misc import utils +from .. import _tl + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +Callback = typing.Callable[[typing.Any], typing.Any] + + +async def set_receive_updates(self: 'TelegramClient', receive_updates): + self._no_updates = not receive_updates + if receive_updates: + await self(_tl.fn.updates.GetState()) + +async def run_until_disconnected(self: 'TelegramClient'): + # Make a high-level request to notify that we want updates + await self(_tl.fn.updates.GetState()) + await self._sender.wait_disconnected() + +def on(self: 'TelegramClient', event: EventBuilder): + def decorator(f): + self.add_event_handler(f, event) + return f + + return decorator + +def add_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None): + builders = _get_handlers(callback) + if builders is not None: + for event in builders: + self._event_builders.append((event, callback)) + return + + if isinstance(event, type): + event = event() + elif not event: + event = Raw() + + self._event_builders.append((event, callback)) + +def remove_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None) -> int: + found = 0 + if event and not isinstance(event, type): + event = type(event) + + i = len(self._event_builders) + while i: + i -= 1 + ev, cb = self._event_builders[i] + if cb == callback and (not event or isinstance(ev, event)): + del self._event_builders[i] + found += 1 + + return found + +def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + return [(callback, event) for event, callback in self._event_builders] + +async def catch_up(self: 'TelegramClient'): + # The update loop is probably blocked on either timeout or an update to arrive. + # Unblock the loop by pushing a dummy update which will always trigger a gap. + # This, in return, causes the update loop to catch up. + await self._updates_queue.put(_tl.UpdatesTooLong()) + +async def _update_loop(self: 'TelegramClient'): + try: + updates_to_dispatch = deque() + while self.is_connected(): + if updates_to_dispatch: + # TODO dispatch + updates_to_dispatch.popleft() + continue + + get_diff = self._message_box.get_difference() + if get_diff: + self._log[__name__].info('Getting difference for account updates') + diff = await self(get_diff) + updates, users, chats = self._message_box.apply_difference(diff, self._entity_cache) + self._entity_cache.extend(users, chats) + updates_to_dispatch.extend(updates) + continue + + get_diff = self._message_box.get_channel_difference(self._entity_cache) + if get_diff: + self._log[__name__].info('Getting difference for channel updates') + diff = await self(get_diff) + updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._entity_cache) + self._entity_cache.extend(users, chats) + updates_to_dispatch.extend(updates) + continue + + deadline = self._message_box.check_deadlines() + try: + updates = await asyncio.wait_for( + self._updates_queue.get(), + deadline - asyncio.get_running_loop().time() + ) + except asyncio.TimeoutError: + self._log[__name__].info('Timeout waiting for updates expired') + continue + + processed = [] + users, chats = self._message_box.process_updates(updates, self._entity_cache, processed) + self._entity_cache.extend(users, chats) + updates_to_dispatch.extend(processed) + except Exception: + self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)') diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py new file mode 100644 index 00000000..a92df8f4 --- /dev/null +++ b/telethon/_client/uploads.py @@ -0,0 +1,332 @@ +import hashlib +import io +import itertools +import os +import pathlib +import re +import typing +from io import BytesIO + +from .._crypto import AES + +from .._misc import utils, helpers, hints +from ..types import _custom +from .. import _tl + +try: + import PIL + import PIL.Image +except ImportError: + PIL = None + + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +def _resize_photo_if_needed( + file, is_image, width=1280, height=1280, background=(255, 255, 255)): + + # https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254 + if (not is_image + or PIL is None + or (isinstance(file, io.IOBase) and not file.seekable())): + return file + + if isinstance(file, bytes): + file = io.BytesIO(file) + + before = file.tell() if isinstance(file, io.IOBase) else None + + try: + # Don't use a `with` block for `image`, or `file` would be closed. + # See https://github.com/LonamiWebs/Telethon/issues/1121 for more. + image = PIL.Image.open(file) + try: + kwargs = {'exif': image.info['exif']} + except KeyError: + kwargs = {} + + if image.width <= width and image.height <= height: + return file + + image.thumbnail((width, height), PIL.Image.ANTIALIAS) + + alpha_index = image.mode.find('A') + if alpha_index == -1: + # If the image mode doesn't have alpha + # channel then don't bother masking it away. + result = image + else: + # We could save the resized image with the original format, but + # JPEG often compresses better -> smaller size -> faster upload + # We need to mask away the alpha channel ([3]), since otherwise + # IOError is raised when trying to save alpha channels in JPEG. + result = PIL.Image.new('RGB', image.size, background) + result.paste(image, mask=image.split()[alpha_index]) + + buffer = io.BytesIO() + result.save(buffer, 'JPEG', **kwargs) + buffer.seek(0) + return buffer + + except IOError: + return file + finally: + if before is not None: + file.seek(before, io.SEEK_SET) + + +async def send_file( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: typing.Optional[hints.FileLike] = None, + *, + # - Message contents + # Formatting + caption: 'hints.MessageLike' = '', + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + clear_draft: bool = False, + background: bool = None, + noforwards: bool = None, + send_as: 'hints.EntityLike' = None, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, _tl.Message]' = None, +) -> '_tl.Message': + self.send_message( + entity=entity, + message=caption, + markdown=markdown, + html=html, + formatting_entities=formatting_entities, + link_preview=link_preview, + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + silent=silent, + buttons=buttons, + ttl=ttl, + reply_to=reply_to, + clear_draft=clear_draft, + background=background, + schedule=schedule, + comment_to=comment_to, + noforwards=noforwards, + send_as=send_as + ) + +async def _send_album(self: 'TelegramClient', entity, files, caption='', + progress_callback=None, reply_to=None, + parse_mode=(), silent=None, schedule=None, + supports_streaming=None, clear_draft=None, + force_document=False, background=None, ttl=None, + send_as=None, noforwards=None): + """Specialized version of .send_file for albums""" + # We don't care if the user wants to avoid cache, we will use it + # anyway. Why? The cached version will be exactly the same thing + # we need to produce right now to send albums (uploadMedia), and + # cache only makes a difference for documents where the user may + # want the attributes used on them to change. + # + # In theory documents can be sent inside the albums but they appear + # as different messages (not inside the album), and the logic to set + # the attributes/avoid cache is already written in .send_file(). + entity = await self.get_input_entity(entity) + if not utils.is_list_like(caption): + caption = (caption,) + + captions = [] + for c in reversed(caption): # Pop from the end (so reverse) + captions.append(await self._parse_message_text(c or '', parse_mode)) + + reply_to = utils.get_message_id(reply_to) + + # Need to upload the media first, but only if they're not cached yet + media = [] + for file in files: + # Albums want :tl:`InputMedia` which, in theory, includes + # :tl:`InputMediaUploadedPhoto`. However using that will + # make it `raise MediaInvalidError`, so we need to upload + # it as media and then convert that to :tl:`InputMediaPhoto`. + fh, fm, _ = await _file_to_media( + self, file, supports_streaming=supports_streaming, + force_document=force_document, ttl=ttl) + if isinstance(fm, (_tl.InputMediaUploadedPhoto, _tl.InputMediaPhotoExternal)): + r = await self(_tl.fn.messages.UploadMedia( + entity, media=fm + )) + + fm = utils.get_input_media(r.photo) + elif isinstance(fm, _tl.InputMediaUploadedDocument): + r = await self(_tl.fn.messages.UploadMedia( + entity, media=fm + )) + + fm = utils.get_input_media( + r.document, supports_streaming=supports_streaming) + + if captions: + caption, msg_entities = captions.pop() + else: + caption, msg_entities = '', None + media.append(_tl.InputSingleMedia( + fm, + message=caption, + entities=msg_entities + # random_id is autogenerated + )) + + # Now we can construct the multi-media request + request = _tl.fn.messages.SendMultiMedia( + entity, reply_to_msg_id=reply_to, multi_media=media, + silent=silent, schedule_date=schedule, clear_draft=clear_draft, + background=background, noforwards=noforwards, send_as=send_as + ) + result = await self(request) + + random_ids = [m.random_id for m in media] + return self._get_response_message(random_ids, result, entity) + +async def upload_file( + self: 'TelegramClient', + 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) -> '_tl.TypeInputFile': + if isinstance(file, (_tl.InputFile, _tl.InputFileBig)): + return file # Already uploaded + + pos = 0 + async with helpers._FileStream(file, file_size=file_size) as stream: + # Opening the stream will determine the correct file size + file_size = stream.file_size + + if not part_size_kb: + part_size_kb = utils.get_appropriated_part_size(file_size) + + if part_size_kb > 512: + raise ValueError('The part size must be less or equal to 512KB') + + part_size = int(part_size_kb * 1024) + if part_size % 1024 != 0: + raise ValueError( + 'The part size must be evenly divisible by 1024') + + # Set a default file name if None was specified + file_id = helpers.generate_random_long() + if not file_name: + file_name = stream.name or str(file_id) + + # If the file name lacks extension, add it if possible. + # Else Telegram complains with `PHOTO_EXT_INVALID_ERROR` + # even if the uploaded image is indeed a photo. + if not os.path.splitext(file_name)[-1]: + file_name += utils._get_extension(stream) + + # Determine whether the file is too big (over 10MB) or not + # Telegram does make a distinction between smaller or larger files + is_big = file_size > 10 * 1024 * 1024 + hash_md5 = hashlib.md5() + + 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) + + pos = 0 + for part_index in range(part_count): + # Read the file by in chunks of size 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) + + # Encryption part if needed + if key and iv: + part = AES.encrypt_ige(part, key, iv) + + 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) + + # The SavePart is different depending on whether + # the file is too large or not (over or less than 10MB) + if is_big: + request = _tl.fn.upload.SaveBigFilePart( + file_id, part_index, part_count, part) + else: + request = _tl.fn.upload.SaveFilePart( + file_id, part_index, part) + + result = await self(request) + if result: + self._log[__name__].debug('Uploaded %d/%d', + part_index + 1, part_count) + if progress_callback: + await helpers._maybe_await(progress_callback(pos, file_size)) + else: + raise RuntimeError( + 'Failed to upload file part {}.'.format(part_index)) + + if is_big: + return _tl.InputFileBig(file_id, part_count, file_name) + else: + return _custom.InputSizedFile( + file_id, part_count, file_name, md5=hash_md5, size=file_size + ) + + diff --git a/telethon/_client/users.py b/telethon/_client/users.py new file mode 100644 index 00000000..9f381af6 --- /dev/null +++ b/telethon/_client/users.py @@ -0,0 +1,393 @@ +import asyncio +import datetime +import itertools +import time +import typing + +from ..errors._custom import MultiError +from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, UnauthorizedError +from .._misc import helpers, utils, hints +from .._sessions.types import Entity +from .. import errors, _tl +from .account import ignore_takeout + +_NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): + return ( + 'Sleeping%s for %ds (%s) on %s flood wait', + ' early' if early else '', + delay, + td(seconds=delay), + request.__class__.__name__ + ) + + +async def call(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): + return await _call(self, self._sender, request, ordered=ordered) + + +async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): + if flood_sleep_threshold is None: + flood_sleep_threshold = self.flood_sleep_threshold + requests = (request if utils.is_list_like(request) else (request,)) + for r in requests: + if not isinstance(r, _tl.TLRequest): + raise _NOT_A_REQUEST() + await r.resolve(self, utils) + + # Avoid making the request if it's already in a flood wait + if r.CONSTRUCTOR_ID in self._flood_waited_requests: + due = self._flood_waited_requests[r.CONSTRUCTOR_ID] + diff = round(due - time.time()) + if diff <= 3: # Flood waits below 3 seconds are "ignored" + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + elif diff <= flood_sleep_threshold: + self._log[__name__].info(*_fmt_flood(diff, r, early=True)) + await asyncio.sleep(diff) + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + else: + raise errors.FLOOD_WAIT(420, f'FLOOD_WAIT_{diff}', request=r) + + if self._session_state.takeout_id and not ignore_takeout.get(): + r = _tl.fn.InvokeWithTakeout(self._session_state.takeout_id, r) + + if self._no_updates: + r = _tl.fn.InvokeWithoutUpdates(r) + + request_index = 0 + last_error = None + self._last_request = time.time() + + for attempt in helpers.retry_range(self._request_retries): + try: + future = sender.send(request, ordered=ordered) + if isinstance(future, list): + results = [] + exceptions = [] + for f in future: + try: + result = await f + except RpcError as e: + exceptions.append(e) + results.append(None) + continue + exceptions.append(None) + results.append(result) + request_index += 1 + if any(x is not None for x in exceptions): + raise MultiError(exceptions, results, requests) + else: + return results + else: + result = await future + return result + except ServerError as e: + last_error = e + self._log[__name__].warning( + 'Telegram is having internal issues %s: %s', + e.__class__.__name__, e) + + await asyncio.sleep(2) + except FloodError as e: + last_error = e + if utils.is_list_like(request): + request = request[request_index] + + # SLOWMODE_WAIT is chat-specific, not request-specific + if not isinstance(e, errors.SLOWMODE_WAIT): + 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) + else: + raise + except InvalidDcError as e: + last_error = e + self._log[__name__].info('Phone migrated to %d', e.new_dc) + should_raise = isinstance(e, ( + errors.PHONE_MIGRATE, errors.NETWORK_MIGRATE + )) + if should_raise and await self.is_user_authorized(): + raise + await self._switch_dc(e.new_dc) + + raise last_error + + +async def get_me(self: 'TelegramClient', input_peer: bool = False) \ + -> 'typing.Union[_tl.User, _tl.InputPeerUser]': + try: + me = (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0] + return utils.get_input_peer(me, allow_self=False) if input_peer else me + except UnauthorizedError: + return None + +async def is_bot(self: 'TelegramClient') -> bool: + return self._session_state.bot if self._session_state else False + +async def is_user_authorized(self: 'TelegramClient') -> bool: + try: + # Any request that requires authorization will work + await self(_tl.fn.updates.GetState()) + return True + except RpcError: + return False + +async def get_entity( + self: 'TelegramClient', + entity: 'hints.EntitiesLike') -> 'hints.Entity': + single = not utils.is_list_like(entity) + if single: + entity = (entity,) + + # Group input entities by string (resolve username), + # input users (get users), input chat (get chats) and + # input channels (get channels) to get the most entities + # in the less amount of calls possible. + inputs = [] + for x in entity: + if isinstance(x, str): + inputs.append(x) + else: + inputs.append(await self.get_input_entity(x)) + + lists = { + helpers._EntityType.USER: [], + helpers._EntityType.CHAT: [], + helpers._EntityType.CHANNEL: [], + } + for x in inputs: + try: + lists[helpers._entity_type(x)].append(x) + except TypeError: + pass + + users = lists[helpers._EntityType.USER] + chats = lists[helpers._EntityType.CHAT] + channels = lists[helpers._EntityType.CHANNEL] + if users: + # GetUsers has a limit of 200 per call + tmp = [] + while users: + curr, users = users[:200], users[200:] + tmp.extend(await self(_tl.fn.users.GetUsers(curr))) + users = tmp + if chats: # TODO Handle chats slice? + chats = (await self( + _tl.fn.messages.GetChats([x.chat_id for x in chats]))).chats + if channels: + channels = (await self( + _tl.fn.channels.GetChannels(channels))).chats + + # Merge users, chats and channels into a single dictionary + id_entity = { + utils.get_peer_id(x): x + for x in itertools.chain(users, chats, channels) + } + + # We could check saved usernames and put them into the users, + # chats and channels list from before. While this would reduce + # the amount of ResolveUsername calls, it would fail to catch + # username changes. + result = [] + for x in inputs: + if isinstance(x, str): + result.append(await _get_entity_from_string(self, x)) + elif not isinstance(x, _tl.InputPeerSelf): + result.append(id_entity[utils.get_peer_id(x)]) + else: + result.append(next( + u for u in id_entity.values() + if isinstance(u, _tl.User) and u.is_self + )) + + return result[0] if single else result + +async def get_input_entity( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> '_tl.TypeInputPeer': + # Short-circuit if the input parameter directly maps to an InputPeer + try: + return utils.get_input_peer(peer) + except TypeError: + pass + + # Then come known strings that take precedence + if peer in ('me', 'self'): + return _tl.InputPeerSelf() + + # No InputPeer, cached peer, or known string. Fetch from session cache + try: + peer_id = utils.get_peer_id(peer) + except TypeError: + pass + else: + entity = await self._session.get_entity(None, peer_id) + if entity: + if entity.ty in (Entity.USER, Entity.BOT): + return _tl.InputPeerUser(entity.id, entity.access_hash) + elif entity.ty in (Entity.GROUP): + return _tl.InputPeerChat(peer.chat_id) + elif entity.ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP): + return _tl.InputPeerChannel(entity.id, entity.access_hash) + + # Only network left to try + if isinstance(peer, str): + return utils.get_input_peer( + await _get_entity_from_string(self, peer)) + + # If we're a bot and the user has messaged us privately users.getUsers + # will work with access_hash = 0. Similar for channels.getChannels. + # If we're not a bot but the user is in our contacts, it seems to work + # regardless. These are the only two special-cased requests. + peer = utils.get_peer(peer) + if isinstance(peer, _tl.PeerUser): + users = await self(_tl.fn.users.GetUsers([ + _tl.InputUser(peer.user_id, access_hash=0)])) + if users and not isinstance(users[0], _tl.UserEmpty): + # If the user passed a valid ID they expect to work for + # channels but would be valid for users, we get UserEmpty. + # Avoid returning the invalid empty input peer for that. + # + # We *could* try to guess if it's a channel first, and if + # it's not, work as a chat and try to validate it through + # another request, but that becomes too much work. + return utils.get_input_peer(users[0]) + elif isinstance(peer, _tl.PeerChat): + return _tl.InputPeerChat(peer.chat_id) + elif isinstance(peer, _tl.PeerChannel): + try: + channels = await self(_tl.fn.channels.GetChannels([ + _tl.InputChannel(peer.channel_id, access_hash=0)])) + return utils.get_input_peer(channels.chats[0]) + except errors.CHANNEL_INVALID: + pass + + raise ValueError( + 'Could not find the input entity for {} ({}). Please read https://' + 'docs.telethon.dev/en/latest/concepts/entities.html to' + ' find out more details.' + .format(peer, type(peer).__name__) + ) + +async def get_peer_id( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> int: + if isinstance(peer, int): + return utils.get_peer_id(peer) + + try: + if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): + # 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer' + peer = await self.get_input_entity(peer) + except AttributeError: + peer = await self.get_input_entity(peer) + + if isinstance(peer, _tl.InputPeerSelf): + peer = await self.get_me(input_peer=True) + + return utils.get_peer_id(peer) + + +async def _get_entity_from_string(self: 'TelegramClient', string): + """ + Gets a full entity from the given string, which may be a phone or + a username, and processes all the found entities on the session. + The string may also be a user link, or a channel/chat invite link. + + This method has the side effect of adding the found users to the + session database, so it can be queried later without API calls, + if this option is enabled on the session. + + Returns the found entity, or raises TypeError if not found. + """ + phone = utils.parse_phone(string) + if phone: + try: + for user in (await self( + _tl.fn.contacts.GetContacts(0))).users: + if user.phone == phone: + return user + except errors.BOT_METHOD_INVALID: + raise ValueError('Cannot get entity by phone number as a ' + 'bot (try using integer IDs, not strings)') + elif string.lower() in ('me', 'self'): + return await self.get_me() + else: + username, is_join_chat = utils.parse_username(string) + if is_join_chat: + invite = await self( + _tl.fn.messages.CheckChatInvite(username)) + + if isinstance(invite, _tl.ChatInvite): + raise ValueError( + 'Cannot get entity from a channel (or group) ' + 'that you are not part of. Join the group and retry' + ) + elif isinstance(invite, _tl.ChatInviteAlready): + return invite.chat + elif username: + try: + result = await self( + _tl.fn.contacts.ResolveUsername(username)) + except errors.USERNAME_NOT_OCCUPIED as e: + raise ValueError('No user has "{}" as username' + .format(username)) from e + + try: + pid = utils.get_peer_id(result.peer) + if isinstance(result.peer, _tl.PeerUser): + return next(x for x in result.users if x.id == pid) + else: + return next(x for x in result.chats if x.id == pid) + except StopIteration: + pass + + raise ValueError( + 'Cannot find any entity corresponding to "{}"'.format(string) + ) + +async def _get_input_dialog(self: 'TelegramClient', dialog): + """ + Returns a :tl:`InputDialogPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') + dialog.peer = await self.get_input_entity(dialog.peer) + return dialog + elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') + return _tl.InputDialogPeer(dialog) + except AttributeError: + pass + + return _tl.InputDialogPeer(await self.get_input_entity(dialog)) + +async def _get_input_notify(self: 'TelegramClient', notify): + """ + Returns a :tl:`InputNotifyPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if notify.SUBCLASS_OF_ID == 0x58981615: + if isinstance(notify, _tl.InputNotifyPeer): + notify.peer = await self.get_input_entity(notify.peer) + return notify + except AttributeError: + pass + + return _tl.InputNotifyPeer(await self.get_input_entity(notify)) diff --git a/telethon/crypto/__init__.py b/telethon/_crypto/__init__.py similarity index 88% rename from telethon/crypto/__init__.py rename to telethon/_crypto/__init__.py index 69be1da8..f10c9ad7 100644 --- a/telethon/crypto/__init__.py +++ b/telethon/_crypto/__init__.py @@ -7,4 +7,3 @@ from .aes import AES from .aesctr import AESModeCTR from .authkey import AuthKey from .factorization import Factorization -from .cdndecrypter import CdnDecrypter diff --git a/telethon/crypto/aes.py b/telethon/_crypto/aes.py similarity index 100% rename from telethon/crypto/aes.py rename to telethon/_crypto/aes.py diff --git a/telethon/crypto/aesctr.py b/telethon/_crypto/aesctr.py similarity index 100% rename from telethon/crypto/aesctr.py rename to telethon/_crypto/aesctr.py diff --git a/telethon/crypto/authkey.py b/telethon/_crypto/authkey.py similarity index 97% rename from telethon/crypto/authkey.py rename to telethon/_crypto/authkey.py index 8475ec17..54c66c76 100644 --- a/telethon/crypto/authkey.py +++ b/telethon/_crypto/authkey.py @@ -4,7 +4,7 @@ This module holds the AuthKey class. import struct from hashlib import sha1 -from ..extensions import BinaryReader +from .._misc.binaryreader import BinaryReader class AuthKey: diff --git a/telethon/crypto/factorization.py b/telethon/_crypto/factorization.py similarity index 100% rename from telethon/crypto/factorization.py rename to telethon/_crypto/factorization.py diff --git a/telethon/crypto/libssl.py b/telethon/_crypto/libssl.py similarity index 100% rename from telethon/crypto/libssl.py rename to telethon/_crypto/libssl.py diff --git a/telethon/crypto/rsa.py b/telethon/_crypto/rsa.py similarity index 97% rename from telethon/crypto/rsa.py rename to telethon/_crypto/rsa.py index 91ca7bad..eca09743 100644 --- a/telethon/crypto/rsa.py +++ b/telethon/_crypto/rsa.py @@ -11,7 +11,7 @@ except ImportError: rsa = None raise ImportError('Missing module "rsa", please install via pip.') -from ..tl import TLObject +from .._misc import tlobject # {fingerprint: (Crypto.PublicKey.RSA._RSAobj, old)} dictionary @@ -41,8 +41,8 @@ def _compute_fingerprint(key): :param key: the Crypto.RSA key. :return: its 8-bytes-long fingerprint. """ - n = TLObject.serialize_bytes(get_byte_array(key.n)) - e = TLObject.serialize_bytes(get_byte_array(key.e)) + n = tlobject.TLObject.serialize_bytes(get_byte_array(key.n)) + e = tlobject.TLObject.serialize_bytes(get_byte_array(key.e)) # Telegram uses the last 8 bytes as the fingerprint return struct.unpack(' 1: return super().filter(event) - class Event(EventCommon, SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a new album. Members: - messages (Sequence[`Message `]): + messages (Sequence[`Message `]): The list of messages belonging to the same album. """ def __init__(self, messages): message = messages[0] - if not message.out and isinstance(message.peer_id, types.PeerUser): + if not message.out and isinstance(message.peer_id, _tl.PeerUser): # Incoming message (e.g. from a bot) has peer_id=us, and # from_id=bot (the actual "chat" from a user's perspective). chat_peer = message.from_id @@ -160,16 +160,17 @@ class Album(EventBuilder): super().__init__(chat_peer=chat_peer, msg_id=message.id, broadcast=bool(message.post)) - SenderGetter.__init__(self, message.sender_id) + _custom.sendergetter.SenderGetter.__init__(self, message.sender_id) self.messages = messages def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) - for msg in self.messages: - msg._finish_init(client, self._entities, None) + self.messages = [ + _custom.Message._new(client, m, self._entities, None) + for m in self.messages + ] if len(self.messages) == 1: # This will require hacks to be a proper album event @@ -217,7 +218,7 @@ class Album(EventBuilder): @property def forward(self): """ - The `Forward ` + The `Forward ` information for the first message in the album if it was forwarded. """ # Each individual message in an album all reply to the same message @@ -229,7 +230,7 @@ class Album(EventBuilder): async def get_reply_message(self): """ - The `Message ` + The `Message ` that this album is replying to, or `None`. The result will be cached after its first use. @@ -308,12 +309,12 @@ class Album(EventBuilder): async def mark_read(self): """ Marks the entire album as read. Shorthand for - `client.send_read_acknowledge() - ` + `client.mark_read() + ` with both ``entity`` and ``message`` already set. """ if self._client: - await self._client.send_read_acknowledge( + await self._client.mark_read( await self.get_input_chat(), max_id=self.messages[-1].id) async def pin(self, *, notify=False): diff --git a/telethon/events/__init__.py b/telethon/_events/base.py similarity index 92% rename from telethon/events/__init__.py rename to telethon/_events/base.py index 28f85b12..8f913ad7 100644 --- a/telethon/events/__init__.py +++ b/telethon/_events/base.py @@ -1,13 +1,4 @@ from .raw import Raw -from .album import Album -from .chataction import ChatAction -from .messagedeleted import MessageDeleted -from .messageedited import MessageEdited -from .messageread import MessageRead -from .newmessage import NewMessage -from .userupdate import UserUpdate -from .callbackquery import CallbackQuery -from .inlinequery import InlineQuery _HANDLERS_ATTRIBUTE = '__tl.handlers' diff --git a/telethon/events/callbackquery.py b/telethon/_events/callbackquery.py similarity index 84% rename from telethon/events/callbackquery.py rename to telethon/_events/callbackquery.py index 94e03b7b..0e3e2d67 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/_events/callbackquery.py @@ -1,10 +1,26 @@ import re import struct +import asyncio +import functools from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types, functions -from ..tl.custom.sendergetter import SenderGetter +from .._misc import utils +from .. import _tl +from ..types import _custom + + +def auto_answer(func): + @functools.wraps(func) + async def wrapped(self, *args, **kwargs): + if self._answered: + return await func(*args, **kwargs) + else: + return (await asyncio.gather( + self._answer(), + func(*args, **kwargs), + ))[1] + + return wrapped @name_inner_event @@ -87,14 +103,14 @@ class CallbackQuery(EventBuilder): )) @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateBotCallbackQuery): + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, _tl.UpdateBotCallbackQuery): return cls.Event(update, update.peer, update.msg_id) - elif isinstance(update, types.UpdateInlineBotCallbackQuery): + elif isinstance(update, _tl.UpdateInlineBotCallbackQuery): # See https://github.com/LonamiWebs/Telethon/pull/1005 # The long message ID is actually just msg_id + peer_id mid, pid = struct.unpack('`, + `Message.edit `, since the message object is normally not present. """ - self._client.loop.create_task(self.answer()) - if isinstance(self.query.msg_id, types.InputBotInlineMessageID): + if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID): return await self._client.edit_message( - self.query.msg_id, *args, **kwargs + None, self.query.msg_id, *args, **kwargs ) else: return await self._client.edit_message( @@ -322,6 +333,7 @@ class CallbackQuery(EventBuilder): *args, **kwargs ) + @auto_answer async def delete(self, *args, **kwargs): """ Deletes the message. Shorthand for @@ -332,11 +344,10 @@ class CallbackQuery(EventBuilder): this `delete` method. Use a `telethon.client.telegramclient.TelegramClient` instance directly. - This method also creates a task to `answer` the callback. + This method will also `answer` the callback if necessary. This method will likely fail if `via_inline` is `True`. """ - self._client.loop.create_task(self.answer()) return await self._client.delete_messages( await self.get_input_chat(), [self.query.msg_id], *args, **kwargs diff --git a/telethon/events/chataction.py b/telethon/_events/chataction.py similarity index 86% rename from telethon/events/chataction.py rename to telethon/_events/chataction.py index 8261d6e1..0bf83aa1 100644 --- a/telethon/events/chataction.py +++ b/telethon/_events/chataction.py @@ -1,6 +1,7 @@ from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types +from .._misc import utils +from .. import _tl +from ..types import _custom @name_inner_event @@ -32,27 +33,27 @@ class ChatAction(EventBuilder): """ @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): # Rely on specific pin updates for unpins, but otherwise ignore them # for new pins (we'd rather handle the new service message with pin, # so that we can act on that message'). - if isinstance(update, types.UpdatePinnedChannelMessages) and not update.pinned: - return cls.Event(types.PeerChannel(update.channel_id), + if isinstance(update, _tl.UpdatePinnedChannelMessages) and not update.pinned: + return cls.Event(_tl.PeerChannel(update.channel_id), pin_ids=update.messages, pin=update.pinned) - elif isinstance(update, types.UpdatePinnedMessages) and not update.pinned: + elif isinstance(update, _tl.UpdatePinnedMessages) and not update.pinned: return cls.Event(update.peer, pin_ids=update.messages, pin=update.pinned) - elif isinstance(update, types.UpdateChatParticipantAdd): - return cls.Event(types.PeerChat(update.chat_id), + elif isinstance(update, _tl.UpdateChatParticipantAdd): + return cls.Event(_tl.PeerChat(update.chat_id), added_by=update.inviter_id or True, users=update.user_id) - elif isinstance(update, types.UpdateChatParticipantDelete): - return cls.Event(types.PeerChat(update.chat_id), + elif isinstance(update, _tl.UpdateChatParticipantDelete): + return cls.Event(_tl.PeerChat(update.chat_id), kicked_by=True, users=update.user_id) @@ -61,50 +62,55 @@ class ChatAction(EventBuilder): # better not to rely on this. Rely only in MessageActionChatDeleteUser. elif (isinstance(update, ( - types.UpdateNewMessage, types.UpdateNewChannelMessage)) - and isinstance(update.message, types.MessageService)): + _tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)) + and isinstance(update.message, _tl.MessageService)): msg = update.message action = update.message.action - if isinstance(action, types.MessageActionChatJoinedByLink): + if isinstance(action, _tl.MessageActionChatJoinedByLink): return cls.Event(msg, added_by=True, users=msg.from_id) - elif isinstance(action, types.MessageActionChatAddUser): + elif isinstance(action, _tl.MessageActionChatAddUser): # If a user adds itself, it means they joined via the public chat username added_by = ([msg.sender_id] == action.users) or msg.from_id return cls.Event(msg, added_by=added_by, users=action.users) - elif isinstance(action, types.MessageActionChatDeleteUser): + elif isinstance(action, _tl.MessageActionChatJoinedByRequest): + # user joined from join request (after getting admin approval) + return cls.Event(msg, + from_approval=True, + users=msg.from_id) + elif isinstance(action, _tl.MessageActionChatDeleteUser): return cls.Event(msg, kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True, users=action.user_id) - elif isinstance(action, types.MessageActionChatCreate): + elif isinstance(action, _tl.MessageActionChatCreate): return cls.Event(msg, users=action.users, created=True, new_title=action.title) - elif isinstance(action, types.MessageActionChannelCreate): + elif isinstance(action, _tl.MessageActionChannelCreate): return cls.Event(msg, created=True, users=msg.from_id, new_title=action.title) - elif isinstance(action, types.MessageActionChatEditTitle): + elif isinstance(action, _tl.MessageActionChatEditTitle): return cls.Event(msg, users=msg.from_id, new_title=action.title) - elif isinstance(action, types.MessageActionChatEditPhoto): + elif isinstance(action, _tl.MessageActionChatEditPhoto): return cls.Event(msg, users=msg.from_id, new_photo=action.photo) - elif isinstance(action, types.MessageActionChatDeletePhoto): + elif isinstance(action, _tl.MessageActionChatDeletePhoto): return cls.Event(msg, users=msg.from_id, new_photo=True) - elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to: + elif isinstance(action, _tl.MessageActionPinMessage) and msg.reply_to: return cls.Event(msg, pin_ids=[msg.reply_to_msg_id]) - elif isinstance(action, types.MessageActionGameScore): + elif isinstance(action, _tl.MessageActionGameScore): return cls.Event(msg, new_score=action.score) @@ -137,12 +143,16 @@ class ChatAction(EventBuilder): user_kicked (`bool`): `True` if the user was kicked by some other. + user_approved (`bool`): + `True` if the user's join request was approved. + along with `user_joined` will be also True. + created (`bool`, optional): `True` if this chat was just created. new_title (`str`, optional): The new title string for the chat, if applicable. - + new_score (`str`, optional): The new score string for the game, if applicable. @@ -151,9 +161,9 @@ class ChatAction(EventBuilder): """ def __init__(self, where, new_photo=None, - added_by=None, kicked_by=None, created=None, + added_by=None, kicked_by=None, created=None, from_approval=None, users=None, new_title=None, pin_ids=None, pin=None, new_score=None): - if isinstance(where, types.MessageService): + if isinstance(where, _tl.MessageService): self.action_message = where where = where.peer_id else: @@ -169,18 +179,19 @@ class ChatAction(EventBuilder): self.new_photo = new_photo is not None self.photo = \ - new_photo if isinstance(new_photo, types.Photo) else None + new_photo if isinstance(new_photo, _tl.Photo) else None self._added_by = None self._kicked_by = None self.user_added = self.user_joined = self.user_left = \ self.user_kicked = self.unpin = False - if added_by is True: + if added_by is True or from_approval is True: self.user_joined = True elif added_by: self.user_added = True self._added_by = added_by + self.user_approved = from_approval # If `from_id` was not present (it's `True`) or the affected # user was "kicked by itself", then it left. Else it was kicked. @@ -205,11 +216,6 @@ class ChatAction(EventBuilder): self.new_score = new_score self.unpin = not pin - def _set_client(self, client): - super()._set_client(client) - if self.action_message: - self.action_message._finish_init(client, self._entities, None) - async def respond(self, *args, **kwargs): """ Responds to the chat action message (not as a reply). Shorthand for @@ -283,7 +289,7 @@ class ChatAction(EventBuilder): """ The user who added ``users``, if applicable (`None` otherwise). """ - if self._added_by and not isinstance(self._added_by, types.User): + if self._added_by and not isinstance(self._added_by, _tl.User): aby = self._entities.get(utils.get_peer_id(self._added_by)) if aby: self._added_by = aby @@ -304,7 +310,7 @@ class ChatAction(EventBuilder): """ The user who kicked ``users``, if applicable (`None` otherwise). """ - if self._kicked_by and not isinstance(self._kicked_by, types.User): + if self._kicked_by and not isinstance(self._kicked_by, _tl.User): kby = self._entities.get(utils.get_peer_id(self._kicked_by)) if kby: self._kicked_by = kby @@ -393,7 +399,7 @@ class ChatAction(EventBuilder): await self.action_message._reload_message() self._users = [ u for u in self.action_message.action_entities - if isinstance(u, (types.User, types.UserEmpty))] + if isinstance(u, (_tl.User, _tl.UserEmpty))] return self._users @@ -405,20 +411,13 @@ 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 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): @@ -433,7 +432,7 @@ class ChatAction(EventBuilder): self._input_users = [ utils.get_input_peer(u) for u in self.action_message.action_entities - if isinstance(u, (types.User, types.UserEmpty))] + if isinstance(u, (_tl.User, _tl.UserEmpty))] return self._input_users or [] diff --git a/telethon/events/common.py b/telethon/_events/common.py similarity index 86% rename from telethon/events/common.py rename to telethon/_events/common.py index e8978f31..fb941980 100644 --- a/telethon/events/common.py +++ b/telethon/_events/common.py @@ -2,9 +2,9 @@ import abc import asyncio import warnings -from .. import utils -from ..tl import TLObject, types -from ..tl.custom.chatgetter import ChatGetter +from .. import _tl +from .._misc import utils, tlobject +from ..types._custom.chatgetter import ChatGetter async def _into_id_set(client, chats): @@ -18,20 +18,13 @@ async def _into_id_set(client, chats): result = set() for chat in chats: if isinstance(chat, int): - if chat < 0: - result.add(chat) # Explicitly marked IDs are negative - else: - result.update({ # Support all valid types of peers - utils.get_peer_id(types.PeerUser(chat)), - utils.get_peer_id(types.PeerChat(chat)), - utils.get_peer_id(types.PeerChannel(chat)), - }) - elif isinstance(chat, TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: + result.add(chat) + elif isinstance(chat, tlobject.TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: # 0x2d45687 == crc32(b'Peer') result.add(utils.get_peer_id(chat)) else: chat = await client.get_input_entity(chat) - if isinstance(chat, types.InputPeerSelf): + if isinstance(chat, _tl.InputPeerSelf): chat = await client.get_me(input_peer=True) result.add(utils.get_peer_id(chat)) @@ -74,7 +67,7 @@ class EventBuilder(abc.ABC): @classmethod @abc.abstractmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others, self_id, entities, client): """ Builds an event for the given update if possible, or returns None. @@ -151,10 +144,10 @@ class EventCommon(ChatGetter, abc.ABC): """ Setter so subclasses can act accordingly when the client is set. """ + # TODO Nuke self._client = client if self._chat_peer: - self._chat, self._input_chat = utils._get_entity_pair( - self.chat_id, self._entities, client._entity_cache) + self._chat, self._input_chat = utils._get_entity_pair(self.chat_id, self._entities) else: self._chat = self._input_chat = None @@ -166,10 +159,10 @@ class EventCommon(ChatGetter, abc.ABC): return self._client def __str__(self): - return TLObject.pretty_format(self.to_dict()) + return _tl.TLObject.pretty_format(self.to_dict()) def stringify(self): - return TLObject.pretty_format(self.to_dict(), indent=0) + return _tl.TLObject.pretty_format(self.to_dict(), indent=0) def to_dict(self): d = {k: v for k, v in self.__dict__.items() if k[0] != '_'} diff --git a/telethon/events/inlinequery.py b/telethon/_events/inlinequery.py similarity index 92% rename from telethon/events/inlinequery.py rename to telethon/_events/inlinequery.py index 75cfcb01..76401962 100644 --- a/telethon/events/inlinequery.py +++ b/telethon/_events/inlinequery.py @@ -4,9 +4,9 @@ import re import asyncio from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types, functions, custom -from ..tl.custom.sendergetter import SenderGetter +from .._misc import utils +from .. import _tl +from ..types import _custom @name_inner_event @@ -61,8 +61,8 @@ class InlineQuery(EventBuilder): raise TypeError('Invalid pattern type given') @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateBotInlineQuery): + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, _tl.UpdateBotInlineQuery): return cls.Event(update) def filter(self, event): @@ -74,7 +74,7 @@ class InlineQuery(EventBuilder): return super().filter(event) - class Event(EventCommon, SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a new callback query. @@ -90,16 +90,15 @@ class InlineQuery(EventBuilder): function, which is ``re.compile(...).match`` by default. """ def __init__(self, query): - super().__init__(chat_peer=types.PeerUser(query.user_id)) - SenderGetter.__init__(self, query.user_id) + super().__init__(chat_peer=_tl.PeerUser(query.user_id)) + _custom.sendergetter.SenderGetter.__init__(self, query.user_id) self.query = query self.pattern_match = None self._answered = False def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) @property def id(self): @@ -223,10 +222,10 @@ class InlineQuery(EventBuilder): results = [] if switch_pm: - switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param) + switch_pm = _tl.InlineBotSwitchPM(switch_pm, switch_pm_param) return await self._client( - functions.messages.SetInlineBotResultsRequest( + _tl.fn.messages.SetInlineBotResults( query_id=self.query.query_id, results=results, cache_time=cache_time, @@ -242,6 +241,6 @@ class InlineQuery(EventBuilder): if inspect.isawaitable(obj): return asyncio.ensure_future(obj) - f = asyncio.get_event_loop().create_future() + f = asyncio.get_running_loop().create_future() f.set_result(obj) return f diff --git a/telethon/events/messagedeleted.py b/telethon/_events/messagedeleted.py similarity index 87% rename from telethon/events/messagedeleted.py rename to telethon/_events/messagedeleted.py index f631fd4f..58f9ff5f 100644 --- a/telethon/events/messagedeleted.py +++ b/telethon/_events/messagedeleted.py @@ -1,5 +1,5 @@ from .common import EventBuilder, EventCommon, name_inner_event -from ..tl import types +from .. import _tl @name_inner_event @@ -36,16 +36,16 @@ class MessageDeleted(EventBuilder): print('Message', msg_id, 'was deleted in', event.chat_id) """ @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateDeleteMessages): + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, _tl.UpdateDeleteMessages): return cls.Event( deleted_ids=update.messages, peer=None ) - elif isinstance(update, types.UpdateDeleteChannelMessages): + elif isinstance(update, _tl.UpdateDeleteChannelMessages): return cls.Event( deleted_ids=update.messages, - peer=types.PeerChannel(update.channel_id) + peer=_tl.PeerChannel(update.channel_id) ) class Event(EventCommon): diff --git a/telethon/events/messageedited.py b/telethon/_events/messageedited.py similarity index 89% rename from telethon/events/messageedited.py rename to telethon/_events/messageedited.py index c4a2b4a7..3f430a68 100644 --- a/telethon/events/messageedited.py +++ b/telethon/_events/messageedited.py @@ -1,6 +1,6 @@ from .common import name_inner_event from .newmessage import NewMessage -from ..tl import types +from .. import _tl @name_inner_event @@ -43,9 +43,9 @@ class MessageEdited(NewMessage): print('Message', event.id, 'changed at', event.date) """ @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, (types.UpdateEditMessage, - types.UpdateEditChannelMessage)): + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, (_tl.UpdateEditMessage, + _tl.UpdateEditChannelMessage)): return cls.Event(update.message) class Event(NewMessage.Event): diff --git a/telethon/events/messageread.py b/telethon/_events/messageread.py similarity index 87% rename from telethon/events/messageread.py rename to telethon/_events/messageread.py index 29f17ab8..0cd50de0 100644 --- a/telethon/events/messageread.py +++ b/telethon/_events/messageread.py @@ -1,6 +1,6 @@ from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types +from .._misc import utils +from .. import _tl @name_inner_event @@ -35,22 +35,22 @@ class MessageRead(EventBuilder): self.inbox = inbox @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateReadHistoryInbox): + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, _tl.UpdateReadHistoryInbox): return cls.Event(update.peer, update.max_id, False) - elif isinstance(update, types.UpdateReadHistoryOutbox): + elif isinstance(update, _tl.UpdateReadHistoryOutbox): return cls.Event(update.peer, update.max_id, True) - elif isinstance(update, types.UpdateReadChannelInbox): - return cls.Event(types.PeerChannel(update.channel_id), + elif isinstance(update, _tl.UpdateReadChannelInbox): + return cls.Event(_tl.PeerChannel(update.channel_id), update.max_id, False) - elif isinstance(update, types.UpdateReadChannelOutbox): - return cls.Event(types.PeerChannel(update.channel_id), + elif isinstance(update, _tl.UpdateReadChannelOutbox): + return cls.Event(_tl.PeerChannel(update.channel_id), update.max_id, True) - elif isinstance(update, types.UpdateReadMessagesContents): + elif isinstance(update, _tl.UpdateReadMessagesContents): return cls.Event(message_ids=update.messages, contents=True) - elif isinstance(update, types.UpdateChannelReadMessagesContents): - return cls.Event(types.PeerChannel(update.channel_id), + elif isinstance(update, _tl.UpdateChannelReadMessagesContents): + return cls.Event(_tl.PeerChannel(update.channel_id), message_ids=update.messages, contents=True) diff --git a/telethon/events/newmessage.py b/telethon/_events/newmessage.py similarity index 90% rename from telethon/events/newmessage.py rename to telethon/_events/newmessage.py index d2077a71..e4887002 100644 --- a/telethon/events/newmessage.py +++ b/telethon/_events/newmessage.py @@ -1,8 +1,9 @@ import re from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set -from .. import utils -from ..tl import types +from .._misc import utils +from .. import _tl +from ..types import _custom @name_inner_event @@ -94,21 +95,21 @@ class NewMessage(EventBuilder): self.from_users = await _into_id_set(client, self.from_users) @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others, self_id, entities, client): if isinstance(update, - (types.UpdateNewMessage, types.UpdateNewChannelMessage)): - if not isinstance(update.message, types.Message): + (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)): + if not isinstance(update.message, _tl.Message): return # We don't care about MessageService's here - event = cls.Event(update.message) - elif isinstance(update, types.UpdateShortMessage): - event = cls.Event(types.Message( + msg = update.message + elif isinstance(update, _tl.UpdateShortMessage): + msg = _tl.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, silent=update.silent, id=update.id, - peer_id=types.PeerUser(update.user_id), - from_id=types.PeerUser(self_id if update.out else update.user_id), + peer_id=_tl.PeerUser(update.user_id), + from_id=_tl.PeerUser(self_id if update.out else update.user_id), message=update.message, date=update.date, fwd_from=update.fwd_from, @@ -116,16 +117,16 @@ class NewMessage(EventBuilder): reply_to=update.reply_to, entities=update.entities, ttl_period=update.ttl_period - )) - elif isinstance(update, types.UpdateShortChatMessage): - event = cls.Event(types.Message( + ) + elif isinstance(update, _tl.UpdateShortChatMessage): + msg = _tl.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, silent=update.silent, id=update.id, - from_id=types.PeerUser(self_id if update.out else update.from_id), - peer_id=types.PeerChat(update.chat_id), + from_id=_tl.PeerUser(self_id if update.out else update.from_id), + peer_id=_tl.PeerChat(update.chat_id), message=update.message, date=update.date, fwd_from=update.fwd_from, @@ -133,11 +134,11 @@ class NewMessage(EventBuilder): reply_to=update.reply_to, entities=update.entities, ttl_period=update.ttl_period - )) + ) else: return - return event + return cls.Event(_custom.Message._new(client, msg, entities, None)) def filter(self, event): if self._no_check: @@ -207,7 +208,6 @@ class NewMessage(EventBuilder): def _set_client(self, client): super()._set_client(client) m = self.message - m._finish_init(client, self._entities, None) self.__dict__['_init'] = True # No new attributes can be set def __getattr__(self, item): diff --git a/telethon/events/raw.py b/telethon/_events/raw.py similarity index 94% rename from telethon/events/raw.py rename to telethon/_events/raw.py index 84910778..496f39e5 100644 --- a/telethon/events/raw.py +++ b/telethon/_events/raw.py @@ -1,5 +1,5 @@ from .common import EventBuilder -from .. import utils +from .._misc import utils class Raw(EventBuilder): @@ -42,7 +42,7 @@ class Raw(EventBuilder): self.resolved = True @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): return update def filter(self, event): diff --git a/telethon/events/userupdate.py b/telethon/_events/userupdate.py similarity index 74% rename from telethon/events/userupdate.py rename to telethon/_events/userupdate.py index e0179f01..35e8044c 100644 --- a/telethon/events/userupdate.py +++ b/telethon/_events/userupdate.py @@ -2,9 +2,9 @@ import datetime import functools from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types -from ..tl.custom.sendergetter import SenderGetter +from .._misc import utils +from .. import _tl +from ..types import _custom # TODO Either the properties are poorly named or they should be @@ -49,23 +49,23 @@ class UserUpdate(EventBuilder): await client.send_message(event.user_id, 'What are you sending?') """ @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateUserStatus): - return cls.Event(types.PeerUser(update.user_id), + def build(cls, update, others=None, self_id=None, *todo, **todo2): + if isinstance(update, _tl.UpdateUserStatus): + return cls.Event(_tl.PeerUser(update.user_id), status=update.status) - elif isinstance(update, types.UpdateChannelUserTyping): + elif isinstance(update, _tl.UpdateChannelUserTyping): return cls.Event(update.from_id, - chat_peer=types.PeerChannel(update.channel_id), + chat_peer=_tl.PeerChannel(update.channel_id), typing=update.action) - elif isinstance(update, types.UpdateChatUserTyping): + elif isinstance(update, _tl.UpdateChatUserTyping): return cls.Event(update.from_id, - chat_peer=types.PeerChat(update.chat_id), + chat_peer=_tl.PeerChat(update.chat_id), typing=update.action) - elif isinstance(update, types.UpdateUserTyping): + elif isinstance(update, _tl.UpdateUserTyping): return cls.Event(update.user_id, typing=update.action) - class Event(EventCommon, SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a user update such as gone online, started typing, etc. @@ -87,15 +87,14 @@ class UserUpdate(EventBuilder): """ def __init__(self, peer, *, status=None, chat_peer=None, typing=None): super().__init__(chat_peer or peer) - SenderGetter.__init__(self, utils.get_peer_id(peer)) + _custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer)) self.status = status self.action = typing def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) @property def user(self): @@ -126,7 +125,7 @@ class UserUpdate(EventBuilder): """ `True` if the action is typing a message. """ - return isinstance(self.action, types.SendMessageTypingAction) + return isinstance(self.action, _tl.SendMessageTypingAction) @property @_requires_action @@ -135,13 +134,13 @@ class UserUpdate(EventBuilder): `True` if the action is uploading something. """ return isinstance(self.action, ( - types.SendMessageChooseContactAction, - types.SendMessageChooseStickerAction, - types.SendMessageUploadAudioAction, - types.SendMessageUploadDocumentAction, - types.SendMessageUploadPhotoAction, - types.SendMessageUploadRoundAction, - types.SendMessageUploadVideoAction + _tl.SendMessageChooseContactAction, + _tl.SendMessageChooseStickerAction, + _tl.SendMessageUploadAudioAction, + _tl.SendMessageUploadDocumentAction, + _tl.SendMessageUploadPhotoAction, + _tl.SendMessageUploadRoundAction, + _tl.SendMessageUploadVideoAction )) @property @@ -151,9 +150,9 @@ class UserUpdate(EventBuilder): `True` if the action is recording something. """ return isinstance(self.action, ( - types.SendMessageRecordAudioAction, - types.SendMessageRecordRoundAction, - types.SendMessageRecordVideoAction + _tl.SendMessageRecordAudioAction, + _tl.SendMessageRecordRoundAction, + _tl.SendMessageRecordVideoAction )) @property @@ -162,7 +161,7 @@ class UserUpdate(EventBuilder): """ `True` if the action is playing a game. """ - return isinstance(self.action, types.SendMessageGamePlayAction) + return isinstance(self.action, _tl.SendMessageGamePlayAction) @property @_requires_action @@ -170,7 +169,7 @@ class UserUpdate(EventBuilder): """ `True` if the action was cancelling other actions. """ - return isinstance(self.action, types.SendMessageCancelAction) + return isinstance(self.action, _tl.SendMessageCancelAction) @property @_requires_action @@ -178,7 +177,7 @@ class UserUpdate(EventBuilder): """ `True` if what's being uploaded is a geo. """ - return isinstance(self.action, types.SendMessageGeoLocationAction) + return isinstance(self.action, _tl.SendMessageGeoLocationAction) @property @_requires_action @@ -187,8 +186,8 @@ class UserUpdate(EventBuilder): `True` if what's being recorded/uploaded is an audio. """ return isinstance(self.action, ( - types.SendMessageRecordAudioAction, - types.SendMessageUploadAudioAction + _tl.SendMessageRecordAudioAction, + _tl.SendMessageUploadAudioAction )) @property @@ -198,8 +197,8 @@ class UserUpdate(EventBuilder): `True` if what's being recorded/uploaded is a round video. """ return isinstance(self.action, ( - types.SendMessageRecordRoundAction, - types.SendMessageUploadRoundAction + _tl.SendMessageRecordRoundAction, + _tl.SendMessageUploadRoundAction )) @property @@ -209,8 +208,8 @@ class UserUpdate(EventBuilder): `True` if what's being recorded/uploaded is an video. """ return isinstance(self.action, ( - types.SendMessageRecordVideoAction, - types.SendMessageUploadVideoAction + _tl.SendMessageRecordVideoAction, + _tl.SendMessageUploadVideoAction )) @property @@ -219,7 +218,7 @@ class UserUpdate(EventBuilder): """ `True` if what's being uploaded (selected) is a contact. """ - return isinstance(self.action, types.SendMessageChooseContactAction) + return isinstance(self.action, _tl.SendMessageChooseContactAction) @property @_requires_action @@ -227,7 +226,7 @@ class UserUpdate(EventBuilder): """ `True` if what's being uploaded is document. """ - return isinstance(self.action, types.SendMessageUploadDocumentAction) + return isinstance(self.action, _tl.SendMessageUploadDocumentAction) @property @_requires_action @@ -235,7 +234,7 @@ class UserUpdate(EventBuilder): """ `True` if what's being uploaded is a sticker. """ - return isinstance(self.action, types.SendMessageChooseStickerAction) + return isinstance(self.action, _tl.SendMessageChooseStickerAction) @property @_requires_action @@ -243,7 +242,7 @@ class UserUpdate(EventBuilder): """ `True` if what's being uploaded is a photo. """ - return isinstance(self.action, types.SendMessageUploadPhotoAction) + return isinstance(self.action, _tl.SendMessageUploadPhotoAction) @property @_requires_action @@ -251,7 +250,7 @@ class UserUpdate(EventBuilder): """ Exact `datetime.datetime` when the user was last seen if known. """ - if isinstance(self.status, types.UserStatusOffline): + if isinstance(self.status, _tl.UserStatusOffline): return self.status.was_online @property @@ -260,19 +259,19 @@ class UserUpdate(EventBuilder): """ The `datetime.datetime` until when the user should appear online. """ - if isinstance(self.status, types.UserStatusOnline): + if isinstance(self.status, _tl.UserStatusOnline): return self.status.expires def _last_seen_delta(self): - if isinstance(self.status, types.UserStatusOffline): + if isinstance(self.status, _tl.UserStatusOffline): return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online - elif isinstance(self.status, types.UserStatusOnline): + elif isinstance(self.status, _tl.UserStatusOnline): return datetime.timedelta(days=0) - elif isinstance(self.status, types.UserStatusRecently): + elif isinstance(self.status, _tl.UserStatusRecently): return datetime.timedelta(days=1) - elif isinstance(self.status, types.UserStatusLastWeek): + elif isinstance(self.status, _tl.UserStatusLastWeek): return datetime.timedelta(days=7) - elif isinstance(self.status, types.UserStatusLastMonth): + elif isinstance(self.status, _tl.UserStatusLastMonth): return datetime.timedelta(days=30) else: return datetime.timedelta(days=365) diff --git a/telethon/extensions/__init__.py b/telethon/_misc/__init__.py similarity index 86% rename from telethon/extensions/__init__.py rename to telethon/_misc/__init__.py index a3c77295..71c26b8a 100644 --- a/telethon/extensions/__init__.py +++ b/telethon/_misc/__init__.py @@ -3,4 +3,3 @@ Several extensions Python is missing, such as a proper class to handle a TCP communication with support for cancelling the operation, and a utility class to read arbitrary binary data in a more comfortable way, with int/strings/etc. """ -from .binaryreader import BinaryReader diff --git a/telethon/extensions/binaryreader.py b/telethon/_misc/binaryreader.py similarity index 96% rename from telethon/extensions/binaryreader.py rename to telethon/_misc/binaryreader.py index 996f362e..4117653f 100644 --- a/telethon/extensions/binaryreader.py +++ b/telethon/_misc/binaryreader.py @@ -7,9 +7,9 @@ from datetime import datetime, timezone, timedelta from io import BytesIO from struct import unpack -from ..errors import TypeNotFoundError -from ..tl.alltlobjects import tlobjects -from ..tl.core import core_objects +from ..errors._custom import TypeNotFoundError +from .. import _tl +from ..types import _core _EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) _EPOCH = _EPOCH_NAIVE.replace(tzinfo=timezone.utc) @@ -118,7 +118,7 @@ class BinaryReader: def tgread_object(self): """Reads a Telegram object.""" constructor_id = self.read_int(signed=False) - clazz = tlobjects.get(constructor_id, None) + clazz = _tl.tlobjects.get(constructor_id, None) if clazz is None: # The class was None, but there's still a # chance of it being a manually parsed value like bool! @@ -130,7 +130,7 @@ class BinaryReader: elif value == 0x1cb5c415: # Vector return [self.tgread_object() for _ in range(self.read_int())] - clazz = core_objects.get(constructor_id, None) + clazz = _core.core_objects.get(constructor_id, None) if clazz is None: # If there was still no luck, give up self.seek(-4) # Go back diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py new file mode 100644 index 00000000..79b20242 --- /dev/null +++ b/telethon/_misc/enums.py @@ -0,0 +1,131 @@ +from enum import Enum + + +def _impl_op(which): + def op(self, other): + if not isinstance(other, type(self)): + return NotImplemented + + return getattr(self._val(), which)(other._val()) + + return op + + +class ConnectionMode(Enum): + FULL = 'full' + INTERMEDIATE = 'intermediate' + ABRIDGED = 'abridged' + + +class Participant(Enum): + ADMIN = 'admin' + BOT = 'bot' + KICKED = 'kicked' + BANNED = 'banned' + CONTACT = 'contact' + + +class Action(Enum): + TYPING = 'typing' + CONTACT = 'contact' + GAME = 'game' + LOCATION = 'location' + STICKER = 'sticker' + RECORD_AUDIO = 'record-audio' + RECORD_VOICE = RECORD_AUDIO + RECORD_ROUND = 'record-round' + RECORD_VIDEO = 'record-video' + AUDIO = 'audio' + VOICE = AUDIO + SONG = AUDIO + ROUND = 'round' + VIDEO = 'video' + PHOTO = 'photo' + DOCUMENT = 'document' + FILE = DOCUMENT + CANCEL = 'cancel' + + +class Size(Enum): + """ + See https://core.telegram.org/api/files#image-thumbnail-types. + + * ``'s'``. The image fits within a box of 100x100. + * ``'m'``. The image fits within a box of 320x320. + * ``'x'``. The image fits within a box of 800x800. + * ``'y'``. The image fits within a box of 1280x1280. + * ``'w'``. The image fits within a box of 2560x2560. + * ``'a'``. The image was cropped to be at most 160x160. + * ``'b'``. The image was cropped to be at most 320x320. + * ``'c'``. The image was cropped to be at most 640x640. + * ``'d'``. The image was cropped to be at most 1280x1280. + * ``'i'``. The image comes inline (no need to download anything). + * ``'j'``. Only the image outline is present (for stickers). + * ``'u'``. The image is actually a short MPEG4 animated video. + * ``'v'``. The image is actually a short MPEG4 video preview. + + The sorting order is first dimensions, then ``cropped < boxed < video < other``. + """ + SMALL = 's' + MEDIUM = 'm' + LARGE = 'x' + EXTRA_LARGE = 'y' + ORIGINAL = 'w' + CROPPED_SMALL = 'a' + CROPPED_MEDIUM = 'b' + CROPPED_LARGE = 'c' + CROPPED_EXTRA_LARGE = 'd' + INLINE = 'i' + OUTLINE = 'j' + ANIMATED = 'u' + VIDEO = 'v' + + def __hash__(self): + return object.__hash__(self) + + __sub__ = _impl_op('__sub__') + __lt__ = _impl_op('__lt__') + __le__ = _impl_op('__le__') + __eq__ = _impl_op('__eq__') + __ne__ = _impl_op('__ne__') + __gt__ = _impl_op('__gt__') + __ge__ = _impl_op('__ge__') + + def _val(self): + return self._category() * 100 + self._size() + + def _category(self): + return { + Size.SMALL: 2, + Size.MEDIUM: 2, + Size.LARGE: 2, + Size.EXTRA_LARGE: 2, + Size.ORIGINAL: 2, + Size.CROPPED_SMALL: 1, + Size.CROPPED_MEDIUM: 1, + Size.CROPPED_LARGE: 1, + Size.CROPPED_EXTRA_LARGE: 1, + Size.INLINE: 4, + Size.OUTLINE: 5, + Size.ANIMATED: 3, + Size.VIDEO: 3, + }[self] + + def _size(self): + return { + Size.SMALL: 1, + Size.MEDIUM: 3, + Size.LARGE: 5, + Size.EXTRA_LARGE: 6, + Size.ORIGINAL: 7, + Size.CROPPED_SMALL: 2, + Size.CROPPED_MEDIUM: 3, + Size.CROPPED_LARGE: 4, + Size.CROPPED_EXTRA_LARGE: 6, + # 0, since they're not the original photo at all + Size.INLINE: 0, + Size.OUTLINE: 0, + # same size as original or extra large (videos are large) + Size.ANIMATED: 7, + Size.VIDEO: 6, + }[self] diff --git a/telethon/helpers.py b/telethon/_misc/helpers.py similarity index 84% rename from telethon/helpers.py rename to telethon/_misc/helpers.py index 6c782b0b..c6c76b8e 100644 --- a/telethon/helpers.py +++ b/telethon/_misc/helpers.py @@ -102,23 +102,11 @@ def strip_text(text, entities): return text -def retry_range(retries, force_retry=True): +def retry_range(retries): """ - Generates an integer sequence starting from 1. If `retries` is - not a zero or a positive integer value, the sequence will be - infinite, otherwise it will end at `retries + 1`. + Generates an integer sequence starting from 1, always returning once, and adding the given retries. """ - - # We need at least one iteration even if the retries are 0 - # when force_retry is True. - if force_retry and not (retries is None or retries < 0): - retries += 1 - - attempt = 0 - while attempt != retries: - attempt += 1 - yield attempt - + return range(1, max(retries, 0) + 2) async def _maybe_await(value): @@ -165,34 +153,6 @@ async def _cancel(log, **tasks): '%s (%s)', name, type(task), task) -def _sync_enter(self): - """ - Helps to cut boilerplate on async context - managers that offer synchronous variants. - """ - if hasattr(self, 'loop'): - loop = self.loop - else: - loop = self._client.loop - - if loop.is_running(): - raise RuntimeError( - 'You must use "async with" if the event loop ' - 'is running (i.e. you are inside an "async def")' - ) - - return loop.run_until_complete(self.__aenter__()) - - -def _sync_exit(self, *args): - if hasattr(self, 'loop'): - loop = self.loop - else: - loop = self._client.loop - - return loop.run_until_complete(self.__aexit__(*args)) - - def _entity_type(entity): # This could be a `utils` method that just ran a few `isinstance` on # `utils.get_peer(...)`'s result. However, there are *a lot* of auto @@ -228,6 +188,73 @@ def _entity_type(entity): # 'Empty' in name or not found, we don't care, not a valid entity. raise TypeError('{} does not have any entity type'.format(entity)) + +def pretty_print(obj, indent=None, max_depth=float('inf')): + max_depth -= 1 + if max_depth < 0: + return '...' + + to_d = getattr(obj, '_to_dict', None) or getattr(obj, 'to_dict', None) + if callable(to_d): + obj = to_d() + + if indent is None: + if isinstance(obj, dict): + return '{}({})'.format(obj.get('_', 'dict'), ', '.join( + '{}={}'.format(k, pretty_print(v, indent, max_depth)) + for k, v in obj.items() if k != '_' + )) + elif isinstance(obj, str) or isinstance(obj, bytes): + return repr(obj) + elif hasattr(obj, '__iter__'): + return '[{}]'.format( + ', '.join(pretty_print(x, indent, max_depth) for x in obj) + ) + else: + return repr(obj) + else: + result = [] + + if isinstance(obj, dict): + result.append(obj.get('_', 'dict')) + result.append('(') + if obj: + result.append('\n') + indent += 1 + for k, v in obj.items(): + if k == '_': + continue + result.append('\t' * indent) + result.append(k) + result.append('=') + result.append(pretty_print(v, indent, max_depth)) + result.append(',\n') + result.pop() # last ',\n' + indent -= 1 + result.append('\n') + result.append('\t' * indent) + result.append(')') + + elif isinstance(obj, str) or isinstance(obj, bytes): + result.append(repr(obj)) + + elif hasattr(obj, '__iter__'): + result.append('[\n') + indent += 1 + for x in obj: + result.append('\t' * indent) + result.append(pretty_print(x, indent, max_depth)) + result.append(',\n') + indent -= 1 + result.append('\t' * indent) + result.append(']') + + else: + result.append(repr(obj)) + + return ''.join(result) + + # endregion # region Cryptographic related utils diff --git a/telethon/_misc/hints.py b/telethon/_misc/hints.py new file mode 100644 index 00000000..52951cec --- /dev/null +++ b/telethon/_misc/hints.py @@ -0,0 +1,60 @@ +import datetime +import typing + +from . import helpers +from .. import _tl +from ..types import _custom + +Phone = str +Username = str +PeerID = int +Entity = typing.Union[_tl.User, _tl.Chat, _tl.Channel] +FullEntity = typing.Union[_tl.UserFull, _tl.messages.ChatFull, _tl.ChatFull, _tl.ChannelFull] + +EntityLike = typing.Union[ + Phone, + Username, + PeerID, + _tl.TypePeer, + _tl.TypeInputPeer, + Entity, + FullEntity +] +EntitiesLike = typing.Union[EntityLike, typing.Sequence[EntityLike]] + +ButtonLike = typing.Union[_tl.TypeKeyboardButton, _custom.Button] +MarkupLike = typing.Union[ + _tl.TypeReplyMarkup, + ButtonLike, + typing.Sequence[ButtonLike], + typing.Sequence[typing.Sequence[ButtonLike]] +] + +TotalList = helpers.TotalList + +DateLike = typing.Optional[typing.Union[float, datetime.datetime, datetime.date, datetime.timedelta]] + +LocalPath = str +ExternalUrl = str +BotFileID = str +FileLike = typing.Union[ + LocalPath, + ExternalUrl, + BotFileID, + bytes, + typing.BinaryIO, + _tl.TypeMessageMedia, + _tl.TypeInputFile, + _tl.TypeInputFileLocation +] + +OutFileLike = typing.Union[ + str, + typing.Type[bytes], + typing.BinaryIO +] + +MessageLike = typing.Union[str, _tl.Message] +MessageIDLike = typing.Union[int, _tl.Message, _tl.TypeInputMessage] + +ProgressCallback = typing.Callable[[int, int], None] diff --git a/telethon/extensions/html.py b/telethon/_misc/html.py similarity index 80% rename from telethon/extensions/html.py rename to telethon/_misc/html.py index 62e622ba..cdf4ced4 100644 --- a/telethon/extensions/html.py +++ b/telethon/_misc/html.py @@ -7,14 +7,8 @@ from html import escape from html.parser import HTMLParser from typing import Iterable, Optional, Tuple, List -from .. import helpers -from ..tl.types import ( - MessageEntityBold, MessageEntityItalic, MessageEntityCode, - MessageEntityPre, MessageEntityEmail, MessageEntityUrl, - MessageEntityTextUrl, MessageEntityMentionName, - MessageEntityUnderline, MessageEntityStrike, MessageEntityBlockquote, - TypeMessageEntity -) +from .._misc import helpers +from .. import _tl # Helpers from markdown.py @@ -46,15 +40,17 @@ class HTMLToTelegramParser(HTMLParser): EntityType = None args = {} if tag == 'strong' or tag == 'b': - EntityType = MessageEntityBold + EntityType = _tl.MessageEntityBold elif tag == 'em' or tag == 'i': - EntityType = MessageEntityItalic + EntityType = _tl.MessageEntityItalic elif tag == 'u': - EntityType = MessageEntityUnderline + EntityType = _tl.MessageEntityUnderline elif tag == 'del' or tag == 's': - EntityType = MessageEntityStrike + EntityType = _tl.MessageEntityStrike + elif tag == 'tg-spoiler': + EntityType = _tl.MessageEntitySpoiler elif tag == 'blockquote': - EntityType = MessageEntityBlockquote + EntityType = _tl.MessageEntityBlockquote elif tag == 'code': try: # If we're in the middle of a
 tag, this  tag is
@@ -69,9 +65,9 @@ class HTMLToTelegramParser(HTMLParser):
                 except KeyError:
                     pass
             except KeyError:
-                EntityType = MessageEntityCode
+                EntityType = _tl.MessageEntityCode
         elif tag == 'pre':
-            EntityType = MessageEntityPre
+            EntityType = _tl.MessageEntityPre
             args['language'] = ''
         elif tag == 'a':
             try:
@@ -80,12 +76,12 @@ class HTMLToTelegramParser(HTMLParser):
                 return
             if url.startswith('mailto:'):
                 url = url[len('mailto:'):]
-                EntityType = MessageEntityEmail
+                EntityType = _tl.MessageEntityEmail
             else:
                 if self.get_starttag_text() == url:
-                    EntityType = MessageEntityUrl
+                    EntityType = _tl.MessageEntityUrl
                 else:
-                    EntityType = MessageEntityTextUrl
+                    EntityType = _tl.MessageEntityTextUrl
                     args['url'] = url
                     url = None
             self._open_tags_meta.popleft()
@@ -121,10 +117,10 @@ class HTMLToTelegramParser(HTMLParser):
             self.entities.append(entity)
 
 
-def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
+def parse(html: str) -> Tuple[str, List[_tl.TypeMessageEntity]]:
     """
     Parses the given HTML message and returns its stripped representation
-    plus a list of the MessageEntity's that were found.
+    plus a list of the _tl.MessageEntity's that were found.
 
     :param html: the message with HTML to be parsed.
     :return: a tuple consisting of (clean message, [message entities]).
@@ -138,14 +134,14 @@ def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
     return _del_surrogate(text), parser.entities
 
 
-def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
+def unparse(text: str, entities: Iterable[_tl.TypeMessageEntity], _offset: int = 0,
             _length: Optional[int] = None) -> str:
     """
     Performs the reverse operation to .parse(), effectively returning HTML
-    given a normal text and its MessageEntity's.
+    given a normal text and its _tl.MessageEntity's.
 
     :param text: the text to be reconverted into HTML.
-    :param entities: the MessageEntity's applied to the text.
+    :param entities: the _tl.MessageEntity's applied to the text.
     :return: a HTML representation of the combination of both inputs.
     """
     if not text:
@@ -185,19 +181,19 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
                               _offset=entity.offset, _length=length)
         entity_type = type(entity)
 
-        if entity_type == MessageEntityBold:
+        if entity_type == _tl.MessageEntityBold:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityItalic:
+        elif entity_type == _tl.MessageEntityItalic:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityCode:
+        elif entity_type == _tl.MessageEntityCode:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityUnderline:
+        elif entity_type == _tl.MessageEntityUnderline:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityStrike:
+        elif entity_type == _tl.MessageEntityStrike:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityBlockquote:
+        elif entity_type == _tl.MessageEntityBlockquote:
             html.append('
{}
'.format(entity_text)) - elif entity_type == MessageEntityPre: + elif entity_type == _tl.MessageEntityPre: if entity.language: html.append( "
\n"
@@ -208,14 +204,14 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
             else:
                 html.append('
{}
' .format(entity_text)) - elif entity_type == MessageEntityEmail: + elif entity_type == _tl.MessageEntityEmail: html.append('{0}'.format(entity_text)) - elif entity_type == MessageEntityUrl: + elif entity_type == _tl.MessageEntityUrl: html.append('{0}'.format(entity_text)) - elif entity_type == MessageEntityTextUrl: + elif entity_type == _tl.MessageEntityTextUrl: html.append('{}' .format(escape(entity.url), entity_text)) - elif entity_type == MessageEntityMentionName: + elif entity_type == _tl.MessageEntityMentionName: html.append('{}' .format(entity.user_id, entity_text)) else: diff --git a/telethon/_misc/markdown.py b/telethon/_misc/markdown.py new file mode 100644 index 00000000..3b62c995 --- /dev/null +++ b/telethon/_misc/markdown.py @@ -0,0 +1,169 @@ +""" +Simple markdown parser which does not support nesting. Intended primarily +for use within the library, which attempts to handle emojies correctly, +since they seem to count as two characters and it's a bit strange. +""" +import re +import warnings +import markdown_it + +from .helpers import add_surrogate, del_surrogate, within_surrogate, strip_text +from .. import _tl +from .._misc import tlobject + + +MARKDOWN = markdown_it.MarkdownIt().enable('strikethrough') +DELIMITERS = { + _tl.MessageEntityBlockquote: ('> ', ''), + _tl.MessageEntityBold: ('**', '**'), + _tl.MessageEntityCode: ('`', '`'), + _tl.MessageEntityItalic: ('_', '_'), + _tl.MessageEntityStrike: ('~~', '~~'), + _tl.MessageEntitySpoiler: ('||', '||'), + _tl.MessageEntityUnderline: ('# ', ''), +} + +# Not trying to be complete; just enough to have an alternative (mostly for inline underline). +# The fact headings are treated as underline is an implementation detail. +TAG_PATTERN = re.compile(r'<\s*(/?)\s*(\w+)') +HTML_TO_TYPE = { + 'i': ('em_close', 'em_open'), + 'em': ('em_close', 'em_open'), + 'b': ('strong_close', 'strong_open'), + 'strong': ('strong_close', 'strong_open'), + 's': ('s_close', 's_open'), + 'del': ('s_close', 's_open'), + 'u': ('heading_open', 'heading_close'), + 'mark': ('heading_open', 'heading_close'), +} + + +def expand_inline_and_html(tokens): + for token in tokens: + if token.type == 'inline': + yield from expand_inline_and_html(token.children) + elif token.type == 'html_inline': + match = TAG_PATTERN.match(token.content) + if match: + close, tag = match.groups() + tys = HTML_TO_TYPE.get(tag.lower()) + if tys: + token.type = tys[bool(close)] + token.nesting = -1 if close else 1 + yield token + else: + yield token + + +def parse(message): + """ + Parses the given markdown message and returns its stripped representation + plus a list of the _tl.MessageEntity's that were found. + """ + if not message: + return message, [] + + def push(ty, **extra): + nonlocal message, entities, token + if token.nesting > 0: + entities.append(ty(offset=len(message), length=0, **extra)) + else: + for entity in reversed(entities): + if isinstance(entity, ty): + entity.length = len(message) - entity.offset + break + + parsed = MARKDOWN.parse(add_surrogate(message.strip())) + message = '' + entities = [] + last_map = [0, 0] + for token in expand_inline_and_html(parsed): + if token.map is not None and token.map != last_map: + # paragraphs, quotes fences have a line mapping. Use it to determine how many newlines to insert. + # But don't inssert any (leading) new lines if we're yet to reach the first textual content, or + # if the mappings are the same (e.g. a quote then opens a paragraph but the mapping is equal). + if message: + message += '\n' + '\n' * (token.map[0] - last_map[-1]) + last_map = token.map + + if token.type in ('blockquote_close', 'blockquote_open'): + push(_tl.MessageEntityBlockquote) + elif token.type == 'code_block': + entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language='')) + message += token.content + elif token.type == 'code_inline': + entities.append(_tl.MessageEntityCode(offset=len(message), length=len(token.content))) + message += token.content + elif token.type in ('em_close', 'em_open'): + push(_tl.MessageEntityItalic) + elif token.type == 'fence': + entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language=token.info)) + message += token.content[:-1] # remove a single trailing newline + elif token.type == 'hardbreak': + message += '\n' + elif token.type in ('heading_close', 'heading_open'): + push(_tl.MessageEntityUnderline) + elif token.type == 'hr': + message += '\u2015\n\n' + elif token.type in ('link_close', 'link_open'): + if token.markup != 'autolink': # telegram already picks up on these automatically + push(_tl.MessageEntityTextUrl, url=token.attrs.get('href')) + elif token.type in ('s_close', 's_open'): + push(_tl.MessageEntityStrike) + elif token.type == 'softbreak': + message += ' ' + elif token.type in ('strong_close', 'strong_open'): + push(_tl.MessageEntityBold) + elif token.type == 'text': + message += token.content + + return del_surrogate(message), entities + + +def unparse(text, entities): + """ + Performs the reverse operation to .parse(), effectively returning + markdown-like syntax given a normal text and its _tl.MessageEntity's. + + Because there are many possible ways for markdown to produce a certain + output, this function cannot invert .parse() perfectly. + """ + if not text or not entities: + return text + + if isinstance(entities, tlobject.TLObject): + entities = (entities,) + + text = add_surrogate(text) + insert_at = [] + for entity in entities: + s = entity.offset + e = entity.offset + entity.length + delimiter = DELIMITERS.get(type(entity), None) + if delimiter: + insert_at.append((s, delimiter[0])) + insert_at.append((e, delimiter[1])) + elif isinstance(entity, _tl.MessageEntityPre): + insert_at.append((s, f'```{entity.language}\n')) + insert_at.append((e, '```\n')) + elif isinstance(entity, _tl.MessageEntityTextUrl): + insert_at.append((s, '[')) + insert_at.append((e, f']({entity.url})')) + elif isinstance(entity, _tl.MessageEntityMentionName): + insert_at.append((s, '[')) + insert_at.append((e, f'](tg://user?id={entity.user_id})')) + + insert_at.sort(key=lambda t: t[0]) + while insert_at: + at, what = insert_at.pop() + + # If we are in the middle of a surrogate nudge the position by -1. + # Otherwise we would end up with malformed text and fail to encode. + # For example of bad input: "Hi \ud83d\ude1c" + # https://en.wikipedia.org/wiki/UTF-16#U+010000_to_U+10FFFF + while within_surrogate(text, at): + at += 1 + + text = text[:at] + what + text[at:] + + return del_surrogate(text) diff --git a/telethon/extensions/messagepacker.py b/telethon/_misc/messagepacker.py similarity index 96% rename from telethon/extensions/messagepacker.py rename to telethon/_misc/messagepacker.py index c0f46f48..22367ca3 100644 --- a/telethon/extensions/messagepacker.py +++ b/telethon/_misc/messagepacker.py @@ -3,9 +3,8 @@ import collections import io import struct -from ..tl import TLRequest -from ..tl.core.messagecontainer import MessageContainer -from ..tl.core.tlmessage import TLMessage +from .._tl import TLRequest +from ..types._core import MessageContainer, TLMessage class MessagePacker: diff --git a/telethon/password.py b/telethon/_misc/password.py similarity index 94% rename from telethon/password.py rename to telethon/_misc/password.py index 0f950254..b18e6b10 100644 --- a/telethon/password.py +++ b/telethon/_misc/password.py @@ -1,8 +1,8 @@ import hashlib import os -from .crypto import factorization -from .tl import types +from .._crypto import factorization +from .. import _tl def check_prime_and_good_check(prime: int, g: int): @@ -110,7 +110,7 @@ def pbkdf2sha512(password: bytes, salt: bytes, iterations: int): return hashlib.pbkdf2_hmac('sha512', password, salt, iterations) -def compute_hash(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, +def compute_hash(algo: _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, password: str): hash1 = sha256(algo.salt1, password.encode('utf-8'), algo.salt1) hash2 = sha256(algo.salt2, hash1, algo.salt2) @@ -118,7 +118,7 @@ def compute_hash(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter1000 return sha256(algo.salt2, hash3, algo.salt2) -def compute_digest(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, +def compute_digest(algo: _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, password: str): try: check_prime_and_good(algo.p, algo.g) @@ -133,9 +133,9 @@ def compute_digest(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter10 # https://github.com/telegramdesktop/tdesktop/blob/18b74b90451a7db2379a9d753c9cbaf8734b4d5d/Telegram/SourceFiles/core/core_cloud_password.cpp -def compute_check(request: types.account.Password, password: str): +def compute_check(request: _tl.account.Password, password: str): algo = request.current_algo - if not isinstance(algo, types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow): + if not isinstance(algo, _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow): raise ValueError('unsupported password algorithm {}' .format(algo.__class__.__name__)) @@ -190,5 +190,5 @@ def compute_check(request: types.account.Password, password: str): K ) - return types.InputCheckPasswordSRP( + return _tl.InputCheckPasswordSRP( request.srp_id, bytes(a_for_hash), bytes(M1)) diff --git a/telethon/requestiter.py b/telethon/_misc/requestiter.py similarity index 84% rename from telethon/requestiter.py rename to telethon/_misc/requestiter.py index fd28419d..96ceb97a 100644 --- a/telethon/requestiter.py +++ b/telethon/_misc/requestiter.py @@ -12,9 +12,6 @@ class RequestIter(abc.ABC): It has some facilities, such as automatically sleeping a desired amount of time between requests if needed (but not more). - Can be used synchronously if the event loop is not running and - as an asynchronous iterator otherwise. - `limit` is the total amount of items that the iterator should return. This is handled on this base class, and will be always ``>= 0``. @@ -31,12 +28,13 @@ class RequestIter(abc.ABC): self.reverse = reverse self.wait_time = wait_time self.kwargs = kwargs - self.limit = max(float('inf') if limit is None else limit, 0) + self.limit = max(float('inf') if limit is None or limit == () else limit, 0) self.left = self.limit self.buffer = None self.index = 0 self.total = None self.last_load = 0 + self.return_single = limit == 1 or limit == () async def _init(self, **kwargs): """ @@ -82,12 +80,6 @@ class RequestIter(abc.ABC): self.index += 1 return result - def __next__(self): - try: - return self.client.loop.run_until_complete(self.__anext__()) - except StopAsyncIteration: - raise StopIteration - def __aiter__(self): self.buffer = None self.index = 0 @@ -95,20 +87,20 @@ class RequestIter(abc.ABC): self.left = self.limit return self - def __iter__(self): - if self.client.loop.is_running(): - raise RuntimeError( - 'You must use "async for" if the event loop ' - 'is running (i.e. you are inside an "async def")' - ) - - return self.__aiter__() - - async def collect(self): + async def collect(self, force_list=True): """ Create a `self` iterator and collect it into a `TotalList` (a normal list with a `.total` attribute). + + If ``force_list`` is ``False`` and ``self.return_single`` is ``True``, no list + will be returned. Instead, either a single item or ``None`` will be returned. """ + if not force_list and self.return_single: + self.limit = 1 + async for message in self: + return message + return None + result = helpers.TotalList() async for message in self: result.append(message) @@ -132,3 +124,6 @@ class RequestIter(abc.ABC): def __reversed__(self): self.reverse = not self.reverse return self # __aiter__ will be called after, too + + def __await__(self): + return self.collect(force_list=False).__await__() diff --git a/telethon/tl/tlobject.py b/telethon/_misc/tlobject.py similarity index 57% rename from telethon/tl/tlobject.py rename to telethon/_misc/tlobject.py index 4b94e00f..6b4bddf8 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/_misc/tlobject.py @@ -3,6 +3,7 @@ import json import struct from datetime import datetime, date, timedelta, timezone import time +from .helpers import pretty_print _EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) _EPOCH_NAIVE_LOCAL = datetime(*time.localtime(0)[:6]) @@ -13,7 +14,7 @@ def _datetime_to_timestamp(dt): # If no timezone is specified, it is assumed to be in utc zone if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - # We use .total_seconds() method instead of simply dt.timestamp(), + # We use .total_seconds() method instead of simply dt.timestamp(), # because on Windows the latter raises OSError on datetimes ~< datetime(1970,1,1) secs = int((dt - _EPOCH).total_seconds()) # Make sure it's a valid signed 32 bit integer, as used by Telegram. @@ -32,76 +33,10 @@ def _json_default(value): class TLObject: + __slots__ = () CONSTRUCTOR_ID = None SUBCLASS_OF_ID = None - @staticmethod - def pretty_format(obj, indent=None): - """ - Pretty formats the given object as a string which is returned. - If indent is None, a single line will be returned. - """ - if indent is None: - if isinstance(obj, TLObject): - obj = obj.to_dict() - - if isinstance(obj, dict): - return '{}({})'.format(obj.get('_', 'dict'), ', '.join( - '{}={}'.format(k, TLObject.pretty_format(v)) - for k, v in obj.items() if k != '_' - )) - elif isinstance(obj, str) or isinstance(obj, bytes): - return repr(obj) - elif hasattr(obj, '__iter__'): - return '[{}]'.format( - ', '.join(TLObject.pretty_format(x) for x in obj) - ) - else: - return repr(obj) - else: - result = [] - if isinstance(obj, TLObject): - obj = obj.to_dict() - - if isinstance(obj, dict): - result.append(obj.get('_', 'dict')) - result.append('(') - if obj: - result.append('\n') - indent += 1 - for k, v in obj.items(): - if k == '_': - continue - result.append('\t' * indent) - result.append(k) - result.append('=') - result.append(TLObject.pretty_format(v, indent)) - result.append(',\n') - result.pop() # last ',\n' - indent -= 1 - result.append('\n') - result.append('\t' * indent) - result.append(')') - - elif isinstance(obj, str) or isinstance(obj, bytes): - result.append(repr(obj)) - - elif hasattr(obj, '__iter__'): - result.append('[\n') - indent += 1 - for x in obj: - result.append('\t' * indent) - result.append(TLObject.pretty_format(x, indent)) - result.append(',\n') - indent -= 1 - result.append('\t' * indent) - result.append(']') - - else: - result.append(repr(obj)) - - return ''.join(result) - @staticmethod def serialize_bytes(data): """Write bytes by using Telegram guidelines""" @@ -163,31 +98,32 @@ class TLObject: def __ne__(self, o): return not isinstance(o, type(self)) or self.to_dict() != o.to_dict() + def __repr__(self): + return pretty_print(self) + def __str__(self): - return TLObject.pretty_format(self) + return pretty_print(self, max_depth=2) def stringify(self): - return TLObject.pretty_format(self, indent=0) + return pretty_print(self, indent=0) def to_dict(self): - raise NotImplementedError - - def to_json(self, fp=None, default=_json_default, **kwargs): - """ - Represent the current `TLObject` as JSON. - - If ``fp`` is given, the JSON will be dumped to said - file pointer, otherwise a JSON string will be returned. - - Note that bytes and datetimes cannot be represented - in JSON, so if those are found, they will be base64 - encoded and ISO-formatted, respectively, by default. - """ - d = self.to_dict() - if fp: - return json.dump(d, fp, default=default, **kwargs) + res = {} + pre = ('', 'fn.')[isinstance(self, TLRequest)] + mod = self.__class__.__module__[self.__class__.__module__.rfind('.') + 1:] + if mod in ('_tl', 'fn'): + res['_'] = f'{pre}{self.__class__.__name__}' else: - return json.dumps(d, default=default, **kwargs) + res['_'] = f'{pre}{mod}.{self.__class__.__name__}' + + for slot in self.__slots__: + attr = getattr(self, slot) + if isinstance(attr, list): + res[slot] = [val.to_dict() if hasattr(val, 'to_dict') else val for val in attr] + else: + res[slot] = attr.to_dict() if hasattr(attr, 'to_dict') else attr + + return res def __bytes__(self): try: diff --git a/telethon/utils.py b/telethon/_misc/utils.py similarity index 67% rename from telethon/utils.py rename to telethon/_misc/utils.py index ac5db1c1..b8c62b6a 100644 --- a/telethon/utils.py +++ b/telethon/_misc/utils.py @@ -19,9 +19,9 @@ from collections import namedtuple from mimetypes import guess_extension from types import GeneratorType -from .extensions import markdown, html from .helpers import add_surrogate, del_surrogate, strip_text -from .tl import types +from . import markdown, html +from .. import _tl try: import hachoir @@ -92,7 +92,7 @@ def get_display_name(entity): Gets the display name for the given :tl:`User`, :tl:`Chat` or :tl:`Channel`. Returns an empty string otherwise. """ - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.last_name and entity.first_name: return '{} {}'.format(entity.first_name, entity.last_name) elif entity.first_name: @@ -102,7 +102,7 @@ def get_display_name(entity): else: return '' - elif isinstance(entity, (types.Chat, types.ChatForbidden, types.Channel)): + elif isinstance(entity, (_tl.Chat, _tl.ChatForbidden, _tl.Channel)): return entity.title return '' @@ -117,14 +117,14 @@ def get_extension(media): return '.jpg' except TypeError: # These cases are not handled by input photo because it can't - if isinstance(media, (types.UserProfilePhoto, types.ChatPhoto)): + if isinstance(media, (_tl.UserProfilePhoto, _tl.ChatPhoto)): return '.jpg' # Documents will come with a mime type - if isinstance(media, types.MessageMediaDocument): + if isinstance(media, _tl.MessageMediaDocument): media = media.document if isinstance(media, ( - types.Document, types.WebDocument, types.WebDocumentNoProxy)): + _tl.Document, _tl.WebDocument, _tl.WebDocumentNoProxy)): if media.mime_type == 'application/octet-stream': # Octet stream are just bytes, which have no default extension return '' @@ -184,53 +184,53 @@ def get_input_peer(entity, allow_self=True, check_hash=True): else: _raise_cast_fail(entity, 'InputPeer') - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.is_self and allow_self: - return types.InputPeerSelf() + return _tl.InputPeerSelf() elif (entity.access_hash is not None and not entity.min) or not check_hash: - return types.InputPeerUser(entity.id, entity.access_hash) + return _tl.InputPeerUser(entity.id, entity.access_hash) else: raise TypeError('User without access_hash or min info cannot be input') - if isinstance(entity, (types.Chat, types.ChatEmpty, types.ChatForbidden)): - return types.InputPeerChat(entity.id) + if isinstance(entity, (_tl.Chat, _tl.ChatEmpty, _tl.ChatForbidden)): + return _tl.InputPeerChat(entity.id) - if isinstance(entity, types.Channel): + if isinstance(entity, _tl.Channel): if (entity.access_hash is not None and not entity.min) or not check_hash: - return types.InputPeerChannel(entity.id, entity.access_hash) + return _tl.InputPeerChannel(entity.id, entity.access_hash) else: raise TypeError('Channel without access_hash or min info cannot be input') - if isinstance(entity, types.ChannelForbidden): + if isinstance(entity, _tl.ChannelForbidden): # "channelForbidden are never min", and since their hash is # also not optional, we assume that this truly is the case. - return types.InputPeerChannel(entity.id, entity.access_hash) + return _tl.InputPeerChannel(entity.id, entity.access_hash) - if isinstance(entity, types.InputUser): - return types.InputPeerUser(entity.user_id, entity.access_hash) + if isinstance(entity, _tl.InputUser): + return _tl.InputPeerUser(entity.user_id, entity.access_hash) - if isinstance(entity, types.InputChannel): - return types.InputPeerChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, _tl.InputChannel): + return _tl.InputPeerChannel(entity.channel_id, entity.access_hash) - if isinstance(entity, types.InputUserSelf): - return types.InputPeerSelf() + if isinstance(entity, _tl.InputUserSelf): + return _tl.InputPeerSelf() - if isinstance(entity, types.InputUserFromMessage): - return types.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id) + if isinstance(entity, _tl.InputUserFromMessage): + return _tl.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id) - if isinstance(entity, types.InputChannelFromMessage): - return types.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) + if isinstance(entity, _tl.InputChannelFromMessage): + return _tl.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) - if isinstance(entity, types.UserEmpty): - return types.InputPeerEmpty() + if isinstance(entity, _tl.UserEmpty): + return _tl.InputPeerEmpty() - if isinstance(entity, types.UserFull): + if isinstance(entity, _tl.UserFull): return get_input_peer(entity.user) - if isinstance(entity, types.ChatFull): - return types.InputPeerChat(entity.id) + if isinstance(entity, _tl.ChatFull): + return _tl.InputPeerChat(entity.id) - if isinstance(entity, types.PeerChat): - return types.InputPeerChat(entity.chat_id) + if isinstance(entity, _tl.PeerChat): + return _tl.InputPeerChat(entity.chat_id) _raise_cast_fail(entity, 'InputPeer') @@ -251,14 +251,14 @@ def get_input_channel(entity): except AttributeError: _raise_cast_fail(entity, 'InputChannel') - if isinstance(entity, (types.Channel, types.ChannelForbidden)): - return types.InputChannel(entity.id, entity.access_hash or 0) + if isinstance(entity, (_tl.Channel, _tl.ChannelForbidden)): + return _tl.InputChannel(entity.id, entity.access_hash or 0) - if isinstance(entity, types.InputPeerChannel): - return types.InputChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, _tl.InputPeerChannel): + return _tl.InputChannel(entity.channel_id, entity.access_hash) - if isinstance(entity, types.InputPeerChannelFromMessage): - return types.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) + if isinstance(entity, _tl.InputPeerChannelFromMessage): + return _tl.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) _raise_cast_fail(entity, 'InputChannel') @@ -279,26 +279,26 @@ def get_input_user(entity): except AttributeError: _raise_cast_fail(entity, 'InputUser') - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.is_self: - return types.InputUserSelf() + return _tl.InputUserSelf() else: - return types.InputUser(entity.id, entity.access_hash or 0) + return _tl.InputUser(entity.id, entity.access_hash or 0) - if isinstance(entity, types.InputPeerSelf): - return types.InputUserSelf() + if isinstance(entity, _tl.InputPeerSelf): + return _tl.InputUserSelf() - if isinstance(entity, (types.UserEmpty, types.InputPeerEmpty)): - return types.InputUserEmpty() + if isinstance(entity, (_tl.UserEmpty, _tl.InputPeerEmpty)): + return _tl.InputUserEmpty() - if isinstance(entity, types.UserFull): + if isinstance(entity, _tl.UserFull): return get_input_user(entity.user) - if isinstance(entity, types.InputPeerUser): - return types.InputUser(entity.user_id, entity.access_hash) + if isinstance(entity, _tl.InputPeerUser): + return _tl.InputUser(entity.user_id, entity.access_hash) - if isinstance(entity, types.InputPeerUserFromMessage): - return types.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id) + if isinstance(entity, _tl.InputPeerUserFromMessage): + return _tl.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id) _raise_cast_fail(entity, 'InputUser') @@ -309,12 +309,12 @@ def get_input_dialog(dialog): if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') return dialog if dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return types.InputDialogPeer(dialog) + return _tl.InputDialogPeer(dialog) except AttributeError: _raise_cast_fail(dialog, 'InputDialogPeer') try: - return types.InputDialogPeer(get_input_peer(dialog)) + return _tl.InputDialogPeer(get_input_peer(dialog)) except TypeError: pass @@ -329,18 +329,18 @@ def get_input_document(document): except AttributeError: _raise_cast_fail(document, 'InputDocument') - if isinstance(document, types.Document): - return types.InputDocument( + if isinstance(document, _tl.Document): + return _tl.InputDocument( id=document.id, access_hash=document.access_hash, file_reference=document.file_reference) - if isinstance(document, types.DocumentEmpty): - return types.InputDocumentEmpty() + if isinstance(document, _tl.DocumentEmpty): + return _tl.InputDocumentEmpty() - if isinstance(document, types.MessageMediaDocument): + if isinstance(document, _tl.MessageMediaDocument): return get_input_document(document.document) - if isinstance(document, types.Message): + if isinstance(document, _tl.Message): return get_input_document(document.media) _raise_cast_fail(document, 'InputDocument') @@ -354,32 +354,32 @@ def get_input_photo(photo): except AttributeError: _raise_cast_fail(photo, 'InputPhoto') - if isinstance(photo, types.Message): + if isinstance(photo, _tl.Message): photo = photo.media - if isinstance(photo, (types.photos.Photo, types.MessageMediaPhoto)): + if isinstance(photo, (_tl.photos.Photo, _tl.MessageMediaPhoto)): photo = photo.photo - if isinstance(photo, types.Photo): - return types.InputPhoto(id=photo.id, access_hash=photo.access_hash, + if isinstance(photo, _tl.Photo): + return _tl.InputPhoto(id=photo.id, access_hash=photo.access_hash, file_reference=photo.file_reference) - if isinstance(photo, types.PhotoEmpty): - return types.InputPhotoEmpty() + if isinstance(photo, _tl.PhotoEmpty): + return _tl.InputPhotoEmpty() - if isinstance(photo, types.messages.ChatFull): + if isinstance(photo, _tl.messages.ChatFull): photo = photo.full_chat - if isinstance(photo, types.ChannelFull): + if isinstance(photo, _tl.ChannelFull): return get_input_photo(photo.chat_photo) - elif isinstance(photo, types.UserFull): + elif isinstance(photo, _tl.UserFull): return get_input_photo(photo.profile_photo) - elif isinstance(photo, (types.Channel, types.Chat, types.User)): + elif isinstance(photo, (_tl.Channel, _tl.Chat, _tl.User)): return get_input_photo(photo.photo) - if isinstance(photo, (types.UserEmpty, types.ChatEmpty, - types.ChatForbidden, types.ChannelForbidden)): - return types.InputPhotoEmpty() + if isinstance(photo, (_tl.UserEmpty, _tl.ChatEmpty, + _tl.ChatForbidden, _tl.ChannelForbidden)): + return _tl.InputPhotoEmpty() _raise_cast_fail(photo, 'InputPhoto') @@ -390,15 +390,15 @@ def get_input_chat_photo(photo): if photo.SUBCLASS_OF_ID == 0xd4eb2d74: # crc32(b'InputChatPhoto') return photo elif photo.SUBCLASS_OF_ID == 0xe7655f1f: # crc32(b'InputFile'): - return types.InputChatUploadedPhoto(photo) + return _tl.InputChatUploadedPhoto(photo) except AttributeError: _raise_cast_fail(photo, 'InputChatPhoto') photo = get_input_photo(photo) - if isinstance(photo, types.InputPhoto): - return types.InputChatPhoto(photo) - elif isinstance(photo, types.InputPhotoEmpty): - return types.InputChatPhotoEmpty() + if isinstance(photo, _tl.InputPhoto): + return _tl.InputChatPhoto(photo) + elif isinstance(photo, _tl.InputPhotoEmpty): + return _tl.InputChatPhotoEmpty() _raise_cast_fail(photo, 'InputChatPhoto') @@ -411,16 +411,16 @@ def get_input_geo(geo): except AttributeError: _raise_cast_fail(geo, 'InputGeoPoint') - if isinstance(geo, types.GeoPoint): - return types.InputGeoPoint(lat=geo.lat, long=geo.long) + if isinstance(geo, _tl.GeoPoint): + return _tl.InputGeoPoint(lat=geo.lat, long=geo.long) - if isinstance(geo, types.GeoPointEmpty): - return types.InputGeoPointEmpty() + if isinstance(geo, _tl.GeoPointEmpty): + return _tl.InputGeoPointEmpty() - if isinstance(geo, types.MessageMediaGeo): + if isinstance(geo, _tl.MessageMediaGeo): return get_input_geo(geo.geo) - if isinstance(geo, types.Message): + if isinstance(geo, _tl.Message): return get_input_geo(geo.media) _raise_cast_fail(geo, 'InputGeoPoint') @@ -443,39 +443,39 @@ def get_input_media( if media.SUBCLASS_OF_ID == 0xfaf846f4: # crc32(b'InputMedia') return media elif media.SUBCLASS_OF_ID == 0x846363e0: # crc32(b'InputPhoto') - return types.InputMediaPhoto(media, ttl_seconds=ttl) + return _tl.InputMediaPhoto(media, ttl_seconds=ttl) elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument') - return types.InputMediaDocument(media, ttl_seconds=ttl) + return _tl.InputMediaDocument(media, ttl_seconds=ttl) except AttributeError: _raise_cast_fail(media, 'InputMedia') - if isinstance(media, types.MessageMediaPhoto): - return types.InputMediaPhoto( + if isinstance(media, _tl.MessageMediaPhoto): + return _tl.InputMediaPhoto( id=get_input_photo(media.photo), ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)): - return types.InputMediaPhoto( + if isinstance(media, (_tl.Photo, _tl.photos.Photo, _tl.PhotoEmpty)): + return _tl.InputMediaPhoto( id=get_input_photo(media), ttl_seconds=ttl ) - if isinstance(media, types.MessageMediaDocument): - return types.InputMediaDocument( + if isinstance(media, _tl.MessageMediaDocument): + return _tl.InputMediaDocument( id=get_input_document(media.document), ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Document, types.DocumentEmpty)): - return types.InputMediaDocument( + if isinstance(media, (_tl.Document, _tl.DocumentEmpty)): + return _tl.InputMediaDocument( id=get_input_document(media), ttl_seconds=ttl ) - if isinstance(media, (types.InputFile, types.InputFileBig)): + if isinstance(media, (_tl.InputFile, _tl.InputFileBig)): if is_photo: - return types.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl) + return _tl.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl) else: attrs, mime = get_attributes( media, @@ -485,29 +485,29 @@ def get_input_media( video_note=video_note, supports_streaming=supports_streaming ) - return types.InputMediaUploadedDocument( + return _tl.InputMediaUploadedDocument( file=media, mime_type=mime, attributes=attrs, force_file=force_document, ttl_seconds=ttl) - if isinstance(media, types.MessageMediaGame): - return types.InputMediaGame(id=types.InputGameID( + if isinstance(media, _tl.MessageMediaGame): + return _tl.InputMediaGame(id=_tl.InputGameID( id=media.game.id, access_hash=media.game.access_hash )) - if isinstance(media, types.MessageMediaContact): - return types.InputMediaContact( + if isinstance(media, _tl.MessageMediaContact): + return _tl.InputMediaContact( phone_number=media.phone_number, first_name=media.first_name, last_name=media.last_name, vcard='' ) - if isinstance(media, types.MessageMediaGeo): - return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) + if isinstance(media, _tl.MessageMediaGeo): + return _tl.InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) - if isinstance(media, types.MessageMediaVenue): - return types.InputMediaVenue( + if isinstance(media, _tl.MessageMediaVenue): + return _tl.InputMediaVenue( geo_point=get_input_geo(media.geo), title=media.title, address=media.address, @@ -516,19 +516,19 @@ def get_input_media( venue_type='' ) - if isinstance(media, types.MessageMediaDice): - return types.InputMediaDice(media.emoticon) + if isinstance(media, _tl.MessageMediaDice): + return _tl.InputMediaDice(media.emoticon) if isinstance(media, ( - types.MessageMediaEmpty, types.MessageMediaUnsupported, - types.ChatPhotoEmpty, types.UserProfilePhotoEmpty, - types.ChatPhoto, types.UserProfilePhoto)): - return types.InputMediaEmpty() + _tl.MessageMediaEmpty, _tl.MessageMediaUnsupported, + _tl.ChatPhotoEmpty, _tl.UserProfilePhotoEmpty, + _tl.ChatPhoto, _tl.UserProfilePhoto)): + return _tl.InputMediaEmpty() - if isinstance(media, types.Message): + if isinstance(media, _tl.Message): return get_input_media(media.media, is_photo=is_photo, ttl=ttl) - if isinstance(media, types.MessageMediaPoll): + if isinstance(media, _tl.MessageMediaPoll): if media.poll.quiz: if not media.results.results: # A quiz has correct answers, which we don't know until answered. @@ -539,15 +539,15 @@ def get_input_media( else: correct_answers = None - return types.InputMediaPoll( + return _tl.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) + if isinstance(media, _tl.Poll): + return _tl.InputMediaPoll(media) _raise_cast_fail(media, 'InputMedia') @@ -556,11 +556,11 @@ def get_input_message(message): """Similar to :meth:`get_input_peer`, but for input messages.""" try: if isinstance(message, int): # This case is really common too - return types.InputMessageID(message) + return _tl.InputMessageID(message) elif message.SUBCLASS_OF_ID == 0x54b6bcc5: # crc32(b'InputMessage'): return message elif message.SUBCLASS_OF_ID == 0x790009e3: # crc32(b'Message'): - return types.InputMessageID(message.id) + return _tl.InputMessageID(message.id) except AttributeError: pass @@ -573,25 +573,21 @@ def get_input_group_call(call): if call.SUBCLASS_OF_ID == 0x58611ab1: # crc32(b'InputGroupCall') return call elif call.SUBCLASS_OF_ID == 0x20b4f320: # crc32(b'GroupCall') - return types.InputGroupCall(id=call.id, access_hash=call.access_hash) + return _tl.InputGroupCall(id=call.id, access_hash=call.access_hash) except AttributeError: _raise_cast_fail(call, 'InputGroupCall') -def _get_entity_pair(entity_id, entities, cache, +def _get_entity_pair(entity_id, entities, get_input_peer=get_input_peer): """ Returns ``(entity, input_entity)`` for the given entity ID. """ entity = entities.get(entity_id) try: - input_entity = cache[entity_id] - except KeyError: - # KeyError is unlikely, so another TypeError won't hurt - try: - input_entity = get_input_peer(entity) - except TypeError: - input_entity = None + input_entity = get_input_peer(entity) + except TypeError: + input_entity = None return entity, input_entity @@ -677,8 +673,8 @@ def get_attributes(file, *, attributes=None, mime_type=None, if mime_type is None: mime_type = mimetypes.guess_type(name)[0] - attr_dict = {types.DocumentAttributeFilename: - types.DocumentAttributeFilename(os.path.basename(name))} + attr_dict = {_tl.DocumentAttributeFilename: + _tl.DocumentAttributeFilename(os.path.basename(name))} if is_audio(file): m = _get_metadata(file) @@ -690,8 +686,8 @@ def get_attributes(file, *, attributes=None, mime_type=None, else: performer = None - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio( + attr_dict[_tl.DocumentAttributeAudio] = \ + _tl.DocumentAttributeAudio( voice=voice_note, title=m.get('title') if m.has('title') else None, performer=performer, @@ -702,7 +698,7 @@ def get_attributes(file, *, attributes=None, mime_type=None, if not force_document and is_video(file): m = _get_metadata(file) if m: - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( round_message=video_note, w=m.get('width') if m.has('width') else 1, h=m.get('height') if m.has('height') else 1, @@ -719,22 +715,22 @@ def get_attributes(file, *, attributes=None, mime_type=None, if t_m and t_m.has("height"): height = t_m.get("height") - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( 0, width, height, round_message=video_note, supports_streaming=supports_streaming) else: - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( 0, 1, 1, round_message=video_note, supports_streaming=supports_streaming) - attr_dict[types.DocumentAttributeVideo] = doc + attr_dict[_tl.DocumentAttributeVideo] = doc if voice_note: - if types.DocumentAttributeAudio in attr_dict: - attr_dict[types.DocumentAttributeAudio].voice = True + if _tl.DocumentAttributeAudio in attr_dict: + attr_dict[_tl.DocumentAttributeAudio].voice = True else: - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio(0, voice=True) + attr_dict[_tl.DocumentAttributeAudio] = \ + _tl.DocumentAttributeAudio(0, voice=True) # Now override the attributes if any. As we have a dict of # {cls: instance}, we can override any class with the list @@ -803,23 +799,23 @@ def _get_file_info(location): except AttributeError: _raise_cast_fail(location, 'InputFileLocation') - if isinstance(location, types.Message): + if isinstance(location, _tl.Message): location = location.media - if isinstance(location, types.MessageMediaDocument): + if isinstance(location, _tl.MessageMediaDocument): location = location.document - elif isinstance(location, types.MessageMediaPhoto): + elif isinstance(location, _tl.MessageMediaPhoto): location = location.photo - if isinstance(location, types.Document): - return _FileInfo(location.dc_id, types.InputDocumentFileLocation( + if isinstance(location, _tl.Document): + return _FileInfo(location.dc_id, _tl.InputDocumentFileLocation( id=location.id, access_hash=location.access_hash, file_reference=location.file_reference, thumb_size='' # Presumably to download one of its thumbnails ), location.size) - elif isinstance(location, types.Photo): - return _FileInfo(location.dc_id, types.InputPhotoFileLocation( + elif isinstance(location, _tl.Photo): + return _FileInfo(location.dc_id, _tl.InputPhotoFileLocation( id=location.id, access_hash=location.access_hash, file_reference=location.file_reference, @@ -856,11 +852,7 @@ def is_image(file): """ Returns `True` if the file extension looks like an image file to Telegram. """ - match = re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE) - if match: - return True - else: - return isinstance(resolve_bot_file_id(file), types.Photo) + return bool(re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE)) def is_gif(file): @@ -965,59 +957,45 @@ def get_inner_text(text, entities): def get_peer(peer): try: - if isinstance(peer, int): - pid, cls = resolve_id(peer) - return cls(pid) - elif peer.SUBCLASS_OF_ID == 0x2d45687: + if peer.SUBCLASS_OF_ID == 0x2d45687: return peer elif isinstance(peer, ( - types.contacts.ResolvedPeer, types.InputNotifyPeer, - types.TopPeer, types.Dialog, types.DialogPeer)): + _tl.contacts.ResolvedPeer, _tl.InputNotifyPeer, + _tl.TopPeer, _tl.Dialog, _tl.DialogPeer)): return peer.peer - elif isinstance(peer, types.ChannelFull): - return types.PeerChannel(peer.id) - elif isinstance(peer, types.UserEmpty): - return types.PeerUser(peer.id) - elif isinstance(peer, types.ChatEmpty): - return types.PeerChat(peer.id) + elif isinstance(peer, _tl.ChannelFull): + return _tl.PeerChannel(peer.id) + elif isinstance(peer, _tl.UserEmpty): + return _tl.PeerUser(peer.id) + elif isinstance(peer, _tl.ChatEmpty): + return _tl.PeerChat(peer.id) if peer.SUBCLASS_OF_ID in (0x7d7c6f86, 0xd9c7fc18): # ChatParticipant, ChannelParticipant - return types.PeerUser(peer.user_id) + return _tl.PeerUser(peer.user_id) peer = get_input_peer(peer, allow_self=False, check_hash=False) - if isinstance(peer, (types.InputPeerUser, types.InputPeerUserFromMessage)): - return types.PeerUser(peer.user_id) - elif isinstance(peer, types.InputPeerChat): - return types.PeerChat(peer.chat_id) - elif isinstance(peer, (types.InputPeerChannel, types.InputPeerChannelFromMessage)): - return types.PeerChannel(peer.channel_id) + if isinstance(peer, (_tl.InputPeerUser, _tl.InputPeerUserFromMessage)): + return _tl.PeerUser(peer.user_id) + elif isinstance(peer, _tl.InputPeerChat): + return _tl.PeerChat(peer.chat_id) + elif isinstance(peer, (_tl.InputPeerChannel, _tl.InputPeerChannelFromMessage)): + return _tl.PeerChannel(peer.channel_id) except (AttributeError, TypeError): pass _raise_cast_fail(peer, 'Peer') -def get_peer_id(peer, add_mark=True): +def get_peer_id(peer): """ - Convert the given peer into its marked ID by default. - - This "mark" comes from the "bot api" format, and with it the peer type - can be identified back. User ID is left unmodified, chat ID is negated, - and channel ID is "prefixed" with -100: - - * ``user_id`` - * ``-chat_id`` - * ``-100channel_id`` - - The original ID and the peer type class can be returned with - a call to :meth:`resolve_id(marked_id)`. + Extract the integer ID from the given peer. """ # First we assert it's a Peer TLObject, or early return for integers if isinstance(peer, int): - return peer if add_mark else resolve_id(peer)[0] + return peer # Tell the user to use their client to resolve InputPeerSelf if we got one - if isinstance(peer, types.InputPeerSelf): + if isinstance(peer, _tl.InputPeerSelf): _raise_cast_fail(peer, 'int (you might want to use client.get_peer_id)') try: @@ -1025,37 +1003,12 @@ def get_peer_id(peer, add_mark=True): except TypeError: _raise_cast_fail(peer, 'int') - if isinstance(peer, types.PeerUser): + if isinstance(peer, _tl.PeerUser): return peer.user_id - elif isinstance(peer, types.PeerChat): - # Check in case the user mixed things up to avoid blowing up - if not (0 < peer.chat_id <= 9999999999): - peer.chat_id = resolve_id(peer.chat_id)[0] - - return -peer.chat_id if add_mark else peer.chat_id - else: # if isinstance(peer, types.PeerChannel): - # Check in case the user mixed things up to avoid blowing up - if not (0 < peer.channel_id <= 9999999999): - peer.channel_id = resolve_id(peer.channel_id)[0] - - if not add_mark: - return peer.channel_id - - # Growing backwards from -100_0000_000_000 indicates it's a channel - return -(1000000000000 + peer.channel_id) - - -def resolve_id(marked_id): - """Given a marked ID, returns the original ID and its :tl:`Peer` type.""" - if marked_id >= 0: - return marked_id, types.PeerUser - - marked_id = -marked_id - if marked_id > 1000000000000: - marked_id -= 1000000000000 - return marked_id, types.PeerChannel - else: - return marked_id, types.PeerChat + elif isinstance(peer, _tl.PeerChat): + return peer.chat_id + else: # if isinstance(peer, _tl.PeerChannel): + return peer.channel_id def _rle_decode(data): @@ -1119,198 +1072,6 @@ def _encode_telegram_base64(string): return None # not valid base64, not valid ascii, not a string -def resolve_bot_file_id(file_id): - """ - Given a Bot API-style `file_id `, - returns the media it represents. If the `file_id ` - is not valid, `None` is returned instead. - - Note that the `file_id ` does not have information - such as image dimensions or file size, so these will be zero if present. - - For thumbnails, the photo ID and hash will always be zero. - """ - data = _rle_decode(_decode_telegram_base64(file_id)) - if not data: - return None - - # This isn't officially documented anywhere, but - # we assume the last byte is some kind of "version". - data, version = data[:-1], data[-1] - if version not in (2, 4): - return None - - if (version == 2 and len(data) == 24) or (version == 4 and len(data) == 25): - if version == 2: - file_type, dc_id, media_id, access_hash = struct.unpack('LQ', payload)) - elif len(payload) == 16: - return struct.unpack('>LLQ', payload) - else: - pass - except (struct.error, TypeError): - pass - return None, None, None - - def resolve_inline_message_id(inline_msg_id): """ Resolves an inline message ID. Returns a tuple of @@ -1326,7 +1087,7 @@ def resolve_inline_message_id(inline_msg_id): try: dc_id, message_id, pid, access_hash = \ struct.unpack('= UPDATE_BUFFER_FULL_WARN_DELAY: + self._log.warning( + 'Cannot dispatch update because the buffer capacity of %d was reached', + self._updates_queue.maxsize + ) + self._last_update_warn = now + + def _store_own_updates(self, obj, *, _update_ids=frozenset(( + _tl.UpdateShortMessage.CONSTRUCTOR_ID, + _tl.UpdateShortChatMessage.CONSTRUCTOR_ID, + _tl.UpdateShort.CONSTRUCTOR_ID, + _tl.UpdatesCombined.CONSTRUCTOR_ID, + _tl.Updates.CONSTRUCTOR_ID, + _tl.UpdateShortSentMessage.CONSTRUCTOR_ID, + ))): + try: + if obj.CONSTRUCTOR_ID in _update_ids: + self._updates_queue.put_nowait(obj) + except AttributeError: + pass async def _handle_pong(self, message): """ @@ -777,7 +798,7 @@ class MTProtoSender: self._log.debug('Handling acknowledge for %s', str(ack.msg_ids)) for msg_id in ack.msg_ids: state = self._pending_state.get(msg_id) - if state and isinstance(state.request, LogOutRequest): + if state and isinstance(state.request, _tl.fn.auth.LogOut): del self._pending_state[msg_id] if not state.future.cancelled(): state.future.set_result(True) @@ -802,7 +823,7 @@ class MTProtoSender: Handles both :tl:`MsgsStateReq` and :tl:`MsgResendReq` by enqueuing a :tl:`MsgsStateInfo` to be sent at a later point. """ - self._send_queue.append(RequestState(MsgsStateInfo( + self._send_queue.append(RequestState(_tl.MsgsStateInfo( req_msg_id=message.msg_id, info=chr(1) * len(message.obj.msg_ids) ))) @@ -817,7 +838,7 @@ class MTProtoSender: It behaves pretty much like handling an RPC result. """ for msg_id, state in self._pending_state.items(): - if isinstance(state.request, DestroySessionRequest)\ + if isinstance(state.request, _tl.fn.DestroySession)\ and state.request.session_id == message.obj.session_id: break else: diff --git a/telethon/network/mtprotostate.py b/telethon/_network/mtprotostate.py similarity index 95% rename from telethon/network/mtprotostate.py rename to telethon/_network/mtprotostate.py index 0fe9cc08..19578da3 100644 --- a/telethon/network/mtprotostate.py +++ b/telethon/_network/mtprotostate.py @@ -3,13 +3,12 @@ import struct import time from hashlib import sha256 -from ..crypto import AES -from ..errors import SecurityError, InvalidBufferError -from ..extensions import BinaryReader -from ..tl.core import TLMessage -from ..tl.tlobject import TLRequest -from ..tl.functions import InvokeAfterMsgRequest -from ..tl.core.gzippacked import GzipPacked +from .._crypto import AES +from ..errors._custom import SecurityError, InvalidBufferError +from .._misc.binaryreader import BinaryReader +from ..types._core import TLMessage, GzipPacked +from .._misc.tlobject import TLRequest +from .. import _tl class _OpaqueRequest(TLRequest): @@ -103,7 +102,7 @@ class MTProtoState: # The `RequestState` stores `bytes(request)`, not the request itself. # `invokeAfterMsg` wants a `TLRequest` though, hence the wrapping. body = GzipPacked.gzip_if_smaller(content_related, - bytes(InvokeAfterMsgRequest(after_id, _OpaqueRequest(data)))) + bytes(_tl.fn.InvokeAfterMsg(after_id, _OpaqueRequest(data)))) buffer.write(struct.pack(' bytes: + if self._init: + header = b'' + else: + header = b'\xef' + self._init = True + + length = len(data) >> 2 + if length < 127: + length = struct.pack('B', length) + else: + length = b'\x7f' + int.to_bytes(length, 3, 'little') + + return header + length + data + + def unpack(self, input: bytes) -> (int, bytes): + if len(input) < 4: + raise EOFError() + + length = input[0] + if length < 127: + offset = 1 + else: + offset = 4 + length = struct.unpack(' bytes: + # https://core.telegram.org/mtproto#tcp-transport + length = len(input) + 12 + data = struct.pack(' (int, bytes): + if len(input) < 12: + raise EOFError() + + length, seq = struct.unpack(' bytes: + if self._init: + header = b'' + else: + header = b'\xee\xee\xee\xee' + self._init = True + + return header + struct.pack(' (int, bytes): + if len(input) < 4: + raise EOFError() + + length = struct.unpack(' bytes: + pass + + # Should raise EOFError if it does not have enough bytes + @abc.abstractmethod + def unpack(self, input: bytes) -> (int, bytes): + pass diff --git a/telethon/sessions/__init__.py b/telethon/_sessions/__init__.py similarity index 100% rename from telethon/sessions/__init__.py rename to telethon/_sessions/__init__.py diff --git a/telethon/_sessions/abstract.py b/telethon/_sessions/abstract.py new file mode 100644 index 00000000..cdb747a4 --- /dev/null +++ b/telethon/_sessions/abstract.py @@ -0,0 +1,92 @@ +from .types import DataCenter, ChannelState, SessionState, EntityType, Entity + +from abc import ABC, abstractmethod +from typing import List, Optional + + +class Session(ABC): + @abstractmethod + async def insert_dc(self, dc: DataCenter): + """ + Store a new or update an existing `DataCenter` with matching ``id``. + """ + raise NotImplementedError + + @abstractmethod + async def get_all_dc(self) -> List[DataCenter]: + """ + Get a list of all currently-stored `DataCenter`. Should not contain duplicate ``id``. + """ + raise NotImplementedError + + @abstractmethod + async def set_state(self, state: SessionState): + """ + Set the state about the current session. + """ + raise NotImplementedError + + @abstractmethod + async def get_state(self) -> Optional[SessionState]: + """ + Get the state about the current session. + """ + raise NotImplementedError + + @abstractmethod + async def insert_channel_state(self, state: ChannelState): + """ + Store a new or update an existing `ChannelState` with matching ``id``. + """ + raise NotImplementedError + + @abstractmethod + async def get_all_channel_states(self) -> List[ChannelState]: + """ + Get a list of all currently-stored `ChannelState`. Should not contain duplicate ``id``. + """ + raise NotImplementedError + + @abstractmethod + async def insert_entities(self, entities: List[Entity]): + """ + Store new or update existing `Entity` with matching ``id``. + + Entities should be saved on a best-effort. It is okay to not save them, although the + library may need to do extra work if a previously-saved entity is missing, or even be + unable to continue without the entity. + """ + raise NotImplementedError + + @abstractmethod + async def get_entity(self, ty: Optional[EntityType], id: int) -> Optional[Entity]: + """ + Get the `Entity` with matching ``ty`` and ``id``. + + The following groups of ``ty`` should be treated to be equivalent, that is, for a given + ``ty`` and ``id``, if the ``ty`` is in a given group, a matching ``access_hash`` with + that ``id`` from within any ``ty`` in that group should be returned. + + * `EntityType.USER` and `EntityType.BOT`. + * `EntityType.GROUP`. + * `EntityType.CHANNEL`, `EntityType.MEGAGROUP` and `EntityType.GIGAGROUP`. + + For example, if a ``ty`` representing a bot is stored but the asking ``ty`` is a user, + the corresponding ``access_hash`` should still be returned. + + You may use ``EntityType.canonical`` to find out the canonical type. + + A ``ty`` with the value of ``None`` should be treated as "any entity with matching ID". + """ + raise NotImplementedError + + @abstractmethod + async def save(self): + """ + Save the session. + + May do nothing if the other methods already saved when they were called. + + May return custom data when manual saving is intended. + """ + raise NotImplementedError diff --git a/telethon/_sessions/memory.py b/telethon/_sessions/memory.py new file mode 100644 index 00000000..1c86aff7 --- /dev/null +++ b/telethon/_sessions/memory.py @@ -0,0 +1,47 @@ +from .types import DataCenter, ChannelState, SessionState, Entity +from .abstract import Session +from .._misc import utils, tlobject +from .. import _tl + +from typing import List, Optional + + +class MemorySession(Session): + __slots__ = ('dcs', 'state', 'channel_states', 'entities') + + def __init__(self): + self.dcs = {} + self.state = None + self.channel_states = {} + self.entities = {} + + async def insert_dc(self, dc: DataCenter): + self.dcs[dc.id] = dc + + async def get_all_dc(self) -> List[DataCenter]: + return list(self.dcs.values()) + + async def set_state(self, state: SessionState): + self.state = state + + async def get_state(self) -> Optional[SessionState]: + return self.state + + async def insert_channel_state(self, state: ChannelState): + self.channel_states[state.channel_id] = state + + async def get_all_channel_states(self) -> List[ChannelState]: + return list(self.channel_states.values()) + + async def insert_entities(self, entities: List[Entity]): + self.entities.update((e.id, (e.ty, e.access_hash)) for e in entities) + + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: + try: + ty, access_hash = self.entities[id] + return Entity(ty, id, access_hash) + except KeyError: + return None + + async def save(self): + pass diff --git a/telethon/_sessions/sqlite.py b/telethon/_sessions/sqlite.py new file mode 100644 index 00000000..b41975fb --- /dev/null +++ b/telethon/_sessions/sqlite.py @@ -0,0 +1,284 @@ +import datetime +import os +import time +import ipaddress +from typing import Optional, List + +from .abstract import Session +from .._misc import utils +from .. import _tl +from .types import DataCenter, ChannelState, SessionState, Entity + +try: + import sqlite3 + sqlite3_err = None +except ImportError as e: + sqlite3 = None + sqlite3_err = type(e) + +EXTENSION = '.session' +CURRENT_VERSION = 8 # database version + + +class SQLiteSession(Session): + """ + This session contains the required information to login into your + Telegram account. NEVER give the saved session file to anyone, since + they would gain instant access to all your messages and contacts. + + If you think the session has been compromised, close all the sessions + through an official Telegram client to revoke the authorization. + """ + + def __init__(self, session_id=None): + if sqlite3 is None: + raise sqlite3_err + + super().__init__() + self.filename = ':memory:' + self.save_entities = True + + if session_id: + self.filename = session_id + if not self.filename.endswith(EXTENSION): + self.filename += EXTENSION + + self._conn = None + c = self._cursor() + c.execute("select name from sqlite_master " + "where type='table' and name='version'") + if c.fetchone(): + # Tables already exist, check for the version + c.execute("select version from version") + version = c.fetchone()[0] + if version < CURRENT_VERSION: + self._upgrade_database(old=version) + c.execute("delete from version") + c.execute("insert into version values (?)", (CURRENT_VERSION,)) + self._conn.commit() + else: + # Tables don't exist, create new ones + self._create_table(c, 'version (version integer primary key)') + self._mk_tables(c) + c.execute("insert into version values (?)", (CURRENT_VERSION,)) + self._conn.commit() + + # Must have committed or else the version will not have been updated while new tables + # exist, leading to a half-upgraded state. + c.close() + + def _upgrade_database(self, old): + c = self._cursor() + if old == 1: + old += 1 + # old == 1 doesn't have the old sent_files so no need to drop + if old == 2: + old += 1 + # Old cache from old sent_files lasts then a day anyway, drop + c.execute('drop table sent_files') + self._create_table(c, """sent_files ( + md5_digest blob, + file_size integer, + type integer, + id integer, + hash integer, + primary key(md5_digest, file_size, type) + )""") + if old == 3: + old += 1 + self._create_table(c, """update_state ( + id integer primary key, + pts integer, + qts integer, + date integer, + seq integer + )""") + if old == 4: + old += 1 + c.execute("alter table sessions add column takeout_id integer") + if old == 5: + # Not really any schema upgrade, but potentially all access + # hashes for User and Channel are wrong, so drop them off. + old += 1 + c.execute('delete from entities') + if old == 6: + old += 1 + c.execute("alter table entities add column date integer") + if old == 7: + self._mk_tables(c) + c.execute(''' + insert into datacenter (id, ipv4, ipv6, port, auth) + select dc_id, server_address, server_address, port, auth_key + from sessions + ''') + c.execute(''' + insert into session (user_id, dc_id, bot, pts, qts, date, seq, takeout_id) + select + 0, + s.dc_id, + 0, + coalesce(u.pts, 0), + coalesce(u.qts, 0), + coalesce(u.date, 0), + coalesce(u.seq, 0), + s.takeout_id + from sessions s + left join update_state u on u.id = 0 + limit 1 + ''') + c.execute(''' + insert into entity (id, access_hash, ty) + select + case + when id < -1000000000000 then -(id + 1000000000000) + when id < 0 then -id + else id + end, + hash, + case + when id < -1000000000000 then 67 + when id < 0 then 71 + else 85 + end + from entities + ''') + c.execute('drop table sessions') + c.execute('drop table entities') + c.execute('drop table sent_files') + c.execute('drop table update_state') + + def _mk_tables(self, c): + self._create_table( + c, + '''datacenter ( + id integer primary key, + ipv4 text not null, + ipv6 text, + port integer not null, + auth blob not null + )''', + '''session ( + user_id integer primary key, + dc_id integer not null, + bot integer not null, + pts integer not null, + qts integer not null, + date integer not null, + seq integer not null, + takeout_id integer + )''', + '''channel ( + channel_id integer primary key, + pts integer not null + )''', + '''entity ( + id integer primary key, + access_hash integer not null, + ty integer not null + )''', + ) + + async def insert_dc(self, dc: DataCenter): + self._execute( + 'insert or replace into datacenter values (?,?,?,?,?)', + dc.id, + str(ipaddress.ip_address(dc.ipv4)), + str(ipaddress.ip_address(dc.ipv6)) if dc.ipv6 else None, + dc.port, + dc.auth + ) + + async def get_all_dc(self) -> List[DataCenter]: + c = self._cursor() + res = [] + for (id, ipv4, ipv6, port, auth) in c.execute('select * from datacenter'): + res.append(DataCenter( + id=id, + ipv4=int(ipaddress.ip_address(ipv4)), + ipv6=int(ipaddress.ip_address(ipv6)) if ipv6 else None, + port=port, + auth=auth, + )) + return res + + async def set_state(self, state: SessionState): + c = self._cursor() + try: + self._execute('delete from session') + self._execute( + 'insert into session values (?,?,?,?,?,?,?,?)', + state.user_id, + state.dc_id, + int(state.bot), + state.pts, + state.qts, + state.date, + state.seq, + state.takeout_id, + ) + finally: + c.close() + + async def get_state(self) -> Optional[SessionState]: + row = self._execute('select * from session') + return SessionState(*row) if row else None + + async def insert_channel_state(self, state: ChannelState): + self._execute( + 'insert or replace into channel values (?,?)', + state.channel_id, + state.pts, + ) + + async def get_all_channel_states(self) -> List[ChannelState]: + c = self._cursor() + try: + return [ + ChannelState(*row) + for row in c.execute('select * from channel') + ] + finally: + c.close() + + async def insert_entities(self, entities: List[Entity]): + c = self._cursor() + try: + c.executemany( + 'insert or replace into entity values (?,?,?)', + [(e.id, e.access_hash, e.ty.value) for e in entities] + ) + finally: + c.close() + + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: + row = self._execute('select ty, id, access_hash from entity where id = ?', id) + return Entity(*row) if row else None + + async def save(self): + # This is a no-op if there are no changes to commit, so there's + # no need for us to keep track of an "unsaved changes" variable. + if self._conn is not None: + self._conn.commit() + + @staticmethod + def _create_table(c, *definitions): + for definition in definitions: + c.execute('create table {}'.format(definition)) + + def _cursor(self): + """Asserts that the connection is open and returns a cursor""" + if self._conn is None: + self._conn = sqlite3.connect(self.filename, + check_same_thread=False) + return self._conn.cursor() + + def _execute(self, stmt, *values): + """ + Gets a cursor, executes `stmt` and closes the cursor, + fetching one row afterwards and returning its result. + """ + c = self._cursor() + try: + return c.execute(stmt, values).fetchone() + finally: + c.close() diff --git a/telethon/sessions/string.py b/telethon/_sessions/string.py similarity index 59% rename from telethon/sessions/string.py rename to telethon/_sessions/string.py index fb971d82..2cb66aa6 100644 --- a/telethon/sessions/string.py +++ b/telethon/_sessions/string.py @@ -4,7 +4,7 @@ import struct from .abstract import Session from .memory import MemorySession -from ..crypto import AuthKey +from .types import DataCenter, ChannelState, SessionState, Entity _STRUCT_PREFORMAT = '>B{}sH256s' @@ -34,12 +34,33 @@ class StringSession(MemorySession): string = string[1:] ip_len = 4 if len(string) == 352 else 16 - self._dc_id, ip, self._port, key = struct.unpack( + dc_id, ip, port, key = struct.unpack( _STRUCT_PREFORMAT.format(ip_len), StringSession.decode(string)) - self._server_address = ipaddress.ip_address(ip).compressed - if any(key): - self._auth_key = AuthKey(key) + self.state = SessionState( + dc_id=dc_id, + user_id=0, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=0 + ) + if ip_len == 4: + ipv4 = int.from_bytes(ip, 'big', False) + ipv6 = None + else: + ipv4 = None + ipv6 = int.from_bytes(ip, 'big', signed=False) + + self.dcs[dc_id] = DataCenter( + id=dc_id, + ipv4=ipv4, + ipv6=ipv6, + port=port, + auth=key + ) @staticmethod def encode(x: bytes) -> str: @@ -50,14 +71,18 @@ class StringSession(MemorySession): return base64.urlsafe_b64decode(x) def save(self: Session): - if not self.auth_key: + if not self.state: return '' - ip = ipaddress.ip_address(self.server_address).packed + if self.state.ipv6 is not None: + ip = self.state.ipv6.to_bytes(16, 'big', signed=False) + else: + ip = self.state.ipv6.to_bytes(4, 'big', signed=False) + return CURRENT_VERSION + StringSession.encode(struct.pack( _STRUCT_PREFORMAT.format(len(ip)), - self.dc_id, + self.state.dc_id, ip, - self.port, - self.auth_key.key + self.state.port, + self.dcs[self.state.dc_id].auth )) diff --git a/telethon/_sessions/types.py b/telethon/_sessions/types.py new file mode 100644 index 00000000..a9738709 --- /dev/null +++ b/telethon/_sessions/types.py @@ -0,0 +1,116 @@ +from typing import Optional, Tuple +from dataclasses import dataclass +from enum import IntEnum + + +@dataclass(frozen=True) +class DataCenter: + """ + Stores the information needed to connect to a datacenter. + + * id: 32-bit number representing the datacenter identifier as given by Telegram. + * ipv4 and ipv6: 32-bit or 128-bit number storing the IP address of the datacenter. + * port: 16-bit number storing the port number needed to connect to the datacenter. + * bytes: arbitrary binary payload needed to authenticate to the datacenter. + """ + __slots__ = ('id', 'ipv4', 'ipv6', 'port', 'auth') + + id: int + ipv4: int + ipv6: Optional[int] + port: int + auth: bytes + + +@dataclass(frozen=True) +class SessionState: + """ + Stores the information needed to fetch updates and about the current user. + + * user_id: 64-bit number representing the user identifier. + * dc_id: 32-bit number relating to the datacenter identifier where the user is. + * bot: is the logged-in user a bot? + * pts: 64-bit number holding the state needed to fetch updates. + * qts: alternative 64-bit number holding the state needed to fetch updates. + * date: 64-bit number holding the date needed to fetch updates. + * seq: 64-bit-number holding the sequence number needed to fetch updates. + * takeout_id: 64-bit-number holding the identifier of the current takeout session. + + Note that some of the numbers will only use 32 out of the 64 available bits. + However, for future-proofing reasons, we recommend you pretend they are 64-bit long. + """ + __slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id') + + user_id: int + dc_id: int + bot: bool + pts: int + qts: int + date: int + seq: int + takeout_id: Optional[int] + + +@dataclass(frozen=True) +class ChannelState: + """ + Stores the information needed to fetch updates from a channel. + + * channel_id: 64-bit number representing the channel identifier. + * pts: 64-bit number holding the state needed to fetch updates. + """ + __slots__ = ('channel_id', 'pts') + + channel_id: int + pts: int + + +class EntityType(IntEnum): + """ + You can rely on the type value to be equal to the ASCII character one of: + + * 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``. + * 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``. + * 'G' (71): this entity belongs to a small group :tl:`Chat`. + * 'C' (67): this entity belongs to a standard broadcast :tl:`Channel`. + * 'M' (77): this entity belongs to a megagroup :tl:`Channel`. + * 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`. + """ + USER = ord('U') + BOT = ord('B') + GROUP = ord('G') + CHANNEL = ord('C') + MEGAGROUP = ord('M') + GIGAGROUP = ord('E') + + def canonical(self): + """ + Return the canonical version of this type. + """ + return _canon_entity_types[self] + + +_canon_entity_types = { + EntityType.USER: EntityType.USER, + EntityType.BOT: EntityType.USER, + EntityType.GROUP: EntityType.GROUP, + EntityType.CHANNEL: EntityType.CHANNEL, + EntityType.MEGAGROUP: EntityType.CHANNEL, + EntityType.GIGAGROUP: EntityType.CHANNEL, +} + + +@dataclass(frozen=True) +class Entity: + """ + Stores the information needed to use a certain user, chat or channel with the API. + + * ty: 8-bit number indicating the type of the entity. + * id: 64-bit number uniquely identifying the entity among those of the same type. + * access_hash: 64-bit number needed to use this entity with the API. + """ + __slots__ = ('ty', 'id', 'access_hash') + + ty: EntityType + id: int + access_hash: int diff --git a/telethon/_updates/__init__.py b/telethon/_updates/__init__.py new file mode 100644 index 00000000..7951c9aa --- /dev/null +++ b/telethon/_updates/__init__.py @@ -0,0 +1,2 @@ +from .entitycache import EntityCache, PackedChat +from .messagebox import MessageBox diff --git a/telethon/_updates/entitycache.py b/telethon/_updates/entitycache.py new file mode 100644 index 00000000..8dc95693 --- /dev/null +++ b/telethon/_updates/entitycache.py @@ -0,0 +1,103 @@ +import inspect +import itertools +from dataclasses import dataclass, field +from collections import namedtuple + +from .._misc import utils +from .. import _tl +from .._sessions.types import EntityType, Entity + + +class PackedChat(namedtuple('PackedChat', 'ty id hash')): + __slots__ = () + + @property + def is_user(self): + return self.ty in (EntityType.USER, EntityType.BOT) + + @property + def is_chat(self): + return self.ty in (EntityType.GROUP,) + + @property + def is_channel(self): + return self.ty in (EntityType.CHANNEL, EntityType.MEGAGROUP, EntityType.GIGAGROUP) + + def to_peer(self): + if self.is_user: + return _tl.PeerUser(user_id=self.id) + elif self.is_chat: + return _tl.PeerChat(chat_id=self.id) + elif self.is_channel: + return _tl.PeerChannel(channel_id=self.id) + + def to_input_peer(self): + if self.is_user: + return _tl.InputPeerUser(user_id=self.id, access_hash=self.hash) + elif self.is_chat: + return _tl.InputPeerChat(chat_id=self.id) + elif self.is_channel: + return _tl.InputPeerChannel(channel_id=self.id, access_hash=self.hash) + + def try_to_input_user(self): + if self.is_user: + return _tl.InputUser(user_id=self.id, access_hash=self.hash) + else: + return None + + def try_to_chat_id(self): + if self.is_chat: + return self.id + else: + return None + + def try_to_input_channel(self): + if self.is_channel: + return _tl.InputChannel(channel_id=self.id, access_hash=self.hash) + else: + return None + + def __str__(self): + return f'{chr(self.ty.value)}.{self.id}.{self.hash}' + + +@dataclass +class EntityCache: + hash_map: dict = field(default_factory=dict) # id -> (hash, ty) + self_id: int = None + self_bot: bool = False + + def set_self_user(self, id, bot): + self.self_id = id + self.self_bot = bot + + def get(self, id): + value = self.hash_map.get(id) + return PackedChat(ty=value[1], id=id, hash=value[0]) if value else None + + def extend(self, users, chats): + # See https://core.telegram.org/api/min for "issues" with "min constructors". + self.hash_map.update( + (u.id, ( + u.access_hash, + EntityType.BOT if u.bot else EntityType.USER, + )) + for u in users + if getattr(u, 'access_hash', None) and not u.min + ) + self.hash_map.update( + (c.id, ( + c.access_hash, + EntityType.MEGAGROUP if c.megagroup else ( + EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL + ), + )) + for c in chats + if getattr(c, 'access_hash', None) and not getattr(c, 'min', None) + ) + + def get_all_entities(self): + return [Entity(ty, id, hash) for id, (hash, ty) in self.hash_map.items()] + + def put(self, entity): + self.hash_map[entity.id] = (entity.access_hash, entity.ty) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py new file mode 100644 index 00000000..1562cd21 --- /dev/null +++ b/telethon/_updates/messagebox.py @@ -0,0 +1,576 @@ +""" +This module deals with correct handling of updates, including gaps, and knowing when the code +should "get difference" (the set of updates that the client should know by now minus the set +of updates that it actually knows). + +Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point"). +At any given time, the message box may be either getting difference for them (entry is in +[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be +found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is +on its happy path. + +Gaps are cleared when they are either resolved on their own (by waiting for a short time) +or because we got the difference for the corresponding entry. + +While there are entries for which their difference must be fetched, +[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time +to get the difference. +""" +import asyncio +from dataclasses import dataclass, field +from .._sessions.types import SessionState, ChannelState +from .. import _tl + + +# Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too. +NO_SEQ = 0 + +# See https://core.telegram.org/method/updates.getChannelDifference. +BOT_CHANNEL_DIFF_LIMIT = 100000 +USER_CHANNEL_DIFF_LIMIT = 100 + +# > It may be useful to wait up to 0.5 seconds +POSSIBLE_GAP_TIMEOUT = 0.5 + +# After how long without updates the client will "timeout". +# +# When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the +# updates that arrive in the meantime. After all updates are fetched when this happens, the +# client will resume normal operation, and the timeout will reset. +# +# Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates). +NO_UPDATES_TIMEOUT = 15 * 60 + +# Entry "enum". +# Account-wide `pts` includes private conversations (one-to-one) and small group chats. +ENTRY_ACCOUNT = object() +# Account-wide `qts` includes only "secret" one-to-one chats. +ENTRY_SECRET = object() +# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels. + + +def next_updates_deadline(): + return asyncio.get_running_loop().time() + NO_UPDATES_TIMEOUT + + +class GapError(ValueError): + pass + + +# Represents the information needed to correctly handle a specific `tl::enums::Update`. +@dataclass +class PtsInfo: + pts: int + pts_count: int + entry: object + + @classmethod + def from_update(cls, update): + pts = getattr(update, 'pts', None) + if pts: + pts_count = getattr(update, 'pts_count', None) or 0 + try: + entry = update.message.peer_id.channel_id + except AttributeError: + entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT + return cls(pts=pts, pts_count=pts_count, entry=entry) + + qts = getattr(update, 'qts', None) + if qts: + pts_count = 1 if isinstance(update, _tl.UpdateNewEncryptedMessage) else 0 + return cls(pts=qts, pts_count=pts_count, entry=ENTRY_SECRET) + + return None + + +# The state of a particular entry in the message box. +@dataclass +class State: + # Current local persistent timestamp. + pts: int + + # Next instant when we would get the update difference if no updates arrived before then. + deadline: float + + +# > ### Recovering gaps +# > […] Manually obtaining updates is also required in the following situations: +# > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above). +# > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update +# > arrives, that fills the gap. +# +# This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because +# the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone). +@dataclass +class PossibleGap: + deadline: float + # Pending updates (those with a larger PTS, producing the gap which may later be filled). + updates: list # of updates + + +# Represents a "message box" (event `pts` for a specific entry). +# +# See https://core.telegram.org/api/updates#message-related-event-sequences. +@dataclass +class MessageBox: + # Map each entry to their current state. + map: dict = field(default_factory=dict) # entry -> state + + # Additional fields beyond PTS needed by `ENTRY_ACCOUNT`. + date: int = 1 + seq: int = NO_SEQ + + # Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline). + next_deadline: object = None # entry + + # Which entries have a gap and may soon trigger a need to get difference. + # + # If a gap is found, stores the required information to resolve it (when should it timeout and what updates + # should be held in case the gap is resolved on its own). + # + # Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have + # a gap in them). + possible_gaps: dict = field(default_factory=dict) # entry -> possiblegap + + # For which entries are we currently getting difference. + getting_diff_for: set = field(default_factory=set) # entry + + # Temporarily stores which entries should have their update deadline reset. + # Stored in the message box in order to reuse the allocation. + reset_deadlines_for: set = field(default_factory=set) # entry + + # region Creation, querying, and setting base state. + + def load(self, session_state, channel_states): + """ + Create a [`MessageBox`] from a previously known update state. + """ + deadline = next_updates_deadline() + + self.map.clear() + if session_state.pts != NO_SEQ: + self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline) + if session_state.qts != NO_SEQ: + self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline) + self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states) + + self.date = session_state.date + self.seq = session_state.seq + self.next_deadline = ENTRY_ACCOUNT + + def session_state(self): + """ + Return the current state. + + This should be used for persisting the state. + """ + return dict( + pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, + date=self.date, + seq=self.seq, + ), {id: state.pts for id, state in self.map.items() if isinstance(id, int)} + + def is_empty(self) -> bool: + """ + Return true if the message box is empty and has no state yet. + """ + return ENTRY_ACCOUNT not in self.map + + def check_deadlines(self): + """ + Return the next deadline when receiving updates should timeout. + + If a deadline expired, the corresponding entries will be marked as needing to get its difference. + While there are entries pending of getting their difference, this method returns the current instant. + """ + now = asyncio.get_running_loop().time() + + if self.getting_diff_for: + return now + + deadline = next_updates_deadline() + + # Most of the time there will be zero or one gap in flight so finding the minimum is cheap. + if self.possible_gaps: + deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values())) + elif self.next_deadline in self.map: + deadline = min(deadline, self.map[self.next_deadline].deadline) + + if now > deadline: + # Check all expired entries and add them to the list that needs getting difference. + self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now > gap.deadline) + self.getting_diff_for.update(entry for entry, state in self.map.items() if now > state.deadline) + + # When extending `getting_diff_for`, it's important to have the moral equivalent of + # `begin_get_diff` (that is, clear possible gaps if we're now getting difference). + for entry in self.getting_diff_for: + self.possible_gaps.pop(entry, None) + + return deadline + + # Reset the deadline for the periods without updates for a given entry. + # + # It also updates the next deadline time to reflect the new closest deadline. + def reset_deadline(self, entry, deadline): + if entry in self.map: + self.map[entry].deadline = deadline + # TODO figure out why not in map may happen + + if self.next_deadline == entry: + # If the updated deadline was the closest one, recalculate the new minimum. + self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0] + elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline: + # If the updated deadline is smaller than the next deadline, change the next deadline to be the new one. + self.next_deadline = entry + # else an unrelated deadline was updated, so the closest one remains unchanged. + + # Convenience to reset a channel's deadline, with optional timeout. + def reset_channel_deadline(self, channel_id, timeout): + self.reset_deadline(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT)) + + # Reset all the deadlines in `reset_deadlines_for` and then empty the set. + def apply_deadlines_reset(self): + next_deadline = next_updates_deadline() + + reset_deadlines_for = self.reset_deadlines_for + self.reset_deadlines_for = set() # "move" the set to avoid self.reset_deadline() from touching it during iter + + for entry in reset_deadlines_for: + self.reset_deadline(entry, next_deadline) + + reset_deadlines_for.clear() # reuse allocation, the other empty set was a temporary dummy value + self.reset_deadlines_for = reset_deadlines_for + + # Sets the update state. + # + # Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable + # updates will be fetched. + def set_state(self, state): + deadline = next_updates_deadline() + + if state.pts != NO_SEQ: + self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline) + else: + self.map.pop(ENTRY_ACCOUNT, None) + + if state.qts != NO_SEQ: + self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline) + else: + self.map.pop(ENTRY_SECRET, None) + + self.date = state.date + self.seq = state.seq + + # Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs. + # + # The update state will only be updated if no entry was known previously. + def try_set_channel_state(self, id, pts): + if id not in self.map: + self.map[id] = State(pts=pts, deadline=next_updates_deadline()) + + # Begin getting difference for the given entry. + # + # Clears any previous gaps. + def begin_get_diff(self, entry): + self.getting_diff_for.add(entry) + self.possible_gaps.pop(entry, None) + + # Finish getting difference for the given entry. + # + # It also resets the deadline. + def end_get_diff(self, entry): + try: + self.getting_diff_for.remove(entry) + except KeyError: + pass + self.reset_deadline(entry, next_updates_deadline()) + assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference" + + # endregion Creation, querying, and setting base state. + + # region "Normal" updates flow (processing and detection of gaps). + + # Process an update and return what should be done with it. + # + # Updates corresponding to entries for which their difference is currently being fetched + # will be ignored. While according to the [updates' documentation]: + # + # > Implementations [have] to postpone updates received via the socket while + # > filling gaps in the event and `Update` sequences, as well as avoid filling + # > gaps in the same sequence. + # + # In practice, these updates should have also been retrieved through getting difference. + # + # [updates documentation] https://core.telegram.org/api/updates + def process_updates( + self, + updates, + chat_hashes, + result, # out list of updates; returns list of user, chat, or raise if gap + ): + date = getattr(updates, 'date', None) + if date is None: + # updatesTooLong is the only one with no date (we treat it as a gap) + raise GapError + + seq = getattr(updates, 'seq', None) or NO_SEQ + seq_start = getattr(updates, 'seq_start', None) or seq + users = getattr(updates, 'users') or [] + chats = getattr(updates, 'chats') or [] + updates = getattr(updates, 'updates', None) or [updates] + + # > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors + # > there is no need to check `seq` or change a local state. + if seq_start != NO_SEQ: + if self.seq + 1 > seq_start: + # Skipping updates that were already handled + return (users, chats) + elif self.seq + 1 < seq_start: + # Gap detected + self.begin_get_diff(ENTRY_ACCOUNT) + raise GapError + # else apply + + self.date = date + if seq != NO_SEQ: + self.seq = seq + + result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates))) + + self.apply_deadlines_reset() + + def _sort_gaps(update): + pts = PtsInfo.from_update(update) + return pts.pts - pts.pts_count if pts else 0 + + if self.possible_gaps: + # For each update in possible gaps, see if the gap has been resolved already. + for key in list(self.possible_gaps.keys()): + self.possible_gaps[key].updates.sort(key=_sort_gaps) + + for _ in range(len(self.possible_gaps[key].updates)): + update = self.possible_gaps[key].updates.pop(0) + + # If this fails to apply, it will get re-inserted at the end. + # All should fail, so the order will be preserved (it would've cycled once). + update = self.apply_pts_info(update, reset_deadline=False) + if update: + result.append(update) + + # Clear now-empty gaps. + self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates} + + return (users, chats) + + # Tries to apply the input update if its `PtsInfo` follows the correct order. + # + # If the update can be applied, it is returned; otherwise, the update is stored in a + # possible gap (unless it was already handled or would be handled through getting + # difference) and `None` is returned. + def apply_pts_info( + self, + update, + *, + reset_deadline, + ): + pts = PtsInfo.from_update(update) + if not pts: + # No pts means that the update can be applied in any order. + return update + + # As soon as we receive an update of any form related to messages (has `PtsInfo`), + # the "no updates" period for that entry is reset. + # + # Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry. + if reset_deadline: + self.reset_deadlines_for.add(pts.entry) + + if pts.entry in self.getting_diff_for: + # Note: early returning here also prevents gap from being inserted (which they should + # not be while getting difference). + return None + + if pts.entry in self.map: + local_pts = self.map[pts.entry].pts + if local_pts + pts.pts_count > pts.pts: + # Ignore + return None + elif local_pts + pts.pts_count < pts.pts: + # Possible gap + # TODO store chats too? + if pts.entry not in self.possible_gaps: + self.possible_gaps[pts.entry] = PossibleGap( + deadline=asyncio.get_running_loop().time() + POSSIBLE_GAP_TIMEOUT, + updates=[] + ) + + self.possible_gaps[pts.entry].updates.append(update) + return None + else: + # Apply + pass + else: + # No previous `pts` known, and because this update has to be "right" (it's the first one) our + # `local_pts` must be one less. + local_pts = pts.pts - 1 + + # For example, when we're in a channel, we immediately receive: + # * ReadChannelInbox (pts = X) + # * NewChannelMessage (pts = X, pts_count = 1) + # + # Notice how both `pts` are the same. If we stored the one from the first, then the second one would + # be considered "already handled" and ignored, which is not desirable. Instead, advance local `pts` + # by `pts_count` (which is 0 for updates not directly related to messages, like reading inbox). + if pts.entry in self.map: + self.map[pts.entry].pts = local_pts + pts.pts_count + else: + self.map[pts.entry] = State(pts=local_pts + pts.pts_count, deadline=next_updates_deadline()) + + return update + + # endregion "Normal" updates flow (processing and detection of gaps). + + # region Getting and applying account difference. + + # Return the request that needs to be made to get the difference, if any. + def get_difference(self): + entry = ENTRY_ACCOUNT + if entry in self.getting_diff_for: + if entry in self.map: + return _tl.fn.updates.GetDifference( + pts=self.map[ENTRY_ACCOUNT].pts, + pts_total_limit=None, + date=self.date, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, + ) + else: + # TODO investigate when/why/if this can happen + self.end_get_diff(entry) + + return None + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_difference( + self, + diff, + chat_hashes, + ): + if isinstance(diff, _tl.updates.DifferenceEmpty): + self.date = diff.date + self.seq = diff.seq + self.end_get_diff(ENTRY_ACCOUNT) + return [], [], [] + elif isinstance(diff, _tl.updates.Difference): + self.end_get_diff(ENTRY_ACCOUNT) + chat_hashes.extend(diff.users, diff.chats) + return self.apply_difference_type(diff) + elif isinstance(diff, _tl.updates.DifferenceSlice): + chat_hashes.extend(diff.users, diff.chats) + return self.apply_difference_type(diff) + elif isinstance(diff, _tl.updates.DifferenceTooLong): + # TODO when are deadlines reset if we update the map?? + self.map[ENTRY_ACCOUNT].pts = diff.pts + self.end_get_diff(ENTRY_ACCOUNT) + return [], [], [] + + def apply_difference_type( + self, + diff, + ): + state = getattr(diff, 'intermediate_state', None) or diff.state + self.set_state(state) + + for u in diff.other_updates: + if isinstance(u, _tl.UpdateChannelTooLong): + self.begin_get_diff(u.channel_id) + + diff.other_updates.extend(_tl.UpdateNewMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + diff.other_updates.extend(_tl.UpdateNewEncryptedMessage( + message=m, + qts=NO_SEQ, + ) for m in diff.new_encrypted_messages) + + return diff.other_updates, diff.users, diff.chats + + # endregion Getting and applying account difference. + + # region Getting and applying channel difference. + + # Return the request that needs to be made to get a channel's difference, if any. + def get_channel_difference( + self, + chat_hashes, + ): + entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None) + if not entry: + return None + + packed = chat_hashes.get(entry) + if not packed: + # Cannot get channel difference as we're missing its hash + self.end_get_diff(entry) + # Remove the outdated `pts` entry from the map so that the next update can correct + # it. Otherwise, it will spam that the access hash is missing. + self.map.pop(entry, None) + return None + + state = self.map.get(entry) + if not state: + # TODO investigate when/why/if this can happen + # Cannot get channel difference as we're missing its pts + self.end_get_diff(entry) + return None + + return _tl.fn.updates.GetChannelDifference( + force=False, + channel=packed.try_to_input_channel(), + filter=_tl.ChannelMessagesFilterEmpty(), + pts=state.pts, + limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT + ) + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_channel_difference( + self, + request, + diff, + chat_hashes, + ): + entry = request.channel.channel_id + self.possible_gaps.pop(entry, None) + + if isinstance(diff, _tl.updates.ChannelDifferenceEmpty): + assert diff.final + self.end_get_diff(entry) + self.map[entry].pts = diff.pts + return [], [], [] + elif isinstance(diff, _tl.updates.ChannelDifferenceTooLong): + assert diff.final + self.map[entry].pts = diff.dialog.pts + chat_hashes.extend(diff.users, diff.chats) + self.reset_channel_deadline(entry, diff.timeout) + # This `diff` has the "latest messages and corresponding chats", but it would + # be strange to give the user only partial changes of these when they would + # expect all updates to be fetched. Instead, nothing is returned. + return [], [], [] + elif isinstance(diff, _tl.updates.ChannelDifference): + if diff.final: + self.end_get_diff(entry) + + self.map[entry].pts = diff.pts + diff.other_updates.extend(_tl.UpdateNewMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + chat_hashes.extend(diff.users, diff.chats) + self.reset_channel_deadline(entry, None) + + return diff.other_updates, diff.users, diff.chats + + # endregion Getting and applying channel difference. diff --git a/telethon/client/__init__.py b/telethon/client/__init__.py deleted file mode 100644 index e0463ab0..00000000 --- a/telethon/client/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -This package defines clients as subclasses of others, and then a single -`telethon.client.telegramclient.TelegramClient` which is subclass of them -all to provide the final unified interface while the methods can live in -different subclasses to be more maintainable. - -The ABC is `telethon.client.telegrambaseclient.TelegramBaseClient` and the -first implementor is `telethon.client.users.UserMethods`, since calling -requests require them to be resolved first, and that requires accessing -entities (users). -""" -from .telegrambaseclient import TelegramBaseClient -from .users import UserMethods # Required for everything -from .messageparse import MessageParseMethods # Required for messages -from .uploads import UploadMethods # Required for messages to send files -from .updates import UpdateMethods # Required for buttons (register callbacks) -from .buttons import ButtonMethods # Required for messages to use buttons -from .messages import MessageMethods -from .chats import ChatMethods -from .dialogs import DialogMethods -from .downloads import DownloadMethods -from .account import AccountMethods -from .auth import AuthMethods -from .bots import BotMethods -from .telegramclient import TelegramClient diff --git a/telethon/client/account.py b/telethon/client/account.py deleted file mode 100644 index d82235b6..00000000 --- a/telethon/client/account.py +++ /dev/null @@ -1,243 +0,0 @@ -import functools -import inspect -import typing - -from .users import _NOT_A_REQUEST -from .. import helpers, utils -from ..tl import functions, TLRequest - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -# TODO Make use of :tl:`InvokeWithMessagesRange` somehow -# For that, we need to use :tl:`GetSplitRanges` first. -class _TakeoutClient: - """ - Proxy object over the client. - """ - __PROXY_INTERFACE = ('__enter__', '__exit__', '__aenter__', '__aexit__') - - def __init__(self, finalize, client, request): - # We use the name mangling for attributes to make them inaccessible - # from within the shadowed client object and to distinguish them from - # its own attributes where needed. - self.__finalize = finalize - self.__client = client - self.__request = request - self.__success = None - - @property - def success(self): - return self.__success - - @success.setter - def success(self, value): - self.__success = value - - async def __aenter__(self): - # Enter/Exit behaviour is "overrode", we don't want to call start. - client = self.__client - if client.session.takeout_id is None: - client.session.takeout_id = (await client(self.__request)).id - elif self.__request is not None: - raise ValueError("Can't send a takeout request while another " - "takeout for the current session still not been finished yet.") - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - if self.__success is None and self.__finalize: - self.__success = exc_type is None - - if self.__success is not None: - result = await self(functions.account.FinishTakeoutSessionRequest( - self.__success)) - if not result: - raise ValueError("Failed to finish the takeout.") - self.session.takeout_id = None - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - - async def __call__(self, request, ordered=False): - takeout_id = self.__client.session.takeout_id - if takeout_id is None: - raise ValueError('Takeout mode has not been initialized ' - '(are you calling outside of "with"?)') - - single = not utils.is_list_like(request) - requests = ((request,) if single else request) - wrapped = [] - for r in requests: - if not isinstance(r, TLRequest): - raise _NOT_A_REQUEST() - await r.resolve(self, utils) - wrapped.append(functions.InvokeWithTakeoutRequest(takeout_id, r)) - - return await self.__client( - wrapped[0] if single else wrapped, ordered=ordered) - - def __getattribute__(self, name): - # We access class via type() because __class__ will recurse infinitely. - # Also note that since we've name-mangled our own class attributes, - # they'll be passed to __getattribute__() as already decorated. For - # example, 'self.__client' will be passed as '_TakeoutClient__client'. - # https://docs.python.org/3/tutorial/classes.html#private-variables - if name.startswith('__') and name not in type(self).__PROXY_INTERFACE: - raise AttributeError # force call of __getattr__ - - # Try to access attribute in the proxy object and check for the same - # attribute in the shadowed object (through our __getattr__) if failed. - return super().__getattribute__(name) - - def __getattr__(self, name): - value = getattr(self.__client, name) - if inspect.ismethod(value): - # Emulate bound methods behavior by partially applying our proxy - # class as the self parameter instead of the client. - return functools.partial( - getattr(self.__client.__class__, name), self) - - return value - - def __setattr__(self, name, value): - if name.startswith('_{}__'.format(type(self).__name__.lstrip('_'))): - # This is our own name-mangled attribute, keep calm. - return super().__setattr__(name, value) - return setattr(self.__client, name, value) - - -class AccountMethods: - def takeout( - self: 'TelegramClient', - finalize: bool = True, - *, - contacts: bool = None, - users: bool = None, - chats: bool = None, - megagroups: bool = None, - channels: bool = None, - files: bool = None, - max_file_size: bool = None) -> 'TelegramClient': - """ - Returns a :ref:`telethon-client` which calls methods behind a takeout session. - - It does so by creating a proxy object over the current client through - which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap - them. In other words, returns the current client modified so that - requests are done as a takeout: - - Some of the calls made through the takeout session will have lower - flood limits. This is useful if you want to export the data from - conversations or mass-download media, since the rate limits will - be lower. Only some requests will be affected, and you will need - to adjust the `wait_time` of methods like `client.iter_messages - `. - - By default, all parameters are `None`, and you need to enable those - you plan to use by setting them to either `True` or `False`. - - You should ``except errors.TakeoutInitDelayError as e``, since this - exception will raise depending on the condition of the session. You - can then access ``e.seconds`` to know how long you should wait for - before calling the method again. - - There's also a `success` property available in the takeout proxy - object, so from the `with` body you can set the boolean result that - will be sent back to Telegram. But if it's left `None` as by - default, then the action is based on the `finalize` parameter. If - it's `True` then the takeout will be finished, and if no exception - occurred during it, then `True` will be considered as a result. - Otherwise, the takeout will not be finished and its ID will be - preserved for future usage as `client.session.takeout_id - `. - - Arguments - finalize (`bool`): - Whether the takeout session should be finalized upon - exit or not. - - contacts (`bool`): - Set to `True` if you plan on downloading contacts. - - users (`bool`): - Set to `True` if you plan on downloading information - from users and their private conversations with you. - - chats (`bool`): - Set to `True` if you plan on downloading information - from small group chats, such as messages and media. - - megagroups (`bool`): - Set to `True` if you plan on downloading information - from megagroups (channels), such as messages and media. - - channels (`bool`): - Set to `True` if you plan on downloading information - from broadcast channels, such as messages and media. - - files (`bool`): - Set to `True` if you plan on downloading media and - you don't only wish to export messages. - - max_file_size (`int`): - The maximum file size, in bytes, that you plan - to download for each message with media. - - Example - .. code-block:: python - - from telethon import errors - - try: - async with client.takeout() as takeout: - await client.get_messages('me') # normal call - await takeout.get_messages('me') # wrapped through takeout (less limits) - - async for message in takeout.iter_messages(chat, wait_time=0): - ... # Do something with the message - - except errors.TakeoutInitDelayError as e: - print('Must wait', e.seconds, 'before takeout') - """ - request_kwargs = dict( - contacts=contacts, - message_users=users, - message_chats=chats, - message_megagroups=megagroups, - message_channels=channels, - files=files, - file_max_size=max_file_size - ) - arg_specified = (arg is not None for arg in request_kwargs.values()) - - if self.session.takeout_id is None or any(arg_specified): - request = functions.account.InitTakeoutSessionRequest( - **request_kwargs) - else: - request = None - - return _TakeoutClient(finalize, self, request) - - async def end_takeout(self: 'TelegramClient', success: bool) -> bool: - """ - Finishes the current takeout session. - - Arguments - success (`bool`): - Whether the takeout completed successfully or not. - - Returns - `True` if the operation was successful, `False` otherwise. - - Example - .. code-block:: python - - await client.end_takeout(success=False) - """ - try: - async with _TakeoutClient(True, self, None) as takeout: - takeout.success = success - except ValueError: - return False - return True diff --git a/telethon/client/auth.py b/telethon/client/auth.py deleted file mode 100644 index 9665262b..00000000 --- a/telethon/client/auth.py +++ /dev/null @@ -1,721 +0,0 @@ -import getpass -import inspect -import os -import sys -import typing -import warnings - -from .. import utils, helpers, errors, password as pwd_mod -from ..tl import types, functions, custom - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -class AuthMethods: - - # region Public methods - - def start( - self: 'TelegramClient', - phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), - password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), - *, - bot_token: str = None, - force_sms: bool = False, - code_callback: typing.Callable[[], typing.Union[str, int]] = None, - first_name: str = 'New User', - last_name: str = '', - max_attempts: int = 3) -> 'TelegramClient': - """ - Starts the client (connects and logs in if necessary). - - By default, this method will be interactive (asking for - user input if needed), and will handle 2FA if enabled too. - - If the phone doesn't belong to an existing account (and will hence - `sign_up` for a new one), **you are agreeing to Telegram's - Terms of Service. This is required and your account - will be banned otherwise.** See https://telegram.org/tos - and https://core.telegram.org/api/terms. - - If the event loop is already running, this method returns a - coroutine that you should await on your own code; otherwise - the loop is ran until said coroutine completes. - - Arguments - phone (`str` | `int` | `callable`): - The phone (or callable without arguments to get it) - to which the code will be sent. If a bot-token-like - string is given, it will be used as such instead. - The argument may be a coroutine. - - password (`str`, `callable`, optional): - The password for 2 Factor Authentication (2FA). - This is only required if it is enabled in your account. - The argument may be a coroutine. - - bot_token (`str`): - Bot Token obtained by `@BotFather `_ - to log in as a bot. Cannot be specified with ``phone`` (only - one of either allowed). - - force_sms (`bool`, optional): - Whether to force sending the code request as SMS. - This only makes sense when signing in with a `phone`. - - code_callback (`callable`, optional): - A callable that will be used to retrieve the Telegram - login code. Defaults to `input()`. - The argument may be a coroutine. - - first_name (`str`, optional): - The first name to be used if signing up. This has no - effect if the account already exists and you sign in. - - last_name (`str`, optional): - Similar to the first name, but for the last. Optional. - - max_attempts (`int`, optional): - How many times the code/password callback should be - retried or switching between signing in and signing up. - - Returns - This `TelegramClient`, so initialization - can be chained with ``.start()``. - - Example - .. code-block:: python - - client = TelegramClient('anon', api_id, api_hash) - - # Starting as a bot account - await client.start(bot_token=bot_token) - - # Starting as a user account - await client.start(phone) - # Please enter the code you received: 12345 - # Please enter your password: ******* - # (You are now logged in) - - # Starting using a context manager (this calls start()): - with client: - pass - """ - if code_callback is None: - def code_callback(): - return input('Please enter the code you received: ') - elif not callable(code_callback): - raise ValueError( - 'The code_callback parameter needs to be a callable ' - 'function that returns the code you received by Telegram.' - ) - - if not phone and not bot_token: - raise ValueError('No phone number or bot token provided.') - - if phone and bot_token and not callable(phone): - raise ValueError('Both a phone and a bot token provided, ' - 'must only provide one of either') - - coro = self._start( - phone=phone, - password=password, - bot_token=bot_token, - force_sms=force_sms, - code_callback=code_callback, - first_name=first_name, - last_name=last_name, - max_attempts=max_attempts - ) - return ( - coro if self.loop.is_running() - else self.loop.run_until_complete(coro) - ) - - async def _start( - self: 'TelegramClient', phone, password, bot_token, force_sms, - code_callback, first_name, last_name, max_attempts): - if not self.is_connected(): - await self.connect() - - # 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 phone and not callable(phone) and utils.parse_phone(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: - # Turn the callable into a valid phone number (or bot token) - while callable(phone): - value = phone() - if inspect.isawaitable(value): - value = await value - - if ':' in value: - # Bot tokens have 'user_id:access_hash' format - bot_token = value - break - - phone = utils.parse_phone(value) or phone - - if bot_token: - await self.sign_in(bot_token=bot_token) - return self - - me = None - attempts = 0 - two_step_detected = False - - await self.send_code_request(phone, force_sms=force_sms) - sign_up = False # assume login - while attempts < max_attempts: - try: - value = code_callback() - if inspect.isawaitable(value): - value = await value - - # Since sign-in with no code works (it sends the code) - # we must double-check that here. Else we'll assume we - # logged in, and it will return None as the User. - if not value: - raise errors.PhoneCodeEmptyError(request=None) - - if sign_up: - me = await self.sign_up(value, first_name, last_name) - else: - # Raises SessionPasswordNeededError if 2FA enabled - me = await self.sign_in(phone, code=value) - break - except errors.SessionPasswordNeededError: - two_step_detected = True - break - except errors.PhoneNumberOccupiedError: - sign_up = False - except errors.PhoneNumberUnoccupiedError: - sign_up = True - except (errors.PhoneCodeEmptyError, - errors.PhoneCodeExpiredError, - errors.PhoneCodeHashEmptyError, - errors.PhoneCodeInvalidError): - print('Invalid code. Please try again.', file=sys.stderr) - - attempts += 1 - else: - raise RuntimeError( - '{} consecutive sign-in attempts failed. Aborting' - .format(max_attempts) - ) - - if two_step_detected: - if not password: - raise ValueError( - "Two-step verification is enabled for this account. " - "Please provide the 'password' argument to 'start()'." - ) - - if callable(password): - for _ in range(max_attempts): - try: - value = password() - if inspect.isawaitable(value): - value = await value - - me = await self.sign_in(phone=phone, password=value) - break - except errors.PasswordHashInvalidError: - print('Invalid password. Please try again', - file=sys.stderr) - else: - raise errors.PasswordHashInvalidError(request=None) - else: - me = await self.sign_in(phone=phone, password=password) - - # We won't reach here if any step failed (exit by exception) - signed, name = 'Signed in successfully as', utils.get_display_name(me) - try: - print(signed, name) - except UnicodeEncodeError: - # Some terminals don't support certain characters - print(signed, name.encode('utf-8', errors='ignore') - .decode('ascii', errors='ignore')) - - return self - - def _parse_phone_and_hash(self, phone, phone_hash): - """ - Helper method to both parse and validate phone and its hash. - """ - phone = utils.parse_phone(phone) or self._phone - if not phone: - raise ValueError( - 'Please make sure to call send_code_request first.' - ) - - phone_hash = phone_hash or self._phone_code_hash.get(phone, None) - if not phone_hash: - raise ValueError('You also need to provide a phone_code_hash.') - - return phone, phone_hash - - async def sign_in( - self: 'TelegramClient', - phone: str = None, - code: typing.Union[str, int] = None, - *, - password: str = None, - bot_token: str = None, - phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]': - """ - Logs in to Telegram to an existing user or bot account. - - You should only use this if you are not authorized yet. - - This method will send the code if it's not provided. - - .. note:: - - In most cases, you should simply use `start()` and not this method. - - Arguments - phone (`str` | `int`): - The phone to send the code to if no code was provided, - or to override the phone that was previously used with - these requests. - - code (`str` | `int`): - The code that Telegram sent. Note that if you have sent this - code through the application itself it will immediately - expire. If you want to send the code, obfuscate it somehow. - If you're not doing any of this you can ignore this note. - - password (`str`): - 2FA password, should be used if a previous call raised - ``SessionPasswordNeededError``. - - bot_token (`str`): - Used to sign in as a bot. Not all requests will be available. - This should be the hash the `@BotFather `_ - gave you. - - phone_code_hash (`str`, optional): - The hash returned by `send_code_request`. This can be left as - `None` to use the last hash known for the phone to be used. - - Returns - The signed in user, or the information about - :meth:`send_code_request`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - await client.sign_in(phone) # send code - - code = input('enter code: ') - await client.sign_in(phone, code) - """ - me = await self.get_me() - if me: - return me - - if phone and not code and not password: - return await self.send_code_request(phone) - elif code: - phone, phone_code_hash = \ - self._parse_phone_and_hash(phone, phone_code_hash) - - # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, - # PhoneCodeHashEmptyError or PhoneCodeInvalidError. - request = functions.auth.SignInRequest( - phone, phone_code_hash, str(code) - ) - elif password: - pwd = await self(functions.account.GetPasswordRequest()) - request = functions.auth.CheckPasswordRequest( - pwd_mod.compute_check(pwd, password) - ) - elif bot_token: - request = functions.auth.ImportBotAuthorizationRequest( - flags=0, bot_auth_token=bot_token, - api_id=self.api_id, api_hash=self.api_hash - ) - else: - raise ValueError( - 'You must provide a phone and a code the first time, ' - 'and a password only if an RPCError was raised before.' - ) - - result = await self(request) - if isinstance(result, types.auth.AuthorizationSignUpRequired): - # Emulate pre-layer 104 behaviour - self._tos = result.terms_of_service - raise errors.PhoneNumberUnoccupiedError(request=request) - - return self._on_login(result.user) - - async def sign_up( - self: 'TelegramClient', - code: typing.Union[str, int], - first_name: str, - last_name: str = '', - *, - phone: str = None, - phone_code_hash: str = None) -> 'types.User': - """ - Signs up to Telegram as a new user account. - - Use this if you don't have an account yet. - - You must call `send_code_request` first. - - **By using this method you're agreeing to Telegram's - Terms of Service. This is required and your account - will be banned otherwise.** See https://telegram.org/tos - and https://core.telegram.org/api/terms. - - Arguments - code (`str` | `int`): - The code sent by Telegram - - first_name (`str`): - The first name to be used by the new account. - - last_name (`str`, optional) - Optional last name. - - phone (`str` | `int`, optional): - The phone to sign up. This will be the last phone used by - default (you normally don't need to set this). - - phone_code_hash (`str`, optional): - The hash returned by `send_code_request`. This can be left as - `None` to use the last hash known for the phone to be used. - - Returns - The new created :tl:`User`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - await client.send_code_request(phone) - - code = input('enter code: ') - await client.sign_up(code, first_name='Anna', last_name='Banana') - """ - me = await self.get_me() - if me: - return me - - # To prevent abuse, one has to try to sign in before signing up. This - # is the current way in which Telegram validates the code to sign up. - # - # `sign_in` will set `_tos`, so if it's set we don't need to call it - # because the user already tried to sign in. - # - # We're emulating pre-layer 104 behaviour so except the right error: - if not self._tos: - try: - return await self.sign_in( - phone=phone, - code=code, - phone_code_hash=phone_code_hash, - ) - except errors.PhoneNumberUnoccupiedError: - pass # code is correct and was used, now need to sign in - - if self._tos and self._tos.text: - if self.parse_mode: - t = self.parse_mode.unparse(self._tos.text, self._tos.entities) - else: - t = self._tos.text - sys.stderr.write("{}\n".format(t)) - sys.stderr.flush() - - phone, phone_code_hash = \ - self._parse_phone_and_hash(phone, phone_code_hash) - - result = await self(functions.auth.SignUpRequest( - phone_number=phone, - phone_code_hash=phone_code_hash, - first_name=first_name, - last_name=last_name - )) - - if self._tos: - await self( - functions.help.AcceptTermsOfServiceRequest(self._tos.id)) - - return self._on_login(result.user) - - def _on_login(self, user): - """ - Callback called whenever the login or sign up process completes. - - Returns the input user parameter. - """ - self._bot = bool(user.bot) - self._self_input_peer = utils.get_input_peer(user, allow_self=False) - self._authorized = True - - return user - - async def send_code_request( - self: 'TelegramClient', - phone: str, - *, - force_sms: bool = False) -> 'types.auth.SentCode': - """ - Sends the Telegram code needed to login to the given phone number. - - Arguments - phone (`str` | `int`): - The phone to which the code will be sent. - - force_sms (`bool`, optional): - Whether to force sending as SMS. - - Returns - An instance of :tl:`SentCode`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - sent = await client.send_code_request(phone) - print(sent) - """ - result = None - phone = utils.parse_phone(phone) or self._phone - phone_hash = self._phone_code_hash.get(phone) - - if not phone_hash: - try: - result = await self(functions.auth.SendCodeRequest( - phone, self.api_id, self.api_hash, types.CodeSettings())) - except errors.AuthRestartError: - return await self.send_code_request(phone, force_sms=force_sms) - - # If we already sent a SMS, do not resend the code (hash may be empty) - if isinstance(result.type, types.auth.SentCodeTypeSms): - force_sms = False - - # phone_code_hash may be empty, if it is, do not save it (#1283) - if result.phone_code_hash: - self._phone_code_hash[phone] = phone_hash = result.phone_code_hash - else: - force_sms = True - - self._phone = phone - - if force_sms: - result = await self( - functions.auth.ResendCodeRequest(phone, phone_hash)) - - self._phone_code_hash[phone] = result.phone_code_hash - - 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. - - Returns - `True` if the operation was successful. - - Example - .. code-block:: python - - # Note: you will need to login again! - await client.log_out() - """ - try: - await self(functions.auth.LogOutRequest()) - except errors.RPCError: - return False - - self._bot = None - self._self_input_peer = None - self._authorized = False - self._state_cache.reset() - - await self.disconnect() - self.session.delete() - return True - - async def edit_2fa( - self: 'TelegramClient', - current_password: str = None, - new_password: str = None, - *, - hint: str = '', - email: str = None, - email_code_callback: typing.Callable[[int], str] = None) -> bool: - """ - Changes the 2FA settings of the logged in user. - - Review carefully the parameter explanations before using this method. - - Note that this method may be *incredibly* slow depending on the - prime numbers that must be used during the process to make sure - that everything is safe. - - Has no effect if both current and new password are omitted. - - Arguments - current_password (`str`, optional): - The current password, to authorize changing to ``new_password``. - Must be set if changing existing 2FA settings. - Must **not** be set if 2FA is currently disabled. - Passing this by itself will remove 2FA (if correct). - - new_password (`str`, optional): - The password to set as 2FA. - If 2FA was already enabled, ``current_password`` **must** be set. - Leaving this blank or `None` will remove the password. - - hint (`str`, optional): - Hint to be displayed by Telegram when it asks for 2FA. - Leaving unspecified is highly discouraged. - Has no effect if ``new_password`` is not set. - - email (`str`, optional): - Recovery and verification email. If present, you must also - set `email_code_callback`, else it raises ``ValueError``. - - email_code_callback (`callable`, optional): - If an email is provided, a callback that returns the code sent - to it must also be set. This callback may be asynchronous. - It should return a string with the code. The length of the - code will be passed to the callback as an input parameter. - - If the callback returns an invalid code, it will raise - ``CodeInvalidError``. - - Returns - `True` if successful, `False` otherwise. - - Example - .. code-block:: python - - # Setting a password for your account which didn't have - await client.edit_2fa(new_password='I_<3_Telethon') - - # Removing the password - await client.edit_2fa(current_password='I_<3_Telethon') - """ - if new_password is None and current_password is None: - return False - - if email and not callable(email_code_callback): - raise ValueError('email present without email_code_callback') - - pwd = await self(functions.account.GetPasswordRequest()) - pwd.new_algo.salt1 += os.urandom(32) - assert isinstance(pwd, types.account.Password) - if not pwd.has_password and current_password: - current_password = None - - if current_password: - password = pwd_mod.compute_check(pwd, current_password) - else: - password = types.InputCheckPasswordEmpty() - - if new_password: - new_password_hash = pwd_mod.compute_digest( - pwd.new_algo, new_password) - else: - new_password_hash = b'' - - try: - await self(functions.account.UpdatePasswordSettingsRequest( - password=password, - new_settings=types.account.PasswordInputSettings( - new_algo=pwd.new_algo, - new_password_hash=new_password_hash, - hint=hint, - email=email, - new_secure_settings=None - ) - )) - except errors.EmailUnconfirmedError as e: - code = email_code_callback(e.code_length) - if inspect.isawaitable(code): - code = await code - - code = str(code) - await self(functions.account.ConfirmPasswordEmailRequest(code)) - - return True - - # endregion - - # region with blocks - - async def __aenter__(self): - return await self.start() - - async def __aexit__(self, *args): - await self.disconnect() - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - - # endregion diff --git a/telethon/client/bots.py b/telethon/client/bots.py deleted file mode 100644 index 044d8513..00000000 --- a/telethon/client/bots.py +++ /dev/null @@ -1,72 +0,0 @@ -import typing - -from .. import hints -from ..tl import types, functions, custom - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -class BotMethods: - async def inline_query( - self: 'TelegramClient', - bot: 'hints.EntityLike', - query: str, - *, - entity: 'hints.EntityLike' = None, - offset: str = None, - geo_point: 'types.GeoPoint' = None) -> custom.InlineResults: - """ - Makes an inline query to the specified bot (``@vote New Poll``). - - Arguments - bot (`entity`): - The bot entity to which the inline query should be made. - - query (`str`): - The query that should be made to the bot. - - entity (`entity`, optional): - The entity where the inline query is being made from. Certain - bots use this to display different results depending on where - it's used, such as private chats, groups or channels. - - If specified, it will also be the default entity where the - message will be sent after clicked. Otherwise, the "empty - peer" will be used, which some bots may not handle correctly. - - offset (`str`, optional): - The string offset to use for the bot. - - geo_point (:tl:`GeoPoint`, optional) - The geo point location information to send to the bot - for localised results. Available under some bots. - - Returns - A list of `custom.InlineResult - `. - - Example - .. code-block:: python - - # Make an inline query to @like - results = await client.inline_query('like', 'Do you like Telethon?') - - # Send the first result to some chat - message = await results[0].click('TelethonOffTopic') - """ - bot = await self.get_input_entity(bot) - if entity: - peer = await self.get_input_entity(entity) - else: - peer = types.InputPeerEmpty() - - result = await self(functions.messages.GetInlineBotResultsRequest( - bot=bot, - peer=peer, - query=query, - offset=offset or '', - geo_point=geo_point - )) - - return custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py deleted file mode 100644 index 7e848ab1..00000000 --- a/telethon/client/buttons.py +++ /dev/null @@ -1,96 +0,0 @@ -import typing - -from .. import utils, hints -from ..tl import types, custom - - -class ButtonMethods: - @staticmethod - def build_reply_markup( - buttons: 'typing.Optional[hints.MarkupLike]', - inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]': - """ - Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for - the given buttons. - - Does nothing if either no buttons are provided or the provided - argument is already a reply markup. - - You should consider using this method if you are going to reuse - the markup very often. Otherwise, it is not necessary. - - This method is **not** asynchronous (don't use ``await`` on it). - - Arguments - buttons (`hints.MarkupLike`): - The button, list of buttons, array of buttons or markup - to convert into a markup. - - inline_only (`bool`, optional): - Whether the buttons **must** be inline buttons only or not. - - Example - .. code-block:: python - - from telethon import Button - - markup = client.build_reply_markup(Button.inline('hi')) - # later - await client.send_message(chat, 'click me', buttons=markup) - """ - if buttons is None: - return None - - try: - if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: - return buttons # crc32(b'ReplyMarkup'): - except AttributeError: - pass - - if not utils.is_list_like(buttons): - buttons = [[buttons]] - elif not buttons or not utils.is_list_like(buttons[0]): - buttons = [buttons] - - is_inline = False - is_normal = False - resize = None - single_use = None - selective = None - - rows = [] - for row in buttons: - current = [] - for button in row: - if isinstance(button, custom.Button): - if button.resize is not None: - resize = button.resize - if button.single_use is not None: - single_use = button.single_use - if button.selective is not None: - selective = button.selective - - button = button.button - elif isinstance(button, custom.MessageButton): - button = button.button - - inline = custom.Button._is_inline(button) - is_inline |= inline - is_normal |= not inline - - if button.SUBCLASS_OF_ID == 0xbad74a3: - # 0xbad74a3 == crc32(b'KeyboardButton') - current.append(button) - - if current: - rows.append(types.KeyboardButtonRow(current)) - - if inline_only and is_normal: - raise ValueError('You cannot use non-inline buttons here') - elif is_inline == is_normal and is_normal: - raise ValueError('You cannot mix inline with normal buttons') - elif is_inline: - return types.ReplyInlineMarkup(rows) - # elif is_normal: - return types.ReplyKeyboardMarkup( - rows, resize=resize, single_use=single_use, selective=selective) diff --git a/telethon/client/chats.py b/telethon/client/chats.py deleted file mode 100644 index dfbeddcc..00000000 --- a/telethon/client/chats.py +++ /dev/null @@ -1,1368 +0,0 @@ -import asyncio -import inspect -import itertools -import string -import typing - -from .. import helpers, utils, hints, errors -from ..requestiter import RequestIter -from ..tl import types, functions, custom - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - -_MAX_PARTICIPANTS_CHUNK_SIZE = 200 -_MAX_ADMIN_LOG_CHUNK_SIZE = 100 -_MAX_PROFILE_PHOTO_CHUNK_SIZE = 100 - - -class _ChatAction: - _str_mapping = { - 'typing': types.SendMessageTypingAction(), - 'contact': types.SendMessageChooseContactAction(), - 'game': types.SendMessageGamePlayAction(), - 'location': types.SendMessageGeoLocationAction(), - 'sticker': types.SendMessageChooseStickerAction(), - - 'record-audio': types.SendMessageRecordAudioAction(), - 'record-voice': types.SendMessageRecordAudioAction(), # alias - 'record-round': types.SendMessageRecordRoundAction(), - 'record-video': types.SendMessageRecordVideoAction(), - - '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 - - 'cancel': types.SendMessageCancelAction() - } - - def __init__(self, client, chat, action, *, delay, auto_cancel): - self._client = client - self._chat = chat - self._action = action - self._delay = delay - self._auto_cancel = auto_cancel - self._request = None - self._task = None - self._running = False - - async def __aenter__(self): - self._chat = await self._client.get_input_entity(self._chat) - - # Since `self._action` is passed by reference we can avoid - # recreating the request all the time and still modify - # `self._action.progress` directly in `progress`. - self._request = functions.messages.SetTypingRequest( - self._chat, self._action) - - self._running = True - self._task = self._client.loop.create_task(self._update()) - return self - - async def __aexit__(self, *args): - self._running = False - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - self._task = None - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - - async def _update(self): - try: - while self._running: - await self._client(self._request) - await asyncio.sleep(self._delay) - except ConnectionError: - pass - except asyncio.CancelledError: - if self._auto_cancel: - await self._client(functions.messages.SetTypingRequest( - self._chat, types.SendMessageCancelAction())) - - def progress(self, current, total): - if hasattr(self._action, 'progress'): - self._action.progress = 100 * round(current / total) - - -class _ParticipantsIter(RequestIter): - async def _init(self, entity, filter, search, aggressive): - if isinstance(filter, type): - if filter in (types.ChannelParticipantsBanned, - types.ChannelParticipantsKicked, - types.ChannelParticipantsSearch, - types.ChannelParticipantsContacts): - # These require a `q` parameter (support types for convenience) - filter = filter('') - else: - filter = filter() - - entity = await self.client.get_input_entity(entity) - ty = helpers._entity_type(entity) - if search and (filter or ty != helpers._EntityType.CHANNEL): - # We need to 'search' ourselves unless we have a PeerChannel - search = search.casefold() - - self.filter_entity = lambda ent: ( - search in utils.get_display_name(ent).casefold() or - search in (getattr(ent, 'username', None) or '').casefold() - ) - else: - self.filter_entity = lambda ent: True - - # Only used for channels, but we should always set the attribute - self.requests = [] - - if ty == helpers._EntityType.CHANNEL: - if self.limit <= 0: - # May not have access to the channel, but getFull can get the .total. - self.total = (await self.client( - functions.channels.GetFullChannelRequest(entity) - )).full_chat.participants_count - raise StopAsyncIteration - - self.seen = set() - if aggressive and not filter: - self.requests.extend(functions.channels.GetParticipantsRequest( - channel=entity, - filter=types.ChannelParticipantsSearch(x), - offset=0, - limit=_MAX_PARTICIPANTS_CHUNK_SIZE, - hash=0 - ) for x in (search or string.ascii_lowercase)) - else: - self.requests.append(functions.channels.GetParticipantsRequest( - channel=entity, - filter=filter or types.ChannelParticipantsSearch(search), - offset=0, - limit=_MAX_PARTICIPANTS_CHUNK_SIZE, - hash=0 - )) - - elif ty == helpers._EntityType.CHAT: - full = await self.client( - functions.messages.GetFullChatRequest(entity.chat_id)) - if not isinstance( - full.full_chat.participants, types.ChatParticipants): - # ChatParticipantsForbidden won't have ``.participants`` - self.total = 0 - raise StopAsyncIteration - - self.total = len(full.full_chat.participants.participants) - - users = {user.id: user for user in full.users} - for participant in full.full_chat.participants.participants: - if isinstance(participant, types.ChannelParticipantBanned): - user_id = participant.peer.user_id - else: - user_id = participant.user_id - user = users[user_id] - if not self.filter_entity(user): - continue - - user = users[user_id] - user.participant = participant - self.buffer.append(user) - - return True - else: - self.total = 1 - if self.limit != 0: - user = await self.client.get_entity(entity) - if self.filter_entity(user): - user.participant = None - self.buffer.append(user) - - return True - - async def _load_next_chunk(self): - if not self.requests: - return True - - # Only care about the limit for the first request - # (small amount of people, won't be aggressive). - # - # Most people won't care about getting exactly 12,345 - # members so it doesn't really matter not to be 100% - # precise with being out of the offset/limit here. - self.requests[0].limit = min( - self.limit - self.requests[0].offset, _MAX_PARTICIPANTS_CHUNK_SIZE) - - if self.requests[0].offset > self.limit: - return True - - if self.total is None: - f = self.requests[0].filter - if len(self.requests) > 1 or ( - not isinstance(f, types.ChannelParticipantsRecent) - and (not isinstance(f, types.ChannelParticipantsSearch) or f.q) - ): - # Only do an additional getParticipants here to get the total - # if there's a filter which would reduce the real total number. - # getParticipants is cheaper than getFull. - self.total = (await self.client(functions.channels.GetParticipantsRequest( - channel=self.requests[0].channel, - filter=types.ChannelParticipantsRecent(), - offset=0, - limit=1, - hash=0 - ))).count - - results = await self.client(self.requests) - for i in reversed(range(len(self.requests))): - participants = results[i] - if self.total is None: - # Will only get here if there was one request with a filter that matched all users. - self.total = participants.count - if not participants.users: - self.requests.pop(i) - continue - - self.requests[i].offset += len(participants.participants) - users = {user.id: user for user in participants.users} - for participant in participants.participants: - - if isinstance(participant, types.ChannelParticipantBanned): - if not isinstance(participant.peer, types.PeerUser): - # May have the entire channel banned. See #3105. - continue - user_id = participant.peer.user_id - else: - user_id = participant.user_id - - user = users[user_id] - if not self.filter_entity(user) or user.id in self.seen: - continue - self.seen.add(user_id) - user = users[user_id] - user.participant = participant - self.buffer.append(user) - - -class _AdminLogIter(RequestIter): - async def _init( - self, entity, admins, search, min_id, max_id, - join, leave, invite, restrict, unrestrict, ban, unban, - promote, demote, info, settings, pinned, edit, delete, - group_call - ): - if any((join, leave, invite, restrict, unrestrict, ban, unban, - promote, demote, info, settings, pinned, edit, delete, - group_call)): - events_filter = types.ChannelAdminLogEventsFilter( - join=join, leave=leave, invite=invite, ban=restrict, - unban=unrestrict, kick=ban, unkick=unban, promote=promote, - demote=demote, info=info, settings=settings, pinned=pinned, - edit=edit, delete=delete, group_call=group_call - ) - else: - events_filter = None - - self.entity = await self.client.get_input_entity(entity) - - admin_list = [] - if admins: - if not utils.is_list_like(admins): - admins = (admins,) - - for admin in admins: - admin_list.append(await self.client.get_input_entity(admin)) - - self.request = functions.channels.GetAdminLogRequest( - self.entity, q=search or '', min_id=min_id, max_id=max_id, - limit=0, events_filter=events_filter, admins=admin_list or None - ) - - async def _load_next_chunk(self): - self.request.limit = min(self.left, _MAX_ADMIN_LOG_CHUNK_SIZE) - r = await self.client(self.request) - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - self.request.max_id = min((e.id for e in r.events), default=0) - for ev in r.events: - if isinstance(ev.action, - types.ChannelAdminLogEventActionEditMessage): - ev.action.prev_message._finish_init( - self.client, entities, self.entity) - - ev.action.new_message._finish_init( - self.client, entities, self.entity) - - elif isinstance(ev.action, - types.ChannelAdminLogEventActionDeleteMessage): - ev.action.message._finish_init( - self.client, entities, self.entity) - - self.buffer.append(custom.AdminLogEvent(ev, entities)) - - if len(r.events) < self.request.limit: - return True - - -class _ProfilePhotoIter(RequestIter): - async def _init( - self, entity, offset, max_id - ): - entity = await self.client.get_input_entity(entity) - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.USER: - self.request = functions.photos.GetUserPhotosRequest( - entity, - offset=offset, - max_id=max_id, - limit=1 - ) - else: - self.request = functions.messages.SearchRequest( - peer=entity, - q='', - filter=types.InputMessagesFilterChatPhotos(), - min_date=None, - max_date=None, - offset_id=0, - add_offset=offset, - limit=1, - max_id=max_id, - min_id=0, - hash=0 - ) - - if self.limit == 0: - self.request.limit = 1 - result = await self.client(self.request) - if isinstance(result, types.photos.Photos): - self.total = len(result.photos) - elif isinstance(result, types.messages.Messages): - self.total = len(result.messages) - else: - # Luckily both photosSlice and messages have a count for total - self.total = getattr(result, 'count', None) - - async def _load_next_chunk(self): - self.request.limit = min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE) - result = await self.client(self.request) - - if isinstance(result, types.photos.Photos): - self.buffer = result.photos - self.left = len(self.buffer) - self.total = len(self.buffer) - elif isinstance(result, types.messages.Messages): - self.buffer = [x.action.photo for x in result.messages - if isinstance(x.action, types.MessageActionChatEditPhoto)] - - self.left = len(self.buffer) - self.total = len(self.buffer) - elif isinstance(result, types.photos.PhotosSlice): - self.buffer = result.photos - self.total = result.count - if len(self.buffer) < self.request.limit: - self.left = len(self.buffer) - else: - self.request.offset += len(result.photos) - else: - # Some broadcast channels have a photo that this request doesn't - # retrieve for whatever random reason the Telegram server feels. - # - # This means the `total` count may be wrong but there's not much - # that can be done around it (perhaps there are too many photos - # and this is only a partial result so it's not possible to just - # use the len of the result). - self.total = getattr(result, 'count', None) - - # Unconditionally fetch the full channel to obtain this photo and - # yield it with the rest (unless it's a duplicate). - seen_id = None - if isinstance(result, types.messages.ChannelMessages): - channel = await self.client(functions.channels.GetFullChannelRequest(self.request.peer)) - photo = channel.full_chat.chat_photo - if isinstance(photo, types.Photo): - self.buffer.append(photo) - seen_id = photo.id - - self.buffer.extend( - x.action.photo for x in result.messages - if isinstance(x.action, types.MessageActionChatEditPhoto) - and x.action.photo.id != seen_id - ) - - if len(result.messages) < self.request.limit: - self.left = len(self.buffer) - elif result.messages: - self.request.add_offset = 0 - self.request.offset_id = result.messages[-1].id - - -class ChatMethods: - - # region Public methods - - def iter_participants( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - search: str = '', - filter: 'types.TypeChannelParticipantsFilter' = None, - aggressive: bool = False) -> _ParticipantsIter: - """ - Iterator over the participants belonging to the specified chat. - - The order is unspecified. - - Arguments - entity (`entity`): - The entity from which to retrieve the participants list. - - limit (`int`): - Limits amount of participants fetched. - - search (`str`, optional): - Look for participants with this string in name/username. - - If ``aggressive is True``, the symbols from this string will - be used. - - filter (:tl:`ChannelParticipantsFilter`, optional): - The filter to be used, if you want e.g. only admins - Note that you might not have permissions for some filter. - This has no effect for normal chats or users. - - .. note:: - - The filter :tl:`ChannelParticipantsBanned` will return - *restricted* users. If you want *banned* users you should - use :tl:`ChannelParticipantsKicked` instead. - - aggressive (`bool`, optional): - Aggressively looks for all participants in the chat. - - This is useful for channels since 20 July 2018, - Telegram added a server-side limit where only the - first 200 members can be retrieved. With this flag - set, more than 200 will be often be retrieved. - - This has no effect if a ``filter`` is given. - - Yields - The :tl:`User` objects returned by :tl:`GetParticipantsRequest` - with an additional ``.participant`` attribute which is the - matched :tl:`ChannelParticipant` type for channels/megagroups - or :tl:`ChatParticipants` for normal chats. - - Example - .. code-block:: python - - # Show all user IDs in a chat - async for user in client.iter_participants(chat): - print(user.id) - - # Search by name - async for user in client.iter_participants(chat, search='name'): - print(user.username) - - # Filter by admins - from telethon.tl.types import ChannelParticipantsAdmins - async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): - print(user.first_name) - """ - return _ParticipantsIter( - self, - limit, - entity=entity, - filter=filter, - search=search, - aggressive=aggressive - ) - - async def get_participants( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_participants()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - users = await client.get_participants(chat) - print(users[0].first_name) - - for user in users: - if user.username is not None: - print(user.username) - """ - return await self.iter_participants(*args, **kwargs).collect() - - get_participants.__signature__ = inspect.signature(iter_participants) - - - def iter_admin_log( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - max_id: int = 0, - min_id: int = 0, - search: str = None, - admins: 'hints.EntitiesLike' = None, - join: bool = None, - leave: bool = None, - invite: bool = None, - restrict: bool = None, - unrestrict: bool = None, - ban: bool = None, - unban: bool = None, - promote: bool = None, - demote: bool = None, - info: bool = None, - settings: bool = None, - pinned: bool = None, - edit: bool = None, - delete: bool = None, - group_call: bool = None) -> _AdminLogIter: - """ - Iterator over the admin log for the specified channel. - - The default order is from the most recent event to to the oldest. - - Note that you must be an administrator of it to use this method. - - If none of the filters are present (i.e. they all are `None`), - *all* event types will be returned. If at least one of them is - `True`, only those that are true will be returned. - - Arguments - entity (`entity`): - The channel entity from which to get its admin log. - - limit (`int` | `None`, optional): - Number of events to be retrieved. - - The limit may also be `None`, which would eventually return - the whole history. - - max_id (`int`): - All the events with a higher (newer) ID or equal to this will - be excluded. - - min_id (`int`): - All the events with a lower (older) ID or equal to this will - be excluded. - - search (`str`): - The string to be used as a search query. - - admins (`entity` | `list`): - If present, the events will be filtered by these admins - (or single admin) and only those caused by them will be - returned. - - join (`bool`): - If `True`, events for when a user joined will be returned. - - leave (`bool`): - If `True`, events for when a user leaves will be returned. - - invite (`bool`): - If `True`, events for when a user joins through an invite - link will be returned. - - restrict (`bool`): - If `True`, events with partial restrictions will be - returned. This is what the API calls "ban". - - unrestrict (`bool`): - If `True`, events removing restrictions will be returned. - This is what the API calls "unban". - - ban (`bool`): - If `True`, events applying or removing all restrictions will - be returned. This is what the API calls "kick" (restricting - all permissions removed is a ban, which kicks the user). - - unban (`bool`): - If `True`, events removing all restrictions will be - returned. This is what the API calls "unkick". - - promote (`bool`): - If `True`, events with admin promotions will be returned. - - demote (`bool`): - If `True`, events with admin demotions will be returned. - - info (`bool`): - If `True`, events changing the group info will be returned. - - settings (`bool`): - If `True`, events changing the group settings will be - returned. - - pinned (`bool`): - If `True`, events of new pinned messages will be returned. - - edit (`bool`): - If `True`, events of message edits will be returned. - - delete (`bool`): - If `True`, events of message deletions will be returned. - - group_call (`bool`): - If `True`, events related to group calls will be returned. - - Yields - Instances of `AdminLogEvent `. - - Example - .. code-block:: python - - async for event in client.iter_admin_log(channel): - if event.changed_title: - print('The title changed from', event.old, 'to', event.new) - """ - return _AdminLogIter( - self, - limit, - entity=entity, - admins=admins, - search=search, - min_id=min_id, - max_id=max_id, - join=join, - leave=leave, - invite=invite, - restrict=restrict, - unrestrict=unrestrict, - ban=ban, - unban=unban, - promote=promote, - demote=demote, - info=info, - settings=settings, - pinned=pinned, - edit=edit, - delete=delete, - group_call=group_call - ) - - async def get_admin_log( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_admin_log()`, but returns a ``list`` instead. - - Example - .. code-block:: python - - # Get a list of deleted message events which said "heck" - events = await client.get_admin_log(channel, search='heck', delete=True) - - # Print the old message before it was deleted - print(events[0].old) - """ - return await self.iter_admin_log(*args, **kwargs).collect() - - get_admin_log.__signature__ = inspect.signature(iter_admin_log) - - def iter_profile_photos( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: int = None, - *, - offset: int = 0, - max_id: int = 0) -> _ProfilePhotoIter: - """ - Iterator over a user's profile photos or a chat's photos. - - The order is from the most recent photo to the oldest. - - Arguments - entity (`entity`): - The entity from which to get the profile or chat photos. - - limit (`int` | `None`, optional): - Number of photos to be retrieved. - - The limit may also be `None`, which would eventually all - the photos that are still available. - - offset (`int`): - How many photos should be skipped before returning the first one. - - max_id (`int`): - The maximum ID allowed when fetching photos. - - Yields - Instances of :tl:`Photo`. - - Example - .. code-block:: python - - # Download all the profile photos of some user - async for photo in client.iter_profile_photos(user): - await client.download_media(photo) - """ - return _ProfilePhotoIter( - self, - limit, - entity=entity, - offset=offset, - max_id=max_id - ) - - async def get_profile_photos( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_profile_photos()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - # Get the photos of a channel - photos = await client.get_profile_photos(channel) - - # Download the oldest photo - await client.download_media(photos[-1]) - """ - return await self.iter_profile_photos(*args, **kwargs).collect() - - get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) - - def action( - self: 'TelegramClient', - entity: 'hints.EntityLike', - action: 'typing.Union[str, types.TypeSendMessageAction]', - *, - delay: float = 4, - auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': - """ - Returns a context-manager object to represent a "chat action". - - Chat actions indicate things like "user is typing", "user is - uploading a photo", etc. - - If the action is ``'cancel'``, you should just ``await`` the result, - since it makes no sense to use a context-manager for it. - - See the example below for intended usage. - - Arguments - entity (`entity`): - The entity where the action should be showed in. - - action (`str` | :tl:`SendMessageAction`): - The action to show. You can either pass a instance of - :tl:`SendMessageAction` or better, a string used while: - - * ``'typing'``: typing a text message. - * ``'contact'``: choosing a contact. - * ``'game'``: playing a game. - * ``'location'``: choosing a geo location. - * ``'sticker'``: choosing a sticker. - * ``'record-audio'``: recording a voice note. - You may use ``'record-voice'`` as alias. - * ``'record-round'``: recording a round video. - * ``'record-video'``: recording a normal video. - * ``'audio'``: sending an audio file (voice note or song). - You may use ``'voice'`` and ``'song'`` as aliases. - * ``'round'``: uploading a round video. - * ``'video'``: uploading a video file. - * ``'photo'``: uploading a photo. - * ``'document'``: uploading a document file. - You may use ``'file'`` as alias. - * ``'cancel'``: cancel any pending action in this chat. - - Invalid strings will raise a ``ValueError``. - - delay (`int` | `float`): - The delay, in seconds, to wait between sending actions. - For example, if the delay is 5 and it takes 7 seconds to - do something, three requests will be made at 0s, 5s, and - 7s to cancel the action. - - auto_cancel (`bool`): - Whether the action should be cancelled once the context - manager exists or not. The default is `True`, since - you don't want progress to be shown when it has already - completed. - - Returns - Either a context-manager object or a coroutine. - - Example - .. code-block:: python - - # Type for 2 seconds, then send a message - async with client.action(chat, 'typing'): - await asyncio.sleep(2) - await client.send_message(chat, 'Hello world! I type slow ^^') - - # Cancel any previous action - await client.action(chat, 'cancel') - - # Upload a document, showing its progress (most clients ignore this) - async with client.action(chat, 'document') as action: - await client.send_file(chat, zip_file, progress_callback=action.progress) - """ - if isinstance(action, str): - try: - action = _ChatAction._str_mapping[action.lower()] - except KeyError: - raise ValueError( - 'No such action "{}"'.format(action)) from None - elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: - # 0x20b2cc21 = crc32(b'SendMessageAction') - if isinstance(action, type): - raise ValueError('You must pass an instance, not the class') - else: - raise ValueError('Cannot use {} as action'.format(action)) - - if isinstance(action, types.SendMessageCancelAction): - # ``SetTypingRequest.resolve`` will get input peer of ``entity``. - return self(functions.messages.SetTypingRequest( - entity, types.SendMessageCancelAction())) - - return _ChatAction( - self, entity, action, delay=delay, auto_cancel=auto_cancel) - - async def edit_admin( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'hints.EntityLike', - *, - change_info: bool = None, - post_messages: bool = None, - edit_messages: bool = None, - delete_messages: bool = None, - ban_users: bool = None, - invite_users: bool = None, - pin_messages: bool = None, - add_admins: bool = None, - manage_call: bool = None, - anonymous: bool = None, - is_admin: bool = None, - title: str = None) -> types.Updates: - """ - Edits admin permissions for someone in a chat. - - Raises an error if a wrong combination of rights are given - (e.g. you don't have enough permissions to grant one). - - Unless otherwise stated, permissions will work in channels and megagroups. - - Arguments - entity (`entity`): - The channel, megagroup or chat where the promotion should happen. - - user (`entity`): - The user to be promoted. - - change_info (`bool`, optional): - Whether the user will be able to change info. - - post_messages (`bool`, optional): - Whether the user will be able to post in the channel. - This will only work in broadcast channels. - - edit_messages (`bool`, optional): - Whether the user will be able to edit messages in the channel. - This will only work in broadcast channels. - - delete_messages (`bool`, optional): - Whether the user will be able to delete messages. - - ban_users (`bool`, optional): - Whether the user will be able to ban users. - - invite_users (`bool`, optional): - Whether the user will be able to invite users. Needs some testing. - - pin_messages (`bool`, optional): - Whether the user will be able to pin messages. - - add_admins (`bool`, optional): - Whether the user will be able to add admins. - - manage_call (`bool`, optional): - Whether the user will be able to manage group calls. - - anonymous (`bool`, optional): - Whether the user will remain anonymous when sending messages. - The sender of the anonymous messages becomes the group itself. - - .. note:: - - Users may be able to identify the anonymous admin by its - custom title, so additional care is needed when using both - ``anonymous`` and custom titles. For example, if multiple - anonymous admins share the same title, users won't be able - to distinguish them. - - is_admin (`bool`, optional): - Whether the user will be an admin in the chat. - This will only work in small group chats. - Whether the user will be an admin in the chat. This is the - only permission available in small group chats, and when - used in megagroups, all non-explicitly set permissions will - have this value. - - Essentially, only passing ``is_admin=True`` will grant all - permissions, but you can still disable those you need. - - title (`str`, optional): - The custom title (also known as "rank") to show for this admin. - This text will be shown instead of the "admin" badge. - This will only work in channels and megagroups. - - When left unspecified or empty, the default localized "admin" - badge will be shown. - - Returns - The resulting :tl:`Updates` object. - - Example - .. code-block:: python - - # Allowing `user` to pin messages in `chat` - await client.edit_admin(chat, user, pin_messages=True) - - # Granting all permissions except for `add_admins` - await client.edit_admin(chat, user, is_admin=True, add_admins=False) - """ - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - ty = helpers._entity_type(user) - if ty != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - perm_names = ( - 'change_info', 'post_messages', 'edit_messages', 'delete_messages', - 'ban_users', 'invite_users', 'pin_messages', 'add_admins', - 'anonymous', 'manage_call', - ) - - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHANNEL: - # If we try to set these permissions in a megagroup, we - # would get a RIGHT_FORBIDDEN. However, it makes sense - # that an admin can post messages, so we want to avoid the error - if post_messages or edit_messages: - # TODO get rid of this once sessions cache this information - if entity.channel_id not in self._megagroup_cache: - full_entity = await self.get_entity(entity) - self._megagroup_cache[entity.channel_id] = full_entity.megagroup - - if self._megagroup_cache[entity.channel_id]: - post_messages = None - edit_messages = None - - perms = locals() - return await self(functions.channels.EditAdminRequest(entity, user, types.ChatAdminRights(**{ - # A permission is its explicit (not-None) value or `is_admin`. - # This essentially makes `is_admin` be the default value. - name: perms[name] if perms[name] is not None else is_admin - for name in perm_names - }), rank=title or '')) - - elif ty == helpers._EntityType.CHAT: - # If the user passed any permission in a small - # group chat, they must be a full admin to have it. - if is_admin is None: - is_admin = any(locals()[x] for x in perm_names) - - return await self(functions.messages.EditChatAdminRequest( - entity, user, is_admin=is_admin)) - - else: - raise ValueError( - 'You can only edit permissions in groups and channels') - - async def edit_permissions( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'typing.Optional[hints.EntityLike]' = None, - until_date: 'hints.DateLike' = None, - *, - view_messages: bool = True, - send_messages: bool = True, - send_media: bool = True, - send_stickers: bool = True, - 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, - pin_messages: bool = True) -> types.Updates: - """ - Edits user restrictions in a chat. - - Set an argument to `False` to apply a restriction (i.e. remove - the permission), or omit them to use the default `True` (i.e. - don't apply a restriction). - - Raises an error if a wrong combination of rights are given - (e.g. you don't have enough permissions to revoke one). - - By default, each boolean argument is `True`, meaning that it - is true that the user has access to the default permission - and may be able to make use of it. - - If you set an argument to `False`, then a restriction is applied - regardless of the default permissions. - - It is important to note that `True` does *not* mean grant, only - "don't restrict", and this is where the default permissions come - in. A user may have not been revoked the ``pin_messages`` permission - (it is `True`) but they won't be able to use it if the default - permissions don't allow it either. - - Arguments - entity (`entity`): - The channel or megagroup where the restriction should happen. - - user (`entity`, optional): - If specified, the permission will be changed for the specific user. - If left as `None`, the default chat permissions will be updated. - - until_date (`DateLike`, optional): - When the user will be unbanned. - - If the due date or duration is longer than 366 days or shorter than - 30 seconds, the ban will be forever. Defaults to ``0`` (ban forever). - - view_messages (`bool`, optional): - Whether the user is able to view messages or not. - Forbidding someone from viewing messages equals to banning them. - This will only work if ``user`` is set. - - send_messages (`bool`, optional): - Whether the user is able to send messages or not. - - send_media (`bool`, optional): - Whether the user is able to send media or not. - - send_stickers (`bool`, optional): - Whether the user is able to send stickers or not. - - send_gifs (`bool`, optional): - Whether the user is able to send animated gifs or not. - - send_games (`bool`, optional): - Whether the user is able to send games or not. - - 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. - - change_info (`bool`, optional): - Whether the user is able to change info or not. - - invite_users (`bool`, optional): - Whether the user is able to invite other users or not. - - pin_messages (`bool`, optional): - Whether the user is able to pin messages or not. - - Returns - The resulting :tl:`Updates` object. - - Example - .. code-block:: python - - from datetime import timedelta - - # Banning `user` from `chat` for 1 minute - await client.edit_permissions(chat, user, timedelta(minutes=1), - view_messages=False) - - # Banning `user` from `chat` forever - await client.edit_permissions(chat, user, view_messages=False) - - # Kicking someone (ban + un-ban) - await client.edit_permissions(chat, user, view_messages=False) - await client.edit_permissions(chat, user) - """ - entity = await self.get_input_entity(entity) - ty = helpers._entity_type(entity) - if ty != helpers._EntityType.CHANNEL: - raise ValueError('You must pass either a channel or a supergroup') - - rights = types.ChatBannedRights( - until_date=until_date, - view_messages=not view_messages, - send_messages=not send_messages, - send_media=not send_media, - send_stickers=not send_stickers, - 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, - pin_messages=not pin_messages - ) - - if user is None: - return await self(functions.messages.EditChatDefaultBannedRightsRequest( - peer=entity, - banned_rights=rights - )) - - user = await self.get_input_entity(user) - ty = helpers._entity_type(user) - if ty != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - if isinstance(user, types.InputPeerSelf): - raise ValueError('You cannot restrict yourself') - - return await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, - banned_rights=rights - )) - - async def kick_participant( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'typing.Optional[hints.EntityLike]' - ): - """ - Kicks a user from a chat. - - Kicking yourself (``'me'``) will result in leaving the chat. - - .. note:: - - Attempting to kick someone who was banned will remove their - restrictions (and thus unbanning them), since kicking is just - ban + unban. - - Arguments - entity (`entity`): - The channel or chat where the user should be kicked from. - - user (`entity`, optional): - The user to kick. - - Returns - Returns the service `Message ` - produced about a user being kicked, if any. - - Example - .. code-block:: python - - # Kick some user from some chat, and deleting the service message - msg = await client.kick_participant(chat, user) - await msg.delete() - - # Leaving chat - await client.kick_participant(chat, 'me') - """ - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - if helpers._entity_type(user) != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHAT: - resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user)) - elif ty == helpers._EntityType.CHANNEL: - if isinstance(user, types.InputPeerSelf): - # Despite no longer being in the channel, the account still - # seems to get the service message. - resp = await self(functions.channels.LeaveChannelRequest(entity)) - else: - resp = await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, - banned_rights=types.ChatBannedRights( - until_date=None, view_messages=True) - )) - await asyncio.sleep(0.5) - await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, - banned_rights=types.ChatBannedRights(until_date=None) - )) - else: - raise ValueError('You must pass either a channel or a chat') - - return self._get_response_message(None, resp, entity) - - async def get_permissions( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'hints.EntityLike' = None - ) -> 'typing.Optional[custom.ParticipantPermissions]': - """ - Fetches the permissions of a user in a specific chat or channel or - get Default Restricted Rights of Chat or Channel. - - .. note:: - - This request has to fetch the entire chat for small group chats, - which can get somewhat expensive, so use of a cache is advised. - - Arguments - entity (`entity`): - The channel or chat the user is participant of. - - user (`entity`, optional): - Target user. - - Returns - A `ParticipantPermissions ` - instance. Refer to its documentation to see what properties are - available. - - Example - .. code-block:: python - - permissions = await client.get_permissions(chat, user) - if permissions.is_admin: - # do something - - # Get Banned Permissions of Chat - await client.get_permissions(chat) - """ - entity = await self.get_entity(entity) - - if not user: - if isinstance(entity, types.Channel): - FullChat = await self(functions.channels.GetFullChannelRequest(entity)) - elif isinstance(entity, types.Chat): - FullChat = await self(functions.messages.GetFullChatRequest(entity)) - else: - return - return FullChat.chats[0].default_banned_rights - - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - if helpers._entity_type(user) != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: - participant = await self(functions.channels.GetParticipantRequest( - entity, - user - )) - return custom.ParticipantPermissions(participant.participant, False) - elif helpers._entity_type(entity) == helpers._EntityType.CHAT: - chat = await self(functions.messages.GetFullChatRequest( - entity - )) - if isinstance(user, types.InputPeerSelf): - user = await self.get_me(input_peer=True) - for participant in chat.full_chat.participants.participants: - if participant.user_id == user.user_id: - return custom.ParticipantPermissions(participant, True) - raise errors.UserNotParticipantError(None) - - raise ValueError('You must pass either a channel or a chat') - - async def get_stats( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[int, types.Message]' = None, - ): - """ - 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. - - message (`int` | ``Message``, optional): - The message ID from which to get statistics, if your goal is - to obtain the statistics of a single message. - - 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 - If both ``entity`` and ``message`` were provided, returns - :tl:`MessageStats`. Otherwise, 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 channel entity') - - message = utils.get_message_id(message) - if message is not None: - try: - req = functions.stats.GetMessageStatsRequest(entity, message) - return await self(req) - except errors.StatsMigrateError as e: - dc = e.dc - else: - # 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 deleted file mode 100644 index 8c0860fc..00000000 --- a/telethon/client/dialogs.py +++ /dev/null @@ -1,606 +0,0 @@ -import asyncio -import inspect -import itertools -import typing - -from .. import helpers, utils, hints, errors -from ..requestiter import RequestIter -from ..tl import types, functions, custom - -_MAX_CHUNK_SIZE = 100 - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -def _dialog_message_key(peer, message_id): - """ - Get the key to get messages from a dialog. - - We cannot just use the message ID because channels share message IDs, - and the peer ID is required to distinguish between them. But it is not - necessary in small group chats and private chats. - """ - return (peer.channel_id if isinstance(peer, types.PeerChannel) else None), message_id - - -class _DialogsIter(RequestIter): - async def _init( - self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder - ): - self.request = functions.messages.GetDialogsRequest( - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - limit=1, - hash=0, - exclude_pinned=ignore_pinned, - folder_id=folder - ) - - if self.limit <= 0: - # Special case, get a single dialog and determine count - dialogs = await self.client(self.request) - self.total = getattr(dialogs, 'count', len(dialogs.dialogs)) - raise StopAsyncIteration - - self.seen = set() - self.offset_date = offset_date - self.ignore_migrated = ignore_migrated - - async def _load_next_chunk(self): - self.request.limit = min(self.left, _MAX_CHUNK_SIZE) - r = await self.client(self.request) - - self.total = getattr(r, 'count', len(r.dialogs)) - - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats) - if not isinstance(x, (types.UserEmpty, types.ChatEmpty))} - - messages = {} - for m in r.messages: - m._finish_init(self.client, entities, None) - messages[_dialog_message_key(m.peer_id, m.id)] = m - - for d in r.dialogs: - # We check the offset date here because Telegram may ignore it - message = messages.get(_dialog_message_key(d.peer, d.top_message)) - if self.offset_date: - date = getattr(message, 'date', None) - if not date or date.timestamp() > self.offset_date.timestamp(): - continue - - peer_id = utils.get_peer_id(d.peer) - if peer_id not in self.seen: - self.seen.add(peer_id) - if peer_id not in entities: - # > In which case can a UserEmpty appear in the list of banned members? - # > In a very rare cases. This is possible but isn't an expected behavior. - # Real world example: https://t.me/TelethonChat/271471 - continue - - cd = custom.Dialog(self.client, d, entities, message) - if cd.dialog.pts: - self.client._channel_pts[cd.id] = cd.dialog.pts - - if not self.ignore_migrated or getattr( - cd.entity, 'migrated_to', None) is None: - self.buffer.append(cd) - - if len(r.dialogs) < self.request.limit\ - or not isinstance(r, types.messages.DialogsSlice): - # Less than we requested means we reached the end, or - # we didn't get a DialogsSlice which means we got all. - return True - - # We can't use `messages[-1]` as the offset ID / date. - # Why? Because pinned dialogs will mess with the order - # in this list. Instead, we find the last dialog which - # has a message, and use it as an offset. - last_message = next(filter(None, ( - messages.get(_dialog_message_key(d.peer, d.top_message)) - for d in reversed(r.dialogs) - )), None) - - self.request.exclude_pinned = True - self.request.offset_id = last_message.id if last_message else 0 - self.request.offset_date = last_message.date if last_message else None - self.request.offset_peer = self.buffer[-1].input_entity - - -class _DraftsIter(RequestIter): - async def _init(self, entities, **kwargs): - if not entities: - r = await self.client(functions.messages.GetAllDraftsRequest()) - items = r.updates - else: - peers = [] - for entity in entities: - peers.append(types.InputDialogPeer( - await self.client.get_input_entity(entity))) - - r = await self.client(functions.messages.GetPeerDialogsRequest(peers)) - items = r.dialogs - - # TODO Maybe there should be a helper method for this? - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - self.buffer.extend( - custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) - for d in items - ) - - async def _load_next_chunk(self): - return [] - - -class DialogMethods: - - # region Public methods - - def iter_dialogs( - self: 'TelegramClient', - limit: float = None, - *, - offset_date: 'hints.DateLike' = None, - offset_id: int = 0, - offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), - ignore_pinned: bool = False, - ignore_migrated: bool = False, - folder: int = None, - archived: bool = None - ) -> _DialogsIter: - """ - Iterator over the dialogs (open conversations/subscribed channels). - - The order is the same as the one seen in official applications - (first pinned, them from those with the most recent message to - those with the oldest message). - - Arguments - limit (`int` | `None`): - How many dialogs to be retrieved as maximum. Can be set to - `None` to retrieve all dialogs. Note that this may take - whole minutes if you have hundreds of dialogs, as Telegram - will tell the library to slow down through a - ``FloodWaitError``. - - offset_date (`datetime`, optional): - The offset date to be used. - - offset_id (`int`, optional): - The message ID to be used as an offset. - - offset_peer (:tl:`InputPeer`, optional): - The peer to be used as an offset. - - ignore_pinned (`bool`, optional): - Whether pinned dialogs should be ignored or not. - When set to `True`, these won't be yielded at all. - - ignore_migrated (`bool`, optional): - Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` - should be included or not. By default all the chats in your - dialogs are returned, but setting this to `True` will ignore - (i.e. skip) them in the same way official applications do. - - folder (`int`, optional): - The folder from which the dialogs should be retrieved. - - If left unspecified, all dialogs (including those from - folders) will be returned. - - If set to ``0``, all dialogs that don't belong to any - folder will be returned. - - If set to a folder number like ``1``, only those from - said folder will be returned. - - By default Telegram assigns the folder ID ``1`` to - archived chats, so you should use that if you need - to fetch the archived dialogs. - - archived (`bool`, optional): - Alias for `folder`. If unspecified, all will be returned, - `False` implies ``folder=0`` and `True` implies ``folder=1``. - Yields - Instances of `Dialog `. - - Example - .. code-block:: python - - # Print all dialog IDs and the title, nicely formatted - async for dialog in client.iter_dialogs(): - print('{:>14}: {}'.format(dialog.id, dialog.title)) - """ - if archived is not None: - folder = 1 if archived else 0 - - return _DialogsIter( - self, - limit, - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - ignore_pinned=ignore_pinned, - ignore_migrated=ignore_migrated, - folder=folder - ) - - async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_dialogs()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - # Get all open conversation, print the title of the first - dialogs = await client.get_dialogs() - first = dialogs[0] - print(first.title) - - # Use the dialog somewhere else - await client.send_message(first, 'hi') - - # Getting only non-archived dialogs (both equivalent) - non_archived = await client.get_dialogs(folder=0) - non_archived = await client.get_dialogs(archived=False) - - # Getting only archived dialogs (both equivalent) - archived = await client.get_dialogs(folder=1) - archived = await client.get_dialogs(archived=True) - """ - return await self.iter_dialogs(*args, **kwargs).collect() - - get_dialogs.__signature__ = inspect.signature(iter_dialogs) - - def iter_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None - ) -> _DraftsIter: - """ - Iterator over draft messages. - - The order is unspecified. - - Arguments - entity (`hints.EntitiesLike`, optional): - The entity or entities for which to fetch the draft messages. - If left unspecified, all draft messages will be returned. - - Yields - Instances of `Draft `. - - Example - .. code-block:: python - - # Clear all drafts - async for draft in client.get_drafts(): - await draft.delete() - - # Getting the drafts with 'bot1' and 'bot2' - async for draft in client.iter_drafts(['bot1', 'bot2']): - print(draft.text) - """ - if entity and not utils.is_list_like(entity): - entity = (entity,) - - # TODO Passing a limit here makes no sense - return _DraftsIter(self, None, entities=entity) - - async def get_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None - ) -> 'hints.TotalList': - """ - Same as `iter_drafts()`, but returns a list instead. - - Example - .. code-block:: python - - # Get drafts, print the text of the first - drafts = await client.get_drafts() - print(drafts[0].text) - - # Get the draft in your chat - draft = await client.get_drafts('me') - print(drafts.text) - """ - items = await self.iter_drafts(entity).collect() - if not entity or utils.is_list_like(entity): - return items - else: - return items[0] - - async def edit_folder( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None, - folder: typing.Union[int, typing.Sequence[int]] = None, - *, - unpack=None - ) -> types.Updates: - """ - Edits the folder used by one or more dialogs to archive them. - - Arguments - entity (entities): - The entity or list of entities to move to the desired - archive folder. - - folder (`int`): - The folder to which the dialog should be archived to. - - If you want to "archive" a dialog, use ``folder=1``. - - If you want to "un-archive" it, use ``folder=0``. - - You may also pass a list with the same length as - `entities` if you want to control where each entity - will go. - - unpack (`int`, optional): - If you want to unpack an archived folder, set this - parameter to the folder number that you want to - delete. - - When you unpack a folder, all the dialogs inside are - moved to the folder number 0. - - You can only use this parameter if the other two - are not set. - - Returns - The :tl:`Updates` object that the request produces. - - Example - .. code-block:: python - - # Archiving the first 5 dialogs - dialogs = await client.get_dialogs(5) - await client.edit_folder(dialogs, 1) - - # Un-archiving the third dialog (archiving to folder 0) - await client.edit_folder(dialog[2], 0) - - # Moving the first dialog to folder 0 and the second to 1 - dialogs = await client.get_dialogs(2) - await client.edit_folder(dialogs, [0, 1]) - - # Un-archiving all dialogs - await client.edit_folder(unpack=1) - """ - if (entity is None) == (unpack is None): - raise ValueError('You can only set either entities or unpack, not both') - - if unpack is not None: - return await self(functions.folders.DeleteFolderRequest( - folder_id=unpack - )) - - if not utils.is_list_like(entity): - entities = [await self.get_input_entity(entity)] - else: - entities = await asyncio.gather( - *(self.get_input_entity(x) for x in entity)) - - if folder is None: - raise ValueError('You must specify a folder') - elif not utils.is_list_like(folder): - folder = [folder] * len(entities) - elif len(entities) != len(folder): - raise ValueError('Number of folders does not match number of entities') - - return await self(functions.folders.EditPeerFoldersRequest([ - types.InputFolderPeer(x, folder_id=y) - for x, y in zip(entities, folder) - ])) - - async def delete_dialog( - self: 'TelegramClient', - entity: 'hints.EntityLike', - *, - revoke: bool = False - ): - """ - Deletes a dialog (leaves a chat or channel). - - This method can be used as a user and as a bot. However, - bots will only be able to use it to leave groups and channels - (trying to delete a private conversation will do nothing). - - See also `Dialog.delete() `. - - Arguments - entity (entities): - The entity of the dialog to delete. If it's a chat or - channel, you will leave it. Note that the chat itself - is not deleted, only the dialog, because you left it. - - revoke (`bool`, optional): - On private chats, you may revoke the messages from - the other peer too. By default, it's `False`. Set - it to `True` to delete the history for both. - - This makes no difference for bot accounts, who can - only leave groups and channels. - - Returns - The :tl:`Updates` object that the request produces, - or nothing for private conversations. - - Example - .. code-block:: python - - # Deleting the first dialog - dialogs = await client.get_dialogs(5) - await client.delete_dialog(dialogs[0]) - - # Leaving a channel by username - await client.delete_dialog('username') - """ - # If we have enough information (`Dialog.delete` gives it to us), - # then we know we don't have to kick ourselves in deactivated chats. - if isinstance(entity, types.Chat): - deactivated = entity.deactivated - else: - deactivated = False - - entity = await self.get_input_entity(entity) - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHANNEL: - return await self(functions.channels.LeaveChannelRequest(entity)) - - if ty == helpers._EntityType.CHAT and not deactivated: - try: - result = await self(functions.messages.DeleteChatUserRequest( - entity.chat_id, types.InputUserSelf(), revoke_history=revoke - )) - except errors.PeerIdInvalidError: - # Happens if we didn't have the deactivated information - result = None - else: - result = None - - if not await self.is_bot(): - await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke)) - - return result - - def conversation( - self: 'TelegramClient', - entity: 'hints.EntityLike', - *, - timeout: float = 60, - total_timeout: float = None, - max_messages: int = 100, - exclusive: bool = True, - replies_are_responses: bool = True) -> custom.Conversation: - """ - Creates a `Conversation ` - with the given entity. - - .. note:: - - This Conversation API has certain shortcomings, such as lacking - persistence, poor interaction with other event handlers, and - overcomplicated usage for anything beyond the simplest case. - - If you plan to interact with a bot without handlers, this works - fine, but when running a bot yourself, you may instead prefer - to follow the advice from https://stackoverflow.com/a/62246569/. - - This is not the same as just sending a message to create a "dialog" - with them, but rather a way to easily send messages and await for - responses or other reactions. Refer to its documentation for more. - - Arguments - entity (`entity`): - The entity with which a new conversation should be opened. - - timeout (`int` | `float`, optional): - The default timeout (in seconds) *per action* to be used. You - may also override this timeout on a per-method basis. By - default each action can take up to 60 seconds (the value of - this timeout). - - total_timeout (`int` | `float`, optional): - The total timeout (in seconds) to use for the whole - conversation. This takes priority over per-action - timeouts. After these many seconds pass, subsequent - actions will result in ``asyncio.TimeoutError``. - - max_messages (`int`, optional): - The maximum amount of messages this conversation will - remember. After these many messages arrive in the - specified chat, subsequent actions will result in - ``ValueError``. - - exclusive (`bool`, optional): - By default, conversations are exclusive within a single - chat. That means that while a conversation is open in a - chat, you can't open another one in the same chat, unless - you disable this flag. - - If you try opening an exclusive conversation for - a chat where it's already open, it will raise - ``AlreadyInConversationError``. - - replies_are_responses (`bool`, optional): - Whether replies should be treated as responses or not. - - If the setting is enabled, calls to `conv.get_response - ` - and a subsequent call to `conv.get_reply - ` - will return different messages, otherwise they may return - the same message. - - Consider the following scenario with one outgoing message, - 1, and two incoming messages, the second one replying:: - - Hello! <1 - 2> (reply to 1) Hi! - 3> (reply to 1) How are you? - - And the following code: - - .. code-block:: python - - async with client.conversation(chat) as conv: - msg1 = await conv.send_message('Hello!') - msg2 = await conv.get_response() - msg3 = await conv.get_reply() - - With the setting enabled, ``msg2`` will be ``'Hi!'`` and - ``msg3`` be ``'How are you?'`` since replies are also - responses, and a response was already returned. - - With the setting disabled, both ``msg2`` and ``msg3`` will - be ``'Hi!'`` since one is a response and also a reply. - - Returns - A `Conversation `. - - Example - .. code-block:: python - - # denotes outgoing messages you sent - # denotes incoming response messages - with bot.conversation(chat) as conv: - # Hi! - conv.send_message('Hi!') - - # Hello! - hello = conv.get_response() - - # Please tell me your name - conv.send_message('Please tell me your name') - - # ? - name = conv.get_response().raw_text - - while not any(x.isalpha() for x in name): - # Your name didn't have any letters! Try again - conv.send_message("Your name didn't have any letters! Try again") - - # Human - name = conv.get_response().raw_text - - # Thanks Human! - conv.send_message('Thanks {}!'.format(name)) - """ - return custom.Conversation( - self, - entity, - timeout=timeout, - total_timeout=total_timeout, - max_messages=max_messages, - exclusive=exclusive, - replies_are_responses=replies_are_responses - - ) - - # endregion diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py deleted file mode 100644 index 6d7a8d65..00000000 --- a/telethon/client/downloads.py +++ /dev/null @@ -1,1043 +0,0 @@ -import datetime -import io -import os -import pathlib -import typing -import inspect -import asyncio - -from ..crypto import AES - -from .. import utils, helpers, errors, hints -from ..requestiter import RequestIter -from ..tl import TLObject, types, functions - -try: - import aiohttp -except ImportError: - aiohttp = None - -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 - -# 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files. -TIMED_OUT_SLEEP = 1 - -class _DirectDownloadIter(RequestIter): - async def _init( - self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data - ): - self.request = functions.upload.GetFileRequest( - file, offset=offset, limit=request_size) - - self.total = file_size - self._stride = stride - self._chunk_size = chunk_size - self._last_part = None - self._msg_data = msg_data - self._timed_out = False - - self._exported = dc_id and self.client.session.dc_id != dc_id - if not self._exported: - # The used sender will also change if ``FileMigrateError`` occurs - self._sender = self.client._sender - else: - try: - self._sender = await self.client._borrow_exported_sender(dc_id) - except errors.DcIdInvalidError: - # Can't export a sender for the ID we are currently in - config = await self.client(functions.help.GetConfigRequest()) - for option in config.dc_options: - if option.ip_address == self.client.session.server_address: - self.client.session.set_dc( - option.id, option.ip_address, option.port) - self.client.session.save() - break - - # TODO Figure out why the session may have the wrong DC ID - self._sender = self.client._sender - self._exported = False - - async def _load_next_chunk(self): - cur = await self._request() - self.buffer.append(cur) - if len(cur) < self.request.limit: - self.left = len(self.buffer) - await self.close() - else: - self.request.offset += self._stride - - async def _request(self): - try: - result = await self.client._call(self._sender, self.request) - self._timed_out = False - if isinstance(result, types.upload.FileCdnRedirect): - raise NotImplementedError # TODO Implement - else: - return result.bytes - - except errors.TimeoutError as e: - if self._timed_out: - self.client._log[__name__].warning('Got two timeouts in a row while downloading file') - raise - - self._timed_out = True - self.client._log[__name__].info('Got timeout while downloading file, retrying once') - await asyncio.sleep(TIMED_OUT_SLEEP) - return await self._request() - - except errors.FileMigrateError as e: - self.client._log[__name__].info('File lives in another DC') - self._sender = await self.client._borrow_exported_sender(e.new_dc) - self._exported = True - return await self._request() - - except errors.FilerefUpgradeNeededError as e: - # Only implemented for documents which are the ones that may take that long to download - if not self._msg_data \ - or not isinstance(self.request.location, types.InputDocumentFileLocation) \ - or self.request.location.thumb_size != '': - raise - - self.client._log[__name__].info('File ref expired during download; refetching message') - chat, msg_id = self._msg_data - msg = await self.client.get_messages(chat, ids=msg_id) - - if not isinstance(msg.media, types.MessageMediaDocument): - raise - - document = msg.media.document - - # Message media may have been edited for something else - if document.id != self.request.location.id: - raise - - self.request.location.file_reference = document.file_reference - return await self._request() - - async def close(self): - if not self._sender: - return - - try: - if self._exported: - await self.client._return_exported_sender(self._sender) - elif self._sender != self.client._sender: - await self._sender.disconnect() - finally: - self._sender = None - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - await self.close() - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - - -class _GenericDownloadIter(_DirectDownloadIter): - async def _load_next_chunk(self): - # 1. Fetch enough for one chunk - data = b'' - - # 1.1. ``bad`` is how much into the data we have we need to offset - bad = self.request.offset % self.request.limit - before = self.request.offset - - # 1.2. We have to fetch from a valid offset, so remove that bad part - self.request.offset -= bad - - done = False - while not done and len(data) - bad < self._chunk_size: - cur = await self._request() - self.request.offset += self.request.limit - - data += cur - done = len(cur) < self.request.limit - - # 1.3 Restore our last desired offset - self.request.offset = before - - # 2. Fill the buffer with the data we have - # 2.1. Slicing `bytes` is expensive, yield `memoryview` instead - mem = memoryview(data) - - # 2.2. The current chunk starts at ``bad`` offset into the data, - # and each new chunk is ``stride`` bytes apart of the other - for i in range(bad, len(data), self._stride): - self.buffer.append(mem[i:i + self._chunk_size]) - - # 2.3. We will yield this offset, so move to the next one - self.request.offset += self._stride - - # 2.4. If we are in the last chunk, we will return the last partial data - if done: - self.left = len(self.buffer) - await self.close() - return - - # 2.5. If we are not done, we can't return incomplete chunks. - if len(self.buffer[-1]) != self._chunk_size: - self._last_part = self.buffer.pop().tobytes() - - # 3. Be careful with the offsets. Re-fetching a bit of data - # is fine, since it greatly simplifies things. - # TODO Try to not re-fetch data - self.request.offset -= self._stride - - -class DownloadMethods: - - # region Public methods - - async def download_profile_photo( - self: 'TelegramClient', - entity: 'hints.EntityLike', - file: 'hints.FileLike' = None, - *, - download_big: bool = True) -> typing.Optional[str]: - """ - Downloads the profile photo from the given user, chat or channel. - - Arguments - entity (`entity`): - From who the photo will be downloaded. - - .. note:: - - This method expects the full entity (which has the data - to download the photo), not an input variant. - - It's possible that sometimes you can't fetch the entity - from its input (since you can get errors like - ``ChannelPrivateError``) but you already have it through - another call, like getting a forwarded message from it. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - If file is the type `bytes`, it will be downloaded in-memory - as a bytestring (e.g. ``file=bytes``). - - download_big (`bool`, optional): - Whether to use the big version of the available photos. - - Returns - `None` if no photo was provided, or if it was Empty. On success - the file path is returned since it may differ from the one given. - - Example - .. code-block:: python - - # Download your own profile photo - path = await client.download_profile_photo('me') - print(path) - """ - # hex(crc32(x.encode('ascii'))) for x in - # ('User', 'Chat', 'UserFull', 'ChatFull') - ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) - # ('InputPeer', 'InputUser', 'InputChannel') - INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) - if not isinstance(entity, TLObject) or entity.SUBCLASS_OF_ID in INPUTS: - entity = await self.get_entity(entity) - - thumb = -1 if download_big else 0 - - possible_names = [] - if entity.SUBCLASS_OF_ID not in ENTITIES: - photo = entity - else: - if not hasattr(entity, 'photo'): - # Special case: may be a ChatFull with photo:Photo - # This is different from a normal UserProfilePhoto and Chat - if not hasattr(entity, 'chat_photo'): - return None - - return await self._download_photo( - entity.chat_photo, file, date=None, - thumb=thumb, progress_callback=None - ) - - for attr in ('username', 'first_name', 'title'): - possible_names.append(getattr(entity, attr, None)) - - photo = entity.photo - - if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)): - dc_id = photo.dc_id - loc = types.InputPeerPhotoFileLocation( - peer=await self.get_input_entity(entity), - photo_id=photo.photo_id, - big=download_big - ) - else: - # It doesn't make any sense to check if `photo` can be used - # as input location, because then this method would be able - # to "download the profile photo of a message", i.e. its - # media which should be done with `download_media` instead. - return None - - file = self._get_proper_filename( - file, 'profile_photo', '.jpg', - possible_names=possible_names - ) - - try: - result = await self.download_file(loc, file, dc_id=dc_id) - return result if file is bytes else file - except errors.LocationInvalidError: - # See issue #500, Android app fails as of v4.6.0 (1155). - # The fix seems to be using the full channel chat photo. - ie = await self.get_input_entity(entity) - ty = helpers._entity_type(ie) - if ty == helpers._EntityType.CHANNEL: - full = await self(functions.channels.GetFullChannelRequest(ie)) - return await self._download_photo( - full.full_chat.chat_photo, file, - date=None, progress_callback=None, - thumb=thumb - ) - else: - # Until there's a report for chats, no need to. - return None - - async def download_media( - self: 'TelegramClient', - message: 'hints.MessageLike', - file: 'hints.FileLike' = None, - *, - thumb: 'typing.Union[int, types.TypePhotoSize]' = None, - progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: - """ - Downloads the given media from a message object. - - Note that if the download is too slow, you should consider installing - ``cryptg`` (through ``pip install cryptg``) so that decrypting the - received data is done in C instead of Python (much faster). - - See also `Message.download_media() `. - - Arguments - message (`Message ` | :tl:`Media`): - The media or message containing the media that will be downloaded. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - If file is the type `bytes`, it will be downloaded in-memory - as a bytestring (e.g. ``file=bytes``). - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(received bytes, total)``. - - thumb (`int` | :tl:`PhotoSize`, optional): - Which thumbnail size from the document or photo to download, - instead of downloading the document or photo itself. - - If it's specified but the file does not have a thumbnail, - this method will return `None`. - - 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, 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. - - Example - .. code-block:: python - - path = await client.download_media(message) - await client.download_media(message, filename) - # 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) - """ - # Downloading large documents may be slow enough to require a new file reference - # to be obtained mid-download. Store (input chat, message id) so that the message - # can be re-fetched. - msg_data = None - - # TODO This won't work for messageService - if isinstance(message, types.Message): - date = message.date - media = message.media - msg_data = (message.input_chat, message.id) if message.input_chat else None - else: - date = datetime.datetime.now() - media = message - - if isinstance(media, str): - media = utils.resolve_bot_file_id(media) - - if isinstance(media, types.MessageService): - if isinstance(message.action, - types.MessageActionChatEditPhoto): - media = media.photo - - if isinstance(media, types.MessageMediaWebPage): - if isinstance(media.webpage, types.WebPage): - media = media.webpage.document or media.webpage.photo - - if isinstance(media, (types.MessageMediaPhoto, types.Photo)): - return await self._download_photo( - media, file, date, thumb, progress_callback - ) - elif isinstance(media, (types.MessageMediaDocument, types.Document)): - return await self._download_document( - media, file, date, thumb, progress_callback, msg_data - ) - elif isinstance(media, types.MessageMediaContact) and thumb is None: - return self._download_contact( - media, file - ) - elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)) and thumb is None: - return await self._download_web_document( - media, file, progress_callback - ) - - async def download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - 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. - See `telethon.utils.get_input_location` source for a complete - list of supported types. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - - If the file path is `None` or `bytes`, then the result - will be saved in memory and returned as `bytes`. - - part_size_kb (`int`, optional): - Chunk size when downloading files. The larger, the less - requests will be made (up to 512KB maximum). - - file_size (`int`, optional): - The file size that is about to be downloaded, if known. - Only used if ``progress_callback`` is specified. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(downloaded bytes, total)``. Note that the - ``total`` is the provided ``file_size``. - - dc_id (`int`, optional): - 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 - - # Download a file and print its header - data = await client.download_file(input_file, bytes) - print(data[:16]) - """ - return await self._download_file( - input_location, - file, - part_size_kb=part_size_kb, - file_size=file_size, - progress_callback=progress_callback, - dc_id=dc_id, - key=key, - iv=iv, - ) - - async def _download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None, - key: bytes = None, - iv: bytes = None, - msg_data: tuple = None) -> typing.Optional[bytes]: - if not part_size_kb: - if not file_size: - part_size_kb = 64 # Reasonable default - else: - part_size_kb = utils.get_appropriated_part_size(file_size) - - part_size = int(part_size_kb * 1024) - if part_size % MIN_CHUNK_SIZE != 0: - 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() - elif isinstance(file, str): - # Ensure that we'll be able to download the media - helpers.ensure_parent_dir_exists(file) - f = open(file, 'wb') - else: - f = file - - try: - async for chunk in self._iter_download( - input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): - if iv and key: - chunk = AES.decrypt_ige(chunk, key, iv) - r = f.write(chunk) - if inspect.isawaitable(r): - await r - - if progress_callback: - r = progress_callback(f.tell(), file_size) - if inspect.isawaitable(r): - await r - - # Not all IO objects have flush (see #1227) - if callable(getattr(f, 'flush', None)): - f.flush() - - if in_memory: - return f.getvalue() - finally: - if isinstance(file, str) or in_memory: - f.close() - - def iter_download( - self: 'TelegramClient', - file: 'hints.FileLike', - *, - offset: int = 0, - stride: int = None, - limit: int = None, - chunk_size: int = None, - request_size: int = MAX_CHUNK_SIZE, - file_size: int = None, - dc_id: int = None - ): - """ - Iterates over a file download, yielding chunks of the file. - - This method can be used to stream files in a more convenient - way, since it offers more control (pausing, resuming, etc.) - - .. note:: - - Using a value for `offset` or `stride` which is not a multiple - of the minimum allowed `request_size`, or if `chunk_size` is - different from `request_size`, the library will need to do a - bit more work to fetch the data in the way you intend it to. - - You normally shouldn't worry about this. - - Arguments - file (`hints.FileLike`): - The file of which contents you want to iterate over. - - offset (`int`, optional): - The offset in bytes into the file from where the - download should start. For example, if a file is - 1024KB long and you just want the last 512KB, you - would use ``offset=512 * 1024``. - - stride (`int`, optional): - The stride of each chunk (how much the offset should - advance between reading each chunk). This parameter - should only be used for more advanced use cases. - - It must be bigger than or equal to the `chunk_size`. - - limit (`int`, optional): - The limit for how many *chunks* will be yielded at most. - - chunk_size (`int`, optional): - The maximum size of the chunks that will be yielded. - Note that the last chunk may be less than this value. - By default, it equals to `request_size`. - - request_size (`int`, optional): - How many bytes will be requested to Telegram when more - data is required. By default, as many bytes as possible - are requested. If you would like to request data in - smaller sizes, adjust this parameter. - - Note that values outside the valid range will be clamped, - and the final value will also be a multiple of the minimum - allowed size. - - file_size (`int`, optional): - If the file size is known beforehand, you should set - this parameter to said value. Depending on the type of - the input file passed, this may be set automatically. - - dc_id (`int`, optional): - The data center the library should connect to in order - to download the file. You shouldn't worry about this. - - Yields - - `bytes` objects representing the chunks of the file if the - right conditions are met, or `memoryview` objects instead. - - Example - .. code-block:: python - - # 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 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. - # - # "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 = await stream.__anext__() # "manual" version of `async for` - await stream.close() - assert len(header) == 32 - """ - return self._iter_download( - file, - offset=offset, - stride=stride, - limit=limit, - chunk_size=chunk_size, - request_size=request_size, - file_size=file_size, - dc_id=dc_id, - ) - - def _iter_download( - self: 'TelegramClient', - file: 'hints.FileLike', - *, - offset: int = 0, - stride: int = None, - limit: int = None, - chunk_size: int = None, - request_size: int = MAX_CHUNK_SIZE, - file_size: int = None, - dc_id: int = None, - msg_data: tuple = None - ): - info = utils._get_file_info(file) - if info.dc_id is not None: - dc_id = info.dc_id - - if file_size is None: - file_size = info.size - - file = info.location - - if chunk_size is None: - chunk_size = request_size - - if limit is None and file_size is not None: - limit = (file_size + chunk_size - 1) // chunk_size - - if stride is None: - stride = chunk_size - elif stride < chunk_size: - raise ValueError('stride must be >= chunk_size') - - request_size -= request_size % MIN_CHUNK_SIZE - if request_size < MIN_CHUNK_SIZE: - request_size = MIN_CHUNK_SIZE - elif request_size > MAX_CHUNK_SIZE: - request_size = MAX_CHUNK_SIZE - - if chunk_size == request_size \ - and offset % MIN_CHUNK_SIZE == 0 \ - and stride % MIN_CHUNK_SIZE == 0 \ - and (limit is None or offset % limit == 0): - cls = _DirectDownloadIter - self._log[__name__].info('Starting direct file download in chunks of ' - '%d at %d, stride %d', request_size, offset, stride) - else: - cls = _GenericDownloadIter - self._log[__name__].info('Starting indirect file download in chunks of ' - '%d at %d, stride %d', request_size, offset, stride) - - return cls( - self, - limit, - file=file, - dc_id=dc_id, - offset=offset, - stride=stride, - chunk_size=chunk_size, - request_size=request_size, - file_size=file_size, - msg_data=msg_data, - ) - - # endregion - - # region Private methods - - @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.PhotoSizeProgressive): - return 1, max(thumb.sizes) - 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)) - - for i in reversed(range(len(thumbs))): - # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually - # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this - # thumb size doesn't actually exist (#1655). - if isinstance(thumbs[i], types.PhotoPathSize): - thumbs.pop(i) - - 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.VideoSize)): - return thumb - else: - return None - - def _download_cached_photo_size(self: 'TelegramClient', size, file): - # No need to download anything, simply write the bytes - if isinstance(size, types.PhotoStrippedSize): - data = utils.stripped_photo_to_jpg(size.bytes) - else: - data = size.bytes - - if file is bytes: - return data - elif isinstance(file, str): - helpers.ensure_parent_dir_exists(file) - f = open(file, 'wb') - else: - f = file - - try: - f.write(data) - finally: - if isinstance(file, str): - f.close() - return file - - async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback): - """Specialized version of .download_media() for photos""" - # Determine the photo and its largest size - if isinstance(photo, types.MessageMediaPhoto): - photo = photo.photo - if not isinstance(photo, types.Photo): - return - - # 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 - - 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) - - if isinstance(size, types.PhotoSizeProgressive): - file_size = max(size.sizes) - else: - file_size = size.size - - result = await self.download_file( - types.InputPhotoFileLocation( - id=photo.id, - access_hash=photo.access_hash, - file_reference=photo.file_reference, - thumb_size=size.type - ), - file, - file_size=file_size, - progress_callback=progress_callback - ) - return result if file is bytes else file - - @staticmethod - def _get_kind_and_names(attributes): - """Gets kind and possible names for :tl:`DocumentAttribute`.""" - kind = 'document' - possible_names = [] - for attr in attributes: - if isinstance(attr, types.DocumentAttributeFilename): - possible_names.insert(0, attr.file_name) - - elif isinstance(attr, types.DocumentAttributeAudio): - kind = 'audio' - if attr.performer and attr.title: - possible_names.append('{} - {}'.format( - attr.performer, attr.title - )) - elif attr.performer: - possible_names.append(attr.performer) - elif attr.title: - possible_names.append(attr.title) - elif attr.voice: - kind = 'voice' - - return kind, possible_names - - async def _download_document( - self, document, file, date, thumb, progress_callback, msg_data): - """Specialized version of .download_media() for documents.""" - if isinstance(document, types.MessageMediaDocument): - document = document.document - if not isinstance(document, types.Document): - return - - 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) - - result = await self._download_file( - types.InputDocumentFileLocation( - id=document.id, - access_hash=document.access_hash, - file_reference=document.file_reference, - thumb_size=size.type if size else '' - ), - file, - file_size=size.size if size else document.size, - progress_callback=progress_callback, - msg_data=msg_data, - ) - - return result if file is bytes else file - - @classmethod - def _download_contact(cls, mm_contact, file): - """ - Specialized version of .download_media() for contacts. - Will make use of the vCard 4.0 format. - """ - first_name = mm_contact.first_name - last_name = mm_contact.last_name - phone_number = mm_contact.phone_number - - # Remove these pesky characters - first_name = first_name.replace(';', '') - last_name = (last_name or '').replace(';', '') - result = ( - 'BEGIN:VCARD\n' - 'VERSION:4.0\n' - 'N:{f};{l};;;\n' - 'FN:{f} {l}\n' - 'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n' - 'END:VCARD\n' - ).format(f=first_name, l=last_name, p=phone_number).encode('utf-8') - - if file is bytes: - return result - elif isinstance(file, str): - file = cls._get_proper_filename( - file, 'contact', '.vcard', - possible_names=[first_name, phone_number, last_name] - ) - f = open(file, 'wb') - else: - f = file - - try: - f.write(result) - finally: - # Only close the stream if we opened it - if isinstance(file, str): - f.close() - - return file - - @classmethod - async def _download_web_document(cls, web, file, progress_callback): - """ - Specialized version of .download_media() for web documents. - """ - if not aiohttp: - raise ValueError( - 'Cannot download web documents without the aiohttp ' - 'dependency install it (pip install aiohttp)' - ) - - # TODO Better way to get opened handles of files and auto-close - in_memory = file is bytes - if in_memory: - f = io.BytesIO() - elif isinstance(file, str): - kind, possible_names = cls._get_kind_and_names(web.attributes) - file = cls._get_proper_filename( - file, kind, utils.get_extension(web), - possible_names=possible_names - ) - f = open(file, 'wb') - else: - f = file - - try: - async with aiohttp.ClientSession() as session: - # TODO Use progress_callback; get content length from response - # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 - async with session.get(web.url) as response: - while True: - chunk = await response.content.read(128 * 1024) - if not chunk: - break - f.write(chunk) - finally: - if isinstance(file, str) or file is bytes: - f.close() - - return f.getvalue() if in_memory else file - - @staticmethod - def _get_proper_filename(file, kind, extension, - date=None, possible_names=None): - """Gets a proper filename for 'file', if this is a path. - - 'kind' should be the kind of the output file (photo, document...) - 'extension' should be the extension to be added to the file if - the filename doesn't have any yet - 'date' should be when this file was originally sent, if known - 'possible_names' should be an ordered list of possible names - - If no modification is made to the path, any existing file - will be overwritten. - If any modification is made to the path, this method will - ensure that no existing file will be overwritten. - """ - if isinstance(file, pathlib.Path): - file = str(file.absolute()) - - if file is not None and not isinstance(file, str): - # Probably a stream-like object, we cannot set a filename here - return file - - if file is None: - file = '' - elif os.path.isfile(file): - # Make no modifications to valid existing paths - return file - - if os.path.isdir(file) or not file: - try: - name = None if possible_names is None else next( - x for x in possible_names if x - ) - except StopIteration: - name = None - - if not name: - if not date: - date = datetime.datetime.now() - name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( - kind, - date.year, date.month, date.day, - date.hour, date.minute, date.second, - ) - file = os.path.join(file, name) - - directory, name = os.path.split(file) - name, ext = os.path.splitext(name) - if not ext: - ext = extension - - result = os.path.join(directory, name + ext) - if not os.path.isfile(result): - return result - - i = 1 - while True: - result = os.path.join(directory, '{} ({}){}'.format(name, i, ext)) - if not os.path.isfile(result): - return result - i += 1 - - # endregion diff --git a/telethon/client/messageparse.py b/telethon/client/messageparse.py deleted file mode 100644 index 322c541e..00000000 --- a/telethon/client/messageparse.py +++ /dev/null @@ -1,228 +0,0 @@ -import itertools -import re -import typing - -from .. import helpers, utils -from ..tl import types - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -class MessageParseMethods: - - # region Public properties - - @property - def parse_mode(self: 'TelegramClient'): - """ - This property is the default parse mode used when sending messages. - Defaults to `telethon.extensions.markdown`. It will always - be either `None` or an object with ``parse`` and ``unparse`` - methods. - - When setting a different value it should be one of: - - * Object with ``parse`` and ``unparse`` methods. - * A ``callable`` to act as the parse method. - * A `str` indicating the ``parse_mode``. For Markdown ``'md'`` - or ``'markdown'`` may be used. For HTML, ``'htm'`` or ``'html'`` - may be used. - - The ``parse`` method should be a function accepting a single - parameter, the text to parse, and returning a tuple consisting - of ``(parsed message str, [MessageEntity instances])``. - - The ``unparse`` method should be the inverse of ``parse`` such - that ``assert text == unparse(*parse(text))``. - - See :tl:`MessageEntity` for allowed message entities. - - Example - .. code-block:: python - - # Disabling default formatting - client.parse_mode = None - - # Enabling HTML as the default format - client.parse_mode = 'html' - """ - return self._parse_mode - - @parse_mode.setter - def parse_mode(self: 'TelegramClient', mode: str): - self._parse_mode = utils.sanitize_parse_mode(mode) - - # endregion - - # region Private methods - - async def _replace_with_mention(self: 'TelegramClient', entities, i, user): - """ - Helper method to replace ``entities[i]`` to mention ``user``, - or do nothing if it can't be found. - """ - try: - entities[i] = types.InputMessageEntityMentionName( - entities[i].offset, entities[i].length, - await self.get_input_entity(user) - ) - return True - except (ValueError, TypeError): - return False - - async def _parse_message_text(self: 'TelegramClient', message, parse_mode): - """ - Returns a (parsed message, entities) tuple depending on ``parse_mode``. - """ - if parse_mode == (): - parse_mode = self._parse_mode - else: - parse_mode = utils.sanitize_parse_mode(parse_mode) - - if not parse_mode: - return message, [] - - original_message = message - message, msg_entities = parse_mode.parse(message) - if original_message and not message and not msg_entities: - raise ValueError("Failed to parse message") - - for i in reversed(range(len(msg_entities))): - e = msg_entities[i] - if isinstance(e, types.MessageEntityTextUrl): - m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) - if m: - user = int(m.group(1)) if m.group(1) else e.url - is_mention = await self._replace_with_mention(msg_entities, i, user) - if not is_mention: - del msg_entities[i] - elif isinstance(e, (types.MessageEntityMentionName, - types.InputMessageEntityMentionName)): - is_mention = await self._replace_with_mention(msg_entities, i, e.user_id) - if not is_mention: - del msg_entities[i] - - return message, msg_entities - - def _get_response_message(self: 'TelegramClient', request, result, input_chat): - """ - Extracts the response message known a request and Update result. - The request may also be the ID of the message to match. - - If ``request is None`` this method returns ``{id: message}``. - - If ``request.random_id`` is a list, this method returns a list too. - """ - if isinstance(result, types.UpdateShort): - updates = [result.update] - entities = {} - elif isinstance(result, (types.Updates, types.UpdatesCombined)): - updates = result.updates - entities = {utils.get_peer_id(x): x - for x in - itertools.chain(result.users, result.chats)} - else: - return None - - random_to_id = {} - id_to_message = {} - for update in updates: - if isinstance(update, types.UpdateMessageID): - random_to_id[update.random_id] = update.id - - elif isinstance(update, ( - types.UpdateNewChannelMessage, types.UpdateNewMessage)): - update.message._finish_init(self, entities, input_chat) - - # Pinning a message with `updatePinnedMessage` seems to - # always produce a service message we can't map so return - # it directly. The same happens for kicking users. - # - # 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): - update.message._finish_init(self, entities, input_chat) - - # Live locations use `sendMedia` but Telegram responds with - # `updateEditMessage`, which means we won't have `id` field. - if hasattr(request, 'random_id'): - id_to_message[update.message.id] = update.message - elif request.id == update.message.id: - return update.message - - elif (isinstance(update, types.UpdateEditChannelMessage) - and utils.get_peer_id(request.peer) == - utils.get_peer_id(update.message.peer_id)): - if request.id == update.message.id: - update.message._finish_init(self, entities, input_chat) - return update.message - - elif isinstance(update, types.UpdateNewScheduledMessage): - update.message._finish_init(self, entities, input_chat) - # Scheduled IDs may collide with normal IDs. However, for a - # single request there *shouldn't* be a mix between "some - # scheduled and some not". - id_to_message[update.message.id] = update.message - - elif isinstance(update, types.UpdateMessagePoll): - if request.media.poll.id == update.poll_id: - m = types.Message( - id=request.id, - peer_id=utils.get_peer(request.peer), - media=types.MessageMediaPoll( - poll=update.poll, - results=update.results - ) - ) - m._finish_init(self, entities, input_chat) - return m - - if request is None: - return id_to_message - - random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None) - if random_id is None: - # Can happen when pinning a message does not actually produce a service message. - self._log[__name__].warning( - 'No random_id in %s to map to, returning None message for %s', request, result) - return None - - if not utils.is_list_like(random_id): - msg = id_to_message.get(random_to_id.get(random_id)) - - if not msg: - self._log[__name__].warning( - 'Request %s had missing message mapping %s', request, result) - - return msg - - try: - return [id_to_message[random_to_id[rnd]] for rnd in random_id] - except KeyError: - # Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets - # 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 [ - id_to_message.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 deleted file mode 100644 index 01011b58..00000000 --- a/telethon/client/messages.py +++ /dev/null @@ -1,1448 +0,0 @@ -import inspect -import itertools -import typing -import warnings - -from .. import helpers, utils, errors, hints -from ..requestiter import RequestIter -from ..tl import types, functions - -_MAX_CHUNK_SIZE = 100 - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -class _MessagesIter(RequestIter): - """ - Common factor for all requests that need to iterate over messages. - """ - async def _init( - self, entity, offset_id, min_id, max_id, - from_user, offset_date, add_offset, filter, search, reply_to, - scheduled - ): - # Note that entity being `None` will perform a global search. - if entity: - self.entity = await self.client.get_input_entity(entity) - else: - self.entity = None - if self.reverse: - raise ValueError('Cannot reverse global search') - - # Telegram doesn't like min_id/max_id. If these IDs are low enough - # (starting from last_id - 100), the request will return nothing. - # - # We can emulate their behaviour locally by setting offset = max_id - # and simply stopping once we hit a message with ID <= min_id. - if self.reverse: - offset_id = max(offset_id, min_id) - if offset_id and max_id: - if max_id - offset_id <= 1: - raise StopAsyncIteration - - if not max_id: - max_id = float('inf') - else: - offset_id = max(offset_id, max_id) - if offset_id and min_id: - if offset_id - min_id <= 1: - raise StopAsyncIteration - - if self.reverse: - if offset_id: - offset_id += 1 - elif not offset_date: - # offset_id has priority over offset_date, so don't - # set offset_id to 1 if we want to offset by date. - offset_id = 1 - - if from_user: - from_user = await self.client.get_input_entity(from_user) - self.from_id = await self.client.get_peer_id(from_user) - else: - self.from_id = None - - # `messages.searchGlobal` only works with text `search` or `filter` queries. - # If we want to perform global a search with `from_user` we have to perform - # a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`. - if not self.entity and from_user: - self.entity = types.InputPeerEmpty() - - if filter is None: - filter = types.InputMessagesFilterEmpty() - else: - filter = filter() if isinstance(filter, type) else filter - - if not self.entity: - self.request = functions.messages.SearchGlobalRequest( - q=search or '', - filter=filter, - min_date=None, - max_date=offset_date, - offset_rate=0, - offset_peer=types.InputPeerEmpty(), - offset_id=offset_id, - limit=1 - ) - elif scheduled: - self.request = functions.messages.GetScheduledHistoryRequest( - peer=entity, - hash=0 - ) - elif reply_to is not None: - self.request = functions.messages.GetRepliesRequest( - peer=self.entity, - msg_id=reply_to, - offset_id=offset_id, - offset_date=offset_date, - add_offset=add_offset, - limit=1, - max_id=0, - min_id=0, - hash=0 - ) - elif search is not None or not isinstance(filter, types.InputMessagesFilterEmpty) or from_user: - # Telegram completely ignores `from_id` in private chats - ty = helpers._entity_type(self.entity) - if ty == helpers._EntityType.USER: - # Don't bother sending `from_user` (it's ignored anyway), - # but keep `from_id` defined above to check it locally. - from_user = None - else: - # Do send `from_user` to do the filtering server-side, - # and set `from_id` to None to avoid checking it locally. - self.from_id = None - - self.request = functions.messages.SearchRequest( - peer=self.entity, - q=search or '', - filter=filter, - min_date=None, - max_date=offset_date, - offset_id=offset_id, - add_offset=add_offset, - limit=0, # Search actually returns 0 items if we ask it to - max_id=0, - min_id=0, - hash=0, - from_id=from_user - ) - - # Workaround issue #1124 until a better solution is found. - # Telegram seemingly ignores `max_date` if `filter` (and - # nothing else) is specified, so we have to rely on doing - # a first request to offset from the ID instead. - # - # Even better, using `filter` and `from_id` seems to always - # trigger `RPC_CALL_FAIL` which is "internal issues"... - if not isinstance(filter, types.InputMessagesFilterEmpty) \ - and offset_date and not search and not offset_id: - async for m in self.client.iter_messages( - self.entity, 1, offset_date=offset_date): - self.request.offset_id = m.id + 1 - else: - self.request = functions.messages.GetHistoryRequest( - peer=self.entity, - limit=1, - offset_date=offset_date, - offset_id=offset_id, - min_id=0, - max_id=0, - add_offset=add_offset, - hash=0 - ) - - if self.limit <= 0: - # No messages, but we still need to know the total message count - result = await self.client(self.request) - if isinstance(result, types.messages.MessagesNotModified): - self.total = result.count - else: - self.total = getattr(result, 'count', len(result.messages)) - raise StopAsyncIteration - - if self.wait_time is None: - self.wait_time = 1 if self.limit > 3000 else 0 - - # When going in reverse we need an offset of `-limit`, but we - # also want to respect what the user passed, so add them together. - if self.reverse: - self.request.add_offset -= _MAX_CHUNK_SIZE - - self.add_offset = add_offset - self.max_id = max_id - self.min_id = min_id - self.last_id = 0 if self.reverse else float('inf') - - async def _load_next_chunk(self): - self.request.limit = min(self.left, _MAX_CHUNK_SIZE) - if self.reverse and self.request.limit != _MAX_CHUNK_SIZE: - # Remember that we need -limit when going in reverse - self.request.add_offset = self.add_offset - self.request.limit - - r = await self.client(self.request) - self.total = getattr(r, 'count', len(r.messages)) - - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - messages = reversed(r.messages) if self.reverse else r.messages - for message in messages: - if (isinstance(message, types.MessageEmpty) - or self.from_id and message.sender_id != self.from_id): - continue - - if not self._message_in_range(message): - return True - - # There has been reports that on bad connections this method - # was returning duplicated IDs sometimes. Using ``last_id`` - # is an attempt to avoid these duplicates, since the message - # IDs are returned in descending order (or asc if reverse). - self.last_id = message.id - message._finish_init(self.client, entities, self.entity) - self.buffer.append(message) - - if len(r.messages) < self.request.limit: - return True - - # Get the last message that's not empty (in some rare cases - # it can happen that the last message is :tl:`MessageEmpty`) - if self.buffer: - self._update_offset(self.buffer[-1], r) - else: - # There are some cases where all the messages we get start - # being empty. This can happen on migrated mega-groups if - # the history was cleared, and we're using search. Telegram - # acts incredibly weird sometimes. Messages are returned but - # only "empty", not their contents. If this is the case we - # should just give up since there won't be any new Message. - return True - - def _message_in_range(self, message): - """ - Determine whether the given message is in the range or - it should be ignored (and avoid loading more chunks). - """ - # No entity means message IDs between chats may vary - if self.entity: - if self.reverse: - if message.id <= self.last_id or message.id >= self.max_id: - return False - else: - if message.id >= self.last_id or message.id <= self.min_id: - return False - - return True - - def _update_offset(self, last_message, response): - """ - After making the request, update its offset with the last message. - """ - self.request.offset_id = last_message.id - if self.reverse: - # We want to skip the one we already have - self.request.offset_id += 1 - - if isinstance(self.request, functions.messages.SearchRequest): - # Unlike getHistory and searchGlobal that use *offset* date, - # this is *max* date. This means that doing a search in reverse - # will break it. Since it's not really needed once we're going - # (only for the first request), it's safe to just clear it off. - self.request.max_date = None - else: - # getHistory, searchGlobal and getReplies call it offset_date - self.request.offset_date = last_message.date - - if isinstance(self.request, functions.messages.SearchGlobalRequest): - if last_message.input_chat: - self.request.offset_peer = last_message.input_chat - else: - self.request.offset_peer = types.InputPeerEmpty() - - self.request.offset_rate = getattr(response, 'next_rate', 0) - - -class _IDsIter(RequestIter): - async def _init(self, entity, ids): - self.total = len(ids) - self._ids = list(reversed(ids)) if self.reverse else ids - self._offset = 0 - self._entity = (await self.client.get_input_entity(entity)) if entity else None - self._ty = helpers._entity_type(self._entity) if self._entity else None - - # 30s flood wait every 300 messages (3 requests of 100 each, 30 of 10, etc.) - if self.wait_time is None: - self.wait_time = 10 if self.limit > 300 else 0 - - async def _load_next_chunk(self): - ids = self._ids[self._offset:self._offset + _MAX_CHUNK_SIZE] - if not ids: - raise StopAsyncIteration - - self._offset += _MAX_CHUNK_SIZE - - from_id = None # By default, no need to validate from_id - if self._ty == helpers._EntityType.CHANNEL: - try: - r = await self.client( - functions.channels.GetMessagesRequest(self._entity, ids)) - except errors.MessageIdsEmptyError: - # All IDs were invalid, use a dummy result - r = types.messages.MessagesNotModified(len(ids)) - else: - r = await self.client(functions.messages.GetMessagesRequest(ids)) - if self._entity: - from_id = await self.client._get_peer(self._entity) - - if isinstance(r, types.messages.MessagesNotModified): - self.buffer.extend(None for _ in ids) - return - - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - # Telegram seems to return the messages in the order in which - # we asked them for, so we don't need to check it ourselves, - # unless some messages were invalid in which case Telegram - # may decide to not send them at all. - # - # The passed message IDs may not belong to the desired entity - # since the user can enter arbitrary numbers which can belong to - # arbitrary chats. Validate these unless ``from_id is None``. - for message in r.messages: - if isinstance(message, types.MessageEmpty) or ( - from_id and message.peer_id != from_id): - self.buffer.append(None) - else: - message._finish_init(self.client, entities, self._entity) - self.buffer.append(message) - - -class MessageMethods: - - # region Public methods - - # region Message retrieval - - def iter_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - offset_date: 'hints.DateLike' = None, - offset_id: int = 0, - max_id: int = 0, - min_id: int = 0, - add_offset: int = 0, - search: str = None, - filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None, - from_user: 'hints.EntityLike' = None, - wait_time: float = None, - ids: 'typing.Union[int, typing.Sequence[int]]' = None, - reverse: bool = False, - reply_to: int = None, - scheduled: bool = False - ) -> 'typing.Union[_MessagesIter, _IDsIter]': - """ - Iterator over the messages for the given chat. - - The default order is from newest to oldest, but this - behaviour can be changed with the `reverse` parameter. - - If either `search`, `filter` or `from_user` are provided, - :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. - - .. note:: - - Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to - be around 30 seconds per 10 requests, therefore a sleep of 1 - second is the default for this limit (or above). - - Arguments - entity (`entity`): - The entity from whom to retrieve the message history. - - It may be `None` to perform a global search, or - to get messages by their ID from no particular chat. - Note that some of the offsets will not work if this - is the case. - - Note that if you want to perform a global search, - you **must** set a non-empty `search` string, a `filter`. - or `from_user`. - - limit (`int` | `None`, optional): - Number of messages to be retrieved. Due to limitations with - the API retrieving more than 3000 messages will take longer - than half a minute (or even more based on previous calls). - - The limit may also be `None`, which would eventually return - the whole history. - - offset_date (`datetime`): - Offset date (messages *previous* to this date will be - retrieved). Exclusive. - - offset_id (`int`): - Offset message ID (only messages *previous* to the given - ID will be retrieved). Exclusive. - - max_id (`int`): - All the messages with a higher (newer) ID or equal to this will - be excluded. - - min_id (`int`): - All the messages with a lower (older) ID or equal to this will - be excluded. - - add_offset (`int`): - Additional message offset (all of the specified offsets + - this offset = older messages). - - search (`str`): - The string to be used as a search query. - - filter (:tl:`MessagesFilter` | `type`): - The filter to use when returning messages. For instance, - :tl:`InputMessagesFilterPhotos` would yield only messages - containing photos. - - from_user (`entity`): - Only messages from this entity will be returned. - - wait_time (`int`): - Wait time (in seconds) between different - :tl:`GetHistoryRequest`. Use this parameter to avoid hitting - the ``FloodWaitError`` as needed. If left to `None`, it will - default to 1 second only if the limit is higher than 3000. - - If the ``ids`` parameter is used, this time will default - to 10 seconds only if the amount of IDs is higher than 300. - - ids (`int`, `list`): - A single integer ID (or several IDs) for the message that - should be returned. This parameter takes precedence over - the rest (which will be ignored if this is set). This can - for instance be used to get the message with ID 123 from - a channel. Note that if the message doesn't exist, `None` - will appear in its place, so that zipping the list of IDs - with the messages can match one-to-one. - - .. note:: - - At the time of writing, Telegram will **not** return - :tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that - failed (i.e. the message is not replying to any, or is - replying to a deleted message). This means that it is - **not** possible to match messages one-by-one, so be - careful if you use non-integers in this parameter. - - reverse (`bool`, optional): - If set to `True`, the messages will be returned in reverse - order (from oldest to newest, instead of the default newest - to oldest). This also means that the meaning of `offset_id` - and `offset_date` parameters is reversed, although they will - still be exclusive. `min_id` becomes equivalent to `offset_id` - instead of being `max_id` as well since messages are returned - in ascending order. - - You cannot use this if both `entity` and `ids` are `None`. - - reply_to (`int`, optional): - If set to a message ID, the messages that reply to this ID - will be returned. This feature is also known as comments in - posts of broadcast channels, or viewing threads in groups. - - This feature can only be used in broadcast channels and their - linked megagroups. Using it in a chat or private conversation - will result in ``telethon.errors.PeerIdInvalidError`` to occur. - - When using this parameter, the ``filter`` and ``search`` - parameters have no effect, since Telegram's API doesn't - support searching messages in replies. - - .. note:: - - This feature is used to get replies to a message in the - *discussion* group. If the same broadcast channel sends - a message and replies to it itself, that reply will not - be included in the results. - - scheduled (`bool`, optional): - If set to `True`, messages which are scheduled will be returned. - All other parameter will be ignored for this, except `entity`. - - Yields - Instances of `Message `. - - Example - .. code-block:: python - - # From most-recent to oldest - async for message in client.iter_messages(chat): - print(message.id, message.text) - - # From oldest to most-recent - async for message in client.iter_messages(chat, reverse=True): - print(message.id, message.text) - - # Filter by sender - async for message in client.iter_messages(chat, from_user='me'): - print(message.text) - - # Server-side search with fuzzy text - async for message in client.iter_messages(chat, search='hello'): - print(message.id) - - # Filter by message type: - from telethon.tl.types import InputMessagesFilterPhotos - async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): - print(message.photo) - - # Getting comments from a post in a channel: - async for message in client.iter_messages(channel, reply_to=123): - print(message.chat.title, message.text) - """ - if ids is not None: - if not utils.is_list_like(ids): - ids = [ids] - - return _IDsIter( - client=self, - reverse=reverse, - wait_time=wait_time, - limit=len(ids), - entity=entity, - ids=ids - ) - - return _MessagesIter( - client=self, - reverse=reverse, - wait_time=wait_time, - limit=limit, - entity=entity, - offset_id=offset_id, - min_id=min_id, - max_id=max_id, - from_user=from_user, - offset_date=offset_date, - add_offset=add_offset, - filter=filter, - search=search, - reply_to=reply_to, - scheduled=scheduled - ) - - async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_messages()`, but returns a - `TotalList ` instead. - - If the `limit` is not set, it will be 1 by default unless both - `min_id` **and** `max_id` are set (as *named* arguments), in - which case the entire range will be returned. - - This is so because any integer limit would be rather arbitrary and - it's common to only want to fetch one message, but if a range is - specified it makes sense that it should return the entirety of it. - - If `ids` is present in the *named* arguments and is not a list, - a single `Message ` will be - returned for convenience instead of a list. - - Example - .. code-block:: python - - # Get 0 photos and print the total to show how many photos there are - from telethon.tl.types import InputMessagesFilterPhotos - photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) - print(photos.total) - - # Get all the photos - photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) - - # Get messages by ID: - message_1337 = await client.get_messages(chat, ids=1337) - """ - if len(args) == 1 and 'limit' not in kwargs: - if 'min_id' in kwargs and 'max_id' in kwargs: - kwargs['limit'] = None - else: - kwargs['limit'] = 1 - - it = self.iter_messages(*args, **kwargs) - - ids = kwargs.get('ids') - if ids and not utils.is_list_like(ids): - async for message in it: - return message - else: - # Iterator exhausted = empty, to handle InputMessageReplyTo - return None - - return await it.collect() - - get_messages.__signature__ = inspect.signature(iter_messages) - - # endregion - - # region Message sending/editing/deleting - - async def _get_comment_data( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[int, types.Message]' - ): - r = await self(functions.messages.GetDiscussionMessageRequest( - peer=entity, - msg_id=utils.get_message_id(message) - )) - m = r.messages[0] - chat = next(c for c in r.chats if c.id == m.peer_id.channel_id) - return utils.get_input_peer(chat), m.id - - async def send_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'hints.MessageLike' = '', - *, - reply_to: 'typing.Union[int, types.Message]' = None, - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - parse_mode: typing.Optional[str] = (), - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - clear_draft: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, types.Message]' = None - ) -> 'types.Message': - """ - Sends a message to the specified user, chat or channel. - - The default parse mode is the same as the official applications - (a custom flavour of markdown). ``**bold**, `code` or __italic__`` - are available. In addition you can send ``[links](https://example.com)`` - and ``[mentions](@username)`` (or using IDs like in the Bot API: - ``[mention](tg://user?id=123456789)``) and ``pre`` blocks with three - backticks. - - Sending a ``/start`` command with a parameter (like ``?start=data``) - is also done through this method. Simply send ``'/start data'`` to - the bot. - - See also `Message.respond() ` - and `Message.reply() `. - - Arguments - entity (`entity`): - To who will it be sent. - - message (`str` | `Message `): - The message to be sent, or another message object to resend. - - The maximum length for a message is 35,000 bytes or 4,096 - characters. Longer messages will not be sliced automatically, - and you should slice them manually if the text to send is - longer than said length. - - reply_to (`int` | `Message `, optional): - Whether to reply to a message or not. If an integer is provided, - it should be the ID of the message that it should reply to. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - link_preview (`bool`, optional): - Should the link preview be shown? - - file (`file`, optional): - Sends a message with a file attached (e.g. a photo, - video, audio or document). The ``message`` may be empty. - - thumb (`str` | `bytes` | `file`, optional): - Optional JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - force_document (`bool`, optional): - Whether to send the given file as a document or not. - - clear_draft (`bool`, optional): - Whether the existing draft should be cleared or not. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - All the following limits apply together: - - * There can be 100 buttons at most (any more are ignored). - * There can be 8 buttons per row at most (more are ignored). - * The maximum callback data per button is 64 bytes. - * The maximum data that can be embedded in total is just - over 4KB, shared between inline callback data and text. - - silent (`bool`, optional): - Whether the message should notify people in a broadcast - channel or not. Defaults to `False`, which means it will - notify them. Set it to `True` to alter this behaviour. - - background (`bool`, optional): - Whether the message should be send in background. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the message won't send immediately, and instead - it will be scheduled to be automatically sent at a later - time. - - comment_to (`int` | `Message `, optional): - Similar to ``reply_to``, but replies in the linked group of a - broadcast channel instead (effectively leaving a "comment to" - the specified message). - - This parameter takes precedence over ``reply_to``. If there is - no linked chat, `telethon.errors.sgIdInvalidError` is raised. - - Returns - The sent `custom.Message `. - - Example - .. code-block:: python - - # Markdown is the default - await client.send_message('me', 'Hello **world**!') - - # Default to another parse mode - client.parse_mode = 'html' - - await client.send_message('me', 'Some bold and italic text') - await client.send_message('me', 'An URL') - # code and pre tags also work, but those break the documentation :) - await client.send_message('me', 'Mentions') - - # Explicit parse mode - # No parse mode by default - client.parse_mode = None - - # ...but here I want markdown - await client.send_message('me', 'Hello, **world**!', parse_mode='md') - - # ...and here I need HTML - await client.send_message('me', 'Hello, world!', parse_mode='html') - - # If you logged in as a bot account, you can send buttons - from telethon import events, Button - - @client.on(events.CallbackQuery) - async def callback(event): - await event.edit('Thank you for clicking {}!'.format(event.data)) - - # Single inline button - await client.send_message(chat, 'A single button, with "clk1" as data', - buttons=Button.inline('Click me', b'clk1')) - - # Matrix of inline buttons - await client.send_message(chat, 'Pick one from this grid', buttons=[ - [Button.inline('Left'), Button.inline('Right')], - [Button.url('Check this site!', 'https://example.com')] - ]) - - # Reply keyboard - await client.send_message(chat, 'Welcome', buttons=[ - Button.text('Thanks!', resize=True, single_use=True), - Button.request_phone('Send phone'), - Button.request_location('Send location') - ]) - - # Forcing replies or clearing buttons. - await client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) - await client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) - - # Scheduling a message to be sent after 5 minutes - from datetime import timedelta - await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) - """ - if file is not None: - return await self.send_file( - entity, file, caption=message, reply_to=reply_to, - attributes=attributes, parse_mode=parse_mode, - force_document=force_document, thumb=thumb, - buttons=buttons, clear_draft=clear_draft, silent=silent, - schedule=schedule, supports_streaming=supports_streaming, - formatting_entities=formatting_entities, - comment_to=comment_to, background=background - ) - - entity = await self.get_input_entity(entity) - if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) - - if isinstance(message, types.Message): - if buttons is None: - markup = message.reply_markup - else: - markup = self.build_reply_markup(buttons) - - if silent is None: - silent = message.silent - - if (message.media and not isinstance( - message.media, types.MessageMediaWebPage)): - return await self.send_file( - entity, - message.media, - caption=message.message, - silent=silent, - background=background, - reply_to=reply_to, - buttons=markup, - formatting_entities=message.entities, - schedule=schedule - ) - - request = functions.messages.SendMessageRequest( - peer=entity, - message=message.message or '', - silent=silent, - background=background, - reply_to_msg_id=utils.get_message_id(reply_to), - reply_markup=markup, - entities=message.entities, - clear_draft=clear_draft, - no_webpage=not isinstance( - message.media, types.MessageMediaWebPage), - schedule_date=schedule - ) - message = message.message - else: - if formatting_entities is None: - message, formatting_entities = await self._parse_message_text(message, parse_mode) - if not message: - raise ValueError( - 'The message cannot be empty unless a file is provided' - ) - - request = functions.messages.SendMessageRequest( - peer=entity, - message=message, - entities=formatting_entities, - no_webpage=not link_preview, - reply_to_msg_id=utils.get_message_id(reply_to), - clear_draft=clear_draft, - silent=silent, - background=background, - reply_markup=self.build_reply_markup(buttons), - schedule_date=schedule - ) - - result = await self(request) - if isinstance(result, types.UpdateShortSentMessage): - message = types.Message( - id=result.id, - peer_id=await self._get_peer(entity), - message=message, - date=result.date, - out=result.out, - media=result.media, - entities=result.entities, - reply_markup=request.reply_markup, - ttl_period=result.ttl_period - ) - message._finish_init(self, {}, entity) - return message - - return self._get_response_message(request, result, entity) - - async def forward_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', - from_peer: 'hints.EntityLike' = None, - *, - background: bool = None, - with_my_score: bool = None, - silent: bool = None, - as_album: bool = None, - schedule: 'hints.DateLike' = None - ) -> 'typing.Sequence[types.Message]': - """ - Forwards the given messages to the specified entity. - - If you want to "forward" a message without the forward header - (the "forwarded from" text), you should use `send_message` with - the original message instead. This will send a copy of it. - - See also `Message.forward_to() `. - - Arguments - entity (`entity`): - To which entity the message(s) will be forwarded. - - messages (`list` | `int` | `Message `): - The message(s) to forward, or their integer IDs. - - from_peer (`entity`): - If the given messages are integer IDs and not instances - of the ``Message`` class, this *must* be specified in - order for the forward to work. This parameter indicates - the entity from which the messages should be forwarded. - - silent (`bool`, optional): - Whether the message should notify people with sound or not. - Defaults to `False` (send with a notification sound unless - the person has the chat muted). Set it to `True` to alter - this behaviour. - - background (`bool`, optional): - Whether the message should be forwarded in background. - - with_my_score (`bool`, optional): - Whether forwarded should contain your game score. - - as_album (`bool`, optional): - This flag no longer has any effect. - - schedule (`hints.DateLike`, optional): - If set, the message(s) won't forward immediately, and - instead they will be scheduled to be automatically sent - at a later time. - - Returns - The list of forwarded `Message `, - or a single one if a list wasn't provided as input. - - Note that if all messages are invalid (i.e. deleted) the call - will fail with ``MessageIdInvalidError``. If only some are - invalid, the list will have `None` instead of those messages. - - Example - .. code-block:: python - - # a single one - await client.forward_messages(chat, message) - # or - await client.forward_messages(chat, message_id, from_chat) - # or - await message.forward_to(chat) - - # multiple - await client.forward_messages(chat, messages) - # or - await client.forward_messages(chat, message_ids, from_chat) - - # Forwarding as a copy - await client.send_message(chat, message) - """ - if as_album is not None: - warnings.warn('the as_album argument is deprecated and no longer has any effect') - - single = not utils.is_list_like(messages) - if single: - messages = (messages,) - - entity = await self.get_input_entity(entity) - - if from_peer: - from_peer = await self.get_input_entity(from_peer) - from_peer_id = await self.get_peer_id(from_peer) - else: - from_peer_id = None - - def get_key(m): - if isinstance(m, int): - if from_peer_id is not None: - return from_peer_id - - raise ValueError('from_peer must be given if integer IDs are used') - elif isinstance(m, types.Message): - return m.chat_id - else: - raise TypeError('Cannot forward messages of type {}'.format(type(m))) - - sent = [] - for _chat_id, chunk in itertools.groupby(messages, key=get_key): - chunk = list(chunk) - if isinstance(chunk[0], int): - chat = from_peer - else: - chat = await chunk[0].get_input_chat() - chunk = [m.id for m in chunk] - - req = functions.messages.ForwardMessagesRequest( - from_peer=chat, - id=chunk, - to_peer=entity, - silent=silent, - background=background, - with_my_score=with_my_score, - schedule_date=schedule - ) - result = await self(req) - sent.extend(self._get_response_message(req, result, entity)) - - return sent[0] if single else sent - - async def edit_message( - self: 'TelegramClient', - entity: 'typing.Union[hints.EntityLike, types.Message]', - message: 'hints.MessageLike' = None, - text: str = None, - *, - parse_mode: str = (), - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'hints.FileLike' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - buttons: 'hints.MarkupLike' = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None - ) -> 'types.Message': - """ - Edits the given message to change its text or media. - - See also `Message.edit() `. - - Arguments - entity (`entity` | `Message `): - From which chat to edit the message. This can also be - the message to be edited, and the entity will be inferred - from it, so the next parameter will be assumed to be the - message text. - - You may also pass a :tl:`InputBotInlineMessageID`, - which is the only way to edit messages that were sent - after the user selects an inline query result. - - message (`int` | `Message ` | `str`): - The ID of the message (or `Message - ` itself) to be edited. - If the `entity` was a `Message - `, then this message - will be treated as the new text. - - text (`str`, optional): - The new text of the message. Does nothing if the `entity` - was a `Message `. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - link_preview (`bool`, optional): - Should the link preview be shown? - - file (`str` | `bytes` | `file` | `media`, optional): - The file object that should replace the existing media - in the message. - - thumb (`str` | `bytes` | `file`, optional): - Optional JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - force_document (`bool`, optional): - Whether to send the given file as a document or not. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the message won't be edited immediately, and instead - it will be scheduled to be automatically edited at a later - time. - - Note that this parameter will have no effect if you are - trying to edit a message that was sent via inline bots. - - Returns - The edited `Message `, - unless `entity` was a :tl:`InputBotInlineMessageID` in which - case this method returns a boolean. - - Raises - ``MessageAuthorRequiredError`` if you're not the author of the - message but tried editing it anyway. - - ``MessageNotModifiedError`` if the contents of the message were - not modified at all. - - ``MessageIdInvalidError`` if the ID of the message is invalid - (the ID itself may be correct, but the message with that ID - cannot be edited). For example, when trying to edit messages - with a reply markup (or clear markup) this error will be raised. - - Example - .. code-block:: python - - message = await client.send_message(chat, 'hello') - - await client.edit_message(chat, message, 'hello!') - # or - await client.edit_message(chat, message.id, 'hello!!') - # or - await client.edit_message(message, 'hello!!!') - """ - if isinstance(entity, types.InputBotInlineMessageID): - text = text or message - message = entity - elif isinstance(entity, types.Message): - text = message # Shift the parameters to the right - message = entity - entity = entity.peer_id - - if formatting_entities is None: - text, formatting_entities = await self._parse_message_text(text, parse_mode) - file_handle, media, image = await self._file_to_media(file, - supports_streaming=supports_streaming, - thumb=thumb, - attributes=attributes, - force_document=force_document) - - if isinstance(entity, types.InputBotInlineMessageID): - request = functions.messages.EditInlineBotMessageRequest( - id=entity, - message=text, - no_webpage=not link_preview, - entities=formatting_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( - peer=entity, - id=utils.get_message_id(message), - message=text, - no_webpage=not link_preview, - entities=formatting_entities, - media=media, - reply_markup=self.build_reply_markup(buttons), - schedule_date=schedule - ) - msg = self._get_response_message(request, await self(request), entity) - return msg - - async def delete_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', - *, - revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]': - """ - Deletes the given messages, optionally "for everyone". - - See also `Message.delete() `. - - .. warning:: - - This method does **not** validate that the message IDs belong - to the chat that you passed! It's possible for the method to - delete messages from different private chats and small group - chats at once, so make sure to pass the right IDs. - - Arguments - entity (`entity`): - From who the message will be deleted. This can actually - be `None` for normal chats, but **must** be present - for channels and megagroups. - - message_ids (`list` | `int` | `Message `): - The IDs (or ID) or messages to be deleted. - - revoke (`bool`, optional): - Whether the message should be deleted for everyone or not. - By default it has the opposite behaviour of official clients, - and it will delete the message for everyone. - - `Since 24 March 2019 - `_, you can - also revoke messages of any age (i.e. messages sent long in - the past) the *other* person sent in private conversations - (and of course your messages too). - - Disabling this has no effect on channels or megagroups, - since it will unconditionally delete the message for everyone. - - Returns - A list of :tl:`AffectedMessages`, each item being the result - for the delete calls of the messages in chunks of 100 each. - - Example - .. code-block:: python - - await client.delete_messages(chat, messages) - """ - if not utils.is_list_like(message_ids): - message_ids = (message_ids,) - - message_ids = ( - m.id if isinstance(m, ( - types.Message, types.MessageService, types.MessageEmpty)) - else int(m) for m in message_ids - ) - - 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: - return await self([functions.messages.DeleteMessagesRequest( - list(c), revoke) for c in utils.chunks(message_ids)]) - - # endregion - - # region Miscellaneous - - async def send_read_acknowledge( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, - *, - max_id: int = None, - clear_mentions: bool = False) -> bool: - """ - Marks messages as read and optionally clears mentions. - - This effectively marks a message as read (or more than one) in the - given conversation. - - 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 - entity (`entity`): - The chat where these messages are located. - - message (`list` | `Message `): - Either a list of messages or a single message. - - max_id (`int`): - 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 - there are no more mentions) or not for the given entity. - - If no message is provided, this will be the only action - taken. - - Example - .. code-block:: python - - # using a Message object - await client.send_read_acknowledge(chat, message) - # ...or using the int ID of a Message - await client.send_read_acknowledge(chat, message_id) - # ...or passing a list of messages to mark as read - await client.send_read_acknowledge(chat, messages) - """ - if max_id is None: - if not message: - max_id = 0 - else: - if utils.is_list_like(message): - max_id = max(msg.id for msg in message) - else: - max_id = message.id - - entity = await self.get_input_entity(entity) - if clear_mentions: - await self(functions.messages.ReadMentionsRequest(entity)) - if max_id is None: - return True - - if max_id is not None: - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: - return await self(functions.channels.ReadHistoryRequest( - utils.get_input_channel(entity), max_id=max_id)) - else: - return await self(functions.messages.ReadHistoryRequest( - entity, max_id=max_id)) - - return False - - async def pin_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Optional[hints.MessageIDLike]', - *, - notify: bool = False, - pm_oneside: bool = False - ): - """ - Pins a message in a chat. - - The default behaviour is to *not* notify members, unlike the - official applications. - - See also `Message.pin() `. - - Arguments - entity (`entity`): - The chat where the message should be pinned. - - message (`int` | `Message `): - The message or the message ID to pin. If it's - `None`, all messages will be unpinned instead. - - notify (`bool`, optional): - Whether the pin should notify people or not. - - pm_oneside (`bool`, optional): - Whether the message should be pinned for everyone or not. - By default it has the opposite behaviour of official clients, - and it will pin the message for both sides, in private chats. - - Example - .. code-block:: python - - # Send and pin a message to annoy everyone - message = await client.send_message(chat, 'Pinotifying is fun!') - await client.pin_message(chat, message, notify=True) - """ - return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) - - async def unpin_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Optional[hints.MessageIDLike]' = None, - *, - notify: bool = False - ): - """ - Unpins a message in a chat. - - If no message ID is specified, all pinned messages will be unpinned. - - See also `Message.unpin() `. - - Arguments - entity (`entity`): - The chat where the message should be pinned. - - message (`int` | `Message `): - The message or the message ID to unpin. If it's - `None`, all messages will be unpinned instead. - - Example - .. code-block:: python - - # Unpin all messages from a chat - await client.unpin_message(chat) - """ - return await self._pin(entity, message, unpin=True, notify=notify) - - async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): - message = utils.get_message_id(message) or 0 - entity = await self.get_input_entity(entity) - if message <= 0: # old behaviour accepted negative IDs to unpin - await self(functions.messages.UnpinAllMessagesRequest(entity)) - return - - request = functions.messages.UpdatePinnedMessageRequest( - peer=entity, - id=message, - silent=not notify, - unpin=unpin, - pm_oneside=pm_oneside - ) - result = await self(request) - - # Unpinning does not produce a service message. - # Pinning a message that was already pinned also produces no service message. - # Pinning a message in your own chat does not produce a service message, - # but pinning on a private conversation with someone else does. - if unpin or not result.updates: - return - - # Pinning a message that doesn't exist would RPC-error earlier - return self._get_response_message(request, result, entity) - - # endregion - - # endregion diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py deleted file mode 100644 index 494daf9c..00000000 --- a/telethon/client/telegrambaseclient.py +++ /dev/null @@ -1,875 +0,0 @@ -import abc -import re -import asyncio -import collections -import logging -import platform -import time -import typing - -from .. import version, helpers, __name__ as __base_name__ -from ..crypto import rsa -from ..entitycache import EntityCache -from ..extensions import markdown -from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy -from ..sessions import Session, SQLiteSession, MemorySession -from ..statecache import StateCache -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_PORT = 443 - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - -_base_log = logging.getLogger(__base_name__) - - -# 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): - """ - This is the abstract base class for the client. It defines some - basic stuff like connecting, switching data center, etc, and - leaves the `__call__` unimplemented. - - Arguments - session (`str` | `telethon.sessions.abstract.Session`, `None`): - The file name of the session file to be used if a string is - given (it may be a full path), or the Session instance to be - used otherwise. If it's `None`, the session will not be saved, - and you should call :meth:`.log_out()` when you're done. - - Note that if you pass a string it will be a file in the current - working directory, although you can also pass absolute paths. - - The session file contains enough information for you to login - without re-sending the code, so if you have to enter the code - more than once, maybe you're changing the working directory, - renaming or removing the file, or using random names. - - api_id (`int` | `str`): - The API ID you obtained from https://my.telegram.org. - - api_hash (`str`): - The API hash you obtained from https://my.telegram.org. - - connection (`telethon.network.connection.common.Connection`, optional): - The connection instance to be used when creating a new connection - to the servers. It **must** be a type. - - Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. - - use_ipv6 (`bool`, optional): - Whether to connect to the servers through IPv6 or not. - By default this is `False` as IPv6 support is not - too widespread yet. - - proxy (`tuple` | `list` | `dict`, optional): - An iterable consisting of the proxy info. If `connection` is - one of `MTProxy`, then it should contain MTProxy credentials: - ``('hostname', port, 'secret')``. Otherwise, it's meant to store - function parameters for PySocks, like ``(type, 'hostname', port)``. - See https://github.com/Anorov/PySocks#usage-1 for more. - - local_addr (`str` | `tuple`, optional): - Local host address (and port, optionally) used to bind the socket to locally. - You only need to use this if you have multiple network cards and - want to use a specific one. - - timeout (`int` | `float`, optional): - The timeout in seconds to be used when connecting. - This is **not** the timeout to be used when ``await``'ing for - invoked requests, and you should use ``asyncio.wait`` or - ``asyncio.wait_for`` for that. - - request_retries (`int` | `None`, optional): - How many times a request should be retried. Request are retried - when Telegram is having internal issues (due to either - ``errors.ServerError`` or ``errors.RpcCallFailError``), - when there is a ``errors.FloodWaitError`` less than - `flood_sleep_threshold`, or when there's a migrate error. - - May take a negative or `None` value for infinite retries, but - this is not recommended, since some requests can always trigger - a call fail (such as searching for messages). - - connection_retries (`int` | `None`, optional): - How many times the reconnection should retry, either on the - initial connection or when Telegram disconnects us. May be - set to a negative or `None` value for infinite retries, but - this is not recommended, since the program can get stuck in an - infinite loop. - - retry_delay (`int` | `float`, optional): - The delay in seconds to sleep between automatic reconnections. - - auto_reconnect (`bool`, optional): - Whether reconnection should be retried `connection_retries` - times automatically if Telegram disconnects us or not. - - sequential_updates (`bool`, optional): - By default every incoming update will create a new task, so - you can handle several updates in parallel. Some scripts need - the order in which updates are processed to be sequential, and - this setting allows them to do so. - - If set to `True`, incoming updates will be put in a queue - and processed sequentially. This means your event handlers - should *not* perform long-running operations since new - updates are put inside of an unbounded queue. - - flood_sleep_threshold (`int` | `float`, optional): - The threshold below which the library should automatically - sleep on flood wait and slow mode wait errors (inclusive). For instance, if a - ``FloodWaitError`` for 17s occurs and `flood_sleep_threshold` - is 20s, the library will ``sleep`` automatically. If the error - was for 21s, it would ``raise FloodWaitError`` instead. Values - larger than a day (like ``float('inf')``) will be changed to a day. - - raise_last_call_error (`bool`, optional): - When API calls fail in a way that causes Telethon to retry - automatically, should the RPC error of the last attempt be raised - instead of a generic ValueError. This is mostly useful for - detecting when Telegram has internal issues. - - device_model (`str`, optional): - "Device model" to be sent when creating the initial connection. - Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown. - - system_version (`str`, optional): - "System version" to be sent when creating the initial connection. - Defaults to ``platform.uname().release`` stripped of everything ahead of -. - - app_version (`str`, optional): - "App version" to be sent when creating the initial connection. - Defaults to `telethon.version.__version__`. - - lang_code (`str`, optional): - "Language code" to be sent when creating the initial connection. - Defaults to ``'en'``. - - system_lang_code (`str`, optional): - "System lang code" to be sent when creating the initial connection. - Defaults to `lang_code`. - - loop (`asyncio.AbstractEventLoop`, optional): - 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. - If a `str` is given, it'll be passed to `logging.getLogger()`. If a - `logging.Logger` is given, it'll be used directly. If something - else or nothing is given, the default logger will be used. - - receive_updates (`bool`, optional): - Whether the client will receive updates or not. By default, updates - will be received from Telegram as they occur. - - Turning this off means that Telegram will not send updates at all - so event handlers, conversations, and QR login will not work. - However, certain scripts don't need updates, so this will reduce - the amount of bandwidth used. - """ - - # Current TelegramClient version - __version__ = version.__version__ - - # Cached server configuration (with .dc_options), can be "global" - _config = None - _cdn_config = None - - # region Initialization - - def __init__( - self: 'TelegramClient', - session: 'typing.Union[str, Session]', - api_id: int, - api_hash: str, - *, - connection: 'typing.Type[Connection]' = ConnectionTcpFull, - use_ipv6: bool = False, - proxy: typing.Union[tuple, dict] = None, - local_addr: typing.Union[str, tuple] = None, - timeout: int = 10, - request_retries: int = 5, - connection_retries: int = 5, - retry_delay: int = 1, - auto_reconnect: bool = True, - sequential_updates: bool = False, - flood_sleep_threshold: int = 60, - raise_last_call_error: bool = False, - device_model: str = None, - system_version: str = None, - app_version: str = None, - lang_code: str = 'en', - system_lang_code: str = 'en', - loop: asyncio.AbstractEventLoop = None, - base_logger: typing.Union[str, logging.Logger] = None, - receive_updates: bool = True - ): - if not api_id or not api_hash: - raise ValueError( - "Your API ID or Hash cannot be empty or None. " - "Refer to telethon.rtfd.io for more information.") - - self._use_ipv6 = use_ipv6 - - if isinstance(base_logger, str): - base_logger = logging.getLogger(base_logger) - elif not isinstance(base_logger, logging.Logger): - base_logger = _base_log - - class _Loggers(dict): - def __missing__(self, key): - if key.startswith("telethon."): - key = key.split('.', maxsplit=1)[1] - - return base_logger.getChild(key) - - self._log = _Loggers() - - # Determine what session object we have - if isinstance(session, str) or session is None: - try: - session = SQLiteSession(session) - except ImportError: - import warnings - warnings.warn( - 'The sqlite3 module is not available under this ' - 'Python installation and no custom session ' - 'instance was given; using MemorySession.\n' - 'You will need to re-login every time unless ' - 'you use another session storage' - ) - session = MemorySession() - elif not isinstance(session, Session): - raise TypeError( - 'The given session must be a str or a Session instance.' - ) - - # ':' in session.server_address is True if it's an IPv6 address - if (not session.server_address or - (':' in session.server_address) != use_ipv6): - session.set_dc( - DEFAULT_DC_ID, - DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP, - DEFAULT_PORT - ) - - self.flood_sleep_threshold = flood_sleep_threshold - - # TODO Use AsyncClassWrapper(session) - # ChatGetter and SenderGetter can use the in-memory _entity_cache - # to avoid network access and the need for await in session files. - # - # The session files only wants the entities to persist - # them to disk, and to save additional useful information. - # TODO Session should probably return all cached - # info of entities, not just the input versions - self.session = session - self._entity_cache = EntityCache() - self.api_id = int(api_id) - self.api_hash = api_hash - - # Current proxy implementation requires `sock_connect`, and some - # event loops lack this method. If the current loop is missing it, - # bail out early and suggest an alternative. - # - # TODO A better fix is obviously avoiding the use of `sock_connect` - # - # See https://github.com/LonamiWebs/Telethon/issues/1337 for details. - if not callable(getattr(self.loop, 'sock_connect', None)): - raise TypeError( - 'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n' - 'Change the event loop in use to use proxies:\n' - '# https://github.com/LonamiWebs/Telethon/issues/1337\n' - 'import asyncio\n' - 'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format( - self.loop.__class__.__name__ - ) - ) - - if local_addr is not None: - if use_ipv6 is False and ':' in local_addr: - raise TypeError( - 'A local IPv6 address must only be used with `use_ipv6=True`.' - ) - elif use_ipv6 is True and ':' not in local_addr: - raise TypeError( - '`use_ipv6=True` must only be used with a local IPv6 address.' - ) - - self._raise_last_call_error = raise_last_call_error - - self._request_retries = request_retries - self._connection_retries = connection_retries - self._retry_delay = retry_delay or 0 - self._proxy = proxy - self._local_addr = local_addr - self._timeout = timeout - self._auto_reconnect = auto_reconnect - - assert isinstance(connection, type) - self._connection = connection - init_proxy = None if not issubclass(connection, TcpMTProxy) else \ - types.InputClientProxy(*connection.address_info(proxy)) - - # Used on connection. Capture the variables in a lambda since - # exporting clients need to create this InvokeWithLayerRequest. - system = platform.uname() - - if system.machine in ('x86_64', 'AMD64'): - default_device_model = 'PC 64bit' - elif system.machine in ('i386','i686','x86'): - default_device_model = 'PC 32bit' - else: - default_device_model = system.machine - default_system_version = re.sub(r'-.+','',system.release) - - self._init_request = functions.InitConnectionRequest( - api_id=self.api_id, - device_model=device_model or default_device_model or 'Unknown', - system_version=system_version or default_system_version or '1.0', - app_version=app_version or self.__version__, - lang_code=lang_code, - system_lang_code=system_lang_code, - lang_pack='', # "langPacks are for official apps only" - query=None, - proxy=init_proxy - ) - - self._sender = MTProtoSender( - self.session.auth_key, - loggers=self._log, - retries=self._connection_retries, - delay=self._retry_delay, - auto_reconnect=self._auto_reconnect, - connect_timeout=self._timeout, - auth_key_callback=self._auth_key_callback, - update_callback=self._handle_update, - auto_reconnect_callback=self._handle_auto_reconnect - ) - - # Remember flood-waited requests to avoid making them again - self._flood_waited_requests = {} - - # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders - self._borrowed_senders = {} - self._borrow_sender_lock = asyncio.Lock() - - self._updates_handle = None - self._last_request = time.time() - self._channel_pts = {} - self._no_updates = not receive_updates - - if sequential_updates: - 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. - self._updates_queue = set() - self._dispatching_updates_queue = None - - self._authorized = None # None = unknown, False = no, True = yes - - # Update state (for catching up after a disconnection) - # TODO Get state from channels too - self._state_cache = StateCache( - self.session.get_update_state(0), self._log) - - # Some further state for subclasses - self._event_builders = [] - - # {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 - - # Some fields to easy signing in. Let {phone: hash} be - # a dictionary because the user may change their mind. - self._phone_code_hash = {} - self._phone = None - self._tos = None - - # Sometimes we need to know who we are, cache the self peer - self._self_input_peer = None - self._bot = None - - # A place to store if channels are a megagroup or not (see `edit_admin`) - self._megagroup_cache = {} - - # endregion - - # region Properties - - @property - def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: - """ - Property with the ``asyncio`` event loop used by this client. - - Example - .. code-block:: python - - # Download media in the background - task = client.loop.create_task(message.download_media()) - - # Do some work - ... - - # Join the task (wait for it to complete) - await task - """ - return asyncio.get_event_loop() - - @property - def disconnected(self: 'TelegramClient') -> asyncio.Future: - """ - Property with a ``Future`` that resolves upon disconnection. - - Example - .. code-block:: python - - # Wait for a disconnection to occur - try: - await client.disconnected - except OSError: - print('Error on disconnect') - """ - return self._sender.disconnected - - @property - def flood_sleep_threshold(self): - return self._flood_sleep_threshold - - @flood_sleep_threshold.setter - def flood_sleep_threshold(self, value): - # None -> 0, negative values don't really matter - self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60) - - # endregion - - # region Connecting - - async def connect(self: 'TelegramClient') -> None: - """ - Connects to Telegram. - - .. note:: - - Connect means connect and nothing else, and only one low-level - request is made to notify Telegram about which layer we will be - using. - - Before Telegram sends you updates, you need to make a high-level - request, like `client.get_me() `, - as described in https://core.telegram.org/api/updates. - - Example - .. code-block:: python - - try: - await client.connect() - except OSError: - print('Failed to connect') - """ - if not await self._sender.connect(self._connection( - self.session.server_address, - self.session.port, - self.session.dc_id, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )): - # We don't want to init or modify anything if we were already connected - return - - self.session.auth_key = self._sender.auth_key - self.session.save() - - self._init_request.query = functions.help.GetConfigRequest() - - await self._sender.send(functions.InvokeWithLayerRequest( - LAYER, self._init_request - )) - - self._updates_handle = self.loop.create_task(self._update_loop()) - - def is_connected(self: 'TelegramClient') -> bool: - """ - Returns `True` if the user has connected. - - This method is **not** asynchronous (don't use ``await`` on it). - - Example - .. code-block:: python - - while client.is_connected(): - await asyncio.sleep(1) - """ - sender = getattr(self, '_sender', None) - return sender and sender.is_connected() - - def disconnect(self: 'TelegramClient'): - """ - Disconnects from Telegram. - - If the event loop is already running, this method returns a - coroutine that you should await on your own code; otherwise - the loop is ran until said coroutine completes. - - Example - .. code-block:: python - - # You don't need to use this if you used "with client" - await client.disconnect() - """ - if self.loop.is_running(): - return self._disconnect_coro() - else: - try: - self.loop.run_until_complete(self._disconnect_coro()) - except RuntimeError: - # Python 3.5.x complains when called from - # `__aexit__` and there were pending updates with: - # "Event loop stopped before Future completed." - # - # However, it doesn't really make a lot of sense. - pass - - def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): - """ - Changes the proxy which will be used on next (re)connection. - - Method has no immediate effects if the client is currently connected. - - The new proxy will take it's effect on the next reconnection attempt: - - on a call `await client.connect()` (after complete disconnect) - - on auto-reconnect attempt (e.g, after previous connection was lost) - """ - init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ - types.InputClientProxy(*self._connection.address_info(proxy)) - - self._init_request.proxy = init_proxy - self._proxy = proxy - - # While `await client.connect()` passes new proxy on each new call, - # auto-reconnect attempts use already set up `_connection` inside - # the `_sender`, so the only way to change proxy between those - # is to directly inject parameters. - - connection = getattr(self._sender, "_connection", None) - if connection: - if isinstance(connection, TcpMTProxy): - connection._ip = proxy[0] - connection._port = proxy[1] - else: - connection._proxy = proxy - - 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(): - # Note that we're not checking for `state.should_disconnect()`. - # If the user wants to disconnect the client, ALL connections - # to Telegram (including exported senders) should be closed. - # - # Disconnect should never raise, so there's no try/except. - await sender.disconnect() - # Can't use `mark_disconnected` because it may be borrowed. - state._connected = False - - # If any was borrowed - 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) - self._updates_queue.clear() - - pts, date = self._state_cache[None] - if pts and date: - self.session.set_update_state(0, types.updates.State( - pts=pts, - qts=0, - date=date, - seq=0, - unread_count=0 - )) - - self.session.close() - - async def _disconnect(self: 'TelegramClient'): - """ - Disconnect only, without closing the session. Used in reconnections - to different data centers, where we don't want to close the session - file; user disconnects however should close it since it means that - their job with the client is complete and we should clean it up all. - """ - await self._sender.disconnect() - await helpers._cancel(self._log[__name__], - updates_handle=self._updates_handle) - - async def _switch_dc(self: 'TelegramClient', new_dc): - """ - Permanently switches the current connection to the new data center. - """ - self._log[__name__].info('Reconnecting to new data center %s', new_dc) - dc = await self._get_dc(new_dc) - - self.session.set_dc(dc.id, dc.ip_address, dc.port) - # auth_key's are associated with a server, which has now changed - # so it's not valid anymore. Set to None to force recreating it. - self._sender.auth_key.key = None - self.session.auth_key = None - self.session.save() - await self._disconnect() - return await self.connect() - - def _auth_key_callback(self: 'TelegramClient', auth_key): - """ - Callback from the sender whenever it needed to generate a - new authorization key. This means we are not authorized. - """ - self.session.auth_key = auth_key - self.session.save() - - # endregion - - # region Working with different connections/Data Centers - - async def _get_dc(self: 'TelegramClient', dc_id, cdn=False): - """Gets the Data Center (DC) associated to 'dc_id'""" - cls = self.__class__ - if not cls._config: - cls._config = await self(functions.help.GetConfigRequest()) - - if cdn and not self._cdn_config: - cls._cdn_config = await self(functions.help.GetCdnConfigRequest()) - for pk in cls._cdn_config.public_keys: - rsa.add_key(pk.public_key) - - try: - return next( - dc for dc in cls._config.dc_options - if dc.id == dc_id - and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn - ) - except StopIteration: - self._log[__name__].warning( - 'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check', - dc_id, cdn, self._use_ipv6 - ) - return next( - dc for dc in cls._config.dc_options - if dc.id == dc_id and bool(dc.cdn) == cdn - ) - - async def _create_exported_sender(self: 'TelegramClient', dc_id): - """ - Creates a new exported `MTProtoSender` for the given `dc_id` and - returns it. This method should be used by `_borrow_exported_sender`. - """ - # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt - # for clearly showing how to export the authorization - dc = await self._get_dc(dc_id) - # Can't reuse self._sender._connection as it has its own seqno. - # - # If one were to do that, Telegram would reset the connection - # with no further clues. - sender = MTProtoSender(None, loggers=self._log) - await sender.connect(self._connection( - dc.ip_address, - dc.port, - dc.id, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )) - self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) - auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) - self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) - req = functions.InvokeWithLayerRequest(LAYER, self._init_request) - await sender.send(req) - return sender - - async def _borrow_exported_sender(self: 'TelegramClient', dc_id): - """ - Borrows a connected `MTProtoSender` for the given `dc_id`. - If it's not cached, creates a new one if it doesn't exist yet, - and imports a freshly exported authorization key for it to be usable. - - Once its job is over it should be `_return_exported_sender`. - """ - async with self._borrow_sender_lock: - 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 - 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, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )) - - state.add_borrow() - return sender - - async def _return_exported_sender(self: 'TelegramClient', sender): - """ - Returns a borrowed exported sender. If all borrows have - been returned, the sender is cleanly disconnected. - """ - async with self._borrow_sender_lock: - 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""" - # TODO Implement - raise NotImplementedError - session = self._exported_sessions.get(cdn_redirect.dc_id) - if not session: - dc = await self._get_dc(cdn_redirect.dc_id, cdn=True) - session = self.session.clone() - await session.set_dc(dc.id, dc.ip_address, dc.port) - self._exported_sessions[cdn_redirect.dc_id] = session - - self._log[__name__].info('Creating new CDN client') - client = TelegramBaseClient( - session, self.api_id, self.api_hash, - proxy=self._sender.connection.conn.proxy, - timeout=self._sender.connection.get_timeout() - ) - - # This will make use of the new RSA keys for this specific CDN. - # - # We won't be calling GetConfigRequest because it's only called - # when needed by ._get_dc, and also it's static so it's likely - # set already. Avoid invoking non-CDN methods by not syncing updates. - client.connect(_sync_updates=False) - return client - - # endregion - - # region Invoking Telegram requests - - @abc.abstractmethod - def __call__(self: 'TelegramClient', request, ordered=False): - """ - Invokes (sends) one or more MTProtoRequests and returns (receives) - their result. - - Args: - request (`TLObject` | `list`): - The request or requests to be invoked. - - ordered (`bool`, optional): - Whether the requests (if more than one was given) should be - executed sequentially on the server. They run in arbitrary - order by default. - - flood_sleep_threshold (`int` | `None`, optional): - The flood sleep threshold to use for this request. This overrides - the default value stored in - `client.flood_sleep_threshold ` - - Returns: - The result of the request (often a `TLObject`) or a list of - results if more than one request was given. - """ - raise NotImplementedError - - @abc.abstractmethod - def _handle_update(self: 'TelegramClient', update): - raise NotImplementedError - - @abc.abstractmethod - def _update_loop(self: 'TelegramClient'): - raise NotImplementedError - - @abc.abstractmethod - async def _handle_auto_reconnect(self: 'TelegramClient'): - raise NotImplementedError - - # endregion diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py deleted file mode 100644 index 144a6b2f..00000000 --- a/telethon/client/telegramclient.py +++ /dev/null @@ -1,13 +0,0 @@ -from . import ( - AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, - BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, - MessageParseMethods, UserMethods, TelegramBaseClient -) - - -class TelegramClient( - AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, - BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, - MessageParseMethods, UserMethods, TelegramBaseClient -): - pass diff --git a/telethon/client/updates.py b/telethon/client/updates.py deleted file mode 100644 index bcc983f3..00000000 --- a/telethon/client/updates.py +++ /dev/null @@ -1,656 +0,0 @@ -import asyncio -import inspect -import itertools -import random -import sys -import time -import traceback -import typing -import logging - -from .. import events, utils, errors -from ..events.common import EventBuilder, EventCommon -from ..tl import types, functions - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -Callback = typing.Callable[[typing.Any], typing.Any] - -class UpdateMethods: - - # region Public methods - - async def _run_until_disconnected(self: 'TelegramClient'): - try: - # Make a high-level request to notify that we want updates - await self(functions.updates.GetStateRequest()) - return await self.disconnected - except KeyboardInterrupt: - pass - finally: - await self.disconnect() - - async def set_receive_updates(self: 'TelegramClient', receive_updates): - """ - Change the value of `receive_updates`. - - This is an `async` method, because in order for Telegram to start - sending updates again, a request must be made. - """ - self._no_updates = not receive_updates - if receive_updates: - await self(functions.updates.GetStateRequest()) - - def run_until_disconnected(self: 'TelegramClient'): - """ - Runs the event loop until the library is disconnected. - - It also notifies Telegram that we want to receive updates - as described in https://core.telegram.org/api/updates. - - Manual disconnections can be made by calling `disconnect() - ` - or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on - the console window running the script). - - If a disconnection error occurs (i.e. the library fails to reconnect - automatically), said error will be raised through here, so you have a - chance to ``except`` it on your own code. - - If the loop is already running, this method returns a coroutine - that you should await on your own code. - - .. note:: - - If you want to handle ``KeyboardInterrupt`` in your code, - simply run the event loop in your code too in any way, such as - ``loop.run_forever()`` or ``await client.disconnected`` (e.g. - ``loop.run_until_complete(client.disconnected)``). - - Example - .. code-block:: python - - # Blocks the current task here until a disconnection occurs. - # - # You will still receive updates, since this prevents the - # script from exiting. - await client.run_until_disconnected() - """ - if self.loop.is_running(): - return self._run_until_disconnected() - try: - return self.loop.run_until_complete(self._run_until_disconnected()) - except KeyboardInterrupt: - pass - finally: - # No loop.run_until_complete; it's already syncified - self.disconnect() - - def on(self: 'TelegramClient', event: EventBuilder): - """ - Decorator used to `add_event_handler` more conveniently. - - - Arguments - event (`_EventBuilder` | `type`): - The event builder class or instance to be used, - for instance ``events.NewMessage``. - - Example - .. code-block:: python - - from telethon import TelegramClient, events - client = TelegramClient(...) - - # Here we use client.on - @client.on(events.NewMessage) - async def handler(event): - ... - """ - def decorator(f): - self.add_event_handler(f, event) - return f - - return decorator - - def add_event_handler( - self: 'TelegramClient', - callback: Callback, - event: EventBuilder = None): - """ - Registers a new event handler callback. - - The callback will be called when the specified event occurs. - - Arguments - callback (`callable`): - The callable function accepting one parameter to be used. - - Note that if you have used `telethon.events.register` in - the callback, ``event`` will be ignored, and instead the - events you previously registered will be used. - - event (`_EventBuilder` | `type`, optional): - The event builder class or instance to be used, - for instance ``events.NewMessage``. - - If left unspecified, `telethon.events.raw.Raw` (the - :tl:`Update` objects with no further processing) will - be passed instead. - - Example - .. code-block:: python - - from telethon import TelegramClient, events - client = TelegramClient(...) - - async def handler(event): - ... - - client.add_event_handler(handler, events.NewMessage) - """ - builders = events._get_handlers(callback) - if builders is not None: - for event in builders: - self._event_builders.append((event, callback)) - return - - if isinstance(event, type): - event = event() - elif not event: - event = events.Raw() - - self._event_builders.append((event, callback)) - - def remove_event_handler( - self: 'TelegramClient', - callback: Callback, - event: EventBuilder = None) -> int: - """ - Inverse operation of `add_event_handler()`. - - If no event is given, all events for this callback are removed. - Returns how many callbacks were removed. - - Example - .. code-block:: python - - @client.on(events.Raw) - @client.on(events.NewMessage) - async def handler(event): - ... - - # Removes only the "Raw" handling - # "handler" will still receive "events.NewMessage" - client.remove_event_handler(handler, events.Raw) - - # "handler" will stop receiving anything - client.remove_event_handler(handler) - """ - found = 0 - if event and not isinstance(event, type): - event = type(event) - - i = len(self._event_builders) - while i: - i -= 1 - ev, cb = self._event_builders[i] - if cb == callback and (not event or isinstance(ev, event)): - del self._event_builders[i] - found += 1 - - return found - - def list_event_handlers(self: 'TelegramClient')\ - -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': - """ - Lists all registered event handlers. - - Returns - A list of pairs consisting of ``(callback, event)``. - - Example - .. code-block:: python - - @client.on(events.NewMessage(pattern='hello')) - async def on_greeting(event): - '''Greets someone''' - await event.reply('Hi') - - for callback, event in client.list_event_handlers(): - print(id(callback), type(event)) - """ - return [(callback, event) for event, callback in self._event_builders] - - async def catch_up(self: 'TelegramClient'): - """ - "Catches up" on the missed updates while the client was offline. - You should call this method after registering the event handlers - so that the updates it loads can by processed by your script. - - This can also be used to forcibly fetch new updates if there are any. - - Example - .. code-block:: python - - await client.catch_up() - """ - pts, date = self._state_cache[None] - if not pts: - return - - self.session.catching_up = True - try: - while True: - d = await self(functions.updates.GetDifferenceRequest( - pts, date, 0 - )) - if isinstance(d, (types.updates.DifferenceSlice, - types.updates.Difference)): - if isinstance(d, types.updates.Difference): - state = d.state - else: - state = d.intermediate_state - - pts, date = state.pts, state.date - self._handle_update(types.Updates( - users=d.users, - chats=d.chats, - date=state.date, - seq=state.seq, - updates=d.other_updates + [ - types.UpdateNewMessage(m, 0, 0) - for m in d.new_messages - ] - )) - - # TODO Implement upper limit (max_pts) - # We don't want to fetch updates we already know about. - # - # We may still get duplicates because the Difference - # contains a lot of updates and presumably only has - # the state for the last one, but at least we don't - # unnecessarily fetch too many. - # - # updates.getDifference's pts_total_limit seems to mean - # "how many pts is the request allowed to return", and - # if there is more than that, it returns "too long" (so - # there would be duplicate updates since we know about - # some). This can be used to detect collisions (i.e. - # it would return an update we have already seen). - else: - if isinstance(d, types.updates.DifferenceEmpty): - date = d.date - elif isinstance(d, types.updates.DifferenceTooLong): - pts = d.pts - break - except (ConnectionError, asyncio.CancelledError): - pass - finally: - # TODO Save new pts to session - self._state_cache._pts_date = (pts, date) - self.session.catching_up = False - - # endregion - - # region Private methods - - # It is important to not make _handle_update async because we rely on - # the order that the updates arrive in to update the pts and date to - # be always-increasing. There is also no need to make this async. - def _handle_update(self: 'TelegramClient', update): - self.session.process_entities(update) - self._entity_cache.add(update) - - if isinstance(update, (types.Updates, types.UpdatesCombined)): - entities = {utils.get_peer_id(x): x for x in - itertools.chain(update.users, update.chats)} - for u in update.updates: - self._process_update(u, update.updates, entities=entities) - elif isinstance(update, types.UpdateShort): - self._process_update(update.update, None) - else: - self._process_update(update, None) - - self._state_cache.update(update) - - def _process_update(self: 'TelegramClient', update, others, entities=None): - update._entities = entities or {} - - # This part is somewhat hot so we don't bother patching - # update with channel ID/its state. Instead we just pass - # arguments which is faster. - channel_id = self._state_cache.get_channel_id(update) - args = (update, others, channel_id, self._state_cache[channel_id]) - if self._dispatching_updates_queue is None: - task = self.loop.create_task(self._dispatch_update(*args)) - self._updates_queue.add(task) - task.add_done_callback(lambda _: self._updates_queue.discard(task)) - else: - self._updates_queue.put_nowait(args) - if not self._dispatching_updates_queue.is_set(): - self._dispatching_updates_queue.set() - self.loop.create_task(self._dispatch_queue_updates()) - - self._state_cache.update(update) - - async def _update_loop(self: 'TelegramClient'): - # Pings' ID don't really need to be secure, just "random" - rnd = lambda: random.randrange(-2**63, 2**63) - while self.is_connected(): - try: - await asyncio.wait_for( - self.disconnected, timeout=60 - ) - continue # We actually just want to act upon timeout - except asyncio.TimeoutError: - pass - except asyncio.CancelledError: - return - 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. - if not self._sender._transport_connected(): - continue - - # We also don't really care about their result. - # Just send them periodically. - try: - self._sender._keepalive_ping(rnd()) - except (ConnectionError, asyncio.CancelledError): - return - - # Entities and cached files are not saved when they are - # inserted because this is a rather expensive operation - # (default's sqlite3 takes ~0.1s to commit changes). Do - # it every minute instead. No-op if there's nothing new. - self.session.save() - - # We need to send some content-related request at least hourly - # for Telegram to keep delivering updates, otherwise they will - # just stop even if we're connected. Do so every 30 minutes. - # - # TODO Call getDifference instead since it's more relevant - if time.time() - self._last_request > 30 * 60: - if not await self.is_user_authorized(): - # What can be the user doing for so - # long without being logged in...? - continue - - try: - await self(functions.updates.GetStateRequest()) - except (ConnectionError, asyncio.CancelledError): - return - - async def _dispatch_queue_updates(self: 'TelegramClient'): - while not self._updates_queue.empty(): - await self._dispatch_update(*self._updates_queue.get_nowait()) - - self._dispatching_updates_queue.clear() - - async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date): - if not self._entity_cache.ensure_cached(update): - # We could add a lock to not fetch the same pts twice if we are - # already fetching it. However this does not happen in practice, - # which makes sense, because different updates have different pts. - if self._state_cache.update(update, check_only=True): - # If the update doesn't have pts, fetching won't do anything. - # For example, UpdateUserStatus or UpdateChatUserTyping. - try: - 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 - except ValueError: - # There is a chance that GetFullChannelRequest and GetDifferenceRequest - # inside the _get_difference() function will end up with - # ValueError("Request was unsuccessful N time(s)") for whatever reasons. - 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. - try: - await self.get_me(input_peer=True) - except OSError: - pass # might not have connection - - built = EventBuilderDict(self, update, others) - for conv_set in self._conversations.values(): - for conv in conv_set: - ev = built[events.NewMessage] - if ev: - conv._on_new_message(ev) - - ev = built[events.MessageEdited] - if ev: - conv._on_edit(ev) - - ev = built[events.MessageRead] - if ev: - conv._on_read(ev) - - if conv._custom: - await conv._check_custom(built) - - for builder, callback in self._event_builders: - event = built[type(builder)] - if not 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: - 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 isinstance(builder, events.Raw): - continue - 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: - 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 _get_difference(self: 'TelegramClient', update, channel_id, pts_date): - """ - Get the difference for this `channel_id` if any, then load entities. - - Calls :tl:`updates.getDifference`, which fills the entities cache - (always done by `__call__`) and lets us know about the full entities. - """ - # Fetch since the last known pts/date before this update arrived, - # in order to fetch this update at full, including its entities. - self._log[__name__].debug('Getting difference for entities ' - 'for %r', update.__class__) - if channel_id: - # There are reports where we somehow call get channel difference - # with `InputPeerEmpty`. Check our assumptions to better debug - # this when it happens. - assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update) - try: - # Wrap the ID inside a peer to ensure we get a channel back. - where = await self.get_input_entity(types.PeerChannel(channel_id)) - except ValueError: - # There's a high chance that this fails, since - # we are getting the difference to fetch entities. - return - - if not pts_date: - # First-time, can't get difference. Get pts instead. - result = await self(functions.channels.GetFullChannelRequest( - utils.get_input_channel(where) - )) - self._state_cache[channel_id] = result.full_chat.pts - return - - result = await self(functions.updates.GetChannelDifferenceRequest( - channel=where, - filter=types.ChannelMessagesFilterEmpty(), - pts=pts_date, # just pts - limit=100, - force=True - )) - else: - if not pts_date[0]: - # First-time, can't get difference. Get pts instead. - result = await self(functions.updates.GetStateRequest()) - self._state_cache[None] = result.pts, result.date - return - - result = await self(functions.updates.GetDifferenceRequest( - pts=pts_date[0], - date=pts_date[1], - qts=0 - )) - - if isinstance(result, (types.updates.Difference, - types.updates.DifferenceSlice, - types.updates.ChannelDifference, - types.updates.ChannelDifferenceTooLong)): - update._entities.update({ - utils.get_peer_id(x): x for x in - itertools.chain(result.users, result.chats) - }) - - async def _handle_auto_reconnect(self: 'TelegramClient'): - # TODO Catch-up - # For now we make a high-level request to let Telegram - # know we are still interested in receiving more updates. - try: - await self.get_me() - except Exception as e: - self._log[__name__].warning('Error executing high-level request ' - 'after reconnect: %s: %s', type(e), e) - - return - try: - self._log[__name__].info( - 'Asking for the current state after reconnect...') - - # TODO consider: - # If there aren't many updates while the client is disconnected - # (I tried with up to 20), Telegram seems to send them without - # asking for them (via updates.getDifference). - # - # On disconnection, the library should probably set a "need - # difference" or "catching up" flag so that any new updates are - # ignored, and then the library should call updates.getDifference - # itself to fetch them. - # - # In any case (either there are too many updates and Telegram - # didn't send them, or there isn't a lot and Telegram sent them - # but we dropped them), we fetch the new difference to get all - # missed updates. I feel like this would be the best solution. - - # If a disconnection occurs, the old known state will be - # the latest one we were aware of, so we can catch up since - # the most recent state we were aware of. - await self.catch_up() - - self._log[__name__].info('Successfully fetched missed updates') - except errors.RPCError as e: - self._log[__name__].warning('Failed to get missed updates after ' - 'reconnect: %r', e) - except Exception: - self._log[__name__].exception( - 'Unhandled exception while getting update difference after reconnect') - - # endregion - - -class EventBuilderDict: - """ - Helper "dictionary" to return events from types and cache them. - """ - def __init__(self, client: 'TelegramClient', update, others): - self.client = client - self.update = update - self.others = others - - def __getitem__(self, builder): - try: - return self.__dict__[builder] - except KeyError: - event = self.__dict__[builder] = builder.build( - self.update, self.others, self.client._self_id) - - if isinstance(event, EventCommon): - event.original_update = self.update - event._entities = self.update._entities - event._set_client(self.client) - elif event: - event._client = self.client - - return event diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py deleted file mode 100644 index 4c9f9d32..00000000 --- a/telethon/client/uploads.py +++ /dev/null @@ -1,766 +0,0 @@ -import hashlib -import io -import itertools -import os -import pathlib -import re -import typing -from io import BytesIO - -from ..crypto import AES - -from .. import utils, helpers, hints -from ..tl import types, functions, custom - -try: - import PIL - import PIL.Image -except ImportError: - PIL = None - - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -class _CacheType: - """Like functools.partial but pretends to be the wrapped class.""" - def __init__(self, cls): - self._cls = cls - - def __call__(self, *args, **kwargs): - return self._cls(*args, file_reference=b'', **kwargs) - - def __eq__(self, other): - return self._cls == other - - -def _resize_photo_if_needed( - file, is_image, width=1280, height=1280, background=(255, 255, 255)): - - # https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254 - if (not is_image - or PIL is None - or (isinstance(file, io.IOBase) and not file.seekable())): - return file - - if isinstance(file, bytes): - file = io.BytesIO(file) - - before = file.tell() if isinstance(file, io.IOBase) else None - - try: - # Don't use a `with` block for `image`, or `file` would be closed. - # See https://github.com/LonamiWebs/Telethon/issues/1121 for more. - image = PIL.Image.open(file) - try: - kwargs = {'exif': image.info['exif']} - except KeyError: - kwargs = {} - - if image.width <= width and image.height <= height: - return file - - image.thumbnail((width, height), PIL.Image.ANTIALIAS) - - alpha_index = image.mode.find('A') - if alpha_index == -1: - # If the image mode doesn't have alpha - # channel then don't bother masking it away. - result = image - else: - # We could save the resized image with the original format, but - # JPEG often compresses better -> smaller size -> faster upload - # We need to mask away the alpha channel ([3]), since otherwise - # IOError is raised when trying to save alpha channels in JPEG. - result = PIL.Image.new('RGB', image.size, background) - result.paste(image, mask=image.split()[alpha_index]) - - buffer = io.BytesIO() - result.save(buffer, 'JPEG', **kwargs) - buffer.seek(0) - return buffer - - except IOError: - return file - finally: - if before is not None: - file.seek(before, io.SEEK_SET) - - -class UploadMethods: - - # region Public methods - - async def send_file( - self: 'TelegramClient', - entity: 'hints.EntityLike', - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', - *, - 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, - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - thumb: 'hints.FileLike' = None, - allow_cache: bool = True, - parse_mode: str = (), - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - voice_note: bool = False, - video_note: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, types.Message]' = None, - ttl: int = None, - **kwargs) -> 'types.Message': - """ - Sends message with the given file to the specified entity. - - .. note:: - - If the ``hachoir3`` package (``hachoir`` module) is installed, - it will be used to determine metadata from audio and video files. - - If the ``pillow`` package is installed and you are sending a photo, - it will be resized to fit within the maximum dimensions allowed - by Telegram to avoid ``errors.PhotoInvalidDimensionsError``. This - cannot be done if you are sending :tl:`InputFile`, however. - - Arguments - entity (`entity`): - Who will receive the file. - - file (`str` | `bytes` | `file` | `media`): - The file to send, which can be one of: - - * A local file path to an in-disk file. The file name - will be the path's base name. - - * A `bytes` byte array with the file's data to send - (for example, by using ``text.encode('utf-8')``). - A default file name will be used. - - * A bytes `io.IOBase` stream over the file to send - (for example, by using ``open(file, 'rb')``). - Its ``.name`` property will be used for the file name, - or a default if it doesn't have one. - - * An external URL to a file over the internet. This will - send the file as "external" media, and Telegram is the - one that will fetch the media and send it. - - * A Bot API-like ``file_id``. You can convert previously - sent media to file IDs for later reusing with - `telethon.utils.pack_bot_file_id`. - - * A handle to an existing file (for example, if you sent a - message with media before, you can use its ``message.media`` - as a file here). - - * 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 - sent as an album in the order in which they appear, sliced - in chunks of 10 if more than 10 are given. - - caption (`str`, optional): - Optional caption for the sent media message. When sending an - album, the caption may be a list of strings, which will be - assigned to the files pairwise. - - force_document (`bool`, optional): - If left to `False` and the file is a path that ends with - 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. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(sent bytes, total)``. - - reply_to (`int` | `Message `): - Same as `reply_to` from `send_message`. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - thumb (`str` | `bytes` | `file`, optional): - Optional JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - - allow_cache (`bool`, optional): - This parameter currently does nothing, but is kept for - backward-compatibility (and it may get its use back in - the future). - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - voice_note (`bool`, optional): - If `True` the audio will be sent as a voice note. - - video_note (`bool`, optional): - If `True` the video will be sent as a video note, - also known as a round video message. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - silent (`bool`, optional): - Whether the message should notify people with sound or not. - Defaults to `False` (send with a notification sound unless - the person has the chat muted). Set it to `True` to alter - this behaviour. - - background (`bool`, optional): - Whether the message should be send in background. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the file won't send immediately, and instead - it will be scheduled to be automatically sent at a later - time. - - comment_to (`int` | `Message `, optional): - Similar to ``reply_to``, but replies in the linked group of a - broadcast channel instead (effectively leaving a "comment to" - the specified message). - - This parameter takes precedence over ``reply_to``. If there is - no linked chat, `telethon.errors.sgIdInvalidError` is raised. - - ttl (`int`. optional): - The Time-To-Live of the file (also known as "self-destruct timer" - or "self-destructing media"). If set, files can only be viewed for - a short period of time before they disappear from the message - history automatically. - - The value must be at least 1 second, and at most 60 seconds, - otherwise Telegram will ignore this parameter. - - Not all types of media can be used with this parameter, such - as text documents, which will fail with ``TtlMediaInvalidError``. - - Returns - The `Message ` (or messages) - containing the sent file, or messages if a list of them was passed. - - Example - .. code-block:: python - - # Normal files like photos - await client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") - # or - await client.send_message(chat, "It's me!", file='/my/photos/me.jpg') - - # Voice notes or round videos - await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) - await client.send_file(chat, '/my/videos/video.mp4', video_note=True) - - # Custom thumbnails - await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') - - # Only documents - await client.send_file(chat, '/my/photos/photo.png', force_document=True) - - # Albums - await client.send_file(chat, [ - '/my/photos/holiday1.jpg', - '/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 - if not file: - raise TypeError('Cannot use {!r} as file'.format(file)) - - if not caption: - caption = '' - - entity = await self.get_input_entity(entity) - if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) - else: - reply_to = utils.get_message_id(reply_to) - - # First check if the user passed an iterable, in which case - # we may want to send grouped. - if utils.is_list_like(file): - if utils.is_list_like(caption): - captions = caption - else: - captions = [caption] - - result = [] - while file: - result += await self._send_album( - entity, file[:10], caption=captions[:10], - progress_callback=progress_callback, reply_to=reply_to, - parse_mode=parse_mode, silent=silent, schedule=schedule, - supports_streaming=supports_streaming, clear_draft=clear_draft, - force_document=force_document, background=background, - ) - file = file[10:] - captions = captions[10:] - - for doc, cap in zip(file, captions): - result.append(await self.send_file( - entity, doc, allow_cache=allow_cache, - caption=cap, force_document=force_document, - progress_callback=progress_callback, reply_to=reply_to, - attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, buttons=buttons, silent=silent, - supports_streaming=supports_streaming, schedule=schedule, - clear_draft=clear_draft, background=background, - **kwargs - )) - - return result - - if formatting_entities is not None: - msg_entities = formatting_entities - else: - caption, msg_entities =\ - await self._parse_message_text(caption, parse_mode) - - 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, - supports_streaming=supports_streaming, ttl=ttl - ) - - # e.g. invalid cast from :tl:`MessageMediaWebPage` - if not media: - raise TypeError('Cannot use {!r} as file'.format(file)) - - markup = self.build_reply_markup(buttons) - request = functions.messages.SendMediaRequest( - entity, media, reply_to_msg_id=reply_to, message=caption, - entities=msg_entities, reply_markup=markup, silent=silent, - schedule_date=schedule, clear_draft=clear_draft, - background=background - ) - 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, - parse_mode=(), silent=None, schedule=None, - supports_streaming=None, clear_draft=None, - force_document=False, background=None, ttl=None): - """Specialized version of .send_file for albums""" - # We don't care if the user wants to avoid cache, we will use it - # anyway. Why? The cached version will be exactly the same thing - # we need to produce right now to send albums (uploadMedia), and - # cache only makes a difference for documents where the user may - # want the attributes used on them to change. - # - # In theory documents can be sent inside the albums but they appear - # as different messages (not inside the album), and the logic to set - # the attributes/avoid cache is already written in .send_file(). - entity = await self.get_input_entity(entity) - if not utils.is_list_like(caption): - caption = (caption,) - - captions = [] - for c in reversed(caption): # Pop from the end (so reverse) - captions.append(await self._parse_message_text(c or '', parse_mode)) - - reply_to = utils.get_message_id(reply_to) - - # Need to upload the media first, but only if they're not cached yet - media = [] - for file in files: - # Albums want :tl:`InputMedia` which, in theory, includes - # :tl:`InputMediaUploadedPhoto`. However using that will - # make it `raise MediaInvalidError`, so we need to upload - # it as media and then convert that to :tl:`InputMediaPhoto`. - fh, fm, _ = await self._file_to_media( - file, supports_streaming=supports_streaming, - force_document=force_document, ttl=ttl) - if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)): - r = await self(functions.messages.UploadMediaRequest( - entity, media=fm - )) - - fm = utils.get_input_media(r.photo) - elif isinstance(fm, types.InputMediaUploadedDocument): - r = await self(functions.messages.UploadMediaRequest( - entity, media=fm - )) - - fm = utils.get_input_media( - r.document, supports_streaming=supports_streaming) - - if captions: - caption, msg_entities = captions.pop() - else: - caption, msg_entities = '', None - media.append(types.InputSingleMedia( - fm, - message=caption, - entities=msg_entities - # random_id is autogenerated - )) - - # Now we can construct the multi-media request - request = functions.messages.SendMultiMediaRequest( - entity, reply_to_msg_id=reply_to, multi_media=media, - silent=silent, schedule_date=schedule, clear_draft=clear_draft, - background=background - ) - result = await self(request) - - random_ids = [m.random_id for m in media] - return self._get_response_message(random_ids, result, entity) - - async def upload_file( - self: 'TelegramClient', - 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). - - Uploading a file will simply return a "handle" to the file stored - remotely in the Telegram servers, which can be later used on. This - will **not** upload the file to your own chat or any chat at all. - - Arguments - file (`str` | `bytes` | `file`): - The path of the file, byte array, or stream that will be sent. - Note that if a byte array or a stream is given, a filename - or its type won't be inferred, and it will be sent as an - "unnamed application/octet-stream". - - part_size_kb (`int`, optional): - 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`` - and if this is not a `str`, it will be ``"unnamed"``. - - use_cache (`type`, optional): - This parameter currently does nothing, but is kept for - 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)``. - - Returns - :tl:`InputFileBig` if the file size is larger than 10MB, - `InputSizedFile ` - (subclass of :tl:`InputFile`) otherwise. - - Example - .. code-block:: python - - # Photos as photo and document - file = await client.upload_file('photo.jpg') - await client.send_file(chat, file) # sends as photo - await client.send_file(chat, file, force_document=True) # sends as document - - file.name = 'not a photo.jpg' - await client.send_file(chat, file, force_document=True) # document, new name - - # As song or as voice note - file = await client.upload_file('song.ogg') - await client.send_file(chat, file) # sends as song - await client.send_file(chat, file, voice_note=True) # sends as voice note - """ - if isinstance(file, (types.InputFile, types.InputFileBig)): - return file # Already uploaded - - pos = 0 - async with helpers._FileStream(file, file_size=file_size) as stream: - # Opening the stream will determine the correct file size - file_size = stream.file_size - - if not part_size_kb: - part_size_kb = utils.get_appropriated_part_size(file_size) - - if part_size_kb > 512: - raise ValueError('The part size must be less or equal to 512KB') - - part_size = int(part_size_kb * 1024) - if part_size % 1024 != 0: - raise ValueError( - 'The part size must be evenly divisible by 1024') - - # Set a default file name if None was specified - file_id = helpers.generate_random_long() - if not file_name: - file_name = stream.name or str(file_id) - - # If the file name lacks extension, add it if possible. - # Else Telegram complains with `PHOTO_EXT_INVALID_ERROR` - # even if the uploaded image is indeed a photo. - if not os.path.splitext(file_name)[-1]: - file_name += utils._get_extension(stream) - - # Determine whether the file is too big (over 10MB) or not - # Telegram does make a distinction between smaller or larger files - is_big = file_size > 10 * 1024 * 1024 - hash_md5 = hashlib.md5() - - 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) - - pos = 0 - for part_index in range(part_count): - # Read the file by in chunks of size 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) - - # Encryption part if needed - if key and iv: - part = AES.encrypt_ige(part, key, iv) - - 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) - - # The SavePartRequest is different depending on whether - # the file is too large or not (over or less than 10MB) - if is_big: - request = functions.upload.SaveBigFilePartRequest( - file_id, part_index, part_count, part) - else: - request = functions.upload.SaveFilePartRequest( - file_id, part_index, part) - - result = await self(request) - if result: - self._log[__name__].debug('Uploaded %d/%d', - part_index + 1, part_count) - if progress_callback: - await helpers._maybe_await(progress_callback(pos, file_size)) - else: - raise RuntimeError( - 'Failed to upload file part {}.'.format(part_index)) - - if is_big: - return types.InputFileBig(file_id, part_count, file_name) - else: - return custom.InputSizedFile( - file_id, part_count, file_name, md5=hash_md5, size=file_size - ) - - # endregion - - async def _file_to_media( - 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, - ttl=None): - if not file: - return None, None, None - - if isinstance(file, pathlib.Path): - file = str(file.absolute()) - - is_image = utils.is_image(file) - if as_image is None: - 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, 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. - # - # We pass all attributes since these will be used if the user - # passed :tl:`InputFile`, and all information may be relevant. - try: - return (None, utils.get_input_media( - file, - is_photo=as_image, - attributes=attributes, - force_document=force_document, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - ttl=ttl - ), as_image) - except TypeError: - # Can't turn whatever was given into media - return None, None, as_image - - media = None - file_handle = None - - 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, ttl_seconds=ttl) - else: - media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl) - else: - bot_file = utils.resolve_bot_file_id(file) - if bot_file: - media = utils.get_input_media(bot_file, ttl=ttl) - - if media: - pass # Already have media, don't check the rest - elif not file_handle: - raise ValueError( - 'Failed to convert {} to media. Not an existing file, ' - 'an HTTP URL or a valid bot-API-like file ID'.format(file) - ) - elif as_image: - media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) - else: - attributes, mime_type = utils.get_attributes( - file, - mime_type=mime_type, - attributes=attributes, - force_document=force_document and not is_image, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - thumb=thumb - ) - - if not thumb: - thumb = None - else: - if isinstance(thumb, pathlib.Path): - thumb = str(thumb.absolute()) - thumb = await self.upload_file(thumb, file_size=file_size) - - media = types.InputMediaUploadedDocument( - file=file_handle, - mime_type=mime_type, - attributes=attributes, - thumb=thumb, - force_file=force_document and not is_image, - ttl_seconds=ttl - ) - return file_handle, media, as_image - - # endregion diff --git a/telethon/client/users.py b/telethon/client/users.py deleted file mode 100644 index 22db969e..00000000 --- a/telethon/client/users.py +++ /dev/null @@ -1,611 +0,0 @@ -import asyncio -import datetime -import itertools -import time -import typing - -from .. import errors, helpers, utils, hints -from ..errors import MultiError, RPCError -from ..helpers import retry_range -from ..tl import TLRequest, types, functions - -_NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') - -if typing.TYPE_CHECKING: - from .telegramclient import TelegramClient - - -def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): - return ( - 'Sleeping%s for %ds (%s) on %s flood wait', - ' early' if early else '', - delay, - td(seconds=delay), - request.__class__.__name__ - ) - - -class UserMethods: - async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): - return await self._call(self._sender, request, ordered=ordered) - - async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): - if flood_sleep_threshold is None: - flood_sleep_threshold = self.flood_sleep_threshold - requests = (request if utils.is_list_like(request) else (request,)) - for r in requests: - if not isinstance(r, TLRequest): - raise _NOT_A_REQUEST() - await r.resolve(self, utils) - - # Avoid making the request if it's already in a flood wait - if r.CONSTRUCTOR_ID in self._flood_waited_requests: - due = self._flood_waited_requests[r.CONSTRUCTOR_ID] - diff = round(due - time.time()) - if diff <= 3: # Flood waits below 3 seconds are "ignored" - self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) - elif diff <= flood_sleep_threshold: - self._log[__name__].info(*_fmt_flood(diff, r, early=True)) - await asyncio.sleep(diff) - self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) - else: - raise errors.FloodWaitError(request=r, capture=diff) - - if self._no_updates: - r = functions.InvokeWithoutUpdatesRequest(r) - - request_index = 0 - last_error = None - self._last_request = time.time() - - for attempt in retry_range(self._request_retries): - try: - future = sender.send(request, ordered=ordered) - if isinstance(future, list): - results = [] - exceptions = [] - for f in future: - try: - result = await f - except RPCError as e: - exceptions.append(e) - results.append(None) - continue - self.session.process_entities(result) - self._entity_cache.add(result) - exceptions.append(None) - results.append(result) - request_index += 1 - if any(x is not None for x in exceptions): - raise MultiError(exceptions, results, requests) - else: - return results - else: - result = await future - self.session.process_entities(result) - self._entity_cache.add(result) - return result - except (errors.ServerError, errors.RpcCallFailError, - errors.RpcMcgetFailError, errors.InterdcCallErrorError, - errors.InterdcCallRichErrorError) as e: - last_error = e - self._log[__name__].warning( - 'Telegram is having internal issues %s: %s', - e.__class__.__name__, e) - - await asyncio.sleep(2) - except (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e: - last_error = e - if utils.is_list_like(request): - request = request[request_index] - - # SLOW_MODE_WAIT is chat-specific, not request-specific - if not isinstance(e, errors.SlowModeWaitError): - 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) - else: - raise - except (errors.PhoneMigrateError, errors.NetworkMigrateError, - errors.UserMigrateError) as e: - last_error = e - self._log[__name__].info('Phone migrated to %d', e.new_dc) - should_raise = isinstance(e, ( - errors.PhoneMigrateError, errors.NetworkMigrateError - )) - if should_raise and await self.is_user_authorized(): - raise - await self._switch_dc(e.new_dc) - - if self._raise_last_call_error and last_error is not None: - raise last_error - raise ValueError('Request was unsuccessful {} time(s)' - .format(attempt)) - - # region Public methods - - async def get_me(self: 'TelegramClient', input_peer: bool = False) \ - -> 'typing.Union[types.User, types.InputPeerUser]': - """ - Gets "me", the current :tl:`User` who is logged in. - - If the user has not logged in yet, this method returns `None`. - - Arguments - input_peer (`bool`, optional): - Whether to return the :tl:`InputPeerUser` version or the normal - :tl:`User`. This can be useful if you just need to know the ID - of yourself. - - Returns - Your own :tl:`User`. - - Example - .. code-block:: python - - me = await client.get_me() - print(me.username) - """ - if input_peer and self._self_input_peer: - return self._self_input_peer - - try: - me = (await self( - functions.users.GetUsersRequest([types.InputUserSelf()])))[0] - - self._bot = me.bot - if not self._self_input_peer: - self._self_input_peer = utils.get_input_peer( - me, allow_self=False - ) - - return self._self_input_peer if input_peer else me - except errors.UnauthorizedError: - return None - - @property - def _self_id(self: 'TelegramClient') -> typing.Optional[int]: - """ - Returns the ID of the logged-in user, if known. - - This property is used in every update, and some like `updateLoginToken` - occur prior to login, so it gracefully handles when no ID is known yet. - """ - return self._self_input_peer.user_id if self._self_input_peer else None - - async def is_bot(self: 'TelegramClient') -> bool: - """ - Return `True` if the signed-in user is a bot, `False` otherwise. - - Example - .. code-block:: python - - if await client.is_bot(): - print('Beep') - else: - print('Hello') - """ - if self._bot is None: - self._bot = (await self.get_me()).bot - - return self._bot - - async def is_user_authorized(self: 'TelegramClient') -> bool: - """ - Returns `True` if the user is authorized (logged in). - - Example - .. code-block:: python - - if not await client.is_user_authorized(): - await client.send_code_request(phone) - code = input('enter code: ') - await client.sign_in(phone, code) - """ - if self._authorized is None: - try: - # Any request that requires authorization will work - await self(functions.updates.GetStateRequest()) - self._authorized = True - except errors.RPCError: - self._authorized = False - - return self._authorized - - async def get_entity( - self: 'TelegramClient', - entity: 'hints.EntitiesLike') -> 'hints.Entity': - """ - Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` - or :tl:`Channel`. You can also pass a list or iterable of entities, - and they will be efficiently fetched from the network. - - Arguments - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - If a username is given, **the username will be resolved** making - an API call every time. Resolving usernames is an expensive - operation and will start hitting flood waits around 50 usernames - in a short period of time. - - If you want to get the entity for a *cached* username, you should - first `get_input_entity(username) ` which will - use the cache), and then use `get_entity` with the result of the - previous call. - - Similar limits apply to invite links, and you should use their - ID instead. - - Using phone numbers (from people in your contact list), exact - names, integer IDs or :tl:`Peer` rely on a `get_input_entity` - first, which in turn needs the entity to be in cache, unless - a :tl:`InputPeer` was passed. - - Unsupported types will raise ``TypeError``. - - If the entity can't be found, ``ValueError`` will be raised. - - Returns - :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the - input entity. A list will be returned if more than one was given. - - Example - .. code-block:: python - - from telethon import utils - - me = await client.get_entity('me') - print(utils.get_display_name(me)) - - chat = await client.get_input_entity('username') - async for message in client.iter_messages(chat): - ... - - # Note that you could have used the username directly, but it's - # good to use get_input_entity if you will reuse it a lot. - async for message in client.iter_messages('username'): - ... - - # Note that for this to work the phone number must be in your contacts - some_id = await client.get_peer_id('+34123456789') - """ - single = not utils.is_list_like(entity) - if single: - entity = (entity,) - - # Group input entities by string (resolve username), - # input users (get users), input chat (get chats) and - # input channels (get channels) to get the most entities - # in the less amount of calls possible. - inputs = [] - for x in entity: - if isinstance(x, str): - inputs.append(x) - else: - inputs.append(await self.get_input_entity(x)) - - lists = { - helpers._EntityType.USER: [], - helpers._EntityType.CHAT: [], - helpers._EntityType.CHANNEL: [], - } - for x in inputs: - try: - lists[helpers._entity_type(x)].append(x) - except TypeError: - pass - - users = lists[helpers._EntityType.USER] - chats = lists[helpers._EntityType.CHAT] - channels = lists[helpers._EntityType.CHANNEL] - if users: - # GetUsersRequest has a limit of 200 per call - tmp = [] - while users: - curr, users = users[:200], users[200:] - tmp.extend(await self(functions.users.GetUsersRequest(curr))) - users = tmp - if chats: # TODO Handle chats slice? - chats = (await self( - functions.messages.GetChatsRequest([x.chat_id for x in chats]))).chats - if channels: - channels = (await self( - functions.channels.GetChannelsRequest(channels))).chats - - # Merge users, chats and channels into a single dictionary - id_entity = { - utils.get_peer_id(x): x - for x in itertools.chain(users, chats, channels) - } - - # We could check saved usernames and put them into the users, - # chats and channels list from before. While this would reduce - # the amount of ResolveUsername calls, it would fail to catch - # username changes. - result = [] - for x in inputs: - if isinstance(x, str): - result.append(await self._get_entity_from_string(x)) - elif not isinstance(x, types.InputPeerSelf): - result.append(id_entity[utils.get_peer_id(x)]) - else: - result.append(next( - u for u in id_entity.values() - if isinstance(u, types.User) and u.is_self - )) - - return result[0] if single else result - - async def get_input_entity( - self: 'TelegramClient', - peer: 'hints.EntityLike') -> 'types.TypeInputPeer': - """ - Turns the given entity into its input entity version. - - Most requests use this kind of :tl:`InputPeer`, so this is the most - suitable call to make for those cases. **Generally you should let the - library do its job** and don't worry about getting the input entity - first, but if you're going to use an entity often, consider making the - call: - - Arguments - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - If a username or invite link is given, **the library will - use the cache**. This means that it's possible to be using - a username that *changed* or an old invite link (this only - happens if an invite link for a small group chat is used - after it was upgraded to a mega-group). - - If the username or ID from the invite link is not found in - the cache, it will be fetched. The same rules apply to phone - numbers (``'+34 123456789'``) from people in your contact list. - - If an exact name is given, it must be in the cache too. This - is not reliable as different people can share the same name - and which entity is returned is arbitrary, and should be used - only for quick tests. - - If a positive integer ID is given, the entity will be searched - in cached users, chats or channels, without making any call. - - If a negative integer ID is given, the entity will be searched - exactly as either a chat (prefixed with ``-``) or as a channel - (prefixed with ``-100``). - - If a :tl:`Peer` is given, it will be searched exactly in the - cache as either a user, chat or channel. - - If the given object can be turned into an input entity directly, - said operation will be done. - - Unsupported types will raise ``TypeError``. - - If the entity can't be found, ``ValueError`` will be raised. - - Returns - :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` - or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. - - If you need to get the ID of yourself, you should use - `get_me` with ``input_peer=True``) instead. - - Example - .. code-block:: python - - # If you're going to use "username" often in your code - # (make a lot of calls), consider getting its input entity - # once, and then using the "user" everywhere instead. - user = await client.get_input_entity('username') - - # The same applies to IDs, chats or channels. - chat = await client.get_input_entity(-123456789) - """ - # Short-circuit if the input parameter directly maps to an InputPeer - try: - return utils.get_input_peer(peer) - except TypeError: - pass - - # Next in priority is having a peer (or its ID) cached in-memory - try: - # 0x2d45687 == crc32(b'Peer') - if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687: - return self._entity_cache[peer] - except (AttributeError, KeyError): - pass - - # Then come known strings that take precedence - if peer in ('me', 'self'): - return types.InputPeerSelf() - - # No InputPeer, cached peer, or known string. Fetch from disk cache - try: - return self.session.get_input_entity(peer) - except ValueError: - pass - - # Only network left to try - if isinstance(peer, str): - return utils.get_input_peer( - await self._get_entity_from_string(peer)) - - # If we're a bot and the user has messaged us privately users.getUsers - # will work with access_hash = 0. Similar for channels.getChannels. - # If we're not a bot but the user is in our contacts, it seems to work - # regardless. These are the only two special-cased requests. - peer = utils.get_peer(peer) - if isinstance(peer, types.PeerUser): - users = await self(functions.users.GetUsersRequest([ - types.InputUser(peer.user_id, access_hash=0)])) - if users and not isinstance(users[0], types.UserEmpty): - # If the user passed a valid ID they expect to work for - # channels but would be valid for users, we get UserEmpty. - # Avoid returning the invalid empty input peer for that. - # - # We *could* try to guess if it's a channel first, and if - # it's not, work as a chat and try to validate it through - # another request, but that becomes too much work. - return utils.get_input_peer(users[0]) - elif isinstance(peer, types.PeerChat): - return types.InputPeerChat(peer.chat_id) - elif isinstance(peer, types.PeerChannel): - try: - channels = await self(functions.channels.GetChannelsRequest([ - types.InputChannel(peer.channel_id, access_hash=0)])) - return utils.get_input_peer(channels.chats[0]) - except errors.ChannelInvalidError: - pass - - raise ValueError( - 'Could not find the input entity for {} ({}). Please read https://' - 'docs.telethon.dev/en/latest/concepts/entities.html to' - ' find out more details.' - .format(peer, type(peer).__name__) - ) - - async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): - i, cls = utils.resolve_id(await self.get_peer_id(peer)) - return cls(i) - - async def get_peer_id( - self: 'TelegramClient', - peer: 'hints.EntityLike', - add_mark: bool = True) -> int: - """ - Gets the ID for the given entity. - - This method needs to be ``async`` because `peer` supports usernames, - invite-links, phone numbers (from people in your contact list), etc. - - If ``add_mark is False``, then a positive ID will be returned - instead. By default, bot-API style IDs (signed) are returned. - - Example - .. code-block:: python - - print(await client.get_peer_id('me')) - """ - if isinstance(peer, int): - return utils.get_peer_id(peer, add_mark=add_mark) - - try: - if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): - # 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer' - peer = await self.get_input_entity(peer) - except AttributeError: - peer = await self.get_input_entity(peer) - - if isinstance(peer, types.InputPeerSelf): - peer = await self.get_me(input_peer=True) - - return utils.get_peer_id(peer, add_mark=add_mark) - - # endregion - - # region Private methods - - async def _get_entity_from_string(self: 'TelegramClient', string): - """ - Gets a full entity from the given string, which may be a phone or - a username, and processes all the found entities on the session. - The string may also be a user link, or a channel/chat invite link. - - This method has the side effect of adding the found users to the - session database, so it can be queried later without API calls, - if this option is enabled on the session. - - Returns the found entity, or raises TypeError if not found. - """ - phone = utils.parse_phone(string) - if phone: - try: - for user in (await self( - functions.contacts.GetContactsRequest(0))).users: - if user.phone == phone: - return user - except errors.BotMethodInvalidError: - raise ValueError('Cannot get entity by phone number as a ' - 'bot (try using integer IDs, not strings)') - elif string.lower() in ('me', 'self'): - return await self.get_me() - else: - username, is_join_chat = utils.parse_username(string) - if is_join_chat: - invite = await self( - functions.messages.CheckChatInviteRequest(username)) - - if isinstance(invite, types.ChatInvite): - raise ValueError( - 'Cannot get entity from a channel (or group) ' - 'that you are not part of. Join the group and retry' - ) - elif isinstance(invite, types.ChatInviteAlready): - return invite.chat - elif username: - try: - result = await self( - functions.contacts.ResolveUsernameRequest(username)) - except errors.UsernameNotOccupiedError as e: - raise ValueError('No user has "{}" as username' - .format(username)) from e - - try: - pid = utils.get_peer_id(result.peer, add_mark=False) - if isinstance(result.peer, types.PeerUser): - return next(x for x in result.users if x.id == pid) - else: - return next(x for x in result.chats if x.id == pid) - except StopIteration: - pass - try: - # Nobody with this username, maybe it's an exact name/title - return await self.get_entity( - self.session.get_input_entity(string)) - except ValueError: - pass - - raise ValueError( - 'Cannot find any entity corresponding to "{}"'.format(string) - ) - - async def _get_input_dialog(self: 'TelegramClient', dialog): - """ - Returns a :tl:`InputDialogPeer`. This is a bit tricky because - it may or not need access to the client to convert what's given - into an input entity. - """ - try: - if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') - dialog.peer = await self.get_input_entity(dialog.peer) - return dialog - elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return types.InputDialogPeer(dialog) - except AttributeError: - pass - - return types.InputDialogPeer(await self.get_input_entity(dialog)) - - async def _get_input_notify(self: 'TelegramClient', notify): - """ - Returns a :tl:`InputNotifyPeer`. This is a bit tricky because - it may or not need access to the client to convert what's given - into an input entity. - """ - try: - if notify.SUBCLASS_OF_ID == 0x58981615: - if isinstance(notify, types.InputNotifyPeer): - notify.peer = await self.get_input_entity(notify.peer) - return notify - except AttributeError: - pass - - return types.InputNotifyPeer(await self.get_input_entity(notify)) - - # endregion diff --git a/telethon/crypto/cdndecrypter.py b/telethon/crypto/cdndecrypter.py deleted file mode 100644 index dd615a5a..00000000 --- a/telethon/crypto/cdndecrypter.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -This module holds the CdnDecrypter utility class. -""" -from hashlib import sha256 - -from ..tl.functions.upload import GetCdnFileRequest, ReuploadCdnFileRequest -from ..tl.types.upload import CdnFileReuploadNeeded, CdnFile -from ..crypto import AESModeCTR -from ..errors import CdnFileTamperedError - - -class CdnDecrypter: - """ - Used when downloading a file results in a 'FileCdnRedirect' to - both prepare the redirect, decrypt the file as it downloads, and - ensure the file hasn't been tampered. https://core.telegram.org/cdn - """ - def __init__(self, cdn_client, file_token, cdn_aes, cdn_file_hashes): - """ - Initializes the CDN decrypter. - - :param cdn_client: a client connected to a CDN. - :param file_token: the token of the file to be used. - :param cdn_aes: the AES CTR used to decrypt the file. - :param cdn_file_hashes: the hashes the decrypted file must match. - """ - self.client = cdn_client - self.file_token = file_token - self.cdn_aes = cdn_aes - self.cdn_file_hashes = cdn_file_hashes - - @staticmethod - async def prepare_decrypter(client, cdn_client, cdn_redirect): - """ - Prepares a new CDN decrypter. - - :param client: a TelegramClient connected to the main servers. - :param cdn_client: a new client connected to the CDN. - :param cdn_redirect: the redirect file object that caused this call. - :return: (CdnDecrypter, first chunk file data) - """ - cdn_aes = AESModeCTR( - key=cdn_redirect.encryption_key, - # 12 first bytes of the IV..4 bytes of the offset (0, big endian) - iv=cdn_redirect.encryption_iv[:12] + bytes(4) - ) - - # We assume that cdn_redirect.cdn_file_hashes are ordered by offset, - # and that there will be enough of these to retrieve the whole file. - decrypter = CdnDecrypter( - cdn_client, cdn_redirect.file_token, - cdn_aes, cdn_redirect.cdn_file_hashes - ) - - cdn_file = await cdn_client(GetCdnFileRequest( - file_token=cdn_redirect.file_token, - offset=cdn_redirect.cdn_file_hashes[0].offset, - limit=cdn_redirect.cdn_file_hashes[0].limit - )) - if isinstance(cdn_file, CdnFileReuploadNeeded): - # We need to use the original client here - await client(ReuploadCdnFileRequest( - file_token=cdn_redirect.file_token, - request_token=cdn_file.request_token - )) - - # We want to always return a valid upload.CdnFile - cdn_file = decrypter.get_file() - else: - cdn_file.bytes = decrypter.cdn_aes.encrypt(cdn_file.bytes) - cdn_hash = decrypter.cdn_file_hashes.pop(0) - decrypter.check(cdn_file.bytes, cdn_hash) - - return decrypter, cdn_file - - def get_file(self): - """ - Calls GetCdnFileRequest and decrypts its bytes. - Also ensures that the file hasn't been tampered. - - :return: the CdnFile result. - """ - if self.cdn_file_hashes: - cdn_hash = self.cdn_file_hashes.pop(0) - cdn_file = self.client(GetCdnFileRequest( - self.file_token, cdn_hash.offset, cdn_hash.limit - )) - cdn_file.bytes = self.cdn_aes.encrypt(cdn_file.bytes) - self.check(cdn_file.bytes, cdn_hash) - else: - cdn_file = CdnFile(bytes(0)) - - return cdn_file - - @staticmethod - def check(data, cdn_hash): - """ - Checks the integrity of the given data. - Raises CdnFileTamperedError if the integrity check fails. - - :param data: the data to be hashed. - :param cdn_hash: the expected hash. - """ - if sha256(data).digest() != cdn_hash.hash: - raise CdnFileTamperedError() diff --git a/telethon/entitycache.py b/telethon/entitycache.py deleted file mode 100644 index b6d06b3c..00000000 --- a/telethon/entitycache.py +++ /dev/null @@ -1,147 +0,0 @@ -import inspect -import itertools - -from . import utils -from .tl import types - -# Which updates have the following fields? -_has_field = { - ('user_id', int): [], - ('chat_id', int): [], - ('channel_id', int): [], - ('peer', 'TypePeer'): [], - ('peer', 'TypeDialogPeer'): [], - ('message', 'TypeMessage'): [], -} - -# Note: We don't bother checking for some rare: -# * `UpdateChatParticipantAdd.inviter_id` integer. -# * `UpdateNotifySettings.peer` dialog peer. -# * `UpdatePinnedDialogs.order` list of dialog peers. -# * `UpdateReadMessagesContents.messages` list of messages. -# * `UpdateChatParticipants.participants` list of participants. -# -# There are also some uninteresting `update.message` of type string. - - -def _fill(): - for name in dir(types): - update = getattr(types, name) - if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e: - cid = update.CONSTRUCTOR_ID - sig = inspect.signature(update.__init__) - for param in sig.parameters.values(): - vec = _has_field.get((param.name, param.annotation)) - if vec is not None: - vec.append(cid) - - # Future-proof check: if the documentation format ever changes - # then we won't be able to pick the update types we are interested - # in, so we must make sure we have at least an update for each field - # which likely means we are doing it right. - if not all(_has_field.values()): - raise RuntimeError('FIXME: Did the init signature or updates change?') - - -# We use a function to avoid cluttering the globals (with name/update/cid/doc) -_fill() - - -class EntityCache: - """ - In-memory input entity cache, defaultdict-like behaviour. - """ - def add(self, entities): - """ - Adds the given entities to the cache, if they weren't saved before. - """ - if not utils.is_list_like(entities): - # Invariant: all "chats" and "users" are always iterables, - # and "user" never is (so we wrap it inside a list). - entities = itertools.chain( - getattr(entities, 'chats', []), - getattr(entities, 'users', []), - (hasattr(entities, 'user') and [entities.user]) or [] - ) - - for entity in entities: - try: - pid = utils.get_peer_id(entity) - if pid not in self.__dict__: - # Note: `get_input_peer` already checks for `access_hash` - self.__dict__[pid] = utils.get_input_peer(entity) - except TypeError: - pass - - def __getitem__(self, item): - """ - Gets the corresponding :tl:`InputPeer` for the given ID or peer, - or raises ``KeyError`` on any error (i.e. cannot be found). - """ - if not isinstance(item, int) or item < 0: - try: - return self.__dict__[utils.get_peer_id(item)] - except TypeError: - raise KeyError('Invalid key will not have entity') from None - - for cls in (types.PeerUser, types.PeerChat, types.PeerChannel): - result = self.__dict__.get(utils.get_peer_id(cls(item))) - if result: - return result - - raise KeyError('No cached entity for the given key') - - def clear(self): - """ - Clear the entity cache. - """ - self.__dict__.clear() - - def ensure_cached( - self, - update, - has_user_id=frozenset(_has_field[('user_id', int)]), - has_chat_id=frozenset(_has_field[('chat_id', int)]), - has_channel_id=frozenset(_has_field[('channel_id', int)]), - has_peer=frozenset(_has_field[('peer', 'TypePeer')] + _has_field[('peer', 'TypeDialogPeer')]), - has_message=frozenset(_has_field[('message', 'TypeMessage')]) - ): - """ - Ensures that all the relevant entities in the given update are cached. - """ - # This method is called pretty often and we want it to have the lowest - # overhead possible. For that, we avoid `isinstance` and constantly - # getting attributes out of `types.` by "caching" the constructor IDs - # in sets inside the arguments, and using local variables. - dct = self.__dict__ - cid = update.CONSTRUCTOR_ID - if cid in has_user_id and \ - update.user_id not in dct: - return False - - if cid in has_chat_id and \ - utils.get_peer_id(types.PeerChat(update.chat_id)) not in dct: - return False - - if cid in has_channel_id and \ - utils.get_peer_id(types.PeerChannel(update.channel_id)) not in dct: - return False - - if cid in has_peer and \ - utils.get_peer_id(update.peer) not in dct: - return False - - if cid in has_message: - x = update.message - y = getattr(x, 'peer_id', None) # handle MessageEmpty - if y and utils.get_peer_id(y) not in dct: - return False - - y = getattr(x, 'from_id', None) - if y and utils.get_peer_id(y) not in dct: - return False - - # We don't quite worry about entities anywhere else. - # This is enough. - - return True diff --git a/telethon/enums.py b/telethon/enums.py new file mode 100644 index 00000000..ef7715cc --- /dev/null +++ b/telethon/enums.py @@ -0,0 +1,6 @@ +from ._misc.enums import ( + ConnectionMode, + Participant, + Action, + Size, +) diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index f6bc16e5..0d4ea0cc 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -1,46 +1,28 @@ -""" -This module holds all the base and automatically generated errors that the -Telegram API has. See telethon_generator/errors.json for more. -""" -import re - -from .common import ( - ReadCancelledError, TypeNotFoundError, InvalidChecksumError, - InvalidBufferError, SecurityError, CdnFileTamperedError, - AlreadyInConversationError, BadMessageError, MultiError +from ._custom import ( + ReadCancelledError, + TypeNotFoundError, + InvalidChecksumError, + InvalidBufferError, + SecurityError, + CdnFileTamperedError, + BadMessageError, + MultiError, +) +from ._rpcbase import ( + RpcError, + InvalidDcError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + AuthKeyError, + FloodError, + ServerError, + BotTimeout, + TimedOutError, + _mk_error_type ) -# This imports the base errors too, as they're imported there -from .rpcbaseerrors import * -from .rpcerrorlist import * - - -def rpc_message_to_error(rpc_error, request): - """ - Converts a Telegram's RPC Error to a Python error. - - :param rpc_error: the RpcError instance. - :param request: the request that caused this error. - :return: the RPCError as a Python exception that represents this error. - """ - # Try to get the error by direct look-up, otherwise regex - # Case-insensitive, for things like "timeout" which don't conform. - cls = rpc_errors_dict.get(rpc_error.error_message.upper(), None) - if cls: - return cls(request=request) - - for msg_regex, cls in rpc_errors_re: - m = re.match(msg_regex, rpc_error.error_message) - if m: - capture = int(m.group(1)) if m.groups() else None - return cls(request=request, capture=capture) - - # Some errors are negative: - # * -500 for "No workers running", - # * -503 for "Timeout" - # - # We treat them as if they were positive, so -500 will be treated - # as a `ServerError`, etc. - cls = base_errors.get(abs(rpc_error.error_code), RPCError) - return cls(request=request, message=rpc_error.error_message, - code=rpc_error.error_code) +# https://www.python.org/dev/peps/pep-0562/ +def __getattr__(name): + return _mk_error_type(name=name) diff --git a/telethon/errors/common.py b/telethon/errors/_custom.py similarity index 93% rename from telethon/errors/common.py rename to telethon/errors/_custom.py index de7d95f8..f48cdc94 100644 --- a/telethon/errors/common.py +++ b/telethon/errors/_custom.py @@ -1,7 +1,7 @@ """Errors not related to the Telegram API itself""" import struct -from ..tl import TLRequest +from .. import _tl class ReadCancelledError(Exception): @@ -79,17 +79,6 @@ class CdnFileTamperedError(SecurityError): ) -class AlreadyInConversationError(Exception): - """ - Occurs when another exclusive conversation is opened in the same chat. - """ - def __init__(self): - super().__init__( - 'Cannot open exclusive conversation in a ' - 'chat that already has one open conversation' - ) - - class BadMessageError(Exception): """Occurs when handling a bad_message_notification.""" ErrorMessages = { @@ -149,7 +138,7 @@ class MultiError(Exception): raise TypeError( "Expected an exception object, not '%r'" % e ) - if not isinstance(req, TLRequest): + if not isinstance(req, _tl.TLRequest): raise TypeError( "Expected TLRequest object, not '%r'" % req ) diff --git a/telethon/errors/_rpcbase.py b/telethon/errors/_rpcbase.py new file mode 100644 index 00000000..c67c6ebf --- /dev/null +++ b/telethon/errors/_rpcbase.py @@ -0,0 +1,147 @@ +import re + +from ._generated import _captures, _descriptions +from .. import _tl + + +_NESTS_QUERY = ( + _tl.fn.InvokeAfterMsg, + _tl.fn.InvokeAfterMsgs, + _tl.fn.InitConnection, + _tl.fn.InvokeWithLayer, + _tl.fn.InvokeWithoutUpdates, + _tl.fn.InvokeWithMessagesRange, + _tl.fn.InvokeWithTakeout, +) + + +class RpcError(Exception): + def __init__(self, code, message, request=None): + # Special-case '2fa' to exclude the 2 from values + self.values = [int(x) for x in re.findall(r'-?\d+', re.sub(r'^2fa', '', message, flags=re.IGNORECASE))] + self.value = self.values[0] if self.values else None + + doc = self.__doc__ + if doc is None: + doc = ( + '\n Please report this error at https://github.com/LonamiWebs/Telethon/issues/3169' + '\n (the library is not aware of it yet and we would appreciate your help, thank you!)' + ) + elif not doc: + doc = '(no description available)' + elif self.value: + doc = re.sub(r'{(\w+)}', str(self.value), doc) + + super().__init__(f'{message}, code={code}{self._fmt_request(request)}: {doc}') + self.code = code + self.message = message + self.request = request + + @staticmethod + def _fmt_request(request): + if not request: + return '' + + n = 0 + reason = '' + while isinstance(request, _NESTS_QUERY): + n += 1 + reason += request.__class__.__name__ + '(' + request = request.query + reason += request.__class__.__name__ + ')' * n + + return ', request={}'.format(reason) + + def __reduce__(self): + return type(self), (self.request, self.message, self.code) + + +def _mk_error_type(*, name=None, code=None, doc=None, _errors={}) -> type: + if name is None and code is None: + raise ValueError('at least one of `name` or `code` must be provided') + + if name is not None: + # Special-case '2fa' to 'twofa' + name = re.sub(r'^2fa', 'twofa', name, flags=re.IGNORECASE) + + # Get canonical name + name = re.sub(r'[-_\d]', '', name).lower() + while name.endswith('error'): + name = name[:-len('error')] + + doc = _descriptions.get(name, doc) + capture_alias = _captures.get(name) + + d = {'__doc__': doc} + + if capture_alias: + d[capture_alias] = property( + fget=lambda s: s.value, + doc='Alias for `self.value`. Useful to make the code easier to follow.' + ) + + if (name, None) not in _errors: + _errors[(name, None)] = type(f'RpcError{name.title()}', (RpcError,), d) + + if code is not None: + # Pretend negative error codes are positive + code = str(abs(code)) + if (None, code) not in _errors: + _errors[(None, code)] = type(f'RpcError{code}', (RpcError,), {'__doc__': doc}) + + if (name, code) not in _errors: + specific = _errors[(name, None)] + base = _errors[(None, code)] + _errors[(name, code)] = type(f'RpcError{name.title()}{code}', (specific, base), {'__doc__': doc}) + + return _errors[(name, code)] + + +InvalidDcError = _mk_error_type(code=303, doc=""" + The request must be repeated, but directed to a different data center. +""") + +BadRequestError = _mk_error_type(code=400, doc=""" + The query contains errors. In the event that a request was created + using a form and contains user generated data, the user should be + notified that the data must be corrected before the query is repeated. +""") + +UnauthorizedError = _mk_error_type(code=401, doc=""" + There was an unauthorized attempt to use functionality available only + to authorized users. +""") + +ForbiddenError = _mk_error_type(code=403, doc=""" + Privacy violation. For example, an attempt to write a message to + someone who has blacklisted the current user. +""") + +NotFoundError = _mk_error_type(code=404, doc=""" + An attempt to invoke a non-existent object, such as a method. +""") + +AuthKeyError = _mk_error_type(code=406, doc=""" + Errors related to invalid authorization key, like + AUTH_KEY_DUPLICATED which can cause the connection to fail. +""") + +FloodError = _mk_error_type(code=420, doc=""" + The maximum allowed number of attempts to invoke the given method + with the given input parameters has been exceeded. For example, in an + attempt to request a large number of text messages (SMS) for the same + phone number. +""") + +# Witnessed as -500 for "No workers running" +ServerError = _mk_error_type(code=500, doc=""" + An internal server error occurred while a request was being processed + for example, there was a disruption while accessing a database or file + storage. +""") + +# Witnessed as -503 for "Timeout" +BotTimeout = TimedOutError = _mk_error_type(code=503, doc=""" + Clicking the inline buttons of bots that never (or take to long to) + call ``answerCallbackQuery`` will result in this "special" RPCError. +""") diff --git a/telethon/errors/rpcbaseerrors.py b/telethon/errors/rpcbaseerrors.py deleted file mode 100644 index eecc974f..00000000 --- a/telethon/errors/rpcbaseerrors.py +++ /dev/null @@ -1,131 +0,0 @@ -from ..tl import functions - -_NESTS_QUERY = ( - functions.InvokeAfterMsgRequest, - functions.InvokeAfterMsgsRequest, - functions.InitConnectionRequest, - functions.InvokeWithLayerRequest, - functions.InvokeWithoutUpdatesRequest, - functions.InvokeWithMessagesRangeRequest, - functions.InvokeWithTakeoutRequest, -) - -class RPCError(Exception): - """Base class for all Remote Procedure Call errors.""" - code = None - message = None - - def __init__(self, request, message, code=None): - super().__init__('RPCError {}: {}{}'.format( - code or self.code, message, self._fmt_request(request))) - - self.request = request - self.code = code - self.message = message - - @staticmethod - def _fmt_request(request): - n = 0 - reason = '' - while isinstance(request, _NESTS_QUERY): - n += 1 - reason += request.__class__.__name__ + '(' - request = request.query - reason += request.__class__.__name__ + ')' * n - - return ' (caused by {})'.format(reason) - - def __reduce__(self): - return type(self), (self.request, self.message, self.code) - - -class InvalidDCError(RPCError): - """ - The request must be repeated, but directed to a different data center. - """ - code = 303 - message = 'ERROR_SEE_OTHER' - - -class BadRequestError(RPCError): - """ - The query contains errors. In the event that a request was created - using a form and contains user generated data, the user should be - notified that the data must be corrected before the query is repeated. - """ - code = 400 - message = 'BAD_REQUEST' - - -class UnauthorizedError(RPCError): - """ - There was an unauthorized attempt to use functionality available only - to authorized users. - """ - code = 401 - message = 'UNAUTHORIZED' - - -class ForbiddenError(RPCError): - """ - Privacy violation. For example, an attempt to write a message to - someone who has blacklisted the current user. - """ - code = 403 - message = 'FORBIDDEN' - - -class NotFoundError(RPCError): - """ - An attempt to invoke a non-existent object, such as a method. - """ - code = 404 - message = 'NOT_FOUND' - - -class AuthKeyError(RPCError): - """ - Errors related to invalid authorization key, like - AUTH_KEY_DUPLICATED which can cause the connection to fail. - """ - code = 406 - message = 'AUTH_KEY' - - -class FloodError(RPCError): - """ - The maximum allowed number of attempts to invoke the given method - with the given input parameters has been exceeded. For example, in an - attempt to request a large number of text messages (SMS) for the same - phone number. - """ - code = 420 - message = 'FLOOD' - - -class ServerError(RPCError): - """ - An internal server error occurred while a request was being processed - for example, there was a disruption while accessing a database or file - storage. - """ - code = 500 # Also witnessed as -500 - message = 'INTERNAL' - - -class TimedOutError(RPCError): - """ - Clicking the inline buttons of bots that never (or take to long to) - call ``answerCallbackQuery`` will result in this "special" RPCError. - """ - code = 503 # Only witnessed as -503 - message = 'Timeout' - - -BotTimeout = TimedOutError - - -base_errors = {x.code: x for x in ( - InvalidDCError, BadRequestError, UnauthorizedError, ForbiddenError, - NotFoundError, AuthKeyError, FloodError, ServerError, TimedOutError -)} diff --git a/telethon/events.py b/telethon/events.py new file mode 100644 index 00000000..5dca03ae --- /dev/null +++ b/telethon/events.py @@ -0,0 +1,12 @@ +from ._events.base import StopPropagation, register, unregister, is_handler, list +from ._events.raw import Raw + +from ._events.album import Album +from ._events.chataction import ChatAction +from ._events.messagedeleted import MessageDeleted +from ._events.messageedited import MessageEdited +from ._events.messageread import MessageRead +from ._events.newmessage import NewMessage +from ._events.userupdate import UserUpdate +from ._events.callbackquery import CallbackQuery +from ._events.inlinequery import InlineQuery diff --git a/telethon/extensions/markdown.py b/telethon/extensions/markdown.py deleted file mode 100644 index f6d59106..00000000 --- a/telethon/extensions/markdown.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -Simple markdown parser which does not support nesting. Intended primarily -for use within the library, which attempts to handle emojies correctly, -since they seem to count as two characters and it's a bit strange. -""" -import re -import warnings - -from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text -from ..tl import TLObject -from ..tl.types import ( - MessageEntityBold, MessageEntityItalic, MessageEntityCode, - MessageEntityPre, MessageEntityTextUrl, MessageEntityMentionName, - MessageEntityStrike -) - -DEFAULT_DELIMITERS = { - '**': MessageEntityBold, - '__': MessageEntityItalic, - '~~': MessageEntityStrike, - '`': MessageEntityCode, - '```': MessageEntityPre -} - -DEFAULT_URL_RE = re.compile(r'\[([\S\s]+?)\]\((.+?)\)') -DEFAULT_URL_FORMAT = '[{0}]({1})' - - -def overlap(a, b, x, y): - return max(a, x) < min(b, y) - - -def parse(message, delimiters=None, url_re=None): - """ - Parses the given markdown message and returns its stripped representation - plus a list of the MessageEntity's that were found. - - :param message: the message with markdown-like syntax to be parsed. - :param delimiters: the delimiters to be used, {delimiter: type}. - :param url_re: the URL bytes regex to be used. Must have two groups. - :return: a tuple consisting of (clean message, [message entities]). - """ - if not message: - return message, [] - - if url_re is None: - url_re = DEFAULT_URL_RE - elif isinstance(url_re, str): - url_re = re.compile(url_re) - - if not delimiters: - if delimiters is not None: - return message, [] - delimiters = DEFAULT_DELIMITERS - - # Build a regex to efficiently test all delimiters at once. - # Note that the largest delimiter should go first, we don't - # want ``` to be interpreted as a single back-tick in a code block. - delim_re = re.compile('|'.join('({})'.format(re.escape(k)) - for k in sorted(delimiters, key=len, reverse=True))) - - # Cannot use a for loop because we need to skip some indices - i = 0 - result = [] - - # Work on byte level with the utf-16le encoding to get the offsets right. - # The offset will just be half the index we're at. - message = add_surrogate(message) - while i < len(message): - m = delim_re.match(message, pos=i) - - # Did we find some delimiter here at `i`? - if m: - delim = next(filter(None, m.groups())) - - # +1 to avoid matching right after (e.g. "****") - end = message.find(delim, i + len(delim) + 1) - - # Did we find the earliest closing tag? - if end != -1: - - # Remove the delimiter from the string - message = ''.join(( - message[:i], - message[i + len(delim):end], - message[end + len(delim):] - )) - - # Check other affected entities - for ent in result: - # If the end is after our start, it is affected - if ent.offset + ent.length > i: - # If the old start is also before ours, it is fully enclosed - if ent.offset <= i: - ent.length -= len(delim) * 2 - else: - ent.length -= len(delim) - - # Append the found entity - ent = delimiters[delim] - if ent == MessageEntityPre: - result.append(ent(i, end - i - len(delim), '')) # has 'lang' - else: - result.append(ent(i, end - i - len(delim))) - - # No nested entities inside code blocks - if ent in (MessageEntityCode, MessageEntityPre): - i = end - len(delim) - - continue - - elif url_re: - m = url_re.match(message, pos=i) - if m: - # Replace the whole match with only the inline URL text. - message = ''.join(( - message[:m.start()], - m.group(1), - message[m.end():] - )) - - delim_size = m.end() - m.start() - len(m.group()) - for ent in result: - # If the end is after our start, it is affected - if ent.offset + ent.length > m.start(): - ent.length -= delim_size - - result.append(MessageEntityTextUrl( - offset=m.start(), length=len(m.group(1)), - url=del_surrogate(m.group(2)) - )) - i += len(m.group(1)) - continue - - i += 1 - - message = strip_text(message, result) - return del_surrogate(message), result - - -def unparse(text, entities, delimiters=None, url_fmt=None): - """ - Performs the reverse operation to .parse(), effectively returning - markdown-like syntax given a normal text and its MessageEntity's. - - :param text: the text to be reconverted into markdown. - :param entities: the MessageEntity's applied to the text. - :return: a markdown-like text representing the combination of both inputs. - """ - if not text or not entities: - return text - - if not delimiters: - if delimiters is not None: - return text - delimiters = DEFAULT_DELIMITERS - - if url_fmt is not None: - warnings.warn('url_fmt is deprecated') # since it complicates everything *a lot* - - if isinstance(entities, TLObject): - entities = (entities,) - - text = add_surrogate(text) - delimiters = {v: k for k, v in delimiters.items()} - insert_at = [] - for entity in entities: - s = entity.offset - e = entity.offset + entity.length - delimiter = delimiters.get(type(entity), None) - if delimiter: - insert_at.append((s, delimiter)) - insert_at.append((e, delimiter)) - else: - url = None - if isinstance(entity, MessageEntityTextUrl): - url = entity.url - elif isinstance(entity, MessageEntityMentionName): - url = 'tg://user?id={}'.format(entity.user_id) - if url: - insert_at.append((s, '[')) - insert_at.append((e, ']({})'.format(url))) - - insert_at.sort(key=lambda t: t[0]) - while insert_at: - at, what = insert_at.pop() - - # If we are in the middle of a surrogate nudge the position by -1. - # Otherwise we would end up with malformed text and fail to encode. - # For example of bad input: "Hi \ud83d\ude1c" - # https://en.wikipedia.org/wiki/UTF-16#U+010000_to_U+10FFFF - while within_surrogate(text, at): - at += 1 - - text = text[:at] + what + text[at:] - - return del_surrogate(text) diff --git a/telethon/hints.py b/telethon/hints.py deleted file mode 100644 index 67f830b6..00000000 --- a/telethon/hints.py +++ /dev/null @@ -1,67 +0,0 @@ -import datetime -import typing - -from . import helpers -from .tl import types, custom - -Phone = str -Username = str -PeerID = int -Entity = typing.Union[types.User, types.Chat, types.Channel] -FullEntity = typing.Union[types.UserFull, types.messages.ChatFull, types.ChatFull, types.ChannelFull] - -EntityLike = typing.Union[ - Phone, - Username, - PeerID, - types.TypePeer, - types.TypeInputPeer, - Entity, - FullEntity -] -EntitiesLike = typing.Union[EntityLike, typing.Sequence[EntityLike]] - -ButtonLike = typing.Union[types.TypeKeyboardButton, custom.Button] -MarkupLike = typing.Union[ - types.TypeReplyMarkup, - ButtonLike, - typing.Sequence[ButtonLike], - typing.Sequence[typing.Sequence[ButtonLike]] -] - -TotalList = helpers.TotalList - -DateLike = typing.Optional[typing.Union[float, datetime.datetime, datetime.date, datetime.timedelta]] - -LocalPath = str -ExternalUrl = str -BotFileID = str -FileLike = typing.Union[ - LocalPath, - ExternalUrl, - BotFileID, - bytes, - typing.BinaryIO, - types.TypeMessageMedia, - types.TypeInputFile, - types.TypeInputFileLocation -] - -# Can't use `typing.Type` in Python 3.5.2 -# See https://github.com/python/typing/issues/266 -try: - OutFileLike = typing.Union[ - str, - typing.Type[bytes], - typing.BinaryIO - ] -except TypeError: - OutFileLike = typing.Union[ - str, - typing.BinaryIO - ] - -MessageLike = typing.Union[str, types.Message] -MessageIDLike = typing.Union[int, types.Message, types.TypeInputMessage] - -ProgressCallback = typing.Callable[[int, int], None] diff --git a/telethon/network/connection/__init__.py b/telethon/network/connection/__init__.py deleted file mode 100644 index 88771866..00000000 --- a/telethon/network/connection/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .connection import Connection -from .tcpfull import ConnectionTcpFull -from .tcpintermediate import ConnectionTcpIntermediate -from .tcpabridged import ConnectionTcpAbridged -from .tcpobfuscated import ConnectionTcpObfuscated -from .tcpmtproxy import ( - TcpMTProxy, - ConnectionTcpMTProxyAbridged, - ConnectionTcpMTProxyIntermediate, - ConnectionTcpMTProxyRandomizedIntermediate -) -from .http import ConnectionHttp diff --git a/telethon/network/connection/connection.py b/telethon/network/connection/connection.py deleted file mode 100644 index 8bff043d..00000000 --- a/telethon/network/connection/connection.py +++ /dev/null @@ -1,427 +0,0 @@ -import abc -import asyncio -import socket -import sys - -try: - import ssl as ssl_mod -except ImportError: - ssl_mod = None - -try: - import python_socks -except ImportError: - python_socks = None - -from ...errors import InvalidChecksumError -from ... import helpers - - -class Connection(abc.ABC): - """ - The `Connection` class is a wrapper around ``asyncio.open_connection``. - - Subclasses will implement different transport modes as atomic operations, - which this class eases doing since the exposed interface simply puts and - gets complete data payloads to and from queues. - - The only error that will raise from send and receive methods is - ``ConnectionError``, which will raise when attempting to send if - the client is disconnected (includes remote disconnections). - """ - # this static attribute should be redefined by `Connection` subclasses and - # should be one of `PacketCodec` implementations - packet_codec = None - - def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None): - self._ip = ip - self._port = port - self._dc_id = dc_id # only for MTProxy, it's an abstraction leak - self._log = loggers[__name__] - self._proxy = proxy - self._local_addr = local_addr - self._reader = None - self._writer = None - self._connected = False - self._send_task = None - self._recv_task = None - self._codec = None - self._obfuscation = None # TcpObfuscated and MTProxy - self._send_queue = asyncio.Queue(1) - self._recv_queue = asyncio.Queue(1) - - @staticmethod - def _wrap_socket_ssl(sock): - if ssl_mod is None: - raise RuntimeError( - 'Cannot use proxy that requires SSL ' - 'without the SSL module being available' - ) - - return ssl_mod.wrap_socket( - sock, - do_handshake_on_connect=True, - ssl_version=ssl_mod.PROTOCOL_SSLv23, - ciphers='ADH-AES256-SHA') - - @staticmethod - def _parse_proxy(proxy_type, addr, port, rdns=True, username=None, password=None): - if isinstance(proxy_type, str): - proxy_type = proxy_type.lower() - - # Always prefer `python_socks` when available - if python_socks: - from python_socks import ProxyType - - # We do the check for numerical values here - # to be backwards compatible with PySocks proxy format, - # (since socks.SOCKS5 == 2, socks.SOCKS4 == 1, socks.HTTP == 3) - if proxy_type == ProxyType.SOCKS5 or proxy_type == 2 or proxy_type == "socks5": - protocol = ProxyType.SOCKS5 - elif proxy_type == ProxyType.SOCKS4 or proxy_type == 1 or proxy_type == "socks4": - protocol = ProxyType.SOCKS4 - elif proxy_type == ProxyType.HTTP or proxy_type == 3 or proxy_type == "http": - protocol = ProxyType.HTTP - else: - raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) - - # This tuple must be compatible with `python_socks`' `Proxy.create()` signature - return protocol, addr, port, username, password, rdns - - else: - from socks import SOCKS5, SOCKS4, HTTP - - if proxy_type == 2 or proxy_type == "socks5": - protocol = SOCKS5 - elif proxy_type == 1 or proxy_type == "socks4": - protocol = SOCKS4 - elif proxy_type == 3 or proxy_type == "http": - protocol = HTTP - else: - raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) - - # This tuple must be compatible with `PySocks`' `socksocket.set_proxy()` signature - return protocol, addr, port, rdns, username, password - - async def _proxy_connect(self, timeout=None, local_addr=None): - if isinstance(self._proxy, (tuple, list)): - parsed = self._parse_proxy(*self._proxy) - elif isinstance(self._proxy, dict): - parsed = self._parse_proxy(**self._proxy) - else: - raise TypeError("Proxy of unknown format: {}".format(type(self._proxy))) - - # Always prefer `python_socks` when available - if python_socks: - # python_socks internal errors are not inherited from - # builtin IOError (just from Exception). Instead of adding those - # in exceptions clauses everywhere through the code, we - # rather monkey-patch them in place. - - python_socks._errors.ProxyError = ConnectionError - python_socks._errors.ProxyConnectionError = ConnectionError - python_socks._errors.ProxyTimeoutError = ConnectionError - - from python_socks.async_.asyncio import Proxy - - proxy = Proxy.create(*parsed) - - # WARNING: If `local_addr` is set we use manual socket creation, because, - # unfortunately, `Proxy.connect()` does not expose `local_addr` - # argument, so if we want to bind socket locally, we need to manually - # create, bind and connect socket, and then pass to `Proxy.connect()` method. - - if local_addr is None: - sock = await proxy.connect( - dest_host=self._ip, - dest_port=self._port, - timeout=timeout - ) - else: - # Here we start manual setup of the socket. - # The `address` represents the proxy ip and proxy port, - # not the destination one (!), because the socket - # connects to the proxy server, not destination server. - # IPv family is also checked on proxy address. - if ':' in proxy.proxy_host: - mode, address = socket.AF_INET6, (proxy.proxy_host, proxy.proxy_port, 0, 0) - else: - mode, address = socket.AF_INET, (proxy.proxy_host, proxy.proxy_port) - - # Create a non-blocking socket and bind it (if local address is specified). - sock = socket.socket(mode, socket.SOCK_STREAM) - sock.setblocking(False) - sock.bind(local_addr) - - # Actual TCP connection is performed here. - await asyncio.wait_for( - asyncio.get_event_loop().sock_connect(sock=sock, address=address), - timeout=timeout - ) - - # As our socket is already created and connected, - # this call sets the destination host/port and - # starts protocol negotiations with the proxy server. - sock = await proxy.connect( - dest_host=self._ip, - dest_port=self._port, - timeout=timeout, - _socket=sock - ) - - else: - import socks - - # Here `address` represents destination address (not proxy), because of - # the `PySocks` implementation of the connection routine. - # IPv family is checked on proxy address, not destination address. - if ':' in parsed[1]: - mode, address = socket.AF_INET6, (self._ip, self._port, 0, 0) - else: - mode, address = socket.AF_INET, (self._ip, self._port) - - # Setup socket, proxy, timeout and bind it (if necessary). - sock = socks.socksocket(mode, socket.SOCK_STREAM) - sock.set_proxy(*parsed) - sock.settimeout(timeout) - - if local_addr is not None: - sock.bind(local_addr) - - # Actual TCP connection and negotiation performed here. - await asyncio.wait_for( - asyncio.get_event_loop().sock_connect(sock=sock, address=address), - timeout=timeout - ) - - sock.setblocking(False) - - return sock - - async def _connect(self, timeout=None, ssl=None): - if self._local_addr is not None: - # NOTE: If port is not specified, we use 0 port - # to notify the OS that port should be chosen randomly - # from the available ones. - if isinstance(self._local_addr, tuple) and len(self._local_addr) == 2: - local_addr = self._local_addr - elif isinstance(self._local_addr, str): - local_addr = (self._local_addr, 0) - else: - raise ValueError("Unknown local address format: {}".format(self._local_addr)) - else: - local_addr = None - - if not self._proxy: - self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection( - host=self._ip, - port=self._port, - ssl=ssl, - local_addr=local_addr - ), timeout=timeout) - else: - # Proxy setup, connection and negotiation is performed here. - sock = await self._proxy_connect( - timeout=timeout, - local_addr=local_addr - ) - - # Wrap socket in SSL context (if provided) - if ssl: - sock = self._wrap_socket_ssl(sock) - - self._reader, self._writer = await asyncio.open_connection(sock=sock) - - self._codec = self.packet_codec(self) - self._init_conn() - await self._writer.drain() - - async def connect(self, timeout=None, ssl=None): - """ - Establishes a connection with the server. - """ - await self._connect(timeout=timeout, ssl=ssl) - self._connected = True - - 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): - """ - Disconnects from the server, and clears - pending outgoing and incoming messages. - """ - self._connected = False - - await helpers._cancel( - self._log, - send_task=self._send_task, - recv_task=self._recv_task - ) - - if self._writer: - self._writer.close() - if sys.version_info >= (3, 7): - try: - await self._writer.wait_closed() - except Exception as e: - # Disconnecting should never raise. Seen: - # * OSError: No route to host and - # * OSError: [Errno 32] Broken pipe - # * ConnectionResetError - self._log.info('%s during disconnect: %s', type(e), e) - - def send(self, data): - """ - Sends a packet of data through this connection mode. - - This method returns a coroutine. - """ - if not self._connected: - raise ConnectionError('Not connected') - - return self._send_queue.put(data) - - async def recv(self): - """ - Receives a packet of data through this connection mode. - - This method returns a coroutine. - """ - while self._connected: - result = await self._recv_queue.get() - if result: # None = sentinel value = keep trying - return result - - raise ConnectionError('Not connected') - - async def _send_loop(self): - """ - This loop is constantly popping items off the queue to send them. - """ - try: - while self._connected: - self._send(await self._send_queue.get()) - await self._writer.drain() - except asyncio.CancelledError: - pass - except Exception as e: - if isinstance(e, IOError): - self._log.info('The server closed the connection while sending') - else: - self._log.exception('Unexpected exception in the send loop') - - await self.disconnect() - - async def _recv_loop(self): - """ - This loop is constantly putting items on the queue as they're read. - """ - while self._connected: - try: - data = await self._recv() - except asyncio.CancelledError: - break - except Exception as e: - if isinstance(e, (IOError, asyncio.IncompleteReadError)): - msg = 'The server closed the connection' - self._log.info(msg) - elif isinstance(e, InvalidChecksumError): - msg = 'The server response had an invalid checksum' - self._log.info(msg) - else: - msg = 'Unexpected exception in the receive loop' - self._log.exception(msg) - - await self.disconnect() - - # Add a sentinel value to unstuck recv - if self._recv_queue.empty(): - self._recv_queue.put_nowait(None) - - break - - try: - await self._recv_queue.put(data) - except asyncio.CancelledError: - break - - def _init_conn(self): - """ - This method will be called after `connect` is called. - After this method finishes, the writer will be drained. - - Subclasses should make use of this if they need to send - data to Telegram to indicate which connection mode will - be used. - """ - if self._codec.tag: - self._writer.write(self._codec.tag) - - def _send(self, data): - self._writer.write(self._codec.encode_packet(data)) - - async def _recv(self): - return await self._codec.read_packet(self._reader) - - def __str__(self): - return '{}:{}/{}'.format( - self._ip, self._port, - self.__class__.__name__.replace('Connection', '') - ) - - -class ObfuscatedConnection(Connection): - """ - Base class for "obfuscated" connections ("obfuscated2", "mtproto proxy") - """ - """ - This attribute should be redefined by subclasses - """ - obfuscated_io = None - - def _init_conn(self): - self._obfuscation = self.obfuscated_io(self) - self._writer.write(self._obfuscation.header) - - def _send(self, data): - self._obfuscation.write(self._codec.encode_packet(data)) - - async def _recv(self): - return await self._codec.read_packet(self._obfuscation) - - -class PacketCodec(abc.ABC): - """ - Base class for packet codecs - """ - - """ - This attribute should be re-defined by subclass to define if some - "magic bytes" should be sent to server right after connection is made to - signal which protocol will be used - """ - tag = None - - def __init__(self, connection): - """ - Codec is created when connection is just made. - """ - self._conn = connection - - @abc.abstractmethod - def encode_packet(self, data): - """ - Encodes single packet and returns encoded bytes. - """ - raise NotImplementedError - - @abc.abstractmethod - async def read_packet(self, reader): - """ - Reads single packet from `reader` object that should have - `readexactly(n)` method. - """ - raise NotImplementedError diff --git a/telethon/network/connection/http.py b/telethon/network/connection/http.py deleted file mode 100644 index e2d976f7..00000000 --- a/telethon/network/connection/http.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -from .connection import Connection, PacketCodec - - -SSL_PORT = 443 - - -class HttpPacketCodec(PacketCodec): - tag = None - obfuscate_tag = None - - def encode_packet(self, data): - return ('POST /api HTTP/1.1\r\n' - 'Host: {}:{}\r\n' - 'Content-Type: application/x-www-form-urlencoded\r\n' - 'Connection: keep-alive\r\n' - 'Keep-Alive: timeout=100000, max=10000000\r\n' - 'Content-Length: {}\r\n\r\n' - .format(self._conn._ip, self._conn._port, len(data)) - .encode('ascii') + data) - - async def read_packet(self, reader): - while True: - line = await reader.readline() - if not line or line[-1] != b'\n': - raise asyncio.IncompleteReadError(line, None) - - if line.lower().startswith(b'content-length: '): - await reader.readexactly(2) - length = int(line[16:-2]) - return await reader.readexactly(length) - - -class ConnectionHttp(Connection): - packet_codec = HttpPacketCodec - - async def connect(self, timeout=None, ssl=None): - await super().connect(timeout=timeout, ssl=self._port == SSL_PORT) diff --git a/telethon/network/connection/tcpabridged.py b/telethon/network/connection/tcpabridged.py deleted file mode 100644 index 171b1d8c..00000000 --- a/telethon/network/connection/tcpabridged.py +++ /dev/null @@ -1,33 +0,0 @@ -import struct - -from .connection import Connection, PacketCodec - - -class AbridgedPacketCodec(PacketCodec): - tag = b'\xef' - obfuscate_tag = b'\xef\xef\xef\xef' - - def encode_packet(self, data): - length = len(data) >> 2 - if length < 127: - length = struct.pack('B', length) - else: - length = b'\x7f' + int.to_bytes(length, 3, 'little') - return length + data - - async def read_packet(self, reader): - length = struct.unpack('= 127: - length = struct.unpack( - ' 0: - return packet_with_padding[:-pad_size] - return packet_with_padding - - -class ConnectionTcpIntermediate(Connection): - """ - Intermediate mode between `ConnectionTcpFull` and `ConnectionTcpAbridged`. - Always sends 4 extra bytes for the packet length. - """ - packet_codec = IntermediatePacketCodec diff --git a/telethon/network/connection/tcpmtproxy.py b/telethon/network/connection/tcpmtproxy.py deleted file mode 100644 index 69a43bce..00000000 --- a/telethon/network/connection/tcpmtproxy.py +++ /dev/null @@ -1,152 +0,0 @@ -import asyncio -import hashlib -import os - -from .connection import ObfuscatedConnection -from .tcpabridged import AbridgedPacketCodec -from .tcpintermediate import ( - IntermediatePacketCodec, - RandomizedIntermediatePacketCodec -) - -from ...crypto import AESModeCTR - - -class MTProxyIO: - """ - It's very similar to tcpobfuscated.ObfuscatedIO, but the way - encryption keys, protocol tag and dc_id are encoded is different. - """ - header = None - - def __init__(self, connection): - self._reader = connection._reader - self._writer = connection._writer - - (self.header, - self._encrypt, - self._decrypt) = self.init_header( - connection._secret, connection._dc_id, connection.packet_codec) - - @staticmethod - def init_header(secret, dc_id, packet_codec): - # Validate - is_dd = (len(secret) == 17) and (secret[0] == 0xDD) - is_rand_codec = issubclass( - packet_codec, RandomizedIntermediatePacketCodec) - if is_dd and not is_rand_codec: - raise ValueError( - "Only RandomizedIntermediate can be used with dd-secrets") - secret = secret[1:] if is_dd else secret - if len(secret) != 16: - raise ValueError( - "MTProxy secret must be a hex-string representing 16 bytes") - - # Obfuscated messages secrets cannot start with any of these - keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') - while True: - random = os.urandom(64) - if (random[0] != 0xef and - random[:4] not in keywords and - random[4:4] != b'\0\0\0\0'): - break - - random = bytearray(random) - random_reversed = random[55:7:-1] # Reversed (8, len=48) - - # Encryption has "continuous buffer" enabled - encrypt_key = hashlib.sha256( - bytes(random[8:40]) + secret).digest() - encrypt_iv = bytes(random[40:56]) - decrypt_key = hashlib.sha256( - bytes(random_reversed[:32]) + secret).digest() - decrypt_iv = bytes(random_reversed[32:48]) - - encryptor = AESModeCTR(encrypt_key, encrypt_iv) - decryptor = AESModeCTR(decrypt_key, decrypt_iv) - - random[56:60] = packet_codec.obfuscate_tag - - dc_id_bytes = dc_id.to_bytes(2, "little", signed=True) - random = random[:60] + dc_id_bytes + random[62:] - random[56:64] = encryptor.encrypt(bytes(random))[56:64] - return (random, encryptor, decryptor) - - async def readexactly(self, n): - return self._decrypt.encrypt(await self._reader.readexactly(n)) - - def write(self, data): - self._writer.write(self._encrypt.encrypt(data)) - - -class TcpMTProxy(ObfuscatedConnection): - """ - Connector which allows user to connect to the Telegram via proxy servers - commonly known as MTProxy. - Implemented very ugly due to the leaky abstractions in Telethon networking - classes that should be refactored later (TODO). - - .. warning:: - - The support for TcpMTProxy classes is **EXPERIMENTAL** and prone to - be changed. You shouldn't be using this class yet. - """ - packet_codec = None - obfuscated_io = MTProxyIO - - # noinspection PyUnusedLocal - def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=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, loggers=loggers) - - async def _connect(self, timeout=None, ssl=None): - await super()._connect(timeout=timeout, ssl=ssl) - - # Wait for EOF for 2 seconds (or if _wait_for_data's definition - # is missing or different, just sleep for 2 seconds). This way - # we give the proxy a chance to close the connection if the current - # codec (which the proxy detects with the data we sent) cannot - # be used for this proxy. This is a work around for #1134. - # TODO Sleeping for N seconds may not be the best solution - # TODO This fix could be welcome for HTTP proxies as well - try: - await asyncio.wait_for(self._reader._wait_for_data('proxy'), 2) - except asyncio.TimeoutError: - pass - except Exception: - await asyncio.sleep(2) - - if self._reader.at_eof(): - await self.disconnect() - raise ConnectionError( - 'Proxy closed the connection after sending initial payload') - - @staticmethod - def address_info(proxy_info): - if proxy_info is None: - raise ValueError("No proxy info specified for MTProxy connection") - return proxy_info[:2] - - -class ConnectionTcpMTProxyAbridged(TcpMTProxy): - """ - Connect to proxy using abridged protocol - """ - packet_codec = AbridgedPacketCodec - - -class ConnectionTcpMTProxyIntermediate(TcpMTProxy): - """ - Connect to proxy using intermediate protocol - """ - packet_codec = IntermediatePacketCodec - - -class ConnectionTcpMTProxyRandomizedIntermediate(TcpMTProxy): - """ - Connect to proxy using randomized intermediate protocol (dd-secrets) - """ - packet_codec = RandomizedIntermediatePacketCodec diff --git a/telethon/network/connection/tcpobfuscated.py b/telethon/network/connection/tcpobfuscated.py deleted file mode 100644 index cf2e6af5..00000000 --- a/telethon/network/connection/tcpobfuscated.py +++ /dev/null @@ -1,62 +0,0 @@ -import os - -from .tcpabridged import AbridgedPacketCodec -from .connection import ObfuscatedConnection - -from ...crypto import AESModeCTR - - -class ObfuscatedIO: - header = None - - def __init__(self, connection): - self._reader = connection._reader - self._writer = connection._writer - - (self.header, - self._encrypt, - self._decrypt) = self.init_header(connection.packet_codec) - - @staticmethod - def init_header(packet_codec): - # Obfuscated messages secrets cannot start with any of these - keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') - while True: - random = os.urandom(64) - if (random[0] != 0xef and - random[:4] not in keywords and - random[4:8] != b'\0\0\0\0'): - break - - random = bytearray(random) - random_reversed = random[55:7:-1] # Reversed (8, len=48) - - # Encryption has "continuous buffer" enabled - encrypt_key = bytes(random[8:40]) - encrypt_iv = bytes(random[40:56]) - decrypt_key = bytes(random_reversed[:32]) - decrypt_iv = bytes(random_reversed[32:48]) - - encryptor = AESModeCTR(encrypt_key, encrypt_iv) - decryptor = AESModeCTR(decrypt_key, decrypt_iv) - - random[56:60] = packet_codec.obfuscate_tag - random[56:64] = encryptor.encrypt(bytes(random))[56:64] - return (random, encryptor, decryptor) - - async def readexactly(self, n): - return self._decrypt.encrypt(await self._reader.readexactly(n)) - - def write(self, data): - self._writer.write(self._encrypt.encrypt(data)) - - -class ConnectionTcpObfuscated(ObfuscatedConnection): - """ - Mode that Telegram defines as "obfuscated2". Encodes the packet - just like `ConnectionTcpAbridged`, but encrypts every message with - a randomly generated key using the AES-CTR mode so the packets are - harder to discern. - """ - obfuscated_io = ObfuscatedIO - packet_codec = AbridgedPacketCodec diff --git a/telethon/sessions.py b/telethon/sessions.py new file mode 100644 index 00000000..b4c9bf4f --- /dev/null +++ b/telethon/sessions.py @@ -0,0 +1,12 @@ +from ._sessions.types import ( + DataCenter, + SessionState, + ChannelState, + Entity, +) +from ._sessions import ( + Session, + MemorySession, + SQLiteSession, + StringSession, +) diff --git a/telethon/sessions/abstract.py b/telethon/sessions/abstract.py deleted file mode 100644 index 5fda1c18..00000000 --- a/telethon/sessions/abstract.py +++ /dev/null @@ -1,167 +0,0 @@ -from abc import ABC, abstractmethod - - -class Session(ABC): - def __init__(self): - pass - - def clone(self, to_instance=None): - """ - Creates a clone of this session file. - """ - return to_instance or self.__class__() - - @abstractmethod - def set_dc(self, dc_id, server_address, port): - """ - Sets the information of the data center address and port that - the library should connect to, as well as the data center ID, - which is currently unused. - """ - raise NotImplementedError - - @property - @abstractmethod - def dc_id(self): - """ - Returns the currently-used data center ID. - """ - raise NotImplementedError - - @property - @abstractmethod - def server_address(self): - """ - Returns the server address where the library should connect to. - """ - raise NotImplementedError - - @property - @abstractmethod - def port(self): - """ - Returns the port to which the library should connect to. - """ - raise NotImplementedError - - @property - @abstractmethod - def auth_key(self): - """ - Returns an ``AuthKey`` instance associated with the saved - data center, or `None` if a new one should be generated. - """ - raise NotImplementedError - - @auth_key.setter - @abstractmethod - def auth_key(self, value): - """ - Sets the ``AuthKey`` to be used for the saved data center. - """ - raise NotImplementedError - - @property - @abstractmethod - def takeout_id(self): - """ - Returns an ID of the takeout process initialized for this session, - or `None` if there's no were any unfinished takeout requests. - """ - raise NotImplementedError - - @takeout_id.setter - @abstractmethod - def takeout_id(self, value): - """ - Sets the ID of the unfinished takeout process for this session. - """ - raise NotImplementedError - - @abstractmethod - def get_update_state(self, entity_id): - """ - Returns the ``UpdateState`` associated with the given `entity_id`. - If the `entity_id` is 0, it should return the ``UpdateState`` for - no specific channel (the "general" state). If no state is known - it should ``return None``. - """ - raise NotImplementedError - - @abstractmethod - def set_update_state(self, entity_id, state): - """ - Sets the given ``UpdateState`` for the specified `entity_id`, which - should be 0 if the ``UpdateState`` is the "general" state (and not - for any specific channel). - """ - raise NotImplementedError - - @abstractmethod - def close(self): - """ - Called on client disconnection. Should be used to - free any used resources. Can be left empty if none. - """ - - @abstractmethod - def save(self): - """ - Called whenever important properties change. It should - make persist the relevant session information to disk. - """ - raise NotImplementedError - - @abstractmethod - def delete(self): - """ - Called upon client.log_out(). Should delete the stored - information from disk since it's not valid anymore. - """ - raise NotImplementedError - - @classmethod - def list_sessions(cls): - """ - Lists available sessions. Not used by the library itself. - """ - return [] - - @abstractmethod - def process_entities(self, tlo): - """ - Processes the input ``TLObject`` or ``list`` and saves - whatever information is relevant (e.g., ID or access hash). - """ - raise NotImplementedError - - @abstractmethod - def get_input_entity(self, key): - """ - Turns the given key into an ``InputPeer`` (e.g. ``InputPeerUser``). - The library uses this method whenever an ``InputPeer`` is needed - to suit several purposes (e.g. user only provided its ID or wishes - to use a cached username to avoid extra RPC). - """ - raise NotImplementedError - - @abstractmethod - def cache_file(self, md5_digest, file_size, instance): - """ - Caches the given file information persistently, so that it - doesn't need to be re-uploaded in case the file is used again. - - The ``instance`` will be either an ``InputPhoto`` or ``InputDocument``, - both with an ``.id`` and ``.access_hash`` attributes. - """ - raise NotImplementedError - - @abstractmethod - def get_file(self, md5_digest, file_size, cls): - """ - Returns an instance of ``cls`` if the ``md5_digest`` and ``file_size`` - match an existing saved record. The class will either be an - ``InputPhoto`` or ``InputDocument``, both with two parameters - ``id`` and ``access_hash`` in that order. - """ - raise NotImplementedError diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py deleted file mode 100644 index 1b1a6bfb..00000000 --- a/telethon/sessions/memory.py +++ /dev/null @@ -1,248 +0,0 @@ -from enum import Enum - -from .abstract import Session -from .. import utils -from ..tl import TLObject -from ..tl.types import ( - PeerUser, PeerChat, PeerChannel, - InputPeerUser, InputPeerChat, InputPeerChannel, - InputPhoto, InputDocument -) - - -class _SentFileType(Enum): - DOCUMENT = 0 - PHOTO = 1 - - @staticmethod - def from_type(cls): - if cls == InputDocument: - return _SentFileType.DOCUMENT - elif cls == InputPhoto: - return _SentFileType.PHOTO - else: - raise ValueError('The cls must be either InputDocument/InputPhoto') - - -class MemorySession(Session): - def __init__(self): - super().__init__() - - self._dc_id = 0 - self._server_address = None - self._port = None - self._auth_key = None - self._takeout_id = None - - self._files = {} - self._entities = set() - self._update_states = {} - - def set_dc(self, dc_id, server_address, port): - self._dc_id = dc_id or 0 - self._server_address = server_address - self._port = port - - @property - def dc_id(self): - return self._dc_id - - @property - def server_address(self): - return self._server_address - - @property - def port(self): - return self._port - - @property - def auth_key(self): - return self._auth_key - - @auth_key.setter - def auth_key(self, value): - self._auth_key = value - - @property - def takeout_id(self): - return self._takeout_id - - @takeout_id.setter - def takeout_id(self, value): - self._takeout_id = value - - def get_update_state(self, entity_id): - return self._update_states.get(entity_id, None) - - def set_update_state(self, entity_id, state): - self._update_states[entity_id] = state - - def close(self): - pass - - def save(self): - pass - - def delete(self): - pass - - @staticmethod - def _entity_values_to_row(id, hash, username, phone, name): - # While this is a simple implementation it might be overrode by, - # other classes so they don't need to implement the plural form - # of the method. Don't remove. - return id, hash, username, phone, name - - def _entity_to_row(self, e): - if not isinstance(e, TLObject): - return - try: - p = utils.get_input_peer(e, allow_self=False) - marked_id = utils.get_peer_id(p) - except TypeError: - # Note: `get_input_peer` already checks for non-zero `access_hash`. - # See issues #354 and #392. It also checks that the entity - # is not `min`, because its `access_hash` cannot be used - # anywhere (since layer 102, there are two access hashes). - return - - if isinstance(p, (InputPeerUser, InputPeerChannel)): - p_hash = p.access_hash - elif isinstance(p, InputPeerChat): - p_hash = 0 - else: - return - - username = getattr(e, 'username', None) or None - if username is not None: - username = username.lower() - phone = getattr(e, 'phone', None) - name = utils.get_display_name(e) or None - return self._entity_values_to_row( - marked_id, p_hash, username, phone, name - ) - - def _entities_to_rows(self, tlo): - if not isinstance(tlo, TLObject) and utils.is_list_like(tlo): - # This may be a list of users already for instance - entities = tlo - else: - entities = [] - if hasattr(tlo, 'user'): - entities.append(tlo.user) - if hasattr(tlo, 'chat'): - entities.append(tlo.chat) - if hasattr(tlo, 'chats') and utils.is_list_like(tlo.chats): - entities.extend(tlo.chats) - if hasattr(tlo, 'users') and utils.is_list_like(tlo.users): - entities.extend(tlo.users) - - rows = [] # Rows to add (id, hash, username, phone, name) - for e in entities: - row = self._entity_to_row(e) - if row: - rows.append(row) - return rows - - def process_entities(self, tlo): - self._entities |= set(self._entities_to_rows(tlo)) - - def get_entity_rows_by_phone(self, phone): - try: - return next((id, hash) for id, hash, _, found_phone, _ - in self._entities if found_phone == phone) - except StopIteration: - pass - - def get_entity_rows_by_username(self, username): - try: - return next((id, hash) for id, hash, found_username, _, _ - in self._entities if found_username == username) - except StopIteration: - pass - - def get_entity_rows_by_name(self, name): - try: - return next((id, hash) for id, hash, _, _, found_name - in self._entities if found_name == name) - except StopIteration: - pass - - def get_entity_rows_by_id(self, id, exact=True): - try: - if exact: - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id == id) - else: - ids = ( - utils.get_peer_id(PeerUser(id)), - utils.get_peer_id(PeerChat(id)), - utils.get_peer_id(PeerChannel(id)) - ) - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id in ids) - except StopIteration: - pass - - def get_input_entity(self, key): - try: - if key.SUBCLASS_OF_ID in (0xc91c90b6, 0xe669bf46, 0x40f202fd): - # hex(crc32(b'InputPeer', b'InputUser' and b'InputChannel')) - # We already have an Input version, so nothing else required - return key - # Try to early return if this key can be casted as input peer - return utils.get_input_peer(key) - except (AttributeError, TypeError): - # Not a TLObject or can't be cast into InputPeer - if isinstance(key, TLObject): - key = utils.get_peer_id(key) - exact = True - else: - exact = not isinstance(key, int) or key < 0 - - result = None - if isinstance(key, str): - phone = utils.parse_phone(key) - if phone: - result = self.get_entity_rows_by_phone(phone) - else: - username, invite = utils.parse_username(key) - if username and not invite: - result = self.get_entity_rows_by_username(username) - else: - tup = utils.resolve_invite_link(key)[1] - if tup: - result = self.get_entity_rows_by_id(tup, exact=False) - - elif isinstance(key, int): - result = self.get_entity_rows_by_id(key, exact) - - if not result and isinstance(key, str): - result = self.get_entity_rows_by_name(key) - - if result: - entity_id, entity_hash = result # unpack resulting tuple - entity_id, kind = utils.resolve_id(entity_id) - # removes the mark and returns type of entity - if kind == PeerUser: - return InputPeerUser(entity_id, entity_hash) - elif kind == PeerChat: - return InputPeerChat(entity_id) - elif kind == PeerChannel: - return InputPeerChannel(entity_id, entity_hash) - else: - raise ValueError('Could not find input entity with key ', key) - - def cache_file(self, md5_digest, file_size, instance): - if not isinstance(instance, (InputDocument, InputPhoto)): - raise TypeError('Cannot cache %s instance' % type(instance)) - key = (md5_digest, file_size, _SentFileType.from_type(type(instance))) - value = (instance.id, instance.access_hash) - self._files[key] = value - - def get_file(self, md5_digest, file_size, cls): - key = (md5_digest, file_size, _SentFileType.from_type(cls)) - try: - return cls(*self._files[key]) - except KeyError: - return None diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py deleted file mode 100644 index 82b2129d..00000000 --- a/telethon/sessions/sqlite.py +++ /dev/null @@ -1,354 +0,0 @@ -import datetime -import os -import time - -from telethon.tl import types -from .memory import MemorySession, _SentFileType -from .. import utils -from ..crypto import AuthKey -from ..tl.types import ( - InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel -) - -try: - import sqlite3 - sqlite3_err = None -except ImportError as e: - sqlite3 = None - sqlite3_err = type(e) - -EXTENSION = '.session' -CURRENT_VERSION = 7 # database version - - -class SQLiteSession(MemorySession): - """This session contains the required information to login into your - Telegram account. NEVER give the saved session file to anyone, since - they would gain instant access to all your messages and contacts. - - If you think the session has been compromised, close all the sessions - through an official Telegram client to revoke the authorization. - """ - - def __init__(self, session_id=None): - if sqlite3 is None: - raise sqlite3_err - - super().__init__() - self.filename = ':memory:' - self.save_entities = True - - if session_id: - self.filename = session_id - if not self.filename.endswith(EXTENSION): - self.filename += EXTENSION - - self._conn = None - c = self._cursor() - c.execute("select name from sqlite_master " - "where type='table' and name='version'") - if c.fetchone(): - # Tables already exist, check for the version - c.execute("select version from version") - version = c.fetchone()[0] - if version < CURRENT_VERSION: - self._upgrade_database(old=version) - c.execute("delete from version") - c.execute("insert into version values (?)", (CURRENT_VERSION,)) - self.save() - - # These values will be saved - c.execute('select * from sessions') - tuple_ = c.fetchone() - if tuple_: - self._dc_id, self._server_address, self._port, key, \ - self._takeout_id = tuple_ - self._auth_key = AuthKey(data=key) - - c.close() - else: - # Tables don't exist, create new ones - self._create_table( - c, - "version (version integer primary key)" - , - """sessions ( - dc_id integer primary key, - server_address text, - port integer, - auth_key blob, - takeout_id integer - )""" - , - """entities ( - id integer primary key, - hash integer not null, - username text, - phone integer, - name text, - date integer - )""" - , - """sent_files ( - md5_digest blob, - file_size integer, - type integer, - id integer, - hash integer, - primary key(md5_digest, file_size, type) - )""" - , - """update_state ( - id integer primary key, - pts integer, - qts integer, - date integer, - seq integer - )""" - ) - c.execute("insert into version values (?)", (CURRENT_VERSION,)) - self._update_session_table() - c.close() - self.save() - - def clone(self, to_instance=None): - cloned = super().clone(to_instance) - cloned.save_entities = self.save_entities - return cloned - - def _upgrade_database(self, old): - c = self._cursor() - if old == 1: - old += 1 - # old == 1 doesn't have the old sent_files so no need to drop - if old == 2: - old += 1 - # Old cache from old sent_files lasts then a day anyway, drop - c.execute('drop table sent_files') - self._create_table(c, """sent_files ( - md5_digest blob, - file_size integer, - type integer, - id integer, - hash integer, - primary key(md5_digest, file_size, type) - )""") - if old == 3: - old += 1 - self._create_table(c, """update_state ( - id integer primary key, - pts integer, - qts integer, - date integer, - seq integer - )""") - if old == 4: - old += 1 - c.execute("alter table sessions add column takeout_id integer") - if old == 5: - # Not really any schema upgrade, but potentially all access - # hashes for User and Channel are wrong, so drop them off. - old += 1 - c.execute('delete from entities') - if old == 6: - old += 1 - c.execute("alter table entities add column date integer") - - c.close() - - @staticmethod - def _create_table(c, *definitions): - for definition in definitions: - c.execute('create table {}'.format(definition)) - - # Data from sessions should be kept as properties - # not to fetch the database every time we need it - def set_dc(self, dc_id, server_address, port): - super().set_dc(dc_id, server_address, port) - self._update_session_table() - - # Fetch the auth_key corresponding to this data center - row = self._execute('select auth_key from sessions') - if row and row[0]: - self._auth_key = AuthKey(data=row[0]) - else: - self._auth_key = None - - @MemorySession.auth_key.setter - def auth_key(self, value): - self._auth_key = value - self._update_session_table() - - @MemorySession.takeout_id.setter - def takeout_id(self, value): - self._takeout_id = value - self._update_session_table() - - def _update_session_table(self): - c = self._cursor() - # While we can save multiple rows into the sessions table - # currently we only want to keep ONE as the tables don't - # tell us which auth_key's are usable and will work. Needs - # some more work before being able to save auth_key's for - # multiple DCs. Probably done differently. - c.execute('delete from sessions') - c.execute('insert or replace into sessions values (?,?,?,?,?)', ( - self._dc_id, - self._server_address, - self._port, - self._auth_key.key if self._auth_key else b'', - self._takeout_id - )) - c.close() - - def get_update_state(self, entity_id): - row = self._execute('select pts, qts, date, seq from update_state ' - 'where id = ?', entity_id) - if row: - pts, qts, date, seq = row - date = datetime.datetime.fromtimestamp( - date, tz=datetime.timezone.utc) - return types.updates.State(pts, qts, date, seq, unread_count=0) - - def set_update_state(self, entity_id, state): - self._execute('insert or replace into update_state values (?,?,?,?,?)', - entity_id, state.pts, state.qts, - state.date.timestamp(), state.seq) - - def save(self): - """Saves the current session object as session_user_id.session""" - # This is a no-op if there are no changes to commit, so there's - # no need for us to keep track of an "unsaved changes" variable. - if self._conn is not None: - self._conn.commit() - - def _cursor(self): - """Asserts that the connection is open and returns a cursor""" - if self._conn is None: - self._conn = sqlite3.connect(self.filename, - check_same_thread=False) - return self._conn.cursor() - - def _execute(self, stmt, *values): - """ - Gets a cursor, executes `stmt` and closes the cursor, - fetching one row afterwards and returning its result. - """ - c = self._cursor() - try: - return c.execute(stmt, values).fetchone() - finally: - c.close() - - def close(self): - """Closes the connection unless we're working in-memory""" - if self.filename != ':memory:': - if self._conn is not None: - self._conn.commit() - self._conn.close() - self._conn = None - - def delete(self): - """Deletes the current session file""" - if self.filename == ':memory:': - return True - try: - os.remove(self.filename) - return True - except OSError: - return False - - @classmethod - def list_sessions(cls): - """Lists all the sessions of the users who have ever connected - using this client and never logged out - """ - return [os.path.splitext(os.path.basename(f))[0] - for f in os.listdir('.') if f.endswith(EXTENSION)] - - # Entity processing - - def process_entities(self, tlo): - """ - Processes all the found entities on the given TLObject, - unless .save_entities is False. - """ - if not self.save_entities: - return - - rows = self._entities_to_rows(tlo) - if not rows: - return - - c = self._cursor() - try: - now_tup = (int(time.time()),) - rows = [row + now_tup for row in rows] - c.executemany( - 'insert or replace into entities values (?,?,?,?,?,?)', rows) - finally: - c.close() - - def get_entity_rows_by_phone(self, phone): - return self._execute( - 'select id, hash from entities where phone = ?', phone) - - def get_entity_rows_by_username(self, username): - c = self._cursor() - try: - results = c.execute( - 'select id, hash, date from entities where username = ?', - (username,) - ).fetchall() - - if not results: - return None - - # If there is more than one result for the same username, evict the oldest one - if len(results) > 1: - results.sort(key=lambda t: t[2] or 0) - c.executemany('update entities set username = null where id = ?', - [(t[0],) for t in results[:-1]]) - - return results[-1][0], results[-1][1] - finally: - c.close() - - def get_entity_rows_by_name(self, name): - return self._execute( - 'select id, hash from entities where name = ?', name) - - def get_entity_rows_by_id(self, id, exact=True): - if exact: - return self._execute( - 'select id, hash from entities where id = ?', id) - else: - return self._execute( - 'select id, hash from entities where id in (?,?,?)', - utils.get_peer_id(PeerUser(id)), - utils.get_peer_id(PeerChat(id)), - utils.get_peer_id(PeerChannel(id)) - ) - - # File processing - - def get_file(self, md5_digest, file_size, cls): - row = self._execute( - 'select id, hash from sent_files ' - 'where md5_digest = ? and file_size = ? and type = ?', - md5_digest, file_size, _SentFileType.from_type(cls).value - ) - if row: - # Both allowed classes have (id, access_hash) as parameters - return cls(row[0], row[1]) - - def cache_file(self, md5_digest, file_size, instance): - if not isinstance(instance, (InputDocument, InputPhoto)): - raise TypeError('Cannot cache %s instance' % type(instance)) - - self._execute( - 'insert or replace into sent_files values (?,?,?,?,?)', - md5_digest, file_size, - _SentFileType.from_type(type(instance)).value, - instance.id, instance.access_hash - ) diff --git a/telethon/statecache.py b/telethon/statecache.py deleted file mode 100644 index 0e02bbd4..00000000 --- a/telethon/statecache.py +++ /dev/null @@ -1,164 +0,0 @@ -import inspect - -from .tl import types - - -# Which updates have the following fields? -_has_channel_id = [] - - -# TODO EntityCache does the same. Reuse? -def _fill(): - for name in dir(types): - update = getattr(types, name) - if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e: - cid = update.CONSTRUCTOR_ID - sig = inspect.signature(update.__init__) - for param in sig.parameters.values(): - if param.name == 'channel_id' and param.annotation == int: - _has_channel_id.append(cid) - - if not _has_channel_id: - raise RuntimeError('FIXME: Did the init signature or updates change?') - - -# We use a function to avoid cluttering the globals (with name/update/cid/doc) -_fill() - - -class StateCache: - """ - In-memory update state cache, defaultdict-like behaviour. - """ - def __init__(self, initial, loggers): - # We only care about the pts and the date. By using a tuple which - # is lightweight and immutable we can easily copy them around to - # each update in case they need to fetch missing entities. - self._logger = loggers[__name__] - if initial: - self._pts_date = initial.pts, initial.date - else: - self._pts_date = None, None - - def reset(self): - self.__dict__.clear() - self._pts_date = None, None - - # TODO Call this when receiving responses too...? - def update( - self, - update, - *, - channel_id=None, - has_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateNewMessage, - types.UpdateDeleteMessages, - types.UpdateReadHistoryInbox, - types.UpdateReadHistoryOutbox, - types.UpdateWebPage, - types.UpdateReadMessagesContents, - types.UpdateEditMessage, - types.updates.State, - types.updates.DifferenceTooLong, - types.UpdateShortMessage, - types.UpdateShortChatMessage, - types.UpdateShortSentMessage - )), - has_date=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateUserPhoto, - types.UpdateEncryption, - types.UpdateEncryptedMessagesRead, - types.UpdateChatParticipantAdd, - types.updates.DifferenceEmpty, - types.UpdateShortMessage, - types.UpdateShortChatMessage, - types.UpdateShort, - types.UpdatesCombined, - types.Updates, - types.UpdateShortSentMessage, - )), - has_channel_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateChannelTooLong, - types.UpdateNewChannelMessage, - types.UpdateDeleteChannelMessages, - types.UpdateEditChannelMessage, - types.UpdateChannelWebPage, - types.updates.ChannelDifferenceEmpty, - types.updates.ChannelDifferenceTooLong, - types.updates.ChannelDifference - )), - check_only=False - ): - """ - Update the state with the given update. - """ - cid = update.CONSTRUCTOR_ID - if check_only: - return cid in has_pts or cid in has_date or cid in has_channel_pts - - if cid in has_pts: - if cid in has_date: - self._pts_date = update.pts, update.date - else: - self._pts_date = update.pts, self._pts_date[1] - elif cid in has_date: - self._pts_date = self._pts_date[0], update.date - - if cid in has_channel_pts: - if channel_id is None: - channel_id = self.get_channel_id(update) - - if channel_id is None: - self._logger.info( - 'Failed to retrieve channel_id from %s', update) - else: - self.__dict__[channel_id] = update.pts - - def get_channel_id( - self, - update, - has_channel_id=frozenset(_has_channel_id), - # Hardcoded because only some with message are for channels - has_message=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateNewChannelMessage, - types.UpdateEditChannelMessage - )) - ): - """ - Gets the **unmarked** channel ID from this update, if it has any. - - Fails for ``*difference`` updates, where ``channel_id`` - is supposedly already known from the outside. - """ - cid = update.CONSTRUCTOR_ID - if cid in has_channel_id: - return update.channel_id - elif cid in has_message: - if update.message.peer_id is None: - # Telegram sometimes sends empty messages to give a newer pts: - # UpdateNewChannelMessage(message=MessageEmpty(id), pts=pts, pts_count=1) - # Not sure why, but it's safe to ignore them. - self._logger.debug('Update has None peer_id %s', update) - else: - return update.message.peer_id.channel_id - - return None - - def __getitem__(self, item): - """ - If `item` is `None`, returns the default ``(pts, date)``. - - If it's an **unmarked** channel ID, returns its ``pts``. - - If no information is known, ``pts`` will be `None`. - """ - if item is None: - return self._pts_date - else: - return self.__dict__.get(item) - - def __setitem__(self, where, value): - if where is None: - self._pts_date = value - else: - self.__dict__[where] = value diff --git a/telethon/sync.py b/telethon/sync.py deleted file mode 100644 index 80b80bea..00000000 --- a/telethon/sync.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -This magical module will rewrite all public methods in the public interface -of the library so they can run the loop on their own if it's not already -running. This rewrite may not be desirable if the end user always uses the -methods they way they should be ran, but it's incredibly useful for quick -scripts and the runtime overhead is relatively low. - -Some really common methods which are hardly used offer this ability by -default, such as ``.start()`` and ``.run_until_disconnected()`` (since -you may want to start, and then run until disconnected while using async -event handlers). -""" -import asyncio -import functools -import inspect - -from . import events, errors, utils, connection -from .client.account import _TakeoutClient -from .client.telegramclient import TelegramClient -from .tl import types, functions, custom -from .tl.custom import ( - Draft, Dialog, MessageButton, Forward, Button, - Message, InlineResult, Conversation -) -from .tl.custom.chatgetter import ChatGetter -from .tl.custom.sendergetter import SenderGetter - - -def _syncify_wrap(t, method_name): - method = getattr(t, method_name) - - @functools.wraps(method) - def syncified(*args, **kwargs): - coro = method(*args, **kwargs) - loop = asyncio.get_event_loop() - if loop.is_running(): - return coro - else: - return loop.run_until_complete(coro) - - # Save an accessible reference to the original method - setattr(syncified, '__tl.sync', method) - setattr(t, method_name, syncified) - - -def syncify(*types): - """ - Converts all the methods in the given types (class definitions) - into synchronous, which return either the coroutine or the result - based on whether ``asyncio's`` event loop is running. - """ - # Our asynchronous generators all are `RequestIter`, which already - # provide a synchronous iterator variant, so we don't need to worry - # about asyncgenfunction's here. - for t in types: - for name in dir(t): - if not name.startswith('_') or name == '__call__': - if inspect.iscoroutinefunction(getattr(t, name)): - _syncify_wrap(t, name) - - -syncify(TelegramClient, _TakeoutClient, Draft, Dialog, MessageButton, - ChatGetter, SenderGetter, Forward, Message, InlineResult, Conversation) - - -# Private special case, since a conversation's methods return -# futures (but the public function themselves are synchronous). -_syncify_wrap(Conversation, '_get_result') - -__all__ = [ - 'TelegramClient', 'Button', - 'types', 'functions', 'custom', 'errors', - 'events', 'utils', 'connection' -] diff --git a/telethon/tl/__init__.py b/telethon/tl/__init__.py deleted file mode 100644 index e187537f..00000000 --- a/telethon/tl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .tlobject import TLObject, TLRequest diff --git a/telethon/tl/custom/conversation.py b/telethon/tl/custom/conversation.py deleted file mode 100644 index 6cb973d4..00000000 --- a/telethon/tl/custom/conversation.py +++ /dev/null @@ -1,529 +0,0 @@ -import asyncio -import functools -import inspect -import itertools -import time - -from .chatgetter import ChatGetter -from ... import helpers, utils, errors - -# Sometimes the edits arrive very fast (within the same second). -# In that case we add a small delta so that the age is older, for -# comparision purposes. This value is enough for up to 1000 messages. -_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. - - A conversation keeps track of new messages since it was - created until its exit and easily lets you query the - current state. - - If you need a conversation across two or more chats, - you should use two conversations and synchronize them - as you better see fit. - """ - _id_counter = 0 - _custom_counter = 0 - - def __init__(self, client, input_chat, - *, timeout, total_timeout, max_messages, - exclusive, replies_are_responses): - # This call resets the client - ChatGetter.__init__(self, input_chat=input_chat) - - self._id = Conversation._id_counter - Conversation._id_counter += 1 - - self._client = client - self._timeout = timeout - self._total_timeout = total_timeout - self._total_due = None - - self._outgoing = set() - self._last_outgoing = 0 - self._incoming = [] - self._last_incoming = 0 - self._max_incoming = max_messages - self._last_read = None - self._custom = {} - - self._pending_responses = {} - self._pending_replies = {} - self._pending_edits = {} - self._pending_reads = {} - - self._exclusive = exclusive - self._cancelled = False - - # The user is able to expect two responses for the same message. - # {desired message ID: next incoming index} - self._response_indices = {} - if replies_are_responses: - self._reply_indices = self._response_indices - else: - self._reply_indices = {} - - self._edit_dates = {} - - @_checks_cancelled - async def send_message(self, *args, **kwargs): - """ - Sends a message in the context of this conversation. Shorthand - for `telethon.client.messages.MessageMethods.send_message` with - ``entity`` already set. - """ - sent = await self._client.send_message( - self._input_chat, *args, **kwargs) - - # Albums will be lists, so handle that - ms = sent if isinstance(sent, list) else (sent,) - self._outgoing.update(m.id for m in ms) - 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 - for `telethon.client.uploads.UploadMethods.send_file` with - ``entity`` already set. - """ - sent = await self._client.send_file( - self._input_chat, *args, **kwargs) - - # Albums will be lists, so handle that - ms = sent if isinstance(sent, list) else (sent,) - self._outgoing.update(m.id for m in ms) - 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``. - Otherwise, marks as read until the given message (or message ID). - - This is equivalent to calling `client.send_read_acknowledge - `. - """ - if message is None: - if self._incoming: - message = self._incoming[-1].id - else: - message = 0 - elif not isinstance(message, int): - message = message.id - - return self._client.send_read_acknowledge( - self._input_chat, max_id=message) - - def get_response(self, message=None, *, timeout=None): - """ - 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): - The message (or the message ID) for which a response - is expected. By default this is the last sent message. - - 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, - lambda x, y: True - ) - - def get_reply(self, message=None, *, timeout=None): - """ - Gets the next message that explicitly replies to a previous one. - """ - return self._get_message( - message, self._reply_indices, self._pending_replies, timeout, - lambda x, y: x.reply_to and x.reply_to.reply_to_msg_id == y - ) - - def _get_message( - self, target_message, indices, pending, timeout, condition): - """ - Gets the next desired message under the desired condition. - - Args: - target_message (`object`): - The target message for which we want to find another - response that applies based on `condition`. - - indices (`dict`): - This dictionary remembers the last ID chosen for the - input `target_message`. - - pending (`dict`): - This dictionary remembers {msg_id: Future} to be set - once `condition` is met. - - timeout (`int`): - The timeout (in seconds) override to use for this operation. - - condition (`callable`): - The condition callable that checks if an incoming - message is a valid response. - """ - start_time = time.time() - target_id = self._get_message_id(target_message) - - # If there is no last-chosen ID, make sure to pick one *after* - # the input message, since we don't want responses back in time - if target_id not in indices: - for i, incoming in enumerate(self._incoming): - if incoming.id > target_id: - indices[target_id] = i - break - else: - indices[target_id] = len(self._incoming) - - # We will always return a future from here, even if the result - # can be set immediately. Otherwise, needing to await only - # sometimes is an annoying edge case (i.e. we would return - # a `Message` but `get_response()` always `await`'s). - future = self._client.loop.create_future() - - # If there are enough responses saved return the next one - last_idx = indices[target_id] - if last_idx < len(self._incoming): - incoming = self._incoming[last_idx] - if condition(incoming, target_id): - indices[target_id] += 1 - future.set_result(incoming) - return future - - # Otherwise the next incoming response will be the one to use - # - # Note how we fill "pending" before giving control back to the - # event loop through "await". We want to register it as soon as - # possible, since any other task switch may arrive with the result. - pending[target_id] = future - return self._get_result(future, start_time, timeout, pending, target_id) - - def get_edit(self, message=None, *, timeout=None): - """ - Awaits for an edit after the last message to arrive. - The arguments are the same as those for `get_response`. - """ - start_time = time.time() - target_id = self._get_message_id(message) - - target_date = self._edit_dates.get(target_id, 0) - earliest_edit = min( - (x for x in self._incoming - if x.edit_date - and x.id > target_id - and x.edit_date.timestamp() > target_date - ), - key=lambda x: x.edit_date.timestamp(), - default=None - ) - - future = self._client.loop.create_future() - if earliest_edit and earliest_edit.edit_date.timestamp() > target_date: - self._edit_dates[target_id] = earliest_edit.edit_date.timestamp() - future.set_result(earliest_edit) - return future # we should always return something we can await - - # Otherwise the next incoming response will be the one to use - self._pending_edits[target_id] = future - return self._get_result(future, start_time, timeout, self._pending_edits, target_id) - - def wait_read(self, message=None, *, timeout=None): - """ - Awaits for the sent message to be marked as read. Note that - receiving a response doesn't imply the message was read, and - this action will also trigger even without a response. - """ - start_time = time.time() - future = self._client.loop.create_future() - target_id = self._get_message_id(message) - - if self._last_read is None: - self._last_read = target_id - 1 - - if self._last_read >= target_id: - return - - self._pending_reads[target_id] = future - return self._get_result(future, start_time, timeout, self._pending_reads, target_id) - - async def wait_event(self, event, *, timeout=None): - """ - Waits for a custom event to occur. Timeouts still apply. - - .. note:: - - **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. In this example you will see how to wait for a user - to join a group with proper use of `wait_event`: - - .. 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 - registered. It depends on your use case since this - also means the event can arrive before you send - a previous action. - """ - start_time = time.time() - if isinstance(event, type): - event = event() - - await event.resolve(self._client) - - counter = Conversation._custom_counter - Conversation._custom_counter += 1 - - future = self._client.loop.create_future() - self._custom[counter] = (event, future) - try: - return await self._get_result(future, start_time, timeout, self._custom, counter) - finally: - # Need to remove it from the dict if it times out, else we may - # try and fail to set the result later (#1618). - self._custom.pop(counter, None) - - async def _check_custom(self, built): - for key, (ev, fut) in list(self._custom.items()): - ev_type = type(ev) - inst = built[ev_type] - - 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 - if response.chat_id != self.chat_id or response.out: - return - - if len(self._incoming) == self._max_incoming: - self._cancel_all(ValueError('Too many incoming messages')) - return - - self._incoming.append(response) - - # Most of the time, these dictionaries will contain just one item - # TODO In fact, why not make it be that way? Force one item only. - # How often will people want to wait for two responses at - # the same time? It's impossible, first one will arrive - # and then another, so they can do that. - for msg_id, future in list(self._pending_responses.items()): - self._response_indices[msg_id] = len(self._incoming) - future.set_result(response) - del self._pending_responses[msg_id] - - for msg_id, future in list(self._pending_replies.items()): - if response.reply_to and msg_id == response.reply_to.reply_to_msg_id: - self._reply_indices[msg_id] = len(self._incoming) - future.set_result(response) - del self._pending_replies[msg_id] - - def _on_edit(self, message): - message = message.message - if message.chat_id != self.chat_id or message.out: - return - - # We have to update our incoming messages with the new edit date - for i, m in enumerate(self._incoming): - if m.id == message.id: - self._incoming[i] = message - break - - for msg_id, future in list(self._pending_edits.items()): - if msg_id < message.id: - edit_ts = message.edit_date.timestamp() - - # We compare <= because edit_ts resolution is always to - # seconds, but we may have increased _edit_dates before. - # Since the dates are ever growing this is not a problem. - if edit_ts <= self._edit_dates.get(msg_id, 0): - self._edit_dates[msg_id] += _EDIT_COLLISION_DELTA - else: - self._edit_dates[msg_id] = message.edit_date.timestamp() - - future.set_result(message) - del self._pending_edits[msg_id] - - def _on_read(self, event): - if event.chat_id != self.chat_id or event.inbox: - return - - self._last_read = event.max_id - - for msg_id, pending in list(self._pending_reads.items()): - if msg_id >= self._last_read: - pending.set_result(True) - del self._pending_reads[msg_id] - - def _get_message_id(self, message): - if message is not None: # 0 is valid but false-y, check for None - return message if isinstance(message, int) else message.id - elif self._last_outgoing: - return self._last_outgoing - else: - raise ValueError('No message was sent previously') - - @_checks_cancelled - def _get_result(self, future, start_time, timeout, pending, target_id): - due = self._total_due - if timeout is None: - timeout = self._timeout - - if timeout is not None: - due = min(due, start_time + timeout) - - # NOTE: We can't try/finally to pop from pending here because - # the event loop needs to get back to us, but it might - # dispatch another update before, and in that case a - # response could be set twice. So responses must be - # cleared when their futures are set to a result. - return asyncio.wait_for( - future, - timeout=None if due == float('inf') else due - time.time() - ) - - def _cancel_all(self, exception=None): - self._cancelled = True - for pending in itertools.chain( - self._pending_responses.values(), - self._pending_replies.values(), - self._pending_edits.values()): - if exception: - pending.set_exception(exception) - else: - pending.cancel() - - for _, fut in self._custom.values(): - if exception: - fut.set_exception(exception) - else: - fut.cancel() - - async def __aenter__(self): - self._input_chat = \ - await self._client.get_input_entity(self._input_chat) - - self._chat_peer = utils.get_peer(self._input_chat) - - # Make sure we're the only conversation in this chat if it's exclusive - chat_id = utils.get_peer_id(self._chat_peer) - conv_set = self._client._conversations[chat_id] - if self._exclusive and conv_set: - raise errors.AlreadyInConversationError() - - conv_set.add(self) - self._cancelled = False - - self._last_outgoing = 0 - self._last_incoming = 0 - for d in ( - self._outgoing, self._incoming, - self._pending_responses, self._pending_replies, - self._pending_edits, self._response_indices, - self._reply_indices, self._edit_dates, self._custom): - d.clear() - - if self._total_timeout: - self._total_due = time.time() + self._total_timeout - else: - self._total_due = float('inf') - - return self - - def cancel(self): - """ - Cancels the current conversation. Pending responses and subsequent - calls to get a response will raise ``asyncio.CancelledError``. - - This method is synchronous and should not be awaited. - """ - self._cancel_all() - - async def cancel_all(self): - """ - Calls `cancel` on *all* conversations in this chat. - - Note that you should ``await`` this method, since it's meant to be - used outside of a context manager, and it needs to resolve the chat. - """ - chat_id = await self._client.get_peer_id(self._input_chat) - for conv in self._client._conversations[chat_id]: - conv.cancel() - - async def __aexit__(self, exc_type, exc_val, exc_tb): - chat_id = utils.get_peer_id(self._chat_peer) - conv_set = self._client._conversations[chat_id] - conv_set.discard(self) - if not conv_set: - del self._client._conversations[chat_id] - - self._cancel_all() - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit diff --git a/telethon/tl/patched/__init__.py b/telethon/tl/patched/__init__.py deleted file mode 100644 index 2951f2af..00000000 --- a/telethon/tl/patched/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .. import types, alltlobjects -from ..custom.message import Message as _Message - -class MessageEmpty(_Message, types.MessageEmpty): - pass - -types.MessageEmpty = MessageEmpty -alltlobjects.tlobjects[MessageEmpty.CONSTRUCTOR_ID] = MessageEmpty - -class MessageService(_Message, types.MessageService): - pass - -types.MessageService = MessageService -alltlobjects.tlobjects[MessageService.CONSTRUCTOR_ID] = MessageService - -class Message(_Message, types.Message): - pass - -types.Message = Message -alltlobjects.tlobjects[Message.CONSTRUCTOR_ID] = Message diff --git a/telethon/types/__init__.py b/telethon/types/__init__.py new file mode 100644 index 00000000..ac52ff6d --- /dev/null +++ b/telethon/types/__init__.py @@ -0,0 +1,16 @@ +from .._misc.tlobject import TLObject, TLRequest +from ._custom import ( + AdminLogEvent, + Draft, + Dialog, + InputSizedFile, + MessageButton, + Forward, + Message, + Button, + InlineBuilder, + InlineResult, + InlineResults, + QRLogin, + ParticipantPermissions, +) diff --git a/telethon/tl/core/__init__.py b/telethon/types/_core/__init__.py similarity index 100% rename from telethon/tl/core/__init__.py rename to telethon/types/_core/__init__.py diff --git a/telethon/tl/core/gzippacked.py b/telethon/types/_core/gzippacked.py similarity index 89% rename from telethon/tl/core/gzippacked.py rename to telethon/types/_core/gzippacked.py index fb4094e4..fd153196 100644 --- a/telethon/tl/core/gzippacked.py +++ b/telethon/types/_core/gzippacked.py @@ -1,10 +1,10 @@ import gzip import struct -from .. import TLObject +from ..._misc import tlobject -class GzipPacked(TLObject): +class GzipPacked(tlobject.TLObject): CONSTRUCTOR_ID = 0x3072cfa1 def __init__(self, data): @@ -26,7 +26,7 @@ class GzipPacked(TLObject): def __bytes__(self): return struct.pack('`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionEditMessage) + _tl.ChannelAdminLogEventActionEditMessage) @property def deleted_message(self): @@ -222,7 +222,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDeleteMessage) + _tl.ChannelAdminLogEventActionDeleteMessage) @property def changed_admin(self): @@ -235,7 +235,7 @@ class AdminLogEvent: """ return isinstance( self.original.action, - types.ChannelAdminLogEventActionParticipantToggleAdmin) + _tl.ChannelAdminLogEventActionParticipantToggleAdmin) @property def changed_restrictions(self): @@ -247,7 +247,7 @@ class AdminLogEvent: """ return isinstance( self.original.action, - types.ChannelAdminLogEventActionParticipantToggleBan) + _tl.ChannelAdminLogEventActionParticipantToggleBan) @property def changed_invites(self): @@ -257,7 +257,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleInvites) + _tl.ChannelAdminLogEventActionToggleInvites) @property def changed_location(self): @@ -267,7 +267,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as :tl:`ChannelLocation`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeLocation) + _tl.ChannelAdminLogEventActionChangeLocation) @property def joined(self): @@ -276,7 +276,7 @@ class AdminLogEvent: public username or not. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantJoin) + _tl.ChannelAdminLogEventActionParticipantJoin) @property def joined_invite(self): @@ -288,7 +288,7 @@ class AdminLogEvent: :tl:`ChannelParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantInvite) + _tl.ChannelAdminLogEventActionParticipantInvite) @property def left(self): @@ -296,7 +296,7 @@ class AdminLogEvent: Whether `user` left the channel or not. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantLeave) + _tl.ChannelAdminLogEventActionParticipantLeave) @property def changed_hide_history(self): @@ -307,7 +307,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionTogglePreHistoryHidden) + _tl.ChannelAdminLogEventActionTogglePreHistoryHidden) @property def changed_signatures(self): @@ -318,7 +318,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleSignatures) + _tl.ChannelAdminLogEventActionToggleSignatures) @property def changed_pin(self): @@ -329,7 +329,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionUpdatePinned) + _tl.ChannelAdminLogEventActionUpdatePinned) @property def changed_default_banned_rights(self): @@ -340,7 +340,7 @@ class AdminLogEvent: be present as :tl:`ChatBannedRights`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDefaultBannedRights) + _tl.ChannelAdminLogEventActionDefaultBannedRights) @property def stopped_poll(self): @@ -351,7 +351,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionStopPoll) + _tl.ChannelAdminLogEventActionStopPoll) @property def started_group_call(self): @@ -361,7 +361,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`InputGroupCall`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionStartGroupCall) + _tl.ChannelAdminLogEventActionStartGroupCall) @property def discarded_group_call(self): @@ -371,7 +371,7 @@ class AdminLogEvent: If `True`, `old` will be present as :tl:`InputGroupCall`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDiscardGroupCall) + _tl.ChannelAdminLogEventActionDiscardGroupCall) @property def user_muted(self): @@ -381,7 +381,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantMute) + _tl.ChannelAdminLogEventActionParticipantMute) @property def user_unmutted(self): @@ -391,7 +391,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantUnmute) + _tl.ChannelAdminLogEventActionParticipantUnmute) @property def changed_call_settings(self): @@ -401,7 +401,7 @@ class AdminLogEvent: If `True`, `new` will be `True` if new users are muted on join. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleGroupCallSetting) + _tl.ChannelAdminLogEventActionToggleGroupCallSetting) @property def changed_history_ttl(self): @@ -414,7 +414,7 @@ class AdminLogEvent: If `True`, `old` will be the old TTL, and `new` the new TTL, in seconds. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeHistoryTTL) + _tl.ChannelAdminLogEventActionChangeHistoryTTL) @property def deleted_exported_invite(self): @@ -424,7 +424,7 @@ class AdminLogEvent: If `True`, `old` will be the deleted :tl:`ExportedChatInvite`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteDelete) + _tl.ChannelAdminLogEventActionExportedInviteDelete) @property def edited_exported_invite(self): @@ -435,7 +435,7 @@ class AdminLogEvent: :tl:`ExportedChatInvite`, respectively. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteEdit) + _tl.ChannelAdminLogEventActionExportedInviteEdit) @property def revoked_exported_invite(self): @@ -445,7 +445,7 @@ class AdminLogEvent: If `True`, `old` will be the revoked :tl:`ExportedChatInvite`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteRevoke) + _tl.ChannelAdminLogEventActionExportedInviteRevoke) @property def joined_by_invite(self): @@ -456,7 +456,7 @@ class AdminLogEvent: used to join. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantJoinByInvite) + _tl.ChannelAdminLogEventActionParticipantJoinByInvite) @property def changed_user_volume(self): @@ -466,7 +466,7 @@ class AdminLogEvent: If `True`, `new` will be the updated :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantVolume) + _tl.ChannelAdminLogEventActionParticipantVolume) def __str__(self): return str(self.original) diff --git a/telethon/tl/custom/button.py b/telethon/types/_custom/button.py similarity index 71% rename from telethon/tl/custom/button.py rename to telethon/types/_custom/button.py index 0ed9a43d..27f1aae9 100644 --- a/telethon/tl/custom/button.py +++ b/telethon/types/_custom/button.py @@ -1,5 +1,8 @@ -from .. import types -from ... import utils +import typing + +from .messagebutton import MessageButton +from ... import _tl +from ..._misc import utils, hints class Button: @@ -49,12 +52,13 @@ class Button: Returns `True` if the button belongs to an inline keyboard. """ return isinstance(button, ( - types.KeyboardButtonBuy, - types.KeyboardButtonCallback, - types.KeyboardButtonGame, - types.KeyboardButtonSwitchInline, - types.KeyboardButtonUrl, - types.InputKeyboardButtonUrlAuth + _tl.KeyboardButtonBuy, + _tl.KeyboardButtonCallback, + _tl.KeyboardButtonGame, + _tl.KeyboardButtonSwitchInline, + _tl.KeyboardButtonUserProfile, + _tl.KeyboardButtonUrl, + _tl.InputKeyboardButtonUrlAuth )) @staticmethod @@ -83,7 +87,7 @@ class Button: if len(data) > 64: raise ValueError('Too many bytes for the data') - return types.KeyboardButtonCallback(text, data) + return _tl.KeyboardButtonCallback(text, data) @staticmethod def switch_inline(text, query='', same_peer=False): @@ -101,7 +105,7 @@ class Button: input field will be filled with the username of your bot followed by the query text, ready to make inline queries. """ - return types.KeyboardButtonSwitchInline(text, query, same_peer) + return _tl.KeyboardButtonSwitchInline(text, query, same_peer) @staticmethod def url(text, url=None): @@ -117,7 +121,7 @@ class Button: the domain is trusted, and once confirmed the URL will open in their device. """ - return types.KeyboardButtonUrl(text, url or text) + return _tl.KeyboardButtonUrl(text, url or text) @staticmethod def auth(text, url=None, *, bot=None, write_access=False, fwd_text=None): @@ -157,14 +161,37 @@ class Button: When the user clicks this button, a confirmation box will be shown to the user asking whether they want to login to the specified domain. """ - return types.InputKeyboardButtonUrlAuth( + return _tl.InputKeyboardButtonUrlAuth( text=text, url=url or text, - bot=utils.get_input_user(bot or types.InputUserSelf()), + bot=utils.get_input_user(bot or _tl.InputUserSelf()), request_write_access=write_access, fwd_text=fwd_text ) + @staticmethod + def mention(text, input_entity): + """ + Creates a new inline button linked to the profile of user. + + Args: + input_entity: + Input entity of :tl:User to use for profile button. + By default, this is the logged in user (itself), although + you may pass a different input peer. + + .. note:: + + For now, you cannot use ID or username for this argument. + If you want to use different user, you must manually use + `client.get_input_entity() `. + """ + return _tl.InputKeyboardButtonUserProfile( + text, + utils.get_input_user(input_entity or _tl.InputUserSelf()) + ) + + @classmethod def text(cls, text, *, resize=None, single_use=None, selective=None): """ @@ -191,7 +218,7 @@ class Button: between a button press and the user typing and sending exactly the same text on their own. """ - return cls(types.KeyboardButton(text), + return cls(_tl.KeyboardButton(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -206,7 +233,7 @@ class Button: to the user asking whether they want to share their location with the bot, and if confirmed a message with geo media will be sent. """ - return cls(types.KeyboardButtonRequestGeoLocation(text), + return cls(_tl.KeyboardButtonRequestGeoLocation(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -221,7 +248,7 @@ class Button: to the user asking whether they want to share their phone with the bot, and if confirmed a message with contact media will be sent. """ - return cls(types.KeyboardButtonRequestPhone(text), + return cls(_tl.KeyboardButtonRequestPhone(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -243,7 +270,7 @@ class Button: When the user clicks this button, a screen letting the user create a poll will be shown, and if they do create one, the poll will be sent. """ - return cls(types.KeyboardButtonRequestPoll(text, quiz=force_quiz), + return cls(_tl.KeyboardButtonRequestPoll(text, quiz=force_quiz), resize=resize, single_use=single_use, selective=selective) @staticmethod @@ -255,7 +282,7 @@ class Button: ``selective`` is as documented in `text`. """ - return types.ReplyKeyboardHide(selective=selective) + return _tl.ReplyKeyboardHide(selective=selective) @staticmethod def force_reply(single_use=None, selective=None, placeholder=None): @@ -273,7 +300,7 @@ class Button: crop the text (for example, to 64 characters and adding an ellipsis (…) character as the 65th). """ - return types.ReplyKeyboardForceReply( + return _tl.ReplyKeyboardForceReply( single_use=single_use, selective=selective, placeholder=placeholder) @@ -291,7 +318,7 @@ class Button: `Payments API `__ documentation for more information. """ - return types.KeyboardButtonBuy(text) + return _tl.KeyboardButtonBuy(text) @staticmethod def game(text): @@ -305,4 +332,85 @@ class Button: `Games `__ documentation for more information on using games. """ - return types.KeyboardButtonGame(text) + return _tl.KeyboardButtonGame(text) + + +def build_reply_markup( + buttons: 'typing.Optional[hints.MarkupLike]', + inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]': + """ + Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for + the given buttons. + + Does nothing if either no buttons are provided or the provided + argument is already a reply markup. + + You should consider using this method if you are going to reuse + the markup very often. Otherwise, it is not necessary. + + This method is **not** asynchronous (don't use ``await`` on it). + + Arguments + buttons (`hints.MarkupLike`): + The button, list of buttons, array of buttons or markup + to convert into a markup. + + inline_only (`bool`, optional): + Whether the buttons **must** be inline buttons only or not. + """ + if not buttons: + return None + + try: + if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: + return buttons # crc32(b'ReplyMarkup'): + except AttributeError: + pass + + if not utils.is_list_like(buttons): + buttons = [buttons] + if not utils.is_list_like(buttons[0]): + buttons = [[b] for b in buttons] + + is_inline = False + is_normal = False + resize = None + single_use = None + selective = None + + rows = [] + for row in buttons: + current = [] + for button in row: + if isinstance(button, Button): + if button.resize is not None: + resize = button.resize + if button.single_use is not None: + single_use = button.single_use + if button.selective is not None: + selective = button.selective + + button = button.button + elif isinstance(button, MessageButton): + button = button.button + + inline = Button._is_inline(button) + is_inline |= inline + is_normal |= not inline + + if button.SUBCLASS_OF_ID == 0xbad74a3: + # 0xbad74a3 == crc32(b'KeyboardButton') + current.append(button) + + if current: + rows.append(_tl.KeyboardButtonRow(current)) + + if inline_only and is_normal: + raise ValueError('You cannot use non-inline buttons here') + elif is_inline == is_normal and is_normal: + raise ValueError('You cannot mix inline with normal buttons') + elif is_inline: + return _tl.ReplyInlineMarkup(rows) + # elif is_normal: + return _tl.ReplyKeyboardMarkup( + rows, resize=resize, single_use=single_use, selective=selective) \ No newline at end of file diff --git a/telethon/tl/custom/chatgetter.py b/telethon/types/_custom/chatgetter.py similarity index 89% rename from telethon/tl/custom/chatgetter.py rename to telethon/types/_custom/chatgetter.py index 60f5c79e..6bd4c1c3 100644 --- a/telethon/tl/custom/chatgetter.py +++ b/telethon/types/_custom/chatgetter.py @@ -1,7 +1,7 @@ import abc -from ... import errors, utils -from ...tl import types +from ..._misc import utils +from ... import errors, _tl class ChatGetter(abc.ABC): @@ -64,12 +64,6 @@ class ChatGetter(abc.ABC): Note that this might not be available if the library doesn't have enough information available. """ - if self._input_chat is None and self._chat_peer and self._client: - try: - self._input_chat = self._client._entity_cache[self._chat_peer] - except KeyError: - pass - return self._input_chat async def get_input_chat(self): @@ -114,7 +108,7 @@ class ChatGetter(abc.ABC): Returns `None` if there isn't enough information (e.g. on `events.MessageDeleted `). """ - return isinstance(self._chat_peer, types.PeerUser) if self._chat_peer else None + return isinstance(self._chat_peer, _tl.PeerUser) if self._chat_peer else None @property def is_group(self): @@ -128,20 +122,20 @@ class ChatGetter(abc.ABC): if self._broadcast is None and hasattr(self.chat, 'broadcast'): self._broadcast = bool(self.chat.broadcast) - if isinstance(self._chat_peer, types.PeerChannel): + if isinstance(self._chat_peer, _tl.PeerChannel): if self._broadcast is None: return None else: return not self._broadcast - return isinstance(self._chat_peer, types.PeerChat) + return isinstance(self._chat_peer, _tl.PeerChat) @property def is_channel(self): """`True` if the message was sent on a megagroup or channel.""" # The only case where chat peer could be none is in MessageDeleted, # however those always have the peer in channels. - return isinstance(self._chat_peer, types.PeerChannel) + return isinstance(self._chat_peer, _tl.PeerChannel) async def _refetch_chat(self): """ diff --git a/telethon/tl/custom/dialog.py b/telethon/types/_custom/dialog.py similarity index 89% rename from telethon/tl/custom/dialog.py rename to telethon/types/_custom/dialog.py index 79ef1131..b3b93943 100644 --- a/telethon/tl/custom/dialog.py +++ b/telethon/types/_custom/dialog.py @@ -1,6 +1,6 @@ from . import Draft -from .. import TLObject, types, functions -from ... import utils +from ... import _tl +from ..._misc import utils, tlobject class Dialog: @@ -89,12 +89,12 @@ class Dialog: self.draft = Draft(client, self.entity, self.dialog.draft) - self.is_user = isinstance(self.entity, types.User) + self.is_user = isinstance(self.entity, _tl.User) self.is_group = ( - isinstance(self.entity, (types.Chat, types.ChatForbidden)) or - (isinstance(self.entity, types.Channel) and self.entity.megagroup) + isinstance(self.entity, (_tl.Chat, _tl.ChatForbidden)) or + (isinstance(self.entity, _tl.Channel) and self.entity.megagroup) ) - self.is_channel = isinstance(self.entity, types.Channel) + self.is_channel = isinstance(self.entity, _tl.Channel) async def send_message(self, *args, **kwargs): """ @@ -140,8 +140,8 @@ class Dialog: # Un-archiving dialog.archive(0) """ - return await self._client(functions.folders.EditPeerFoldersRequest([ - types.InputFolderPeer(self.input_entity, folder_id=folder) + return await self._client(_tl.fn.folders.EditPeerFolders([ + _tl.InputFolderPeer(self.input_entity, folder_id=folder) ])) def to_dict(self): @@ -155,7 +155,7 @@ class Dialog: } def __str__(self): - return TLObject.pretty_format(self.to_dict()) + return tlobject.TLObject.pretty_format(self.to_dict()) def stringify(self): - return TLObject.pretty_format(self.to_dict(), indent=0) + return tlobject.TLObject.pretty_format(self.to_dict(), indent=0) diff --git a/telethon/tl/custom/draft.py b/telethon/types/_custom/draft.py similarity index 87% rename from telethon/tl/custom/draft.py rename to telethon/types/_custom/draft.py index a44986b7..46dc9c87 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/types/_custom/draft.py @@ -1,11 +1,9 @@ import datetime -from .. import TLObject -from ..functions.messages import SaveDraftRequest -from ..types import DraftMessage -from ...errors import RPCError -from ...extensions import markdown -from ...utils import get_input_peer, get_peer +from ... import _tl +from ...errors._rpcbase import RpcError +from ..._misc import markdown, tlobject +from ..._misc.utils import get_input_peer, get_peer class Draft: @@ -30,8 +28,8 @@ class Draft: self._entity = entity self._input_entity = get_input_peer(entity) if entity else None - if not draft or not isinstance(draft, DraftMessage): - draft = DraftMessage('', None, None, None, None) + if not draft or not isinstance(draft, _tl.DraftMessage): + draft = _tl.DraftMessage('', None, None, None, None) self._text = markdown.unparse(draft.message, draft.entities) self._raw_text = draft.message @@ -51,12 +49,6 @@ class Draft: """ Input version of the entity. """ - if not self._input_entity: - try: - self._input_entity = self._client._entity_cache[self._peer] - except KeyError: - pass - return self._input_entity async def get_entity(self): @@ -134,7 +126,7 @@ class Draft: raw_text, entities =\ await self._client._parse_message_text(text, parse_mode) - result = await self._client(SaveDraftRequest( + result = await self._client(_tl.fn.SaveDraft( peer=self._peer, message=raw_text, no_webpage=not link_preview, @@ -171,7 +163,7 @@ class Draft: def to_dict(self): try: entity = self.entity - except RPCError as e: + except RpcError as e: entity = e return { @@ -184,7 +176,7 @@ class Draft: } def __str__(self): - return TLObject.pretty_format(self.to_dict()) + return tlobject.TLObject.pretty_format(self.to_dict()) def stringify(self): - return TLObject.pretty_format(self.to_dict(), indent=0) + return tlobject.TLObject.pretty_format(self.to_dict(), indent=0) diff --git a/telethon/tl/custom/file.py b/telethon/types/_custom/file.py similarity index 63% rename from telethon/tl/custom/file.py rename to telethon/types/_custom/file.py index 210eb53d..24f3ee94 100644 --- a/telethon/tl/custom/file.py +++ b/telethon/types/_custom/file.py @@ -1,8 +1,8 @@ import mimetypes import os -from ... import utils -from ...tl import types +from ..._misc import utils +from ... import _tl class File: @@ -18,27 +18,12 @@ class File: def __init__(self, media): self.media = media - @property - def id(self): - """ - The bot-API style ``file_id`` representing this file. - - .. note:: - - This file ID may not work under user accounts, - but should still be usable by bot accounts. - - You can, however, still use it to identify - a file in for example a database. - """ - return utils.pack_bot_file_id(self.media) - @property def name(self): """ The file name of this document. """ - return self._from_attr(types.DocumentAttributeFilename, 'file_name') + return self._from_attr(_tl.DocumentAttributeFilename, 'file_name') @property def ext(self): @@ -49,7 +34,7 @@ class File: from the file name (if any) will be used. """ return ( - mimetypes.guess_extension(self.mime_type) + mime_tl.guess_extension(self.mime_type) or os.path.splitext(self.name or '')[-1] or None ) @@ -59,9 +44,9 @@ class File: """ The mime-type of this file. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return 'image/jpeg' - elif isinstance(self.media, types.Document): + elif isinstance(self.media, _tl.Document): return self.media.mime_type @property @@ -69,22 +54,22 @@ class File: """ The width in pixels of this media if it's a photo or a video. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(getattr(s, 'w', 0) for s in self.media.sizes) return self._from_attr(( - types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'w') + _tl.DocumentAttributeImageSize, _tl.DocumentAttributeVideo), 'w') @property def height(self): """ The height in pixels of this media if it's a photo or a video. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(getattr(s, 'h', 0) for s in self.media.sizes) return self._from_attr(( - types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'h') + _tl.DocumentAttributeImageSize, _tl.DocumentAttributeVideo), 'h') @property def duration(self): @@ -92,35 +77,35 @@ class File: The duration in seconds of the audio or video. """ return self._from_attr(( - types.DocumentAttributeAudio, types.DocumentAttributeVideo), 'duration') + _tl.DocumentAttributeAudio, _tl.DocumentAttributeVideo), 'duration') @property def title(self): """ The title of the song. """ - return self._from_attr(types.DocumentAttributeAudio, 'title') + return self._from_attr(_tl.DocumentAttributeAudio, 'title') @property def performer(self): """ The performer of the song. """ - return self._from_attr(types.DocumentAttributeAudio, 'performer') + return self._from_attr(_tl.DocumentAttributeAudio, 'performer') @property def emoji(self): """ A string with all emoji that represent the current sticker. """ - return self._from_attr(types.DocumentAttributeSticker, 'alt') + return self._from_attr(_tl.DocumentAttributeSticker, 'alt') @property def sticker_set(self): """ The :tl:`InputStickerSet` to which the sticker file belongs. """ - return self._from_attr(types.DocumentAttributeSticker, 'stickerset') + return self._from_attr(_tl.DocumentAttributeSticker, 'stickerset') @property def size(self): @@ -129,13 +114,13 @@ class File: For photos, this is the heaviest thumbnail, as it often repressents the largest dimensions. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(filter(None, map(utils._photo_size_byte_count, self.media.sizes)), default=None) - elif isinstance(self.media, types.Document): + elif isinstance(self.media, _tl.Document): return self.media.size def _from_attr(self, cls, field): - if isinstance(self.media, types.Document): + if isinstance(self.media, _tl.Document): for attr in self.media.attributes: if isinstance(attr, cls): return getattr(attr, field, None) diff --git a/telethon/tl/custom/forward.py b/telethon/types/_custom/forward.py similarity index 82% rename from telethon/tl/custom/forward.py rename to telethon/types/_custom/forward.py index a95eae30..1a4c7672 100644 --- a/telethon/tl/custom/forward.py +++ b/telethon/types/_custom/forward.py @@ -1,7 +1,6 @@ from .chatgetter import ChatGetter from .sendergetter import SenderGetter -from ... import utils, helpers -from ...tl import types +from ..._misc import utils, helpers class Forward(ChatGetter, SenderGetter): @@ -27,7 +26,8 @@ class Forward(ChatGetter, SenderGetter): # Copy all the fields, not reference! It would cause memory cycles: # self.original_fwd.original_fwd.original_fwd.original_fwd # ...would be valid if we referenced. - self.__dict__.update(original.__dict__) + for slot in original.__slots__: + setattr(self, slot, getattr(original, slot)) self.original_fwd = original sender_id = sender = input_sender = peer = chat = input_chat = None @@ -35,13 +35,11 @@ class Forward(ChatGetter, SenderGetter): ty = helpers._entity_type(original.from_id) if ty == helpers._EntityType.USER: sender_id = utils.get_peer_id(original.from_id) - sender, input_sender = utils._get_entity_pair( - sender_id, entities, client._entity_cache) + sender, input_sender = utils._get_entity_pair(sender_id, entities) elif ty in (helpers._EntityType.CHAT, helpers._EntityType.CHANNEL): peer = original.from_id - chat, input_chat = utils._get_entity_pair( - utils.get_peer_id(peer), entities, client._entity_cache) + chat, input_chat = utils._get_entity_pair(utils.get_peer_id(peer), entities) # This call resets the client ChatGetter.__init__(self, peer, chat=chat, input_chat=input_chat) diff --git a/telethon/tl/custom/inlinebuilder.py b/telethon/types/_custom/inlinebuilder.py similarity index 92% rename from telethon/tl/custom/inlinebuilder.py rename to telethon/types/_custom/inlinebuilder.py index f82b7e67..3fea2fc2 100644 --- a/telethon/tl/custom/inlinebuilder.py +++ b/telethon/types/_custom/inlinebuilder.py @@ -1,7 +1,8 @@ import hashlib -from .. import functions, types -from ... import utils +from ... import _tl +from ..._misc import utils +from ...types import _custom _TYPE_TO_MIMES = { 'gif': ['image/gif'], # 'video/mp4' too, but that's used for video @@ -126,7 +127,7 @@ class InlineBuilder: # TODO Does 'article' work always? # article, photo, gif, mpeg4_gif, video, audio, # voice, document, location, venue, contact, game - result = types.InputBotInlineResult( + result = _tl.InputBotInlineResult( id=id or '', type='article', send_message=await self._message( @@ -194,15 +195,15 @@ class InlineBuilder: _, media, _ = await self._client._file_to_media( file, allow_cache=True, as_image=True ) - if isinstance(media, types.InputPhoto): + if isinstance(media, _tl.InputPhoto): fh = media else: - r = await self._client(functions.messages.UploadMediaRequest( - types.InputPeerSelf(), media=media + r = await self._client(_tl.fn.messages.UploadMedia( + _tl.InputPeerSelf(), media=media )) fh = utils.get_input_photo(r.photo) - result = types.InputBotInlineResultPhoto( + result = _tl.InputBotInlineResultPhoto( id=id or '', type='photo', photo=fh, @@ -314,15 +315,15 @@ class InlineBuilder: video_note=video_note, allow_cache=use_cache ) - if isinstance(media, types.InputDocument): + if isinstance(media, _tl.InputDocument): fh = media else: - r = await self._client(functions.messages.UploadMediaRequest( - types.InputPeerSelf(), media=media + r = await self._client(_tl.fn.messages.UploadMedia( + _tl.InputPeerSelf(), media=media )) fh = utils.get_input_document(r.document) - result = types.InputBotInlineResultDocument( + result = _tl.InputBotInlineResultDocument( id=id or '', type=type, document=fh, @@ -361,7 +362,7 @@ class InlineBuilder: short_name (`str`): The short name of the game to use. """ - result = types.InputBotInlineResultGame( + result = _tl.InputBotInlineResultGame( id=id or '', short_name=short_name, send_message=await self._message( @@ -391,7 +392,7 @@ class InlineBuilder: 'text geo contact game'.split(), args) if x[1]) or 'none') ) - markup = self._client.build_reply_markup(buttons, inline_only=True) + markup = _custom.button.build_reply_markup(buttons, inline_only=True) if text is not None: text, msg_entities = await self._client._parse_message_text( text, parse_mode @@ -400,31 +401,31 @@ class InlineBuilder: # "MediaAuto" means it will use whatever media the inline # result itself has (stickers, photos, or documents), while # respecting the user's text (caption) and formatting. - return types.InputBotInlineMessageMediaAuto( + return _tl.InputBotInlineMessageMediaAuto( message=text, entities=msg_entities, reply_markup=markup ) else: - return types.InputBotInlineMessageText( + return _tl.InputBotInlineMessageText( message=text, no_webpage=not link_preview, entities=msg_entities, reply_markup=markup ) - elif isinstance(geo, (types.InputGeoPoint, types.GeoPoint)): - return types.InputBotInlineMessageMediaGeo( + elif isinstance(geo, (_tl.InputGeoPoint, _tl.GeoPoint)): + return _tl.InputBotInlineMessageMediaGeo( geo_point=utils.get_input_geo(geo), period=period, reply_markup=markup ) - elif isinstance(geo, (types.InputMediaVenue, types.MessageMediaVenue)): - if isinstance(geo, types.InputMediaVenue): + elif isinstance(geo, (_tl.InputMediaVenue, _tl.MessageMediaVenue)): + if isinstance(geo, _tl.InputMediaVenue): geo_point = geo.geo_point else: geo_point = geo.geo - return types.InputBotInlineMessageMediaVenue( + return _tl.InputBotInlineMessageMediaVenue( geo_point=geo_point, title=geo.title, address=geo.address, @@ -434,8 +435,8 @@ class InlineBuilder: reply_markup=markup ) elif isinstance(contact, ( - types.InputMediaContact, types.MessageMediaContact)): - return types.InputBotInlineMessageMediaContact( + _tl.InputMediaContact, _tl.MessageMediaContact)): + return _tl.InputBotInlineMessageMediaContact( phone_number=contact.phone_number, first_name=contact.first_name, last_name=contact.last_name, @@ -443,7 +444,7 @@ class InlineBuilder: reply_markup=markup ) elif game: - return types.InputBotInlineMessageGame( + return _tl.InputBotInlineMessageGame( reply_markup=markup ) else: diff --git a/telethon/tl/custom/inlineresult.py b/telethon/types/_custom/inlineresult.py similarity index 90% rename from telethon/tl/custom/inlineresult.py rename to telethon/types/_custom/inlineresult.py index 15639aa5..45867edb 100644 --- a/telethon/tl/custom/inlineresult.py +++ b/telethon/types/_custom/inlineresult.py @@ -1,5 +1,5 @@ -from .. import types, functions -from ... import utils +from ... import _tl +from ..._misc import utils class InlineResult: @@ -77,7 +77,7 @@ class InlineResult: this URL to open it in your browser, you should use Python's `webbrowser.open(url)` for such task. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.url @property @@ -86,9 +86,9 @@ class InlineResult: Returns either the :tl:`WebDocument` thumbnail for normal results or the :tl:`Photo` for media results. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.thumb - elif isinstance(self.result, types.BotInlineMediaResult): + elif isinstance(self.result, _tl.BotInlineMediaResult): return self.result.photo @property @@ -97,14 +97,14 @@ class InlineResult: Returns either the :tl:`WebDocument` content for normal results or the :tl:`Document` for media results. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.content - elif isinstance(self.result, types.BotInlineMediaResult): + elif isinstance(self.result, _tl.BotInlineMediaResult): return self.result.document async def click(self, entity=None, reply_to=None, comment_to=None, silent=False, clear_draft=False, hide_via=False, - background=None): + background=None, send_as=None): """ Clicks this result and sends the associated `message`. @@ -137,6 +137,9 @@ class InlineResult: background (`bool`, optional): Whether the message should be send in background. + send_as (`entity`, optional): + The channel entity on behalf of which, message should be send. + """ if entity: entity = await self._client.get_input_entity(entity) @@ -150,7 +153,7 @@ class InlineResult: else: reply_id = None if reply_to is None else utils.get_message_id(reply_to) - req = functions.messages.SendInlineBotResultRequest( + req = _tl.fn.messages.SendInlineBotResult( peer=entity, query_id=self._query_id, id=self.result.id, @@ -158,7 +161,8 @@ class InlineResult: background=background, clear_draft=clear_draft, hide_via=hide_via, - reply_to_msg_id=reply_id + reply_to_msg_id=reply_id, + send_as=send_as ) return self._client._get_response_message( req, await self._client(req), entity) diff --git a/telethon/tl/custom/inlineresults.py b/telethon/types/_custom/inlineresults.py similarity index 100% rename from telethon/tl/custom/inlineresults.py rename to telethon/types/_custom/inlineresults.py diff --git a/telethon/types/_custom/inputfile.py b/telethon/types/_custom/inputfile.py new file mode 100644 index 00000000..d505fd11 --- /dev/null +++ b/telethon/types/_custom/inputfile.py @@ -0,0 +1,175 @@ +import mimetypes +import os +import re +import time + +from pathlib import Path + +from ... import _tl +from ..._misc import utils + + +class InputFile: + # Expected Time-To-Live for _uploaded_*. + # After this period they should be reuploaded. + # Telegram's limit are unknown, so this value is conservative. + UPLOAD_TTL = 8 * 60 * 60 + + __slots__ = ( + # main media + '_file', # can reupload + '_media', # can only use as-is + '_uploaded_file', # (input file, timestamp) + # thumbnail + '_thumb', # can reupload + '_uploaded_thumb', # (input file, timestamp) + # document parameters + '_mime_type', + '_attributes', + '_video_note', + '_force_file', + '_ttl', + ) + + def __init__( + self, + file = None, + *, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + ttl: int = None, + ): + # main media + self._file = None + self._media = None + self._uploaded_file = None + + if isinstance(file, str) and re.match('https?://', file, flags=re.IGNORECASE): + if not force_file and mime_type.startswith('image'): + self._media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) + else: + self._media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) + + elif isinstance(file, (str, bytes, Path)) or callable(getattr(file, 'read', None)): + self._file = file + + elif isinstance(file, (_tl.InputFile, _tl.InputFileBig)): + self._uploaded_file = (file, time.time()) + + else: + self._media = utils.get_input_media( + file, + is_photo=not force_file and mime_type.startswith('image'), + attributes=[], + force_document=force_file, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + ttl=ttl + ) + + # thumbnail + self._thumb = None + self._uploaded_thumb = None + + if isinstance(thumb, (str, bytes, Path)) or callable(getattr(thumb, 'read', None)): + self._thumb = thumb + + elif isinstance(thumb, (_tl.InputFile, _tl.InputFileBig)): + self._uploaded_thumb = (thumb, time.time()) + + else: + raise TypeError(f'thumb must be a file to upload, but got: {thumb!r}') + + # document parameters (only if it's our file, i.e. there's no media ready yet) + if self._media: + self._mime_type = None + self._attributes = None + self._video_note = None + self._force_file = None + self._ttl = None + else: + if isinstance(file, Path): + if not file_name: + file_name = file.name + file = str(file.absolute()) + elif not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = getattr(file, 'name', 'unnamed') + + if not mime_type: + mime_type = mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + + mime_type = mime_type.lower() + + attributes = [_tl.DocumentAttributeFilename(file_name)] + + # TODO hachoir or tinytag or ffmpeg + if mime_type.startswith('image'): + if width is not None and height is not None: + attributes.append(_tl.DocumentAttributeImageSize( + w=width, + h=height, + )) + elif mime_type.startswith('audio'): + attributes.append(_tl.DocumentAttributeAudio( + duration=duration, + voice=voice_note, + title=title, + performer=performer, + waveform=waveform, + )) + elif mime_type.startswith('video'): + attributes.append(_tl.DocumentAttributeVideo( + duration=duration, + w=width, + h=height, + round_message=video_note, + supports_streaming=supports_streaming, + )) + + self._mime_type = mime_type + self._attributes = attributes + self._video_note = video_note + self._force_file = force_file + self._ttl = ttl + + def _should_upload_thumb(self): + return self._thumb and ( + not self._uploaded_thumb + or time.time() > self._uploaded_thumb[1] + InputFile.UPLOAD_TTL) + + def _should_upload_file(self): + return self._file and ( + not self._uploaded_file + or time.time() > self._uploaded_file[1] + InputFile.UPLOAD_TTL) + + def _set_uploaded_thumb(self, input_file): + self._uploaded_thumb = (input_file, time.time()) + + def _set_uploaded_file(self, input_file): + if not self._force_file and self._mime_type.startswith('image'): + self._media = _tl.InputMediaUploadedPhoto(input_file, ttl_seconds=self._ttl) + else: + self._media = _tl.InputMediaUploadedDocument( + file=input_file, + mime_type=self._mime_type, + attributes=self._attributes, + thumb=self._uploaded_thumb[0] if self._uploaded_thumb else None, + force_file=self._force_file, + ttl_seconds=self._ttl, + ) diff --git a/telethon/types/_custom/inputmessage.py b/telethon/types/_custom/inputmessage.py new file mode 100644 index 00000000..90e1f124 --- /dev/null +++ b/telethon/types/_custom/inputmessage.py @@ -0,0 +1,96 @@ +from typing import Optional +from .inputfile import InputFile +from ... import _misc +from .button import build_reply_markup + + +class InputMessage: + __slots__ = ( + '_text', + '_link_preview', + '_silent', + '_reply_markup', + '_fmt_entities', + '_file', + ) + + _default_parse_mode = (lambda t: (t, []), lambda t, e: t) + _default_link_preview = True + + def __init__( + self, + text: str = None, + *, + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + file=None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + silent: bool = False, + buttons: list = None, + ttl: int = None, + parse_fn = None, + ): + if (text and markdown) or (text and html) or (markdown and html): + raise ValueError('can only set one of: text, markdown, html') + + if formatting_entities: + text = text or markdown or html + elif text: + text, formatting_entities = self._default_parse_mode[0](text) + elif markdown: + text, formatting_entities = _misc.markdown.parse(markdown) + elif html: + text, formatting_entities = _misc.html.parse(html) + + reply_markup = build_reply_markup(buttons) if buttons else None + + if not text: + text = '' + if not formatting_entities: + formatting_entities = None + + if link_preview == (): + link_preview = self._default_link_preview + + if file and not isinstance(file, InputFile): + file = InputFile( + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + ) + + self._text = text + self._link_preview = link_preview + self._silent = silent + self._reply_markup = reply_markup + self._fmt_entities = formatting_entities + self._file = file + + # oh! when this message is used, the file can be cached in here! if not inputfile upload and set inputfile diff --git a/telethon/tl/custom/inputsizedfile.py b/telethon/types/_custom/inputsizedfile.py similarity index 79% rename from telethon/tl/custom/inputsizedfile.py rename to telethon/types/_custom/inputsizedfile.py index fcb743f6..4183ecb7 100644 --- a/telethon/tl/custom/inputsizedfile.py +++ b/telethon/types/_custom/inputsizedfile.py @@ -1,7 +1,7 @@ -from ..types import InputFile +from ... import _tl -class InputSizedFile(InputFile): +class InputSizedFile(_tl.InputFile): """InputFile class with two extra parameters: md5 (digest) and size""" def __init__(self, id_, parts, name, md5, size): super().__init__(id_, parts, name, md5.hexdigest()) diff --git a/telethon/tl/custom/message.py b/telethon/types/_custom/message.py similarity index 56% rename from telethon/tl/custom/message.py rename to telethon/types/_custom/message.py index b55f9065..e1bfff41 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/types/_custom/message.py @@ -1,239 +1,431 @@ from typing import Optional, List, TYPE_CHECKING from datetime import datetime +import mimetypes from .chatgetter import ChatGetter from .sendergetter import SenderGetter from .messagebutton import MessageButton from .forward import Forward from .file import File -from .. import TLObject, types, functions, alltlobjects -from ... import utils, errors +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup +from ..._misc import utils, helpers, tlobject +from ... import _tl, _misc + + +if TYPE_CHECKING: + from ..._misc import hints + + +def _fwd(field, doc): + def fget(self): + return getattr(self._message, field, None) + + def fset(self, value): + setattr(self._message, field, value) + + return property(fget, fset, None, doc) # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class Message(ChatGetter, SenderGetter, TLObject): +class Message(ChatGetter, SenderGetter): """ - This custom class aggregates both :tl:`Message` and - :tl:`MessageService` to ease accessing their members. + Represents a :tl:`Message` (or :tl:`MessageService`) from the API. Remember that this class implements `ChatGetter ` and `SenderGetter ` which means you have access to all their sender and chat properties and methods. - Members: - out (`bool`): - Whether the message is outgoing (i.e. you sent it from - another session) or incoming (i.e. someone else sent it). - - Note that messages in your own chat are always incoming, - but this member will be `True` if you send a message - to your own chat. Messages you forward to your chat are - *not* considered outgoing, just like official clients - display them. - - mentioned (`bool`): - Whether you were mentioned in this message or not. - Note that replies to your own messages also count - as mentions. - - media_unread (`bool`): - Whether you have read the media in this message - or not, e.g. listened to the voice note media. - - silent (`bool`): - Whether the message should notify people with sound or not. - Previously used in channels, but since 9 August 2019, it can - also be `used in private chats - `_. - - post (`bool`): - Whether this message is a post in a broadcast - channel or not. - - from_scheduled (`bool`): - Whether this message was originated from a previously-scheduled - message or not. - - legacy (`bool`): - Whether this is a legacy message or not. - - edit_hide (`bool`): - Whether the edited mark of this message is edited - should be hidden (e.g. in GUI clients) or shown. - - pinned (`bool`): - Whether this message is currently pinned or not. - - id (`int`): - The ID of this message. This field is *always* present. - Any other member is optional and may be `None`. - - from_id (:tl:`Peer`): - The peer who sent this message, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. - This value will be `None` for anonymous messages. - - peer_id (:tl:`Peer`): - The peer to which this message was sent, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This - will always be present except for empty messages. - - fwd_from (:tl:`MessageFwdHeader`): - The original forward header if this message is a forward. - You should probably use the `forward` property instead. - - via_bot_id (`int`): - The ID of the bot used to send this message - through its inline mode (e.g. "via @like"). - - reply_to (:tl:`MessageReplyHeader`): - The original reply header if this message is replying to another. - - date (`datetime`): - The UTC+0 `datetime` object indicating when this message - was sent. This will always be present except for empty - messages. - - message (`str`): - The string text of the message for `Message - ` instances, - which will be `None` for other types of messages. - - media (:tl:`MessageMedia`): - The media sent with this message if any (such as - photos, videos, documents, gifs, stickers, etc.). - - You may want to access the `photo`, `document` - etc. properties instead. - - If the media was not present or it was :tl:`MessageMediaEmpty`, - this member will instead be `None` for convenience. - - reply_markup (:tl:`ReplyMarkup`): - The reply markup for this message (which was sent - either via a bot or by a bot). You probably want - to access `buttons` instead. - - entities (List[:tl:`MessageEntity`]): - The list of markup entities in this message, - such as bold, italics, code, hyperlinks, etc. - - views (`int`): - The number of views this message from a broadcast - channel has. This is also present in forwards. - - forwards (`int`): - The number of times this message has been forwarded. - - replies (`int`): - The number of times another message has replied to this message. - - edit_date (`datetime`): - The date when this message was last edited. - - post_author (`str`): - The display name of the message sender to - show in messages sent to broadcast channels. - - grouped_id (`int`): - If this message belongs to a group of messages - (photo albums or video albums), all of them will - have the same value here. - - restriction_reason (List[:tl:`RestrictionReason`]) - An optional list of reasons why this message was restricted. - If the list is `None`, this message has not been restricted. - - ttl_period (`int`): - The Time To Live period configured for this message. - The message should be erased from wherever it's stored (memory, a - local database, etc.) when - ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. - - action (:tl:`MessageAction`): - The message action object of the message for :tl:`MessageService` - instances, which will be `None` for other types of messages. + You can also create your own instance of this type to customize how a + message should be sent (rather than just plain text). For example, you + can create an instance with a text to be used for the caption of an audio + file with a certain performer, duration and thumbnail. However, most + properties and methods won't work (since messages you create have not yet + been sent). """ + # region Forwarded properties + + out = _fwd('out', """ + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + + Note that messages in your own chat are always incoming, + but this member will be `True` if you send a message + to your own chat. Messages you forward to your chat are + *not* considered outgoing, just like official clients + display them. + """) + + mentioned = _fwd('mentioned', """ + Whether you were mentioned in this message or not. + Note that replies to your own messages also count + as mentions. + """) + + media_unread = _fwd('media_unread', """ + Whether you have read the media in this message + or not, e.g. listened to the voice note media. + """) + + silent = _fwd('silent', """ + Whether the message should notify people with sound or not. + Previously used in channels, but since 9 August 2019, it can + also be `used in private chats + `_. + """) + + post = _fwd('post', """ + Whether this message is a post in a broadcast + channel or not. + """) + + from_scheduled = _fwd('from_scheduled', """ + Whether this message was originated from a previously-scheduled + message or not. + """) + + legacy = _fwd('legacy', """ + Whether this is a legacy message or not. + """) + + edit_hide = _fwd('edit_hide', """ + Whether the edited mark of this message is edited + should be hidden (e.g. in GUI clients) or shown. + """) + + pinned = _fwd('pinned', """ + Whether this message is currently pinned or not. + """) + + id = _fwd('id', """ + The ID of this message. This field is *always* present. + Any other member is optional and may be `None`. + """) + + from_id = _fwd('from_id', """ + The peer who sent this message, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. + This value will be `None` for anonymous messages. + """) + + peer_id = _fwd('peer_id', """ + The peer to which this message was sent, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This + will always be present except for empty messages. + """) + + fwd_from = _fwd('fwd_from', """ + The original forward header if this message is a forward. + You should probably use the `forward` property instead. + """) + + via_bot_id = _fwd('via_bot_id', """ + The ID of the bot used to send this message + through its inline mode (e.g. "via @like"). + """) + + reply_to = _fwd('reply_to', """ + The original reply header if this message is replying to another. + """) + + date = _fwd('date', """ + The UTC+0 `datetime` object indicating when this message + was sent. This will always be present except for empty + messages. + """) + + message = _fwd('message', """ + The string text of the message for `Message + ` instances, + which will be `None` for other types of messages. + """) + + @property + def media(self): + """ + The media sent with this message if any (such as + photos, videos, documents, gifs, stickers, etc.). + + You may want to access the `photo`, `document` + etc. properties instead. + + If the media was not present or it was :tl:`MessageMediaEmpty`, + this member will instead be `None` for convenience. + """ + try: + media = self._message.media + except AttributeError: + return None + + return None if media.CONSTRUCTOR_ID == 0x3ded6320 else media + + @media.setter + def media(self, value): + try: + self._message.media = value + except AttributeError: + pass + + reply_markup = _fwd('reply_markup', """ + The reply markup for this message (which was sent + either via a bot or by a bot). You probably want + to access `buttons` instead. + """) + + entities = _fwd('entities', """ + The list of markup entities in this message, + such as bold, italics, code, hyperlinks, etc. + """) + + views = _fwd('views', """ + The number of views this message from a broadcast + channel has. This is also present in forwards. + """) + + forwards = _fwd('forwards', """ + The number of times this message has been forwarded. + """) + + noforwards = _fwd('noforwards', """ + does the message was sent with noforwards restriction. + """) + + replies = _fwd('replies', """ + The number of times another message has replied to this message. + """) + + edit_date = _fwd('edit_date', """ + The date when this message was last edited. + """) + + post_author = _fwd('post_author', """ + The display name of the message sender to + show in messages sent to broadcast channels. + """) + + grouped_id = _fwd('grouped_id', """ + If this message belongs to a group of messages + (photo albums or video albums), all of them will + have the same value here.""") + + restriction_reason = _fwd('restriction_reason', """ + An optional list of reasons why this message was restricted. + If the list is `None`, this message has not been restricted. + """) + + reactions = _fwd('reactions', """ + emoji reactions attached to the message. + """) + + ttl_period = _fwd('ttl_period', """ + The Time To Live period configured for this message. + The message should be erased from wherever it's stored (memory, a + local database, etc.) when + ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. + """) + + action = _fwd('action', """ + The message action object of the message for :tl:`MessageService` + instances, which will be `None` for other types of messages. + """) + + # endregion + # region Initialization def __init__( - # Common to all - self, id: int, - - # Common to Message and MessageService (mandatory) - peer_id: types.TypePeer = None, - date: Optional[datetime] = None, - - # Common to Message and MessageService (flags) - out: Optional[bool] = None, - mentioned: Optional[bool] = None, - media_unread: Optional[bool] = None, - silent: Optional[bool] = None, - post: Optional[bool] = None, - from_id: Optional[types.TypePeer] = None, - reply_to: Optional[types.TypeMessageReplyHeader] = None, - ttl_period: Optional[int] = None, - - # For Message (mandatory) - message: Optional[str] = None, - - # For Message (flags) - fwd_from: Optional[types.TypeMessageFwdHeader] = None, - via_bot_id: Optional[int] = None, - media: Optional[types.TypeMessageMedia] = None, - reply_markup: Optional[types.TypeReplyMarkup] = None, - entities: Optional[List[types.TypeMessageEntity]] = None, - views: Optional[int] = None, - edit_date: Optional[datetime] = None, - post_author: Optional[str] = None, - grouped_id: Optional[int] = None, - from_scheduled: Optional[bool] = None, - legacy: Optional[bool] = None, - edit_hide: Optional[bool] = None, - pinned: Optional[bool] = None, - restriction_reason: Optional[types.TypeRestrictionReason] = None, - forwards: Optional[int] = None, - replies: Optional[types.TypeMessageReplies] = None, - - # For MessageAction (mandatory) - action: Optional[types.TypeMessageAction] = None + self, + text: str = None, + *, + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: 'Optional[hints.FileLike]' = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, ): - # Common properties to messages, then to service (in the order they're defined in the `.tl`) - self.out = bool(out) - self.mentioned = mentioned - self.media_unread = media_unread - self.silent = silent - self.post = post - self.from_scheduled = from_scheduled - self.legacy = legacy - self.edit_hide = edit_hide - self.id = id - self.from_id = from_id - self.peer_id = peer_id - self.fwd_from = fwd_from - self.via_bot_id = via_bot_id - self.reply_to = reply_to - self.date = date - self.message = message - self.media = None if isinstance(media, types.MessageMediaEmpty) else media - self.reply_markup = reply_markup - self.entities = entities - self.views = views - self.forwards = forwards - self.replies = replies - self.edit_date = edit_date - self.pinned = pinned - self.post_author = post_author - self.grouped_id = grouped_id - self.restriction_reason = restriction_reason - self.ttl_period = ttl_period - self.action = action + """ + The input parameters when creating a new message for sending are: + + :param text: The message text (also known as caption when including media). + This will be parsed according to the default parse mode, which can be changed with + ``set_default_parse_mode``. + + By default it's markdown if the ``markdown-it-py`` package is installed, or none otherwise. + Cannot be used in conjunction with ``text`` or ``html``. + + :param markdown: Sets the text, but forces the parse mode to be markdown. + Cannot be used in conjunction with ``text`` or ``html``. + + :param html: Sets the text, but forces the parse mode to be HTML. + Cannot be used in conjunction with ``text`` or ``markdown``. + + :param formatting_entities: Manually specifies the formatting entities. + Neither of ``text``, ``markdown`` or ``html`` will be processed. + + :param link_preview: Whether to include a link preview media in the message. + The default is to show it, but this can be changed with ``set_default_link_preview``. + Has no effect if the message contains other media (such as photos). + + :param file: Send a file. The library will automatically determine whether to send the + file as a photo or as a document based on the extension. You can force a specific type + by using ``photo`` or ``document`` instead. The file can be one of: + + * A local file path to an in-disk file. The file name will default to the path's base name. + + * A `bytes` byte array with the file's data to send (for example, by using + ``text.encode('utf-8')``). A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send (for example, by using + ``open(file, 'rb')``). Its ``.name`` property will be used for the file name, or a + default if it doesn't have one. + + * An external URL to a file over the internet. This will send the file as "external" + media, and Telegram is the one that will fetch the media and send it. This means + the library won't download the file to send it first, but Telegram may fail to access + the media. The URL must start with either ``'http://'`` or ``https://``. + + * A handle to an existing file (for example, if you sent a message with media before, + you can use its ``message.media`` as a file here). + + * 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`. + + :param file_name: Forces a specific file name to be used, rather than an automatically + determined one. Has no effect with previously-sent media. + + :param mime_type: Sets a fixed mime type for the file, rather than having the library + guess it from the final file name. Useful when an URL does not contain an extension. + The mime-type will be used to determine which media attributes to include (for instance, + whether to send a video, an audio, or a photo). + + * For an image to contain an image size, you must specify width and height. + * For an audio, you must specify the duration. + * For a video, you must specify width, height and duration. + + :param thumb: A file to be used as the document's thumbnail. Only has effect on uploaded + documents. + + :param force_file: Forces whatever file was specified to be sent as a file. + Has no effect with previously-sent media. + + :param file_size: 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. Telegram + requires the file size to be known before-hand (except for external media). + + :param duration: Specifies the duration, in seconds, of the audio or video file. Only has + effect on uploaded documents. + + :param width: Specifies the photo or video width, in pixels. Only has an effect on uploaded + documents. + + :param height: Specifies the photo or video height, in pixels. Only has an effect on + uploaded documents. + + :param title: Specifies the title of the song being sent. Only has effect on uploaded + documents. You must specify the audio duration. + + :param performer: Specifies the performer of the song being sent. Only has effect on + uploaded documents. You must specify the audio duration. + + :param supports_streaming: Whether the video has been recorded in such a way that it + supports streaming. Note that not all format can support streaming. Only has effect on + uploaded documents. You must specify the video duration, width and height. + + :param video_note: Whether the video should be a "video note" and render inside a circle. + Only has effect on uploaded documents. You must specify the video duration, width and + height. + + :param voice_note: Whether the audio should be a "voice note" and render with a waveform. + Only has effect on uploaded documents. You must specify the audio duration. + + :param waveform: The waveform. You must specify the audio duration. + + :param silent: Whether the message should notify people with sound or not. By default, a + notification with sound is sent unless the person has the chat muted). + + :param buttons: The matrix (list of lists), column list or button to be shown after + sending the message. This parameter will only work if you have signed in as a bot. + + :param schedule: If set, the message won't send immediately, and instead it will be + scheduled to be automatically sent at a later time. + + :param ttl: The Time-To-Live of the file (also known as "self-destruct timer" or + "self-destructing media"). If set, files can only be viewed for a short period of time + before they disappear from the message history automatically. + + The value must be at least 1 second, and at most 60 seconds, otherwise Telegram will + ignore this parameter. + + Not all types of media can be used with this parameter, such as text documents, which + will fail with ``TtlMediaInvalidError``. + """ + self._message = InputMessage( + text=text, + markdown=markdown, + html=html, + formatting_entities=formatting_entities, + link_preview=link_preview, + file =file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + silent=silent, + buttons=buttons, + ttl=ttl, + ) + + @classmethod + def _new(cls, client, message, entities, input_chat): + self = cls.__new__(cls) + + sender_id = None + if isinstance(message, _tl.Message): + if message.from_id is not None: + sender_id = utils.get_peer_id(message.from_id) + + # Note that these calls would reset the client + ChatGetter.__init__(self, message.peer_id, broadcast=message.post) + SenderGetter.__init__(self, sender_id) + self._client = client + self._message = message # Convenient storage for custom functions - # TODO This is becoming a bit of bloat - self._client = None self._text = None self._file = None self._reply_message = None @@ -244,76 +436,93 @@ class Message(ChatGetter, SenderGetter, TLObject): self._via_input_bot = None self._action_entities = None self._linked_chat = None - - sender_id = None - if from_id is not None: - sender_id = utils.get_peer_id(from_id) - elif peer_id: - # If the message comes from a Channel, let the sender be it - # ...or... - # incoming messages in private conversations no longer have from_id - # (layer 119+), but the sender can only be the chat we're in. - if post or (not out and isinstance(peer_id, types.PeerUser)): - sender_id = utils.get_peer_id(peer_id) - - # Note that these calls would reset the client - ChatGetter.__init__(self, peer_id, broadcast=post) - SenderGetter.__init__(self, sender_id) - self._forward = None - def _finish_init(self, client, entities, input_chat): - """ - Finishes the initialization of this message by setting - the client that sent the message and making use of the - known entities. - """ - self._client = client - # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. - if self.peer_id == types.PeerUser(client._self_id) and not self.fwd_from: + if self.peer_id == _tl.PeerUser(client._session_state.user_id) and not self.fwd_from: self.out = True - cache = client._entity_cache + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, entities) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, entities, cache) - - self._chat, self._input_chat = utils._get_entity_pair( - self.chat_id, entities, cache) + self._chat, self._input_chat = utils._get_entity_pair(self.chat_id, entities) if input_chat: # This has priority self._input_chat = input_chat if self.via_bot_id: - self._via_bot, self._via_input_bot = utils._get_entity_pair( - self.via_bot_id, entities, cache) + self._via_bot, self._via_input_bot = utils._get_entity_pair(self.via_bot_id, entities) if self.fwd_from: self._forward = Forward(self._client, self.fwd_from, entities) if self.action: - if isinstance(self.action, (types.MessageActionChatAddUser, - types.MessageActionChatCreate)): + if isinstance(self.action, (_tl.MessageActionChatAddUser, + _tl.MessageActionChatCreate)): self._action_entities = [entities.get(i) for i in self.action.users] - elif isinstance(self.action, types.MessageActionChatDeleteUser): + elif isinstance(self.action, _tl.MessageActionChatDeleteUser): self._action_entities = [entities.get(self.action.user_id)] - elif isinstance(self.action, types.MessageActionChatJoinedByLink): + elif isinstance(self.action, _tl.MessageActionChatJoinedByLink): self._action_entities = [entities.get(self.action.inviter_id)] - elif isinstance(self.action, types.MessageActionChatMigrateTo): + elif isinstance(self.action, _tl.MessageActionChatMigrateTo): self._action_entities = [entities.get(utils.get_peer_id( - types.PeerChannel(self.action.channel_id)))] + _tl.PeerChannel(self.action.channel_id)))] elif isinstance( - self.action, types.MessageActionChannelMigrateFrom): + self.action, _tl.MessageActionChannelMigrateFrom): self._action_entities = [entities.get(utils.get_peer_id( - types.PeerChat(self.action.chat_id)))] + _tl.PeerChat(self.action.chat_id)))] if self.replies and self.replies.channel_id: self._linked_chat = entities.get(utils.get_peer_id( - types.PeerChannel(self.replies.channel_id))) + _tl.PeerChannel(self.replies.channel_id))) + return self + + + @staticmethod + def set_default_parse_mode(mode): + """ + Change the default parse mode when creating messages. The ``mode`` can be: + + * ``None``, to disable parsing. + * A string equal to ``'md'`` or ``'markdown`` for parsing with commonmark, + ``'htm'`` or ``'html'`` for parsing HTML. + * A ``callable``, which accepts a ``str`` as input and returns a tuple of + ``(parsed str, formatting entities)``. + * A ``tuple`` of two ``callable``. The first must accept a ``str`` as input and return + a tuple of ``(parsed str, list of formatting entities)``. The second must accept two + parameters, a parsed ``str`` and a ``list`` of formatting entities, and must return + an "unparsed" ``str``. + + If it's not one of these values or types, the method fails accordingly. + """ + if isinstance(mode, str): + mode = mode.lower() + if mode in ('md', 'markdown'): + mode = (_misc.markdown.parse, _misc.markdown.unparse) + elif mode in ('htm', 'html'): + mode = (_misc.html.parse, _misc.html.unparse) + else: + raise ValueError(f'mode must be one of md, markdown, htm or html, but was {mode!r}') + elif callable(mode): + mode = (mode, lambda t, e: t) + elif isinstance(mode, tuple): + if len(mode) == 2 and callable(mode[0]) and callable(mode[1]): + mode = mode + else: + raise ValueError(f'mode must be a tuple of exactly two callables') + else: + raise TypeError(f'mode must be either a str, callable or tuple, but was {mode!r}') + + InputMessage._default_parse_mode = mode + + @classmethod + def set_default_link_preview(cls, enabled): + """ + Change the default value for link preview (either ``True`` or ``False``). + """ + InputMessage._default_link_preview = enabled # endregion Initialization @@ -435,7 +644,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ if self._buttons_count is None: if isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): self._buttons_count = sum( len(row.buttons) for row in self.reply_markup.rows) else: @@ -471,14 +680,14 @@ class Message(ChatGetter, SenderGetter, TLObject): action is :tl:`MessageActionChatEditPhoto`, or if the message has a web preview with a photo. """ - if isinstance(self.media, types.MessageMediaPhoto): - if isinstance(self.media.photo, types.Photo): + if isinstance(self.media, _tl.MessageMediaPhoto): + if isinstance(self.media.photo, _tl.Photo): return self.media.photo - elif isinstance(self.action, types.MessageActionChatEditPhoto): + elif isinstance(self.action, _tl.MessageActionChatEditPhoto): return self.action.photo else: web = self.web_preview - if web and isinstance(web.photo, types.Photo): + if web and isinstance(web.photo, _tl.Photo): return web.photo @property @@ -486,12 +695,12 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if any. """ - if isinstance(self.media, types.MessageMediaDocument): - if isinstance(self.media.document, types.Document): + if isinstance(self.media, _tl.MessageMediaDocument): + if isinstance(self.media.document, _tl.Document): return self.media.document else: web = self.web_preview - if web and isinstance(web.document, types.Document): + if web and isinstance(web.document, _tl.Document): return web.document @property @@ -499,8 +708,8 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`WebPage` media in this message, if any. """ - if isinstance(self.media, types.MessageMediaWebPage): - if isinstance(self.media.webpage, types.WebPage): + if isinstance(self.media, _tl.MessageMediaWebPage): + if isinstance(self.media.webpage, _tl.WebPage): return self.media.webpage @property @@ -508,7 +717,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's an audio file. """ - return self._document_by_attribute(types.DocumentAttributeAudio, + return self._document_by_attribute(_tl.DocumentAttributeAudio, lambda attr: not attr.voice) @property @@ -516,7 +725,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's a voice note. """ - return self._document_by_attribute(types.DocumentAttributeAudio, + return self._document_by_attribute(_tl.DocumentAttributeAudio, lambda attr: attr.voice) @property @@ -524,14 +733,14 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's a video. """ - return self._document_by_attribute(types.DocumentAttributeVideo) + return self._document_by_attribute(_tl.DocumentAttributeVideo) @property def video_note(self): """ The :tl:`Document` media in this message, if it's a video note. """ - return self._document_by_attribute(types.DocumentAttributeVideo, + return self._document_by_attribute(_tl.DocumentAttributeVideo, lambda attr: attr.round_message) @property @@ -543,21 +752,21 @@ class Message(ChatGetter, SenderGetter, TLObject): sound, the so called "animated" media. However, it may be the actual gif format if the file is too large. """ - return self._document_by_attribute(types.DocumentAttributeAnimated) + return self._document_by_attribute(_tl.DocumentAttributeAnimated) @property def sticker(self): """ The :tl:`Document` media in this message, if it's a sticker. """ - return self._document_by_attribute(types.DocumentAttributeSticker) + return self._document_by_attribute(_tl.DocumentAttributeSticker) @property def contact(self): """ The :tl:`MessageMediaContact` in this message, if it's a contact. """ - if isinstance(self.media, types.MessageMediaContact): + if isinstance(self.media, _tl.MessageMediaContact): return self.media @property @@ -565,7 +774,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Game` media in this message, if it's a game. """ - if isinstance(self.media, types.MessageMediaGame): + if isinstance(self.media, _tl.MessageMediaGame): return self.media.game @property @@ -573,9 +782,9 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`GeoPoint` media in this message, if it has a location. """ - if isinstance(self.media, (types.MessageMediaGeo, - types.MessageMediaGeoLive, - types.MessageMediaVenue)): + if isinstance(self.media, (_tl.MessageMediaGeo, + _tl.MessageMediaGeoLive, + _tl.MessageMediaVenue)): return self.media.geo @property @@ -583,7 +792,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaInvoice` in this message, if it's an invoice. """ - if isinstance(self.media, types.MessageMediaInvoice): + if isinstance(self.media, _tl.MessageMediaInvoice): return self.media @property @@ -591,7 +800,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaPoll` in this message, if it's a poll. """ - if isinstance(self.media, types.MessageMediaPoll): + if isinstance(self.media, _tl.MessageMediaPoll): return self.media @property @@ -599,7 +808,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaVenue` in this message, if it's a venue. """ - if isinstance(self.media, types.MessageMediaVenue): + if isinstance(self.media, _tl.MessageMediaVenue): return self.media @property @@ -607,7 +816,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaDice` in this message, if it's a dice roll. """ - if isinstance(self.media, types.MessageMediaDice): + if isinstance(self.media, _tl.MessageMediaDice): return self.media @property @@ -616,7 +825,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Returns a list of entities that took part in this action. Possible cases for this are :tl:`MessageActionChatAddUser`, - :tl:`types.MessageActionChatCreate`, :tl:`MessageActionChatDeleteUser`, + :tl:`_tl.MessageActionChatCreate`, :tl:`MessageActionChatDeleteUser`, :tl:`MessageActionChatJoinedByLink` :tl:`MessageActionChatMigrateTo` and :tl:`MessageActionChannelMigrateFrom`. @@ -660,7 +869,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # If the client wasn't set we can't emulate the behaviour correctly, # so as a best-effort simply return the chat peer. if self._client and not self.out and self.is_private: - return types.PeerUser(self._client._self_id) + return _tl.PeerUser(self._client._session_state.user_id) return self.peer_id @@ -722,7 +931,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # However they can access them through replies... self._reply_message = await self._client.get_messages( await self.get_input_chat() if self.is_channel else None, - ids=types.InputMessageReplyTo(self.id) + ids=_tl.InputMessageReplyTo(self.id) ) if not self._reply_message: # ...unless the current message got deleted. @@ -778,9 +987,6 @@ class Message(ChatGetter, SenderGetter, TLObject): `telethon.client.messages.MessageMethods.edit_message` with both ``entity`` and ``message`` already set. - Returns `None` if the message was incoming, - or the edited `Message` otherwise. - .. note:: This is different from `client.edit_message @@ -793,9 +999,6 @@ class Message(ChatGetter, SenderGetter, TLObject): This is generally the most desired and convenient behaviour, and will work for link previews and message buttons. """ - if self.fwd_from or not self.out or not self._client: - return None # We assume self.out was patched for our chat - if 'link_preview' not in kwargs: kwargs['link_preview'] = bool(self.web_preview) @@ -883,7 +1086,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Clicks the first button or poll option for which the callable returns `True`. The callable should accept a single `MessageButton ` - or `PollAnswer ` argument. + or `PollAnswer ` argument. If you need to select multiple options in a poll, pass a list of indices to the ``i`` parameter. @@ -950,7 +1153,7 @@ class Message(ChatGetter, SenderGetter, TLObject): if not chat: return None - but = types.KeyboardButtonCallback('', data) + but = _tl.KeyboardButtonCallback('', data) return await MessageButton(self._client, but, chat, None, self.id).click( share_phone=share_phone, share_geo=share_geo, password=password) @@ -986,7 +1189,7 @@ class Message(ChatGetter, SenderGetter, TLObject): if options is None: options = [] return await self._client( - functions.messages.SendVoteRequest( + _tl.fn.messages.SendVote( peer=self._input_chat, msg_id=self.id, options=options @@ -1030,12 +1233,12 @@ class Message(ChatGetter, SenderGetter, TLObject): async def mark_read(self): """ Marks the message as read. Shorthand for - `client.send_read_acknowledge() - ` + `client.mark_read() + ` with both ``entity`` and ``message`` already set. """ if self._client: - await self._client.send_read_acknowledge( + await self._client.mark_read( await self.get_input_chat(), max_id=self.id) async def pin(self, *, notify=False, pm_oneside=False): @@ -1065,6 +1268,18 @@ class Message(ChatGetter, SenderGetter, TLObject): # region Private Methods + def _as_input(self): + if isinstance(self._message, InputMessage): + return self._message + + return InputMessage( + text=self.message, + formatting_entities=self.entities, + file=self.media, + silent=self.silent, + buttons=self.reply_markup, + ) + async def _reload_message(self): """ Re-fetches this message to reload the sender and chat entities, @@ -1098,7 +1313,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Helper methods to set the buttons given the input sender and chat. """ if self._client and isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): self._buttons = [[ MessageButton(self._client, button, chat, bot, self.id) for button in row.buttons @@ -1114,12 +1329,12 @@ class Message(ChatGetter, SenderGetter, TLObject): cannot be found but is needed. Returns `None` if it's not needed. """ if self._client and not isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): return None for row in self.reply_markup.rows: for button in row.buttons: - if isinstance(button, types.KeyboardButtonSwitchInline): + if isinstance(button, _tl.KeyboardButtonSwitchInline): # no via_bot_id means the bot sent the message itself (#1619) if button.same_peer or not self.via_bot_id: bot = self.input_sender @@ -1127,10 +1342,7 @@ class Message(ChatGetter, SenderGetter, TLObject): raise ValueError('No input sender') return bot else: - try: - return self._client._entity_cache[self.via_bot_id] - except KeyError: - raise ValueError('No input sender') from None + raise ValueError('No input sender') from None def _document_by_attribute(self, kind, condition=None): """ @@ -1146,3 +1358,51 @@ class Message(ChatGetter, SenderGetter, TLObject): return None # endregion Private Methods + + def to_dict(self): + return self._message.to_dict() + + def _to_dict(self): + return { + '_': 'Message', + 'id': self.id, + 'out': self.out, + 'date': self.date, + 'text': self.text, + 'sender': self.sender, + 'chat': self.chat, + 'mentioned': self.mentioned, + 'media_unread': self.media_unread, + 'silent': self.silent, + 'post': self.post, + 'from_scheduled': self.from_scheduled, + 'legacy': self.legacy, + 'edit_hide': self.edit_hide, + 'pinned': self.pinned, + 'forward': self.forward, + 'via_bot': self.via_bot, + 'reply_to': self.reply_to, + 'reply_markup': self.reply_markup, + 'views': self.views, + 'forwards': self.forwards, + 'replies': self.replies, + 'edit_date': self.edit_date, + 'post_author': self.post_author, + 'grouped_id': self.grouped_id, + 'ttl_period': self.ttl_period, + 'action': self.action, + 'media': self.media, + 'action_entities': self.action_entities, + } + + def __repr__(self): + return helpers.pretty_print(self) + + def __str__(self): + return helpers.pretty_print(self, max_depth=2) + + def stringify(self): + return helpers.pretty_print(self, indent=0) + + +# TODO set md by default if commonmark is installed else nothing diff --git a/telethon/tl/custom/messagebutton.py b/telethon/types/_custom/messagebutton.py similarity index 75% rename from telethon/tl/custom/messagebutton.py rename to telethon/types/_custom/messagebutton.py index 7f6490b2..eee4486e 100644 --- a/telethon/tl/custom/messagebutton.py +++ b/telethon/types/_custom/messagebutton.py @@ -1,5 +1,5 @@ -from .. import types, functions -from ... import password as pwd_mod +from ..._misc import password as pwd_mod +from ... import _tl from ...errors import BotResponseTimeoutError import webbrowser import os @@ -46,19 +46,19 @@ class MessageButton: @property def data(self): """The `bytes` data for :tl:`KeyboardButtonCallback` objects.""" - if isinstance(self.button, types.KeyboardButtonCallback): + if isinstance(self.button, _tl.KeyboardButtonCallback): return self.button.data @property def inline_query(self): """The query `str` for :tl:`KeyboardButtonSwitchInline` objects.""" - if isinstance(self.button, types.KeyboardButtonSwitchInline): + if isinstance(self.button, _tl.KeyboardButtonSwitchInline): return self.button.query @property def url(self): """The url `str` for :tl:`KeyboardButtonUrl` objects.""" - if isinstance(self.button, types.KeyboardButtonUrl): + if isinstance(self.button, _tl.KeyboardButtonUrl): return self.button.url async def click(self, share_phone=None, share_geo=None, *, password=None): @@ -71,8 +71,12 @@ class MessageButton: If it's an inline :tl:`KeyboardButtonCallback` with text and data, it will be "clicked" and the :tl:`BotCallbackAnswer` returned. + If it's an inline :tl:`KeyboardButtonUserProfile` button, the + `client.get_entity` will be called and the resulting :tl:User will be + returned. + If it's an inline :tl:`KeyboardButtonSwitchInline` button, the - :tl:`StartBotRequest` will be invoked and the resulting updates + :tl:`StartBot` will be invoked and the resulting updates returned. If it's a :tl:`KeyboardButtonUrl`, the URL of the button will @@ -91,15 +95,15 @@ class MessageButton: 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): + if isinstance(self.button, _tl.KeyboardButton): return await self._client.send_message( self._chat, self.button.text, parse_mode=None) - elif isinstance(self.button, types.KeyboardButtonCallback): + elif isinstance(self.button, _tl.KeyboardButtonCallback): if password is not None: - pwd = await self._client(functions.account.GetPasswordRequest()) + pwd = await self._client(_tl.fn.account.GetPassword()) password = pwd_mod.compute_check(pwd, password) - req = functions.messages.GetBotCallbackAnswerRequest( + req = _tl.fn.messages.GetBotCallbackAnswer( peer=self._chat, msg_id=self._msg_id, data=self.button.data, password=password ) @@ -107,27 +111,29 @@ class MessageButton: return await self._client(req) except BotResponseTimeoutError: return None - elif isinstance(self.button, types.KeyboardButtonSwitchInline): - return await self._client(functions.messages.StartBotRequest( + elif isinstance(self.button, _tl.KeyboardButtonUserProfile): + return await self._client.get_entity(self.button.user_id) + elif isinstance(self.button, _tl.KeyboardButtonSwitchInline): + return await self._client(_tl.fn.messages.StartBot( bot=self._bot, peer=self._chat, start_param=self.button.query )) - elif isinstance(self.button, types.KeyboardButtonUrl): - return webbrowser.open(self.button.url) - elif isinstance(self.button, types.KeyboardButtonGame): - req = functions.messages.GetBotCallbackAnswerRequest( + elif isinstance(self.button, _tl.KeyboardButtonUrl): + return self.button.url + elif isinstance(self.button, _tl.KeyboardButtonGame): + req = _tl.fn.messages.GetBotCallbackAnswer( peer=self._chat, msg_id=self._msg_id, game=True ) try: return await self._client(req) except BotResponseTimeoutError: return None - elif isinstance(self.button, types.KeyboardButtonRequestPhone): + elif isinstance(self.button, _tl.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( + share_phone = _tl.InputMediaContact( phone_number=me.phone if share_phone == True else share_phone, first_name=me.first_name or '', last_name=me.last_name or '', @@ -135,12 +141,12 @@ class MessageButton: ) return await self._client.send_file(self._chat, share_phone) - elif isinstance(self.button, types.KeyboardButtonRequestGeoLocation): + elif isinstance(self.button, _tl.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)) + share_geo = _tl.InputMediaGeoPoint(_tl.InputGeoPoint(lat=lat, long=long)) - return await self._client.send_file(self._chat, share_geo) + return await self._client.send_file(self._chat, share_geo) \ No newline at end of file diff --git a/telethon/tl/custom/participantpermissions.py b/telethon/types/_custom/participantpermissions.py similarity index 88% rename from telethon/tl/custom/participantpermissions.py rename to telethon/types/_custom/participantpermissions.py index d3719778..7410aa88 100644 --- a/telethon/tl/custom/participantpermissions.py +++ b/telethon/types/_custom/participantpermissions.py @@ -1,4 +1,4 @@ -from .. import types +from ... import _tl def _admin_prop(field_name, doc): @@ -46,8 +46,8 @@ class ParticipantPermissions: also counts as begin an administrator, since they have all permissions. """ return self.is_creator or isinstance(self.participant, ( - types.ChannelParticipantAdmin, - types.ChatParticipantAdmin + _tl.ChannelParticipantAdmin, + _tl.ChatParticipantAdmin )) @property @@ -56,8 +56,8 @@ class ParticipantPermissions: Whether the user is the creator of the chat or not. """ return isinstance(self.participant, ( - types.ChannelParticipantCreator, - types.ChatParticipantCreator + _tl.ChannelParticipantCreator, + _tl.ChatParticipantCreator )) @property @@ -67,9 +67,9 @@ class ParticipantPermissions: not banned either, and has no restrictions applied). """ return isinstance(self.participant, ( - types.ChannelParticipant, - types.ChatParticipant, - types.ChannelParticipantSelf + _tl.ChannelParticipant, + _tl.ChatParticipant, + _tl.ChannelParticipantSelf )) @property @@ -77,15 +77,15 @@ class ParticipantPermissions: """ Whether the user is banned in the chat. """ - return isinstance(self.participant, types.ChannelParticipantBanned) + return isinstance(self.participant, _tl.ChannelParticipantBanned) @property def has_left(self): """ Whether the user left the chat. """ - return isinstance(self.participant, types.ChannelParticipantLeft) - + return isinstance(self.participant, _tl.ChannelParticipantLeft) + @property def add_admins(self): """ @@ -132,7 +132,7 @@ class ParticipantPermissions: anonymous = property(**_admin_prop('anonymous', """ Whether the administrator will remain anonymous when sending messages. """)) - + manage_call = property(**_admin_prop('manage_call', """ Whether the user will be able to manage group calls. """)) diff --git a/telethon/tl/custom/qrlogin.py b/telethon/types/_custom/qrlogin.py similarity index 90% rename from telethon/tl/custom/qrlogin.py rename to telethon/types/_custom/qrlogin.py index 39585f2f..473e4bd0 100644 --- a/telethon/tl/custom/qrlogin.py +++ b/telethon/types/_custom/qrlogin.py @@ -2,8 +2,8 @@ import asyncio import base64 import datetime -from .. import types, functions -from ... import events +from ... import _tl +from ..._events.raw import Raw class QRLogin: @@ -15,7 +15,7 @@ class QRLogin: """ def __init__(self, client, ignored_ids): self._client = client - self._request = functions.auth.ExportLoginTokenRequest( + self._request = _tl.fn.auth.ExportLoginToken( self._client.api_id, self._client.api_hash, ignored_ids) self._resp = None @@ -95,7 +95,7 @@ class QRLogin: async def handler(_update): event.set() - self._client.add_event_handler(handler, events.Raw(types.UpdateLoginToken)) + self._client.add_event_handler(handler, Raw(_tl.UpdateLoginToken)) try: # Will raise timeout error if it doesn't complete quick enough, @@ -106,12 +106,12 @@ class QRLogin: # We got here without it raising timeout error, so we can proceed resp = await self._client(self._request) - if isinstance(resp, types.auth.LoginTokenMigrateTo): + if isinstance(resp, _tl.auth.LoginTokenMigrateTo): await self._client._switch_dc(resp.dc_id) - resp = await self._client(functions.auth.ImportLoginTokenRequest(resp.token)) + resp = await self._client(_tl.fn.auth.ImportLoginToken(resp.token)) # resp should now be auth.loginTokenSuccess - if isinstance(resp, types.auth.LoginTokenSuccess): + if isinstance(resp, _tl.auth.LoginTokenSuccess): user = resp.authorization.user self._client._on_login(user) return user diff --git a/telethon/tl/custom/sendergetter.py b/telethon/types/_custom/sendergetter.py similarity index 92% rename from telethon/tl/custom/sendergetter.py rename to telethon/types/_custom/sendergetter.py index 673cab25..58d84657 100644 --- a/telethon/tl/custom/sendergetter.py +++ b/telethon/types/_custom/sendergetter.py @@ -64,12 +64,6 @@ class SenderGetter(abc.ABC): Note that this might not be available if the library can't find the input chat, or if the message a broadcast on a channel. """ - if self._input_sender is None and self._sender_id and self._client: - try: - self._input_sender = \ - self._client._entity_cache[self._sender_id] - except KeyError: - pass return self._input_sender async def get_input_sender(self): diff --git a/telethon_examples/gui.py b/telethon_examples/gui.py index bd241f60..61027d52 100644 --- a/telethon_examples/gui.py +++ b/telethon_examples/gui.py @@ -132,7 +132,7 @@ class App(tkinter.Tk): command=self.send_message).grid(row=3, column=2) # Post-init (async, connect client) - self.cl.loop.create_task(self.post_init()) + asyncio.create_task(self.post_init()) async def post_init(self): """ @@ -369,10 +369,4 @@ async def main(interval=0.05): 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()) - finally: - if not aio_loop.is_closed(): - aio_loop.close() + asyncio.run(main()) diff --git a/telethon_examples/interactive_telegram_client.py b/telethon_examples/interactive_telegram_client.py index 88f491de..10ca71a1 100644 --- a/telethon_examples/interactive_telegram_client.py +++ b/telethon_examples/interactive_telegram_client.py @@ -9,9 +9,6 @@ from telethon.errors import SessionPasswordNeededError from telethon.network import ConnectionTcpAbridged from telethon.utils import get_display_name -# Create a global variable to hold the loop we will be using -loop = asyncio.get_event_loop() - def sprint(string, *args, **kwargs): """Safe Print (handle UnicodeEncodeErrors on some terminals)""" @@ -50,7 +47,7 @@ async def async_input(prompt): let the loop run while we wait for input. """ print(prompt, end='', flush=True) - return (await loop.run_in_executor(None, sys.stdin.readline)).rstrip() + return (await asyncio.get_running_loop().run_in_executor(None, sys.stdin.readline)).rstrip() def get_env(name, message, cast=str): @@ -109,34 +106,34 @@ class InteractiveTelegramClient(TelegramClient): # media known the message ID, for every message having media. self.found_media = {} + async def init(self): # Calling .connect() may raise a connection error False, so you need # to except those before continuing. Otherwise you may want to retry # as done here. print('Connecting to Telegram servers...') try: - loop.run_until_complete(self.connect()) + await self.connect() except IOError: # We handle IOError and not ConnectionError because # PySocks' errors do not subclass ConnectionError # (so this will work with and without proxies). print('Initial connection failed. Retrying...') - loop.run_until_complete(self.connect()) + await self.connect() # If the user hasn't called .sign_in() or .sign_up() yet, they won't # be authorized. The first thing you must do is authorize. Calling # .sign_in() should only be done once as the information is saved on # the *.session file so you don't need to enter the code every time. - if not loop.run_until_complete(self.is_user_authorized()): + if not await self.is_user_authorized(): print('First run. Sending code request...') user_phone = input('Enter your phone: ') - loop.run_until_complete(self.sign_in(user_phone)) + await self.sign_in(user_phone) self_user = None while self_user is None: code = input('Enter the code you just received: ') try: - self_user =\ - loop.run_until_complete(self.sign_in(code=code)) + self_user = await self.sign_in(code=code) # Two-step verification may be enabled, and .sign_in will # raise this error. If that's the case ask for the password. @@ -146,8 +143,7 @@ class InteractiveTelegramClient(TelegramClient): pw = getpass('Two step verification is enabled. ' 'Please enter your password: ') - self_user =\ - loop.run_until_complete(self.sign_in(password=pw)) + self_user = await self.sign_in(password=pw) async def run(self): """Main loop of the TelegramClient, will wait for user action""" @@ -397,9 +393,13 @@ class InteractiveTelegramClient(TelegramClient): )) -if __name__ == '__main__': +async def main(): SESSION = os.environ.get('TG_SESSION', 'interactive') API_ID = get_env('TG_API_ID', 'Enter your API ID: ', int) API_HASH = get_env('TG_API_HASH', 'Enter your API hash: ') - client = InteractiveTelegramClient(SESSION, API_ID, API_HASH) - loop.run_until_complete(client.run()) + client = await InteractiveTelegramClient(SESSION, API_ID, API_HASH).init() + await client.run() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/telethon_examples/payment.py b/telethon_examples/payment.py index 22358a6a..3eab88f3 100644 --- a/telethon_examples/payment.py +++ b/telethon_examples/payment.py @@ -1,4 +1,4 @@ -from telethon import TelegramClient, events, types, functions +from telethon import TelegramClient, events, _tl import asyncio import logging @@ -7,8 +7,6 @@ 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 @@ -44,13 +42,13 @@ bot = TelegramClient( # 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 we don't `SetBotPrecheckoutResults`, money won't be charged from buyer, and nothing will happen next. +@bot.on(events.Raw(_tl.UpdateBotPrecheckoutQuery)) +async def payment_pre_checkout_handler(event: _tl.UpdateBotPrecheckoutQuery): if event.payload.decode('UTF-8') == 'product A': # so we have to confirm payment await bot( - functions.messages.SetBotPrecheckoutResultsRequest( + _tl.fn.messages.SetBotPrecheckoutResults( query_id=event.query_id, success=True, error=None @@ -59,7 +57,7 @@ async def payment_pre_checkout_handler(event: types.UpdateBotPrecheckoutQuery): elif event.payload.decode('UTF-8') == 'product B': # same for another await bot( - functions.messages.SetBotPrecheckoutResultsRequest( + _tl.fn.messages.SetBotPrecheckoutResults( query_id=event.query_id, success=True, error=None @@ -68,7 +66,7 @@ async def payment_pre_checkout_handler(event: types.UpdateBotPrecheckoutQuery): else: # for example, something went wrong (whatever reason). We can tell customer about that: await bot( - functions.messages.SetBotPrecheckoutResultsRequest( + _tl.fn.messages.SetBotPrecheckoutResults( query_id=event.query_id, success=False, error='Something went wrong' @@ -79,10 +77,10 @@ async def payment_pre_checkout_handler(event: types.UpdateBotPrecheckoutQuery): # That event is handled at the end, when customer payed. -@bot.on(events.Raw(types.UpdateNewMessage)) +@bot.on(events.Raw(_tl.UpdateNewMessage)) async def payment_received_handler(event): - if isinstance(event.message.action, types.MessageActionPaymentSentMe): - payment: types.MessageActionPaymentSentMe = event.message.action + if isinstance(event.message.action, _tl.MessageActionPaymentSentMe): + payment: _tl.MessageActionPaymentSentMe = event.message.action # do something after payment was received if payment.payload.decode('UTF-8') == 'product A': await bot.send_message(event.message.from_id, 'Thank you for buying product A!') @@ -93,9 +91,9 @@ async def payment_received_handler(event): # 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( + description: str, payload: str, start_param: str) -> _tl.InputMediaInvoice: + price = _tl.LabeledPrice(label=price_label, amount=price_amount) # label - just a text, amount=10000 means 100.00 + invoice = _tl.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. @@ -114,14 +112,14 @@ def generate_invoice(price_label: str, price_amount: int, currency: str, title: phone_to_provider=False, email_to_provider=False ) - return types.InputMediaInvoice( + return _tl.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('{}'), + provider_data=_tl.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. @@ -180,4 +178,4 @@ if __name__ == '__main__': if not provider_token: logger.error("No provider token supplied.") exit(1) - loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/telethon_examples/quart_login.py b/telethon_examples/quart_login.py index 98fb35de..20eae383 100644 --- a/telethon_examples/quart_login.py +++ b/telethon_examples/quart_login.py @@ -134,12 +134,13 @@ async def main(): # By default, `Quart.run` uses `asyncio.run()`, which creates a new asyncio -# event loop. If we create the `TelegramClient` before, `telethon` will -# use `asyncio.get_event_loop()`, which is the implicit loop in the main -# thread. These two loops are different, and it won't work. +# event loop. Instead, we use `asyncio.run()` manually in order to make this +# explicit, as the client cannot be "transferred" between loops while +# connected due to the need to schedule work within an event loop. # -# So, we have to manually pass the same `loop` to both applications to -# make 100% sure it works and to avoid headaches. +# In essence one needs to be careful to avoid mixing event loops, but this is +# simple, as `asyncio.run` is generally only used in the entry-point of the +# program. # # To run Quart inside `async def`, we must use `hypercorn.asyncio.serve()` # directly. @@ -149,4 +150,4 @@ async def main(): # won't have to worry about any of this, but it's still good to be # explicit about the event loop. if __name__ == '__main__': - client.loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/telethon_generator/data/api.tl b/telethon_generator/data/api.tl index d1f44341..9aaa0e57 100644 --- a/telethon_generator/data/api.tl +++ b/telethon_generator/data/api.tl @@ -123,13 +123,13 @@ userStatusLastWeek#7bf09fc = UserStatus; userStatusLastMonth#77ebc742 = UserStatus; chatEmpty#29562865 id:long = Chat; -chat#41cbf256 flags:# creator:flags.0?true kicked:flags.1?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chat#41cbf256 flags:# creator:flags.0?true kicked:flags.1?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; chatForbidden#6592a1a7 id:long title:string = Chat; -channel#8261ac61 flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int = Chat; +channel#8261ac61 flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int = Chat; channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; -chatFull#4dbdc099 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string = ChatFull; -channelFull#e9b27a17 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 blocked:flags.22?true id:long 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:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long 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?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string = ChatFull; +chatFull#d18ee226 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?Vector = ChatFull; +channelFull#e13c3d20 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 blocked:flags.22?true id:long 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:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long 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?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?Vector = ChatFull; chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; @@ -142,7 +142,7 @@ chatPhotoEmpty#37c1011c = ChatPhoto; chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; -message#85d6cbe2 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 pinned:flags.24?true id:int from_id:flags.8?Peer peer_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long restriction_reason:flags.22?Vector ttl_period:flags.25?int = Message; +message#38116ee0 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 pinned:flags.24?true noforwards:flags.26?true id:int from_id:flags.8?Peer peer_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int = Message; messageService#2b085862 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction ttl_period:flags.25?int = Message; messageMediaEmpty#3ded6320 = MessageMedia; @@ -188,6 +188,7 @@ messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = messageActionSetMessagesTTL#aa1afbfd period:int = MessageAction; messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; messageActionSetChatTheme#aa786345 emoticon:string = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; dialog#2c171f72 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int = Dialog; 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; @@ -207,7 +208,7 @@ geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radiu auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; -auth.authorization#cd050916 flags:# tmp_sessions:flags.0?int user:User = auth.Authorization; +auth.authorization#33fb7bb8 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int user:User = auth.Authorization; auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; @@ -221,7 +222,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#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 invite_members:flags.8?true geo_distance:flags.6?int = PeerSettings; +peerSettings#a518110d 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 invite_members:flags.8?true request_chat_broadcast:flags.10?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?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#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; @@ -235,7 +236,7 @@ inputReportReasonCopyright#9b89f93a = ReportReason; inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; inputReportReasonFake#f5ddd6e7 = ReportReason; -userFull#d697ff05 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 ttl_period:flags.14?int theme_emoticon:flags.15?string = UserFull; +userFull#cf366521 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 id:long 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 ttl_period:flags.14?int theme_emoticon:flags.15?string private_forward_name:flags.16?string = UserFull; contact#145ade0b user_id:long mutual:Bool = Contact; @@ -378,6 +379,9 @@ updateChannelParticipant#985d3abb flags:# channel_id:long date:int actor_id:long updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#154798c3 peer:Peer msg_id:int reactions:MessageReactions = Update; updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; @@ -535,9 +539,9 @@ 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#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; +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?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; -account.authorizations#1250abde authorizations:Vector = account.Authorizations; +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; account.password#185b184f flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int = account.Password; @@ -549,10 +553,10 @@ auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; -chatInviteExported#b18105e8 flags:# revoked:flags.0?true permanent:flags.5?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int = ExportedChatInvite; +chatInviteExported#ab4a819 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int title:flags.8?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; +chatInvite#300c44c1 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector = ChatInvite; chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; inputStickerSetEmpty#ffb62b95 = InputStickerSet; @@ -565,6 +569,7 @@ inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; stickerSet#d7df217a 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 thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int count:int hash:int = StickerSet; messages.stickerSet#b60a24a6 set:StickerSet packs:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; botCommand#c27ac8c7 command:string description:string = BotCommand; @@ -581,6 +586,8 @@ keyboardButtonBuy#afd93fbb text:string = KeyboardButton; keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; keyboardButtonRequestPoll#bbc7515d flags:# quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#e988037b text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#308660c1 text:string user_id:long = KeyboardButton; keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; @@ -608,6 +615,7 @@ messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; messageEntityBlockquote#20df5d0 offset:int length:int = MessageEntity; messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; inputChannelEmpty#ee8c1e86 = InputChannel; inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; @@ -625,7 +633,7 @@ channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; channelParticipant#c00c07c0 user_id:long date:int = ChannelParticipant; -channelParticipantSelf#28a8bc67 user_id:long inviter_id:long date:int = ChannelParticipant; +channelParticipantSelf#35a8bfa7 flags:# via_request:flags.0?true user_id:long inviter_id:long date:int = ChannelParticipant; channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; channelParticipantBanned#6df8014e flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights = ChannelParticipant; @@ -682,11 +690,13 @@ messageFwdHeader#5f777dce flags:# imported:flags.7?true from_id:flags.0?Peer fro auth.codeTypeSms#72a3158c = auth.CodeType; auth.codeTypeCall#741cd3e3 = auth.CodeType; auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; @@ -908,12 +918,16 @@ channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvit channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#9cf7f76a prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; -channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true = ChannelAdminLogEventsFilter; +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true = ChannelAdminLogEventsFilter; popularContact#5ce14175 client_id:long importers:int = PopularContact; @@ -1082,7 +1096,7 @@ inputWallPaperNoFile#967a462e id:long = InputWallPaper; account.wallPapersNotModified#1c199183 = account.WallPapers; account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; -codeSettings#debebe83 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true = CodeSettings; +codeSettings#8a6469c2 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true logout_tokens:flags.6?Vector = CodeSettings; wallPaperSettings#1dc1bca4 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int = WallPaperSettings; @@ -1122,7 +1136,7 @@ restrictionReason#d072acb4 platform:string reason:string text:string = Restricti inputTheme#3c5693e9 id:long access_hash:long = InputTheme; inputThemeSlug#f5890df1 slug:string = InputTheme; -theme#e802b8dc flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?ThemeSettings installs_count:flags.4?int = Theme; +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; account.themesNotModified#f41eb622 = account.Themes; account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; @@ -1234,7 +1248,7 @@ messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; -chatInviteImporter#b5cd5f4 user_id:long date:int = ChatInviteImporter; +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; @@ -1271,15 +1285,39 @@ account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordR account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; account.resetPasswordOk#e926d63e = account.ResetPasswordResult; -chatTheme#ed0b5c33 emoticon:string theme:Theme dark_theme:Theme = ChatTheme; - -account.chatThemesNotModified#e011e1c4 = account.ChatThemes; -account.chatThemes#fe4cbebd hash:int themes:Vector = account.ChatThemes; - -sponsoredMessage#2a3c381f flags:# random_id:bytes from_id:Peer start_param:flags.0?string message:string entities:flags.1?Vector = SponsoredMessage; +sponsoredMessage#3a836df8 flags:# random_id:bytes from_id:flags.3?Peer chat_invite:flags.4?ChatInvite chat_invite_hash:flags.4?string channel_post:flags.2?int start_param:flags.0?string message:string entities:flags.1?Vector = SponsoredMessage; messages.sponsoredMessages#65a4c7d5 messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#8356cda9 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#6fb250d1 flags:# chosen:flags.0?true reaction:string count:int = ReactionCount; + +messageReactions#87b6e36 flags:# min:flags.0?true can_see_list:flags.2?true results:Vector recent_reactons:flags.1?Vector = MessageReactions; + +messageUserReaction#932844fa user_id:long reaction:string = MessageUserReaction; + +messages.messageReactionsList#a366923c flags:# count:int reactions:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; @@ -1293,7 +1331,7 @@ invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; auth.signIn#bcd51581 phone_number:string phone_code_hash:string phone_code:string = auth.Authorization; -auth.logOut#5717da40 = Bool; +auth.logOut#3e72ba19 = auth.LoggedOut; auth.resetAuthorizations#9fab0d1a = Bool; auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; @@ -1366,10 +1404,10 @@ account.resetWallPapers#bb3b9804 = Bool; account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; -account.createTheme#8432c21f flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Theme; -account.updateTheme#5cb367d5 flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Theme; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; -account.installTheme#7ae43737 flags:# dark:flags.0?true format:flags.1?string theme:flags.1?InputTheme = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; account.getTheme#8d9d742b format:string theme:InputTheme document_id:long = Theme; account.getThemes#7206e458 format:string hash:long = account.Themes; account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; @@ -1380,10 +1418,12 @@ account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = Globa account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; account.resetPassword#9308ce1b = account.ResetPasswordResult; account.declinePasswordReset#4c9409f6 = Bool; -account.getChatThemes#d6d71d7b hash:int = account.ChatThemes; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; users.getUsers#d91a548 id:Vector = Vector; -users.getFullUser#ca30a5b1 id:InputUser = UserFull; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; contacts.getContactIDs#7adc669d hash:long = Vector; @@ -1412,15 +1452,15 @@ messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; messages.search#a0fda762 flags:# peer:InputPeer q:string from_id:flags.0?InputPeer top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; -messages.deleteHistory#1c015b09 flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int = messages.AffectedHistory; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; messages.receivedMessages#5a954c0 max_id:int = Vector; messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; -messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int = Updates; -messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int = Updates; -messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer schedule_date:flags.10?int = Updates; +messages.sendMessage#d9d75a4 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; +messages.sendMedia#e25ff8e0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; +messages.forwardMessages#cc30290b flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.reportSpam#cf1592db peer:InputPeer = Bool; -messages.getPeerSettings#3672e09c peer:InputPeer = PeerSettings; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; messages.report#8953ab4e peer:InputPeer id:Vector reason:ReportReason message:string = Bool; messages.getChats#49e9528f id:Vector = messages.Chats; messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; @@ -1444,10 +1484,10 @@ messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; messages.getWebPagePreview#8b68b0cc flags:# message:string entities:flags.3?Vector = MessageMedia; -messages.exportChatInvite#14b9bcd7 flags:# legacy_revoke_permanent:flags.2?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int = ExportedChatInvite; +messages.exportChatInvite#a02ce5d5 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string = ExportedChatInvite; messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; messages.importChatInvite#6c50051c hash:string = Updates; -messages.getStickerSet#2619a90e stickerset:InputStickerSet = messages.StickerSet; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; @@ -1461,7 +1501,7 @@ messages.getSavedGifs#5cf09635 hash:long = 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; messages.setInlineBotResults#eb5ea206 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM = Bool; -messages.sendInlineBotResult#220815b0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int = Updates; +messages.sendInlineBotResult#7aa11297 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int = Updates; messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; @@ -1497,7 +1537,7 @@ messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; messages.getUnreadMentions#46578472 peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; messages.readMentions#f0189d3 peer:InputPeer = messages.AffectedHistory; messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; -messages.sendMultiMedia#cc0110cb flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector schedule_date:flags.10?int = Updates; +messages.sendMultiMedia#f803138f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; messages.getSplitRanges#1cff7e08 = Vector; @@ -1508,7 +1548,6 @@ messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1? messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; -messages.getStatsURL#812c2ae6 flags:# dark:flags.0?true peer:InputPeer params:string = StatsURL; messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; @@ -1542,15 +1581,27 @@ messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:st messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; -messages.editExportedChatInvite#2e4ffbe flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; -messages.getChatInviteImporters#26fb7289 peer:InputPeer link:string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; messages.setChatTheme#e63be13f peer:InputPeer emoticon:string = Updates; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#49f0bde9 peer:InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#6e9583a3 peer:InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#25690ce4 flags:# peer:InputPeer msg_id:int reaction:flags.0?string = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#e0ee6b77 flags:# peer:InputPeer id:int reaction:flags.0?string offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#14050ea6 peer:InputPeer available_reactions:Vector = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#d960c4d4 reaction:string = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1595,8 +1646,7 @@ help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; -channels.deleteUserHistory#d10dd71b channel:InputChannel user_id:InputUser = messages.AffectedHistory; -channels.reportSpam#fe087810 channel:InputChannel user_id:InputUser id:Vector = Bool; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; @@ -1631,6 +1681,8 @@ channels.getInactiveChannels#11e831ee = messages.InactiveChats; channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; channels.viewSponsoredMessage#beaedb94 channel:InputChannel random_id:bytes = Bool; channels.getSponsoredMessages#ec210fbf channel:InputChannel = messages.SponsoredMessages; +channels.getSendAs#dc770ee peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; @@ -1698,4 +1750,4 @@ stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; -// LAYER 133 +// LAYER 137 diff --git a/telethon_generator/data/errors.csv b/telethon_generator/data/errors.csv index caf9b5e6..04b350f8 100644 --- a/telethon_generator/data/errors.csv +++ b/telethon_generator/data/errors.csv @@ -1,5 +1,5 @@ name,codes,description -2FA_CONFIRM_WAIT_X,420,The account is 2FA protected so it will be deleted in a week. Otherwise it can be reset in {seconds} +2FA_CONFIRM_WAIT_0,420,The account is 2FA protected so it will be deleted in a week. Otherwise it can be reset in {seconds} ABOUT_TOO_LONG,400,The provided bio is too long ACCESS_TOKEN_EXPIRED,400,Bot token expired ACCESS_TOKEN_INVALID,400,The provided token is not valid @@ -67,6 +67,7 @@ CHAT_ABOUT_TOO_LONG,400,Chat about too long CHAT_ADMIN_INVITE_REQUIRED,403,You do not have the rights to do this CHAT_ADMIN_REQUIRED,400,"Chat admin privileges are required to do that in the specified chat (for example, to send a message in a channel which is not yours), or invalid permissions used for the channel or group" CHAT_FORBIDDEN,403,You cannot write in this chat +CHAT_FORWARDS_RESTRICTED,, CHAT_ID_EMPTY,400,The provided chat ID is empty CHAT_ID_INVALID,400,"Invalid object ID for a chat. Make sure to pass the right types, for instance making sure that the request is designed for chats (not channels/megagroups) or otherwise look for a different one more suited\nAn example working with a megagroup and AddChatUserRequest, it will fail because megagroups are channels. Use InviteToChannelRequest instead" CHAT_INVALID,400,The chat is invalid for this request @@ -102,7 +103,7 @@ DH_G_A_INVALID,400,g_a invalid DOCUMENT_INVALID,400,The document file was invalid and can't be used in inline mode 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}" +EMAIL_UNCONFIRMED_0,400,"Email unconfirmed, the length of the code must be {code_length}" EMOJI_INVALID,400, EMOJI_NOT_MODIFIED,400, EMOTICON_EMPTY,400,The emoticon field cannot be empty @@ -117,6 +118,7 @@ ENCRYPTION_OCCUPY_FAILED,500,TDLib developer claimed it is not an error while ac ENTITIES_TOO_LONG,400,It is no longer possible to send such long data inside entity tags (for example inline text URLs) ENTITY_MENTION_USER_INVALID,400,You can't use this entity ERROR_TEXT_EMPTY,400,The provided error message is empty +EXPIRE_DATE_INVALID,400, EXPIRE_FORBIDDEN,400, EXPORT_CARD_INVALID,400,Provided card is invalid EXTERNAL_URL_INVALID,400,External URL invalid @@ -125,22 +127,21 @@ FIELD_NAME_INVALID,400,The field with the name FIELD_NAME is invalid FILEREF_UPGRADE_NEEDED,406,The file reference needs to be refreshed before being used again FILE_CONTENT_TYPE_INVALID,400, FILE_ID_INVALID,400,"The provided file id is invalid. Make sure all parameters are present, have the correct type and are not empty (ID, access hash, file reference, thumb size ...)" -FILE_MIGRATE_X,303,The file to be accessed is currently stored in DC {new_dc} +FILE_MIGRATE_0,303,The file to be accessed is currently stored in DC {new_dc} FILE_PARTS_INVALID,400,The number of file parts is invalid -FILE_PART_0_MISSING,400,File part 0 missing FILE_PART_EMPTY,400,The provided file part is empty FILE_PART_INVALID,400,The file part number is invalid 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_PART_0_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 FILE_REFERENCE_INVALID,400,The file reference is invalid or you can't do that operation on such message FILE_TITLE_EMPTY,400, 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 -FLOOD_WAIT_X,420,A wait of {seconds} seconds is required +FLOOD_TEST_PHONE_WAIT_0,420,A wait of {seconds} seconds is required in the test servers +FLOOD_WAIT_0,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 @@ -176,8 +177,8 @@ INPUT_LAYER_INVALID,400,The provided layer is invalid INPUT_METHOD_INVALID,400,The invoked method does not exist anymore or has never existed INPUT_REQUEST_TOO_LONG,400,The input request was too long. This may be a bug in the library as it can occur when serializing more bytes than it should (like appending the vector constructor code at the end of a message) INPUT_USER_DEACTIVATED,400,The specified user was deleted -INTERDC_X_CALL_ERROR,500,An error occurred while communicating with DC {dc} -INTERDC_X_CALL_RICH_ERROR,500,A rich error occurred while communicating with DC {dc} +INTERDC_0_CALL_ERROR,500,An error occurred while communicating with DC {dc} +INTERDC_0_CALL_RICH_ERROR,500,A rich error occurred while communicating with DC {dc} INVITE_FORBIDDEN_WITH_JOINAS,400, INVITE_HASH_EMPTY,400,The invite hash is empty INVITE_HASH_EXPIRED,400,The chat the user tried to join has expired and is not valid anymore @@ -220,7 +221,7 @@ MT_SEND_QUEUE_TOO_LONG,500, MULTI_MEDIA_TOO_LONG,400,Too many media files were included in the same album NEED_CHAT_INVALID,500,The provided chat is invalid NEED_MEMBER_INVALID,500,The provided member is invalid or does not exist (for example a thumb size) -NETWORK_MIGRATE_X,303,The source IP address is associated with DC {new_dc} +NETWORK_MIGRATE_0,303,The source IP address is associated with DC {new_dc} NEW_SALT_INVALID,400,The new salt is invalid NEW_SETTINGS_INVALID,400,The new settings are invalid NEXT_OFFSET_INVALID,400,The value for next_offset is invalid. Check that it has normal characters and is not too long @@ -233,6 +234,7 @@ PACK_SHORT_NAME_OCCUPIED,400,A stickerpack with this name already exists PARTICIPANTS_TOO_FEW,400,Not enough participants PARTICIPANT_CALL_FAILED,500,Failure while making call PARTICIPANT_JOIN_MISSING,403, +PARTICIPANT_ID_INVALID,, 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 @@ -240,7 +242,7 @@ PASSWORD_MISSING,400,The account must have 2-factor authentication enabled (a pa PASSWORD_RECOVERY_EXPIRED,400, PASSWORD_RECOVERY_NA,400, 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 +PASSWORD_TOO_FRESH_0,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,400,Too many requests PEER_ID_INVALID,400,"An invalid Peer was used. Make sure to pass the right peer type and that the value is valid (for instance, bots cannot start conversations)" @@ -252,7 +254,7 @@ PHONE_CODE_EMPTY,400,The phone code is missing PHONE_CODE_EXPIRED,400,The confirmation code has expired PHONE_CODE_HASH_EMPTY,400,The phone code hash is missing PHONE_CODE_INVALID,400,The phone code entered was invalid -PHONE_MIGRATE_X,303,The phone number a user is trying to use for authorization is associated with DC {new_dc} +PHONE_MIGRATE_0,303,The phone number a user is trying to use for authorization is associated with DC {new_dc} PHONE_NUMBER_APP_SIGNUP_FORBIDDEN,400,You can't sign up using this app PHONE_NUMBER_BANNED,400,The used phone number has been banned from Telegram and cannot be used anymore. Maybe check https://www.telegram.org/faq_spam PHONE_NUMBER_FLOOD,400,You asked for the code too many times. @@ -278,7 +280,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 POLL_VOTE_REQUIRED,403, -PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN,406,"Similar to a flood wait, must wait {minutes} minutes" +PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_0MIN,406,"Similar to a flood wait, must wait {minutes} minutes" PRIVACY_KEY_INVALID,400,The privacy key is invalid PRIVACY_TOO_LONG,400,Cannot add that many entities in a single request PRIVACY_VALUE_INVALID,400,The privacy value is invalid @@ -319,22 +321,24 @@ SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes 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)" +SEND_AS_PEER_INVALID,, +SEND_CODE_UNAVAILABLE,406, SEND_MESSAGE_MEDIA_INVALID,400,The message media was invalid or not specified SEND_MESSAGE_TYPE_INVALID,400,The message type is invalid SENSITIVE_CHANGE_FORBIDDEN,403,Your sensitive content settings cannot be changed at this time 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 +SESSION_TOO_FRESH_0,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 SHORT_NAME_INVALID,400, SHORT_NAME_OCCUPIED,400, -SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat +SLOWMODE_WAIT_0,420,A wait of {seconds} seconds is required before sending another message in this chat SRP_ID_INVALID,400, 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} +STATS_MIGRATE_0,303,The channel statistics must be fetched from DC {dc} STICKERSET_INVALID,400,The provided sticker set is invalid STICKERSET_OWNER_ANONYMOUS,406,This sticker set can't be used as the group's official stickers because it was created by one of its anonymous admins STICKERS_EMPTY,400,No sticker provided @@ -351,7 +355,7 @@ STICKER_THUMB_PNG_NOPNG,400,Stickerset thumb must be a png file but the used fil STICKER_THUMB_TGS_NOTGS,400,Stickerset thumb must be a tgs file but the used file was not tgs STORAGE_CHECK_FAILED,500,Server storage check failed STORE_INVALID_SCALAR_TYPE,500, -TAKEOUT_INIT_DELAY_X,420,A wait of {seconds} seconds is required before being able to initiate the takeout +TAKEOUT_INIT_DELAY_0,420,A wait of {seconds} seconds is required before being able to initiate the takeout TAKEOUT_INVALID,400,The takeout session has been invalidated by another data export session TAKEOUT_REQUIRED,400,You must initialize a takeout request first TEMP_AUTH_KEY_EMPTY,400,No temporary auth key provided @@ -370,6 +374,7 @@ TYPE_CONSTRUCTOR_INVALID,400,The type constructor is invalid UNKNOWN_ERROR,400, UNKNOWN_METHOD,500,The method you tried to call cannot be called on non-CDN DCs UNTIL_DATE_INVALID,400,That date cannot be specified in this request (try using None) +UPDATE_APP_TO_LOGIN,406, URL_INVALID,400,The URL used was invalid (e.g. when answering a callback with a URL that's not t.me/yourbot or your game's URL) USER_VOLUME_INVALID,400, USERNAME_INVALID,400,"Nobody is using this username, or the username is unacceptable. If the latter, it must match r""[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]""" @@ -395,7 +400,7 @@ USER_INVALID,400,The given user was invalid USER_IS_BLOCKED,400 403,User is blocked USER_IS_BOT,400,Bots can't send messages to other bots USER_KICKED,400,This user was kicked from this supergroup/channel -USER_MIGRATE_X,303,The user whose identity is being used to execute queries is associated with DC {new_dc} +USER_MIGRATE_0,303,The user whose identity is being used to execute queries is associated with DC {new_dc} USER_NOT_MUTUAL_CONTACT,400 403,The provided user is not a mutual contact USER_NOT_PARTICIPANT,400,The target user is not a member of the specified megagroup or channel USER_PRIVACY_RESTRICTED,403,The user's privacy settings do not allow you to do this diff --git a/telethon_generator/data/methods.csv b/telethon_generator/data/methods.csv index 79cbdc94..fff32139 100644 --- a/telethon_generator/data/methods.csv +++ b/telethon_generator/data/methods.csv @@ -7,7 +7,7 @@ account.confirmPasswordEmail,user, account.confirmPhone,user,CODE_HASH_INVALID PHONE_CODE_EMPTY account.createTheme,user,THEME_MIME_INVALID account.declinePasswordReset,user,RESET_REQUEST_MISSING -account.deleteAccount,user,2FA_CONFIRM_WAIT_X +account.deleteAccount,user,2FA_CONFIRM_WAIT_0 account.deleteSecureValue,user, account.finishTakeoutSession,user, account.getAccountTTL,user, @@ -57,7 +57,7 @@ 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.updatePasswordSettings,user,EMAIL_UNCONFIRMED_0 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,THEME_INVALID @@ -80,7 +80,7 @@ auth.importLoginToken,user,AUTH_TOKEN_ALREADY_ACCEPTED AUTH_TOKEN_EXPIRED AUTH_T auth.logOut,both, auth.recoverPassword,user,CODE_EMPTY NEW_SETTINGS_INVALID auth.requestPasswordRecovery,user,PASSWORD_EMPTY PASSWORD_RECOVERY_NA -auth.resendCode,user,PHONE_NUMBER_INVALID +auth.resendCode,user,PHONE_NUMBER_INVALID SEND_CODE_UNAVAILABLE auth.resetAuthorizations,user,TIMEOUT auth.sendCode,user,API_ID_INVALID API_ID_PUBLISHED_FLOOD AUTH_RESTART INPUT_REQUEST_TOO_LONG PHONE_NUMBER_APP_SIGNUP_FORBIDDEN PHONE_NUMBER_BANNED PHONE_NUMBER_FLOOD PHONE_NUMBER_INVALID PHONE_PASSWORD_FLOOD PHONE_PASSWORD_PROTECTED auth.signIn,user,PHONE_CODE_EMPTY PHONE_CODE_EXPIRED PHONE_CODE_INVALID PHONE_NUMBER_INVALID PHONE_NUMBER_UNOCCUPIED SESSION_PASSWORD_NEEDED @@ -97,7 +97,7 @@ 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 SRP_ID_INVALID +channels.editCreator,user,PASSWORD_MISSING PASSWORD_TOO_FRESH_0 SESSION_TOO_FRESH_0 SRP_ID_INVALID channels.editLocation,user, channels.editPhoto,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED FILE_REFERENCE_INVALID PHOTO_INVALID channels.editTitle,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED CHAT_NOT_MODIFIED @@ -197,7 +197,7 @@ messages.editChatPhoto,both,CHAT_ID_INVALID INPUT_CONSTRUCTOR_INVALID INPUT_FETC 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 INLINE_BOT_REQUIRED INPUT_USER_DEACTIVATED MEDIA_GROUPED_INVALID 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.exportChatInvite,both,CHAT_ID_INVALID EXPIRE_DATE_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 QUIZ_ANSWER_MISSING 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, @@ -251,7 +251,7 @@ messages.getWebPage,user,WC_CONVERT_URL_INVALID messages.getWebPagePreview,user, 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.initHistoryImport,user,IMPORT_FILE_INVALID IMPORT_FORMAT_UNRECOGNIZED PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN TIMEOUT +messages.initHistoryImport,user,IMPORT_FILE_INVALID IMPORT_FORMAT_UNRECOGNIZED PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_0MIN TIMEOUT messages.installStickerSet,user,STICKERSET_INVALID messages.markDialogUnread,user, messages.migrateChat,user,CHAT_ADMIN_REQUIRED CHAT_ID_INVALID PEER_ID_INVALID @@ -337,8 +337,8 @@ reqPq,both, reqPqMulti,both, rpcDropAnswer,both, setClientDHParams,both, -stats.getBroadcastStats,user,BROADCAST_REQUIRED CHAT_ADMIN_REQUIRED CHP_CALL_FAIL STATS_MIGRATE_X -stats.getMegagroupStats,user,CHAT_ADMIN_REQUIRED MEGAGROUP_REQUIRED STATS_MIGRATE_X +stats.getBroadcastStats,user,BROADCAST_REQUIRED CHAT_ADMIN_REQUIRED CHP_CALL_FAIL STATS_MIGRATE_0 +stats.getMegagroupStats,user,CHAT_ADMIN_REQUIRED MEGAGROUP_REQUIRED STATS_MIGRATE_0 stats.loadAsyncGraph,user,GRAPH_INVALID_RELOAD GRAPH_OUTDATED_RELOAD stickers.addStickerToSet,bot,BOT_MISSING STICKERSET_INVALID STICKER_PNG_NOPNG STICKER_TGS_NOTGS stickers.changeStickerPosition,bot,BOT_MISSING STICKER_INVALID diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index 34b599ff..50a856b0 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -370,7 +370,7 @@ def _write_html_pages(tlobjects, methods, layer, input_res): )) docs.begin_table(column_count=2) for error in errors: - docs.add_row('{}'.format(error.name)) + docs.add_row('{}'.format(error.canonical_name)) docs.add_row('{}.'.format(error.description)) docs.end_table() docs.write_text('You can import these from ' @@ -396,7 +396,7 @@ def _write_html_pages(tlobjects, methods, layer, input_res): docs.write('
') docs.write('''
\
-from telethon.sync import TelegramClient
+from telethon import TelegramClient
 from telethon import functions, types
 
 with TelegramClient(name, api_id, api_hash) as client:
diff --git a/telethon_generator/generators/errors.py b/telethon_generator/generators/errors.py
index 386575be..5771369c 100644
--- a/telethon_generator/generators/errors.py
+++ b/telethon_generator/generators/errors.py
@@ -1,60 +1,12 @@
 def generate_errors(errors, f):
-    # Exact/regex match to create {CODE: ErrorClassName}
-    exact_match = []
-    regex_match = []
-
-    # Find out what subclasses to import and which to create
-    import_base, create_base = set(), {}
+    f.write('_captures = {\n')
     for error in errors:
-        if error.subclass_exists:
-            import_base.add(error.subclass)
-        else:
-            create_base[error.subclass] = error.int_code
+        if error.capture_name:
+            f.write(f"    {error.canonical_name!r}: {error.capture_name!r},\n")
+    f.write('}\n')
 
-        if error.has_captures:
-            regex_match.append(error)
-        else:
-            exact_match.append(error)
-
-    # Imports and new subclass creation
-    f.write('from .rpcbaseerrors import RPCError, {}\n'
-            .format(", ".join(sorted(import_base))))
-
-    for cls, int_code in sorted(create_base.items(), key=lambda t: t[1]):
-        f.write('\n\nclass {}(RPCError):\n    code = {}\n'
-                .format(cls, int_code))
-
-    # Error classes generation
+    f.write('\n\n_descriptions = {\n')
     for error in errors:
-        f.write('\n\nclass {}({}):\n    '.format(error.name, error.subclass))
-
-        if error.has_captures:
-            f.write('def __init__(self, request, capture=0):\n    '
-                    '    self.request = request\n    ')
-            f.write('    self.{} = int(capture)\n        '
-                    .format(error.capture_name))
-        else:
-            f.write('def __init__(self, request):\n    '
-                    '    self.request = request\n        ')
-
-        f.write('super(Exception, self).__init__('
-                '{}'.format(repr(error.description)))
-
-        if error.has_captures:
-            f.write('.format({0}=self.{0})'.format(error.capture_name))
-
-        f.write(' + self._fmt_request(self.request))\n\n')
-        f.write('    def __reduce__(self):\n        ')
-        if error.has_captures:
-            f.write('return type(self), (self.request, self.{})\n'.format(error.capture_name))
-        else:
-            f.write('return type(self), (self.request,)\n')
-
-    # Create the actual {CODE: ErrorClassName} dict once classes are defined
-    f.write('\n\nrpc_errors_dict = {\n')
-    for error in exact_match:
-        f.write('    {}: {},\n'.format(repr(error.pattern), error.name))
-    f.write('}\n\nrpc_errors_re = (\n')
-    for error in regex_match:
-        f.write('    ({}, {}),\n'.format(repr(error.pattern), error.name))
-    f.write(')\n')
+        if error.description:
+            f.write(f"    {error.canonical_name!r}: {error.description!r},\n")
+    f.write('}\n')
diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py
index 4326b189..e5a3d07e 100644
--- a/telethon_generator/generators/tlobject.py
+++ b/telethon_generator/generators/tlobject.py
@@ -32,7 +32,7 @@ AUTO_CASTS = {
 }
 
 NAMED_AUTO_CASTS = {
-    ('chat_id', 'int'): 'await client.get_peer_id({}, add_mark=False)'
+    ('chat_id', 'int'): 'await client.get_peer_id({})'
 }
 
 # Secret chats have a chat_id which may be negative.
@@ -52,7 +52,7 @@ BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128',
 
 
 def _write_modules(
-        out_dir, depth, kind, namespace_tlobjects, type_constructors):
+        out_dir, in_mod, kind, namespace_tlobjects, type_constructors, layer, all_tlobjects):
     # namespace_tlobjects: {'namespace', [TLObject]}
     out_dir.mkdir(parents=True, exist_ok=True)
     for ns, tlobjects in namespace_tlobjects.items():
@@ -60,10 +60,11 @@ def _write_modules(
         with file.open('w') as f, SourceBuilder(f) as builder:
             builder.writeln(AUTO_GEN_NOTICE)
 
-            builder.writeln('from {}.tl.tlobject import TLObject', '.' * depth)
-            if kind != 'TLObject':
-                builder.writeln(
-                    'from {}.tl.tlobject import {}', '.' * depth, kind)
+            if kind == 'TLObject':
+                builder.writeln('from .._misc.tlobject import TLObject, TLRequest')
+                builder.writeln('from . import fn')
+            else:
+                builder.writeln('from ..._misc.tlobject import TLObject, TLRequest')
 
             builder.writeln('from typing import Optional, List, '
                             'Union, TYPE_CHECKING')
@@ -124,7 +125,11 @@ def _write_modules(
                     if not name or name in primitives:
                         continue
 
-                    import_space = '{}.tl.types'.format('.' * depth)
+                    if kind == 'TLObject':
+                        import_space = '.'
+                    else:
+                        import_space = '..'
+
                     if '.' in name:
                         namespace = name.split('.')[0]
                         name = name.split('.')[1]
@@ -158,6 +163,9 @@ def _write_modules(
             for line in type_defs:
                 builder.writeln(line)
 
+            if not ns and kind == 'TLObject':
+                _write_all_tlobjects(all_tlobjects, layer, builder)
+
 
 def _write_source_code(tlobject, kind, builder, type_constructors):
     """
@@ -170,7 +178,6 @@ def _write_source_code(tlobject, kind, builder, type_constructors):
     """
     _write_class_init(tlobject, kind, type_constructors, builder)
     _write_resolve(tlobject, builder)
-    _write_to_dict(tlobject, builder)
     _write_to_bytes(tlobject, builder)
     _write_from_reader(tlobject, builder)
     _write_read_result(tlobject, builder)
@@ -181,6 +188,15 @@ def _write_class_init(tlobject, kind, type_constructors, builder):
     builder.writeln()
     builder.writeln('class {}({}):', tlobject.class_name, kind)
 
+    # Define slots to help reduce the size of the objects a little bit.
+    # It's also good for knowing what fields an object has.
+    builder.write('__slots__ = (')
+    sep = ''
+    for arg in tlobject.real_args:
+        builder.write('{}{!r},', sep, arg.name)
+        sep = ' '
+    builder.writeln(')')
+
     # Class-level variable to store its Telegram's constructor ID
     builder.writeln('CONSTRUCTOR_ID = {:#x}', tlobject.id)
     builder.writeln('SUBCLASS_OF_ID = {:#x}',
@@ -284,42 +300,6 @@ def _write_resolve(tlobject, builder):
         builder.end_block()
 
 
-def _write_to_dict(tlobject, builder):
-    builder.writeln('def to_dict(self):')
-    builder.writeln('return {')
-    builder.current_indent += 1
-
-    builder.write("'_': '{}'", tlobject.class_name)
-    for arg in tlobject.real_args:
-        builder.writeln(',')
-        builder.write("'{}': ", arg.name)
-        if arg.type in BASE_TYPES:
-            if arg.is_vector:
-                builder.write('[] if self.{0} is None else self.{0}[:]',
-                              arg.name)
-            else:
-                builder.write('self.{}', arg.name)
-        else:
-            if arg.is_vector:
-                builder.write(
-                    '[] if self.{0} is None else [x.to_dict() '
-                    'if isinstance(x, TLObject) else x for x in self.{0}]',
-                    arg.name
-                )
-            else:
-                builder.write(
-                    'self.{0}.to_dict() '
-                    'if isinstance(self.{0}, TLObject) else self.{0}',
-                    arg.name
-                )
-
-    builder.writeln()
-    builder.current_indent -= 1
-    builder.writeln("}")
-
-    builder.end_block()
-
-
 def _write_to_bytes(tlobject, builder):
     builder.writeln('def _bytes(self):')
 
@@ -653,12 +633,6 @@ def _write_arg_read_code(builder, arg, tlobject, name):
 
 
 def _write_all_tlobjects(tlobjects, layer, builder):
-    builder.writeln(AUTO_GEN_NOTICE)
-    builder.writeln()
-
-    builder.writeln('from . import types, functions')
-    builder.writeln()
-
     # Create a constant variable to indicate which layer this is
     builder.writeln('LAYER = {}', layer)
     builder.writeln()
@@ -670,18 +644,19 @@ def _write_all_tlobjects(tlobjects, layer, builder):
     # Fill the dictionary (0x1a2b3c4f: tl.full.type.path.Class)
     for tlobject in tlobjects:
         builder.write('{:#010x}: ', tlobject.id)
-        builder.write('functions' if tlobject.is_function else 'types')
+        if tlobject.is_function:
+            builder.write('fn.')
 
         if tlobject.namespace:
-            builder.write('.{}', tlobject.namespace)
+            builder.write('{}.', tlobject.namespace)
 
-        builder.writeln('.{},', tlobject.class_name)
+        builder.writeln('{},', tlobject.class_name)
 
     builder.current_indent -= 1
     builder.writeln('}')
 
 
-def generate_tlobjects(tlobjects, layer, import_depth, output_dir):
+def generate_tlobjects(tlobjects, layer, input_mod, output_dir):
     # Group everything by {namespace: [tlobjects]} to generate __init__.py
     namespace_functions = defaultdict(list)
     namespace_types = defaultdict(list)
@@ -695,15 +670,10 @@ def generate_tlobjects(tlobjects, layer, import_depth, output_dir):
             namespace_types[tlobject.namespace].append(tlobject)
             type_constructors[tlobject.result].append(tlobject)
 
-    _write_modules(output_dir / 'functions', import_depth, 'TLRequest',
-                   namespace_functions, type_constructors)
-    _write_modules(output_dir / 'types', import_depth, 'TLObject',
-                   namespace_types, type_constructors)
-
-    filename = output_dir / 'alltlobjects.py'
-    with filename.open('w') as file:
-        with SourceBuilder(file) as builder:
-            _write_all_tlobjects(tlobjects, layer, builder)
+    _write_modules(output_dir, input_mod, 'TLObject',
+                   namespace_types, type_constructors, layer, tlobjects)
+    _write_modules(output_dir / 'fn', input_mod + '.fn', 'TLRequest',
+                   namespace_functions, type_constructors, layer, tlobjects)
 
 
 def clean_tlobjects(output_dir):
@@ -711,7 +681,3 @@ def clean_tlobjects(output_dir):
         d = output_dir / d
         if d.is_dir():
             shutil.rmtree(str(d))
-
-    tl = output_dir / 'alltlobjects.py'
-    if tl.is_file():
-        tl.unlink()
diff --git a/telethon_generator/parsers/errors.py b/telethon_generator/parsers/errors.py
index 04cd3412..0982edea 100644
--- a/telethon_generator/parsers/errors.py
+++ b/telethon_generator/parsers/errors.py
@@ -17,25 +17,16 @@ KNOWN_BASE_CLASSES = {
 }
 
 
-def _get_class_name(error_code):
+def _get_canonical_name(error_code):
     """
-    Gets the corresponding class name for the given error code,
-    this either being an integer (thus base error name) or str.
+    Gets the corresponding canonical name for the given error code.
     """
-    if isinstance(error_code, int):
-        return KNOWN_BASE_CLASSES.get(
-            abs(error_code), 'RPCError' + str(error_code).replace('-', 'Neg')
-        )
+    # This code should match that of the library itself.
+    name = re.sub(r'[-_\d]', '', error_code).lower()
+    while name.endswith('error'):
+        name = name[:-len('error')]
 
-    if error_code.startswith('2'):
-        error_code = re.sub(r'2', 'TWO_', error_code, count=1)
-
-    if re.match(r'\d+', error_code):
-        raise RuntimeError('error code starting with a digit cannot have valid Python name: {}'.format(error_code))
-
-    return snake_to_camel_case(
-        error_code.replace('FIRSTNAME', 'FIRST_NAME')\
-                  .replace('SLOWMODE', 'SLOW_MODE').lower(), suffix='Error')
+    return name
 
 
 class Error:
@@ -45,18 +36,13 @@ class Error:
         # Telegram isn't exactly consistent with returned errors anyway.
         self.int_code = codes[0]
         self.str_code = name
-        self.subclass = _get_class_name(codes[0])
-        self.subclass_exists = abs(codes[0]) in KNOWN_BASE_CLASSES
+        self.canonical_name = _get_canonical_name(name)
         self.description = description
 
-        self.has_captures = '_X' in name
-        if self.has_captures:
-            self.name = _get_class_name(name.replace('_X', '_'))
-            self.pattern = name.replace('_X', r'_(\d+)')
+        has_captures = '0' in name
+        if has_captures:
             self.capture_name = re.search(r'{(\w+)}', description).group(1)
         else:
-            self.name = _get_class_name(name)
-            self.pattern = name
             self.capture_name = None
 
 
diff --git a/telethon_generator/parsers/tlobject/tlobject.py b/telethon_generator/parsers/tlobject/tlobject.py
index da6c5f65..60b9e996 100644
--- a/telethon_generator/parsers/tlobject/tlobject.py
+++ b/telethon_generator/parsers/tlobject/tlobject.py
@@ -52,8 +52,7 @@ class TLObject:
                 assert self.id == self.infer_id(),\
                     'Invalid inferred ID for ' + repr(self)
 
-        self.class_name = snake_to_camel_case(
-            self.name, suffix='Request' if self.is_function else '')
+        self.class_name = snake_to_camel_case(self.name)
 
         self.real_args = list(a for a in self.sorted_args() if not
                               (a.flag_indicator or a.generic_definition))
diff --git a/tests/telethon/test_helpers.py b/tests/telethon/test_helpers.py
index 689db8af..5ac4a78e 100644
--- a/tests/telethon/test_helpers.py
+++ b/tests/telethon/test_helpers.py
@@ -14,43 +14,6 @@ def test_strip_text():
     # I can't interpret the rest of the code well enough yet
 
 
-class TestSyncifyAsyncContext:
-    class NoopContextManager:
-        def __init__(self, loop):
-            self.count = 0
-            self.loop = loop
-
-        async def __aenter__(self):
-            self.count += 1
-            return self
-
-        async def __aexit__(self, exc_type, *args):
-            assert exc_type is None
-            self.count -= 1
-
-        __enter__ = helpers._sync_enter
-        __exit__ = helpers._sync_exit
-
-    def test_sync_acontext(self, event_loop):
-        contm = self.NoopContextManager(event_loop)
-        assert contm.count == 0
-
-        with contm:
-            assert contm.count == 1
-
-        assert contm.count == 0
-
-    @pytest.mark.asyncio
-    async def test_async_acontext(self, event_loop):
-        contm = self.NoopContextManager(event_loop)
-        assert contm.count == 0
-
-        async with contm:
-            assert contm.count == 1
-
-        assert contm.count == 0
-
-
 def test_generate_key_data_from_nonce():
     gkdfn = helpers.generate_key_data_from_nonce
 
diff --git a/tests/telethon/tl/test_serialization.py b/tests/telethon/tl/test_serialization.py
index 7dbb067d..7bcdf25b 100644
--- a/tests/telethon/tl/test_serialization.py
+++ b/tests/telethon/tl/test_serialization.py
@@ -1,13 +1,13 @@
 import pytest
 
-from telethon.tl import types, functions
+from telethon import _tl
 
 
 def test_nested_invalid_serialization():
     large_long = 2**62
-    request = functions.account.SetPrivacyRequest(
-        key=types.InputPrivacyKeyChatInvite(),
-        rules=[types.InputPrivacyValueDisallowUsers(users=[large_long])]
+    request = _tl.fn.account.SetPrivacy(
+        key=_tl.InputPrivacyKeyChatInvite(),
+        rules=[_tl.InputPrivacyValueDisallowUsers(users=[large_long])]
     )
     with pytest.raises(TypeError):
         bytes(request)