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..40512dbd 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): @@ -75,7 +79,7 @@ useful information. .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _MTProto: https://core.telegram.org/mtproto .. _Telegram: https://telegram.org -.. _Compatibility and Convenience: https://docs.telethon.dev/en/latest/misc/compatibility-and-convenience.html +.. _Compatibility and Convenience: https://docs.telethon.dev/en/stable/misc/compatibility-and-convenience.html .. _Read The Docs: https://docs.telethon.dev .. |logo| image:: logo.svg 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..8a100db9 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.get_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.get_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..00781be7 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? ===================================== @@ -153,7 +75,7 @@ loops or use ``async with``: async with client: # ^ this is an asynchronous with block - async for message in client.iter_messages(chat): + async for message in client.get_messages(chat): # ^ this is a for loop over an asynchronous generator print(message.sender.username) @@ -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/botapi-vs-mtproto.rst b/readthedocs/concepts/botapi-vs-mtproto.rst index cddb52d0..224bca08 100644 --- a/readthedocs/concepts/botapi-vs-mtproto.rst +++ b/readthedocs/concepts/botapi-vs-mtproto.rst @@ -145,7 +145,7 @@ After using Telethon: Key differences: -* The recommended way to do it imports less things. +* The recommended way to do it imports fewer things. * All handlers trigger by default, so we need ``events.StopPropagation``. * Adding handlers, responding and running is a lot less verbose. * Telethon needs ``async def`` and ``await``. diff --git a/readthedocs/concepts/chats-vs-channels.rst b/readthedocs/concepts/chats-vs-channels.rst index 87281373..51ffef7a 100644 --- a/readthedocs/concepts/chats-vs-channels.rst +++ b/readthedocs/concepts/chats-vs-channels.rst @@ -100,12 +100,12 @@ Note that this function can also work with other types, like :tl:`Chat` or :tl:`Channel` instances. If you need to convert other types like usernames which might need to perform -API calls to find out the identifier, you can use ``client.get_peer_id``: +API calls to find out the identifier, you can use ``client.get_profile``: .. code-block:: python - print(await client.get_peer_id('me')) # your id + print((await client.get_profile('me')).id) # your id If there is no "mark" (no minus sign), Telethon will assume your identifier diff --git a/readthedocs/concepts/entities.rst b/readthedocs/concepts/entities.rst index 40bfac30..bd60072a 100644 --- a/readthedocs/concepts/entities.rst +++ b/readthedocs/concepts/entities.rst @@ -1,20 +1,27 @@ .. _entities: -======== -Entities -======== +=============== +Users and Chats +=============== -The library widely uses the concept of "entities". An entity will refer -to any :tl:`User`, :tl:`Chat` or :tl:`Channel` object that the API may return -in response to certain methods, such as :tl:`GetUsersRequest`. +The library widely uses the concept of "users" to refer to both real accounts +and bot accounts, as well as the concept of "chats" to refer to groups and +broadcast channels. + +The most general term you can use to think about these is "an entity", but +recent versions of the library often prefer to opt for names which better +reflect the intention, such as "dialog" when a previously-existing +conversation is expected, or "profile" when referring to the information about +the user or chat. .. note:: - When something "entity-like" is required, it means that you need to - provide something that can be turned into an entity. These things include, - but are not limited to, usernames, exact titles, IDs, :tl:`Peer` objects, - or even entire :tl:`User`, :tl:`Chat` and :tl:`Channel` objects and even - phone numbers **from people you have in your contact list**. + When something "dialog-like" is required, it means that you need to + provide something that can be used to refer to an open conversation. + These things include, but are not limited to, packed chats, usernames, + integer IDs (identifiers), :tl:`Peer` objects, or even entire :tl:`User`, + :tl:`Chat` and :tl:`Channel` objects and even phone numbers **from people + you have in your contact list**. To "encounter" an ID, you would have to "find it" like you would in the normal app. If the peer is in your dialogs, you would need to @@ -23,82 +30,123 @@ in response to certain methods, such as :tl:`GetUsersRequest`. `client.get_participants(group) `. Once you have encountered an ID, the library will (by default) have saved - their ``access_hash`` for you, which is needed to invoke most methods. + its packed version for you, which is needed to invoke most methods. This is why sometimes you might encounter this error when working with the library. You should ``except ValueError`` and run code that you know - should work to find the entity. + should work to find the user or chat. You **cannot** use an ID of someone + you haven't interacted with. Because this is more unreliable, packed chats + are recommended instead. .. contents:: -What is an Entity? -================== +What is a User? +=============== -A lot of methods and requests require *entities* to work. For example, -you send a message to an *entity*, get the username of an *entity*, and -so on. +A `User ` can be either a real user account +(some person who has signed up for an account) or a bot account which is +programmed to perform certain actions (created by a developer via +`@BotFather `_). -There are a lot of things that work as entities: usernames, phone numbers, -chat links, invite links, IDs, and the types themselves. That is, you can -use any of those when you see an "entity" is needed. +A lot of methods and requests require user or chats to work. For example, +you can send a message to a *user*, ban a *user* from a group, and so on. +These methods accept more than just `User ` +as the input parameter. You can also use packed users, usernames, string phone +numbers, or integer IDs, although some have higher cost than others. + +When using the username, the library must fetch it first, which can be +expensive. When using the phone number, the library must fetch it first, which +can be expensive. If you plan to use these, it's recommended you manually use +`client.get_profile() ` to cache +the username or phone number, and then use the value returned instead. .. note:: Remember that the phone number must be in your contact list before you can use it. -You should use, **from better to worse**: +The recommended type to use as input parameters to the methods is either a +`User ` instance or its packed type. -1. Input entities. For example, `event.input_chat - `, - `message.input_sender - `, - or caching an entity you will use a lot with - ``entity = await client.get_input_entity(...)``. - -2. Entities. For example, if you had to get someone's - username, you can just use ``user`` or ``channel``. - It will work. Only use this option if you already have the entity! - -3. IDs. This will always look the entity up from the - cache (the ``*.session`` file caches seen entities). - -4. Usernames, phone numbers and links. The cache will be - used too (unless you force a `client.get_entity() - `), - but may make a request if the username, phone or link - has not been found yet. - -In recent versions of the library, the following two are equivalent: - -.. code-block:: python - - async def handler(event): - await client.send_message(event.sender_id, 'Hi') - await client.send_message(event.input_sender, 'Hi') +In the raw API, users are instances of :tl:`User` (or :tl:`UserEmpty`), which +are returned in response to some requests, such as :tl:`GetUsersRequest`. +There are also variants for use as "input parameters", such as :tl:`InputUser` +and :tl:`InputPeerUser`. You generally **do not need** to worry about these +types unless you're using raw API. -If you need to be 99% sure that the code will work (sometimes it's -simply impossible for the library to find the input entity), or if -you will reuse the chat a lot, consider using the following instead: +What is a Chat? +=============== -.. code-block:: python +A `Chat ` can be a small group chat (the +default group type created by users where many users can join and talk), a +megagroup (also known as "supergroup"), a broadcast channel or a broadcast +group. - async def handler(event): - # This method may make a network request to find the input sender. - # Properties can't make network requests, so we need a method. - sender = await event.get_input_sender() - await client.send_message(sender, 'Hi') - await client.send_message(sender, 'Hi') +The term "chat" is really overloaded in Telegram. The library tries to be +explicit and always use "small group chat", "megagroup" and "broadcast" to +differentiate. However, Telegram's API uses "chat" to refer to both "chat" +(small group chat), and "channel" (megagroup, broadcast or "gigagroup" which +is a broadcast group of type channel). + +A lot of methods and requests require a chat to work. For example, +you can get the participants from a *chat*, kick users from a *chat*, and so on. +These methods accept more than just `Chat ` +as the input parameter. You can also use packed chats, the public link, or +integer IDs, although some have higher cost than others. + +When using the public link, the library must fetch it first, which can be +expensive. If you plan to use these, it's recommended you manually use +`client.get_profile() ` to cache +the link, and then use the value returned instead. + +.. note:: + + The link of a public chat has the form "t.me/username", where the username + can belong to either an actual user or a public chat. + +The recommended type to use as input parameters to the methods is either a +`Chat ` instance or its packed type. + +In the raw API, chats are instances of :tl:`Chat` and :tl:`Channel` (or +:tl:`ChatEmpty`, :tl:`ChatForbidden` and :tl:`ChannelForbidden`), which +are returned in response to some requests, such as :tl:`messages.GetChats` +and :tl:`channels.GetChannels`. There are also variants for use as "input +parameters", such as :tl:`InputChannel` and :tl:`InputPeerChannel`. You +generally **do not need** to worry about these types unless you're using raw API. -Getting Entities -================ +When to use each term? +====================== + +The term "dialog" is used when the library expects a reference to an open +conversation (from the list the user sees when they open the application). + +The term "profile" is used instead of "dialog" when the conversation is not +expected to exist. Because "dialog" is more specific than "profile", "dialog" +is used where possible instead. + +In general, you should not use named arguments for neither "dialogs" or +"profiles", since they're the first argument. The parameter name only exists +for documentation purposes. + +The term "chat" is used where a group or broadcast channel is expected. This +includes small groups, megagroups, broadcast channels and broadcast groups. +Telegram's API has, in the past, made a difference between which methods can +be used for "small group chats" and everything else. For example, small group +chats cannot have a public link (they automatically convert to megagroups). +Group permissions also used to be different, but because Telegram may unify +these eventually, the library attempts to hide this distinction. In general, +this is not something you should worry about. + + +Fetching profile information +============================ Through the use of the :ref:`sessions`, the library will automatically -remember the ID and hash pair, along with some extra information, so -you're able to just do this: +remember the packed users and chats, along with some extra information, +so you're able to just do this: .. code-block:: python @@ -106,146 +154,37 @@ you're able to just do this: # # Dialogs are the "conversations you have open". # This method returns a list of Dialog, which - # has the .entity attribute and other information. + # has the .user and .chat attributes (among others). # - # This part is IMPORTANT, because it fills the entity cache. + # This part is IMPORTANT, because it fills the cache. dialogs = await client.get_dialogs() - # All of these work and do the same. - username = await client.get_entity('username') - username = await client.get_entity('t.me/username') - username = await client.get_entity('https://telegram.dog/username') + # All of these work and do the same, but are more expensive to use. + channel = await client.get_profile('username') + channel = await client.get_profile('t.me/username') + channel = await client.get_profile('https://telegram.dog/username') + contact = await client.get_profile('+34xxxxxxxxx') - # Other kind of entities. - channel = await client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg') - contact = await client.get_entity('+34xxxxxxxxx') - friend = await client.get_entity(friend_id) + # This will work, but only if the ID is in cache. + friend = await client.get_profile(friend_id) - # Getting entities through their ID (User, Chat or Channel) - entity = await client.get_entity(some_id) - - # You can be more explicit about the type for said ID by wrapping - # it inside a Peer instance. This is recommended but not necessary. - from telethon.tl.types import PeerUser, PeerChat, PeerChannel - - my_user = await client.get_entity(PeerUser(some_id)) - my_chat = await client.get_entity(PeerChat(some_id)) - my_channel = await client.get_entity(PeerChannel(some_id)) + # This is the most reliable way to fetch a profile. + user = await client.get_profile('U.123.456789') + group = await client.get_profile('G.456.0') + broadcast = await client.get_profile('C.789.123456') -.. note:: - - You **don't** need to get the entity before using it! Just let the - library do its job. Use a phone from your contacts, username, ID or - input entity (preferred but not necessary), whatever you already have. - -All methods in the :ref:`telethon-client` call `.get_input_entity() -` prior -to sending the request to save you from the hassle of doing so manually. +All methods in the :ref:`telethon-client` accept any of the above +prior to sending the request to save you from the hassle of doing so manually. That way, convenience calls such as `client.send_message('username', 'hi!') -` -become possible. - -Every entity the library encounters (in any response to any call) will by -default be cached in the ``.session`` file (an SQLite database), to avoid -performing unnecessary API calls. If the entity cannot be found, additonal -calls like :tl:`ResolveUsernameRequest` or :tl:`GetContactsRequest` may be -made to obtain the required information. - - -Entities vs. Input Entities -=========================== - -.. note:: - - This section is informative, but worth reading. The library - will transparently handle all of these details for you. - -On top of the normal types, the API also make use of what they call their -``Input*`` versions of objects. The input version of an entity (e.g. -:tl:`InputPeerUser`, :tl:`InputChat`, etc.) only contains the minimum -information that's required from Telegram to be able to identify -who you're referring to: a :tl:`Peer`'s **ID** and **hash**. They -are named like this because they are input parameters in the requests. - -Entities' ID are the same for all user and bot accounts, however, the access -hash is **different for each account**, so trying to reuse the access hash -from one account in another will **not** work. - -Sometimes, Telegram only needs to indicate the type of the entity along -with their ID. For this purpose, :tl:`Peer` versions of the entities also -exist, which just have the ID. You cannot get the hash out of them since -you should not be needing it. The library probably has cached it before. - -Peers are enough to identify an entity, but they are not enough to make -a request with them. You need to know their hash before you can -"use them", and to know the hash you need to "encounter" them, let it -be in your dialogs, participants, message forwards, etc. - -.. note:: - - You *can* use peers with the library. Behind the scenes, they are - replaced with the input variant. Peers "aren't enough" on their own - but the library will do some more work to use the right type. - -As we just mentioned, API calls don't need to know the whole information -about the entities, only their ID and hash. For this reason, another method, -`client.get_input_entity() ` -is available. This will always use the cache while possible, making zero API -calls most of the time. When a request is made, if you provided the full -entity, e.g. an :tl:`User`, the library will convert it to the required -:tl:`InputPeer` automatically for you. - -**You should always favour** -`client.get_input_entity() ` -**over** -`client.get_entity() ` -for this reason! Calling the latter will always make an API call to get -the most recent information about said entity, but invoking requests don't -need this information, just the :tl:`InputPeer`. Only use -`client.get_entity() ` -if you need to get actual information, like the username, name, title, etc. -of the entity. - -To further simplify the workflow, since the version ``0.16.2`` of the -library, the raw requests you make to the API are also able to call -`client.get_input_entity() ` -wherever needed, so you can even do things like: - -.. code-block:: python - - await client(SendMessageRequest('username', 'hello')) - -The library will call the ``.resolve()`` method of the request, which will -resolve ``'username'`` with the appropriated :tl:`InputPeer`. Don't worry if -you don't get this yet, but remember some of the details here are important. - - -Full Entities -============= - -In addition to :tl:`PeerUser`, :tl:`InputPeerUser`, :tl:`User` (and its -variants for chats and channels), there is also the concept of :tl:`UserFull`. - -This full variant has additional information such as whether the user is -blocked, its notification settings, the bio or about of the user, etc. - -There is also :tl:`messages.ChatFull` which is the equivalent of full entities -for chats and channels, with also the about section of the channel. Note that -the ``users`` field only contains bots for the channel (so that clients can -suggest commands to use). - -You can get both of these by invoking :tl:`GetFullUser`, :tl:`GetFullChat` -and :tl:`GetFullChannel` respectively. - - -Accessing Entities -================== +` become possible. +However, it can be expensive to fetch the username every time, so this is +better left for things which are not executed often. Although it's explicitly noted in the documentation that messages *subclass* `ChatGetter ` and `SenderGetter `, -some people still don't get inheritance. +this section will explain what this means. When the documentation says "Bases: `telethon.tl.custom.chatgetter.ChatGetter`" it means that the class you're looking at, *also* can act as the class it @@ -258,9 +197,9 @@ That means you can do this: .. code-block:: python - message.is_private message.chat_id - await message.get_chat() + message.chat + await event.get_chat() # ...etc `SenderGetter ` is similar: @@ -268,8 +207,8 @@ That means you can do this: .. code-block:: python message.user_id - await message.get_input_user() message.user + await event.get_input_user() # ...etc Quite a few things implement them, so it makes sense to reuse the code. @@ -278,11 +217,51 @@ For example, all events (except raw updates) implement `ChatGetter in some chat. +Packed User and packed Chat +=========================== + +A packed `User ` or a packed +`Chat ` can be thought of as +"a small string reference to the actual user or chat". + +It can easily be saved or embedded in the code for later use, +without having to worry if the user is in the session file cache. + +This "packed representation" is a compact way to store the type of the User +or Chat (is it a user account, a bot, a broadcast channel…), the identifier, +and the access hash. This "access hash" is something Telegram uses to ensure +that you can actually use this "User" or "Chat" in requests (so you can't just +create some random user identifier and expect it to work). + +In the raw API, this is pretty much "input peers", but the library uses the +term "packed user or chat" to refer to its custom type and string +representation. + +The User and Chat IDs are the same for all user and bot accounts. However, the +access hash is **different for each account**, so trying to reuse the access +hash from one account in another will **not** work. This also means the packed +representation will only work for the account that created it. + +The library needs to have this access hash in some way for it to work. +If it only has an ID and this ID is not in cache, it will not work. +If using the packed representation, the hash is embedded, and will always work. + +Every method, including raw API, will automatically convert your types to the +expected input type the API uses, meaning the following will work: + + +.. code-block:: python + + await client(_tl.fn.messages.SendMessage('username', 'hello')) + +(This is only a raw API example, there are better ways to send messages.) + + Summary ======= -TL;DR; If you're here because of *"Could not find the input entity for"*, -you must ask yourself "how did I find this entity through official +TL;DR; If you're here because of *"Could not find the input peer for"*, +you must ask yourself, "how did I find this user or chat through official applications"? Now do the same with the library. Use what applies: .. code-block:: python @@ -290,24 +269,28 @@ applications"? Now do the same with the library. Use what applies: # (These examples assume you are inside an "async def") async with client: # Does it have a username? Use it! - entity = await client.get_entity(username) + user = await client.get_profile(username) # Do you have a conversation open with them? Get dialogs. await client.get_dialogs() - # Are they participant of some group? Get them. + # Are they participants of some group? Get them. await client.get_participants('username') - # Is the entity the original sender of a forwarded message? Get it. + # Is the user the original sender of a forwarded message? Fetch the message. await client.get_messages('username', 100) - # NOW you can use the ID, anywhere! + # NOW you can use the ID anywhere! await client.send_message(123456, 'Hi!') - entity = await client.get_entity(123456) - print(entity) + user = await client.get_profile(123456) + print(user) -Once the library has "seen" the entity, you can use their **integer** ID. -You can't use entities from IDs the library hasn't seen. You must make the -library see them *at least once* and disconnect properly. You know where -the entities are and you must tell the library. It won't guess for you. +Once the library has "seen" the user or chat, you can use their **integer** ID. +You can't use users or chats from IDs the library hasn't seen. You must make +the library see them *at least once* and disconnect properly. You know where +the user or chat are, and you must tell the library. It won't guess for you. + +This is why it's recommended to use the packed versions instead. They will +always work (unless Telegram, for some very unlikely reason, changes the way +using users and chats works, of course). diff --git a/readthedocs/concepts/full-api.rst b/readthedocs/concepts/full-api.rst index cce026f1..374633f4 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:: @@ -129,7 +129,7 @@ as you wish. Remember to use the right types! To sum up: .. code-block:: python result = await client(SendMessageRequest( - await client.get_input_entity('username'), 'Hello there!' + await client.get_profile('username'), 'Hello there!' )) 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/concepts/strings.rst b/readthedocs/concepts/strings.rst index a696b684..b8e25c12 100644 --- a/readthedocs/concepts/strings.rst +++ b/readthedocs/concepts/strings.rst @@ -8,7 +8,7 @@ does a result have? Well, the easiest thing to do is printing it: .. code-block:: python - entity = await client.get_entity('username') + entity = await client.get_profile('username') print(entity) That will show a huge **string** similar to the following: diff --git a/readthedocs/concepts/updates.rst b/readthedocs/concepts/updates.rst index 0c80c344..ce6cc1b0 100644 --- a/readthedocs/concepts/updates.rst +++ b/readthedocs/concepts/updates.rst @@ -28,7 +28,7 @@ In short, you should do this: buttons = await event.get_buttons() async def main(): - async for message in client.iter_messages('me', 10): + async for message in client.get_messages('me', 10): # Methods from the client always have these properties ready chat = message.input_chat sender = message.sender diff --git a/readthedocs/developing/test-servers.rst b/readthedocs/developing/test-servers.rst index d93190a4..513eed54 100644 --- a/readthedocs/developing/test-servers.rst +++ b/readthedocs/developing/test-servers.rst @@ -25,13 +25,17 @@ so don't store sensitive data here. Valid phone numbers are ``99966XYYYY``, where ``X`` is the ``dc_id`` and ``YYYY`` is any number you want, for example, ``1234`` in ``dc_id = 2`` would -be ``9996621234``. The code sent by Telegram will be ``dc_id`` repeated six -times, in this case, ``222222`` so we can hardcode that: +be ``9996621234``. The code sent by Telegram will be ``dc_id`` repeated five +times, in this case, ``22222`` so we can hardcode that: .. code-block:: python client = TelegramClient(None, api_id, api_hash) client.session.set_dc(2, '149.154.167.40', 80) client.start( - phone='9996621234', code_callback=lambda: '222222' + phone='9996621234', code_callback=lambda: '22222' ) + +Note that Telegram has changed the length of login codes multiple times in the +past, so if ``dc_id`` repeated five times does not work, try repeating it six +times. 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/chats-and-channels.rst b/readthedocs/examples/chats-and-channels.rst index 53b508be..4d84615b 100644 --- a/readthedocs/examples/chats-and-channels.rst +++ b/readthedocs/examples/chats-and-channels.rst @@ -84,6 +84,10 @@ use is very straightforward, or :tl:`InviteToChannelRequest` for channels: [users_to_add] )) +Note that this method will only really work for friends or bot accounts. +Trying to mass-add users with this approach will not work, and can put both +your account and group to risk, possibly being flagged as spam and limited. + Checking a link without joining =============================== @@ -103,7 +107,7 @@ use :tl:`GetMessagesViewsRequest`, setting ``increment=True``: .. code-block:: python - # Obtain `channel' through dialogs or through client.get_entity() or anyhow. + # Obtain `channel' through dialogs or through client.get_profile() or anyhow. # Obtain `msg_ids' through `.get_messages()` or anyhow. Must be a list. await client(GetMessagesViewsRequest( 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 564effdd..5e85c069 100644 --- a/readthedocs/misc/changelog.rst +++ b/readthedocs/misc/changelog.rst @@ -13,6 +13,61 @@ 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) +=================================== + ++------------------------+ +| Scheme layer used: 133 | ++------------------------+ + +This is a rushed release. It contains a layer recent enough to not fail with +``UPDATE_APP_TO_LOGIN``, but still not the latest, to avoid breaking more +than necessary. + +Breaking Changes +~~~~~~~~~~~~~~~~ + +* The biggest change is user identifiers (and chat identifiers, and others) + **now use up to 64 bits**, rather than 32. If you were storing them in some + storage with fixed size, you may need to update (such as database tables + storing only integers). + +There have been other changes which I currently don't have the time to document. +You can refer to the following link to see them early: +https://github.com/LonamiWebs/Telethon/compare/v1.23.0...v1.24.0 + + +New schema and bug fixes (v1.23) +================================ + ++------------------------+ +| Scheme layer used: 130 | ++------------------------+ + +`View new and changed raw API methods `__. + +Enhancements +~~~~~~~~~~~~ + +* `client.pin_message() ` + can now pin on a single side in PMs. +* Iterating participants should now be less expensive floodwait-wise. + +Bug fixes +~~~~~~~~~ + +* The QR login URL was being encoded incorrectly. +* ``force_document`` was being ignored in inline queries for document. +* ``manage_call`` permission was accidentally set to ``True`` by default. + New schema and bug fixes (v1.22) ================================ diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst new file mode 100644 index 00000000..3c649b46 --- /dev/null +++ b/readthedocs/misc/v2-migration-guide.rst @@ -0,0 +1,991 @@ +========================= +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 +// TODO get_peer_id is gone now too! + + +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? + + +Overhaul of events and updates +------------------------------ + +Updates produced by the client are now also processed by your event handlers. +Before, if you had some code listening for new outgoing messages, only messages you sent with +another client, such as from Telegram Desktop, would be processed. Now, if your own code uses +``client.send_message``, you will also receive the new message event. Be careful, as this can +easily lead to "loops" (a new outgoing message can trigger ``client.send_message``, which +triggers a new outgoing message and the cycle repeats)! + +There are no longer "event builders" and "event" types. Now there are only events, and you +register the type of events you want, not an instance. Because of this, the way filters are +specified have also changed: + +.. code-block:: python + + # OLD + @client.on(events.NewMessage(chats=...)) + async def handler(event): + pass + + # NEW + @client.on(events.NewMessage, chats=...) + async def handler(event): # ^^ ^ + pass + +This also means filters are unified, although not all filters have an effect on all events types. +Type hinting is now done through ``events.NewMessage`` and not ``events.NewMessage.Event``. + +The filter rework also enables more features. For example, you can now mutate a ``chats`` filter +to add or remove a chat that needs to be received by a handler, rather than having to remove and +re-add the event handler. + +The ``from_users`` filter has been renamed to ``senders``. + +The ``inbox`` filter for ``events.MessageRead`` has been removed, in favour of ``outgoing`` and +``incoming``. + +``events.register``, ``events.unregister`` and ``events.is_handler`` have been removed. There is +no longer anything special about methods which are handlers, and they are no longer monkey-patched. +Because pre-defining the event type to handle without a client was useful, you can now instead use +the following syntax: + +.. code-block:: python + + # OLD + @events.register(events.NewMessage) + async def handler(event): + pass + + # NEW + async def handler(event: events.NewMessage): + pass # ^^^^^^^^^^^^^^^^^^^^^^^^ + +As a bonus, you only need to type-hint once, and both your IDE and Telethon will understand what +you meant. This is similar to Python's ``@dataclass`` which uses type hints. + +// TODO document filter creation and usage, showcase how to mutate them + + +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 immutable and 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``. + +Both the raw API types and functions are now immutable. This can enable optimizations in the +future, such as greatly reducing the number of intermediate objects created (something worth +doing for deeply-nested objects). + +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``). + + +Changes to sending messages and files +------------------------------------- + +When sending messages or files, there is no longer a parse mode. Instead, the ``markdown`` or +``html`` parameters can be used instead of the (plaintext) ``message``. + +.. code-block:: python + + await client.send_message(chat, 'Default formatting (_markdown_)') + await client.send_message(chat, html='Force HTML formatting') + await client.send_message(chat, markdown='Force **Markdown** formatting') + +These 3 parameters are exclusive with each other (you can only use one). The goal here is to make +it consistent with the custom ``Message`` class, which also offers ``.markdown`` and ``.html`` +properties to obtain the correctly-formatted text, regardless of the default parse mode, and to +get rid of some implicit behaviour. It's also more convenient to set just one parameter than two +(the message and the parse mode separatedly). + +Although the goal is to reduce raw API exposure, ``formatting_entities`` stays, because it's the +only feasible way to manually specify them. + +When sending files, you can no longer pass a list of attributes. This was a common workaround to +set video size, audio duration, and so on. Now, proper parameters are available. The goal is to +hide raw API as much as possible (which lets the library hide future breaking changes as much as +possible). One can still use raw API if really needed. + + +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 to many friendly methods in the client +---------------------------------------------- + +Some of the parameters used to initialize the ``TelegramClient`` have been renamed to be clearer: + +* ``timeout`` is now ``connect_timeout``. +* ``connection_retries`` is now ``connect_retries``. +* ``retry_delay`` is now ``connect_retry_delay``. +* ``raise_last_call_error`` has been removed and is now the default. This means you won't get a + ``ValueError`` if an API call fails multiple times, but rather the original error. +* ``connection`` to change the connection mode has been removed for the time being. +* ``sequential_updates`` has been removed for the time being. + +// TODO document new parameters too + +``client.send_code_request`` no longer has ``force_sms`` (it was broken and was never reliable). + +``client.send_read_acknowledge`` is now ``client.mark_read``, consistent with the method of +``Message``, being shorter and less awkward to type. The method now only supports a single +message, not a list (the list was a lie, because all messages up to the one with the highest +ID were marked as read, meaning one could not leave unread gaps). ``max_id`` is now removed, +since it has the same meaning as the message to mark as read. The method no longer can clear +mentions without marking the chat as read, but this should not be an issue in practice. + +Every ``client.action`` can now be directly ``await``-ed, not just ``'cancel'``. + +``client.forward_messages`` now requires a list to be specified. The intention is to make it clear +that the method forwards message\ **s** and to reduce the number of strange allowed values, which +needlessly complicate the code. If you still need to forward a single message, manually construct +a list with ``[message]`` or use ``Message.forward_to``. + +``client.delete_messages`` now requires a list to be specified, with the same rationale as forward. + +``client.get_me`` no longer has an ``input_peer`` parameter. The goal is to hide raw API as much +as possible. Input peers are mostly an implementation detail the library needs to deal with +Telegram's API. + +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``. + +The size selector for ``client.download_profile_photo`` and ``client.download_media`` is now using +an enumeration: + +.. code-block:: python + + from telethon import enums + + await client.download_profile_photo(user, thumb=enums.Size.ORIGINAL) + +This new selection mode is also smart enough to pick the "next best" size if the specified one +is not available. The parameter is known as ``thumb`` and not ``size`` because documents don't +have a "size", they have thumbnails of different size. For profile photos, the thumbnail size is +also used. + +// 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. + + +Overhaul of users and chats are no longer raw API types +------------------------------------------------------- + +Users and chats are no longer raw API types. The goal is to reduce the amount of raw API exposed +to the user, and to provide less confusing naming. This also means that **the sender and chat of +messages and events is now a different type**. If you were using `isinstance` to check the types, +you will need to update that code. However, if you were accessing things like the ``first_name`` +or ``username``, you will be fine. + +Raw API is not affected by this change. When using it, the raw :tl:`User`, :tl:`Chat` and +:tl:`Channel` are still returned. + +For friendly methods and events, There are now two main entity types, `User` and `Chat`. +`User`\ s are active entities which can send messages and interact with eachother. There is an +account controlling them. `Chat`\ s are passive entities where multiple users can join and +interact with each other. This includes small groups, supergroups, and broadcast channels. + +``event.get_sender``, ``event.sender``, ``event.get_chat``, and ``event.chat`` (as well as +the same methods on ``message`` and elsewhere) now return this new type. The ``sender`` and +``chat`` is **now always returned** (where it makes sense, so no sender in channel messages), +even if Telegram did not include information about it in the update. This means you can use +send messages to ``event.chat`` without worrying if Telegram included this information or not, +or even access ``event.chat.id``. This was often a papercut. However, if you need other +information like the title, you might still need to use ``await event.get_chat()``, which is +used to signify an API call might be necessary. + +``event.get_input_sender``, ``event.input_sender``, ``message.get_input_sender`` and +``message.input_sender`` (among other variations) have been removed. Instead, a new ``compact`` +method has been added to the new `User` and `Chat` types, which can be used to obtain a compact +representation of the sender. The "input" terminology is confusing for end-users, as it's mostly +an implementation detail of friendly methods. Because the return type would've been different +had these methods been kept, one would have had to review code using them regardless. + +What this means is that, if you now want a compact way to store a user or chat for later use, +you should use ``compact``: + +.. code-block:: python + + compacted_user = message.sender.compact() + # store compacted_user in a database or elsewhere for later use + +Public methods accept this type as input parameters. This means you can send messages to a +compacted user or chat, for example. + +``event.is_private``, ``event.is_group`` and ``event.is_channel`` have **been removed** (among +other variations, such as in ``message``). It didn't make much sense to ask "is this event a +group", and there is no such thing as "group messages" currently either. Instead, it's sensible +to ask if the sender of a message is a group, or the chat of an event is a channel. New properties +have been added to both the `User` and `Chat` classes: + +* ``.is_user`` will always be `True` for `User` and `False` for `Chat`. +* ``.is_group`` will be `False` for `User` and be `True` for small group chats and supergroups. +* ``.is_broadcast`` will be `False` for `User` and `True` for broadcast channels and broadcast groups. + +Because the properties exist both in `User` and `Chat`, you do not need use `isinstance` to check +if a sender is a channel or if a chat is a user. + +Some fields of the new `User` type differ from the naming or value type of its raw API counterpart: + +* ``user.restriction_reason`` has been renamed to ``restriction_reasons`` (with a trailing **s**) + and now always returns a list. +* ``user.bot_chat_history`` has been renamed to ``user.bot_info.chat_history_access``. +* ``user.bot_nochats`` has been renamed to ``user.bot_info.private_only``. +* ``user.bot_inline_geo`` has been renamed to ``user.bot_info.inline_geo``. +* ``user.bot_info_version`` has been renamed to ``user.bot_info.version``. +* ``user.bot_inline_placeholder`` has been renamed to ``user.bot_info.inline_placeholder``. + +The new ``user.bot_info`` field will be `None` for non-bots. The goal is to unify where this +information is found and reduce clutter in the main ``user`` type. + +Some fields of the new `Chat` type differ from the naming or value type of its raw API counterpart: + +* ``chat.date`` is currently not available. It's either the chat creation or join date, but due + to this inconsistency, it's not included to allow for a better solution in the future. +* ``chat.has_link`` is currently not available, to allow for a better alternative in the future. +* ``chat.has_geo`` is currently not available, to allow for a better alternative in the future. +* ``chat.call_active`` is currently not available, until it's decided what to do about calls. +* ``chat.call_not_empty`` is currently not available, until it's decided what to do about calls. +* ``chat.version`` was removed. It's an implementation detail. +* ``chat.min`` was removed. It's an implementation detail. +* ``chat.deactivated`` was removed. It's redundant with ``chat.migrated_to``. +* ``chat.forbidden`` has been added as a replacement for ``isinstance(chat, (ChatForbidden, ChannelForbidden))``. +* ``chat.forbidden_until`` has been added as a replacement for ``until_date`` in forbidden chats. +* ``chat.restriction_reason`` has been renamed to ``restriction_reasons`` (with a trailing **s**) + and now always returns a list. +* ``chat.migrated_to`` no longer returns a raw type, and instead returns this new `Chat` type. + +If you have a need for these, please step in, and explain your use case, so we can work together +to implement a proper design. + +Both the new `User` and `Chat` types offer a ``fetch`` method, which can be used to refetch the +instance with fresh information, including the full information about the user (such as the user's +biography or a chat's about description). + + +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('left'), + Button.inline('center'), + Button.inline('right'), + ]]) + #+ + + +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? + + +Certain client properties and methods are now private or no longer exist +------------------------------------------------------------------------ + +The ``client.loop`` property has been removed. ``asyncio`` has been moving towards implicit loops, +so this is the next step. Async methods can be launched with the much simpler ``asyncio.run`` (as +opposed to the old ``client.loop.run_until_complete``). + +The ``client.upload_file`` method has been removed. It's a low-level method users should not need +to use. Its only purpose could have been to implement a cache of sorts, but this is something the +library needs to do, not the users. + +The methods to deal with folders have been removed. The goal is to find and offer a better +interface to deal with both folders and archived chats in the future if there is demand for it. +This includes the removal of ``client.edit_folder``, ``Dialog.archive``, ``Dialog.archived``, and +the ``archived`` parameter of ``client.get_dialogs``. The ``folder`` parameter remains as it's +unlikely to change. + + +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. + + +--- + +todo update send_message and send_file docs (well review all functions) + +album overhaul. use a list of Message instead. + +is_connected is now a property (consistent with the rest of ``is_`` properties) + +send_code_request now returns a custom type (reducing raw api). +sign_in no longer has phone or phone_hash (these are impl details, and now it's less error prone). also mandatory code=. also no longer is a no-op if already logged in. different error for sign up required. +send code / sign in now only expect a single phone. resend code with new phone is send code, not resend. +sign_up code is also now a kwarg. and no longer noop if already loggedin. +start also mandates phone= or password= as kwarg. +qrlogin expires has been replaced with timeout and expired for parity with tos and auth. the goal is to hide the error-prone system clock and instead use asyncio's clock. recreate was removed (just call qr_login again; parity with get_tos). class renamed to QrLogin. now must be used in a contextmgr to prevent misuse. +"entity" parameters have been renamed to "dialog" (user or chat expected) or "chat" (only chats expected), "profile" (if that makes sense). the goal is to move away from the entity terminology. this is intended to be a documentation change, but because the parameters were renamed, it's breaking. the expected usage of positional arguments is mostly unaffected. this includes the EntityLike hint. +download_media param renamed message to media. iter_download file to media too +less types are supported to get entity (exact names, private links are undocumented but may work). get_entity is get_profile. get_input_entity is gone. get_peer_id is gone (if the isntance needs to be fetched anyway just use get_profile). 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..22409f05 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 ====== @@ -145,7 +136,7 @@ ParticipantPermissions :show-inheritance: -QRLogin +QrLogin ======= .. automodule:: telethon.tl.custom.qrlogin diff --git a/readthedocs/quick-references/client-reference.rst b/readthedocs/quick-references/client-reference.rst index 5b998344..482ce320 100644 --- a/readthedocs/quick-references/client-reference.rst +++ b/readthedocs/quick-references/client-reference.rst @@ -103,11 +103,9 @@ Dialogs iter_dialogs get_dialogs - edit_folder iter_drafts get_drafts delete_dialog - conversation Users ----- @@ -120,9 +118,7 @@ Users get_me is_bot is_user_authorized - get_entity - get_input_entity - get_peer_id + get_profile Chats ----- @@ -169,6 +165,7 @@ Updates remove_event_handler list_event_handlers catch_up + set_receive_updates Bots ---- 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..f1d7df4f 100644 --- a/readthedocs/quick-references/objects-reference.rst +++ b/readthedocs/quick-references/objects-reference.rst @@ -155,39 +155,12 @@ 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 ============= The `AdminLogEvent ` object -is returned by the `client.iter_admin_log() -` method to easily iterate +is returned by the `client.get_admin_log() +` method to easily iterate over past "events" (deleted messages, edits, title changes, leaving members…) These are all the properties you can find in it: @@ -297,7 +270,7 @@ Dialog ====== The `Dialog ` object is returned when -you call `client.iter_dialogs() `. +you call `client.get_dialogs() `. .. currentmodule:: telethon.tl.custom.dialog.Dialog @@ -313,7 +286,7 @@ Draft ====== The `Draft ` object is returned when -you call `client.iter_drafts() `. +you call `client.get_drafts() `. .. currentmodule:: telethon.tl.custom.draft.Draft 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..373fc90c 100755 --- a/setup.py +++ b/setup.py @@ -47,16 +47,16 @@ 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' # Which raw API methods are covered by *friendly* methods in the client? 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_IN_TLS = [Path(x) for x in sorted(GENERATOR_DIR.glob('data/*.tl'))] +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..4451a0a3 --- /dev/null +++ b/telethon/_client/account.py @@ -0,0 +1,75 @@ +import functools +import inspect +import typing +import dataclasses +import asyncio +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(**self._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 self.takeout_active: + raise ValueError('a previous takeout session was already active') + + takeout = await self(_tl.fn.account.InitTakeoutSession( + contacts=contacts, + message_users=users, + message_chats=chats, + message_megagroups=megagroups, + message_channels=channels, + files=files, + file_max_size=max_file_size + )) + await self._replace_session_state(takeout_id=takeout.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 self.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..2a3e3af3 --- /dev/null +++ b/telethon/_client/auth.py @@ -0,0 +1,408 @@ +import asyncio +import getpass +import inspect +import os +import sys +import typing +import warnings +import functools +import time +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 + + +async def sign_in( + self: 'TelegramClient', + *, + code: typing.Union[str, int] = None, + password: str = None, + bot_token: str = None,) -> 'typing.Union[_tl.User, _tl.auth.SentCode]': + if code and bot_token: + raise ValueError('Can only provide one of code or bot_token, not both') + + if not code and not bot_token and not password: + raise ValueError('You must provide code, password, or bot_token.') + + if code: + if not self._phone_code_hash: + raise ValueError('Must call client.send_code_request before sign in') + + # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, + # PhoneCodeHashEmptyError or PhoneCodeInvalidError. + try: + result = await self(_tl.fn.auth.SignIn(*self._phone_code_hash, str(code))) + password = None # user provided a password but it was not needed + except errors.SessionPasswordNeededError: + if not password: + raise + elif bot_token: + result = await self(_tl.fn.auth.ImportBotAuthorization( + flags=0, bot_auth_token=bot_token, + api_id=self._api_id, api_hash=self._api_hash + )) + + if password: + pwd = await self(_tl.fn.account.GetPassword()) + result = await self(_tl.fn.auth.CheckPassword( + pwd_mod.compute_check(pwd, password) + )) + + if isinstance(result, _tl.auth.AuthorizationSignUpRequired): + # The method must return the User but we don't have it, so raise instead (matches pre-layer 104 behaviour) + self._tos = (result.terms_of_service, None) + raise errors.SignUpRequired() + + return await _update_session_state(self, result.user) + + +async def sign_up( + self: 'TelegramClient', + first_name: str, + last_name: str = '', + *, + code: typing.Union[str, int]) -> '_tl.User': + if not self._phone_code_hash: + # This check is also present in sign_in but we do it here to customize the error message + raise ValueError('Must call client.send_code_request before sign up') + + # 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: + try: + return await self.sign_in(code=code) + except errors.SignUpRequired: + pass # code is correct and was used, now need to sign in + + result = await self(_tl.fn.auth.SignUp( + phone_number=phone, + phone_code_hash=phone_code_hash, + first_name=first_name, + last_name=last_name + )) + + return await _update_session_state(self, result.user) + + +async def get_tos(self): + first_time = self._tos is None + no_tos = self._tos and self._tos[0] is None + tos_expired = self._tos and self._tos[1] is not None and asyncio.get_running_loop().time() >= self._tos[1] + + if first_time or no_tos or tos_expired: + result = await self(_tl.fn.help.GetTermsOfServiceUpdate()) + tos = getattr(result, 'terms_of_service', None) + self._tos = (tos, asyncio.get_running_loop().time() + result.expires.timestamp() - time.time()) + + # not stored in the client to prevent a cycle + return _custom.TermsOfService._new(self, *self._tos) + + +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, + ) + + self._phone_code_hash = None + return _custom.User._new(self, 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) -> 'SentCode': + phone = utils.parse_phone(phone) + + if self._phone_code_hash and phone == self._phone_code_hash[0]: + result = await self(_tl.fn.auth.ResendCode(*self._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 not result.phone_code_hash: + # The hash is required to login, so this pretty much means send code failed + raise ValueError('Failed to send code') + + self._phone_code_hash = (phone, result.phone_code_hash) + return _custom.SentCode._new(result) + + +def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QrLogin: + return _custom.QrLoginManager(self, ignored_ids) + + +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..b2b8c1c8 --- /dev/null +++ b/telethon/_client/bots.py @@ -0,0 +1,37 @@ +import typing +import asyncio + +from ..types import _custom +from .._misc import hints +from .. import errors, _tl + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +async def inline_query( + self: 'TelegramClient', + bot: 'hints.DialogLike', + query: str, + *, + dialog: 'hints.DialogLike' = None, + offset: str = None, + geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults: + bot = await self._get_input_peer(bot) + if dialog: + peer = await self._get_input_peer(dialog) + else: + peer = _tl.InputPeerEmpty() + + try: + result = await self(_tl.fn.messages.GetInlineBotResults( + bot=bot, + peer=peer, + query=query, + offset=offset or '', + geo_point=geo_point + )) + except errors.BotResponseTimeoutError: + raise asyncio.TimeoutError from None + + return _custom.InlineResults(self, result, entity=peer if dialog else None) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py new file mode 100644 index 00000000..8737e576 --- /dev/null +++ b/telethon/_client/chats.py @@ -0,0 +1,690 @@ +import asyncio +import inspect +import itertools +import string +import typing +import dataclasses + +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._delay = delay + self._auto_cancel = auto_cancel + self._request = _tl.fn.messages.SetTyping(chat, action) + self._task = None + self._running = False + + def __await__(self): + return self._once().__await__() + + async def __aenter__(self): + self._request = dataclasses.replace(self._request, peer=await self._client._get_input_peer(self._request.peer)) + 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._request = dataclasses.replace(self._request, peer=await self._client._get_input_peer(self._request.peer)) + 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._request.action, 'progress'): + self._request = dataclasses.replace( + self._request, + action=dataclasses.replace(self._request.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_peer(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_profile(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 = dataclasses.replace(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 = dataclasses.replace(self.request, offset=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 + + if isinstance(participant, types.ChannelParticipantLeft): + # These participants should be ignored. See #3231. + continue + + 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_peer(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_peer(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 = dataclasses.replace(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 = dataclasses.replace(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 = dataclasses.replace(ev, action=dataclasses.replace( + ev.action, + prev_message=_custom.Message._new(self.client, ev.action.prev_message, entities, self.entity), + 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_peer(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 = dataclasses.replace(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 = dataclasses.replace(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 = dataclasses.replace(self.request, offset=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 = dataclasses.replace( + self.request, + add_offset=0, + offset_id=result.messages[-1].id + ) + + +def get_participants( + self: 'TelegramClient', + chat: 'hints.DialogLike', + limit: float = (), + *, + search: str = '', + filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter: + return _ParticipantsIter( + self, + limit, + entity=chat, + filter=filter, + search=search + ) + + +def get_admin_log( + self: 'TelegramClient', + chat: 'hints.DialogLike', + limit: float = (), + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.DialogsLike' = 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=chat, + 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', + profile: 'hints.DialogLike', + limit: int = (), + *, + offset: int = 0, + max_id: int = 0) -> _ProfilePhotoIter: + return _ProfilePhotoIter( + self, + limit, + entity=profile, + offset=offset, + max_id=max_id + ) + + +def action( + self: 'TelegramClient', + dialog: 'hints.DialogLike', + 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, dialog, action, delay=delay, auto_cancel=auto_cancel) + +async def edit_admin( + self: 'TelegramClient', + chat: 'hints.DialogLike', + user: 'hints.DialogLike', + *, + 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_peer(chat) + user = await self._get_input_peer(user) + ty = helpers._entity_type(user) + + 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_profile(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', + chat: 'hints.DialogLike', + user: 'typing.Optional[hints.DialogLike]' = 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_peer(chat) + ty = helpers._entity_type(entity) + + 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_peer(user) + + 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', + chat: 'hints.DialogLike', + user: 'typing.Optional[hints.DialogLike]' +): + entity = await self._get_input_peer(chat) + user = await self._get_input_peer(user) + + 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', + chat: 'hints.DialogLike', + user: 'hints.DialogLike' = None +) -> 'typing.Optional[_custom.ParticipantPermissions]': + entity = await self.get_profile(chat) + + if not user: + if helpers._entity_type(entity) != helpers._EntityType.USER: + return entity.default_banned_rights + + entity = await self._get_input_peer(entity) + user = await self._get_input_peer(user) + + 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 = _tl.PeerUser(self._session_state.user_id) + 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', + chat: 'hints.DialogLike', + message: 'typing.Union[int, _tl.Message]' = None, +): + entity = await self._get_input_peer(chat) + + 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..7b3b722f --- /dev/null +++ b/telethon/_client/dialogs.py @@ -0,0 +1,211 @@ +import asyncio +import inspect +import itertools +import typing +import dataclasses + +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 = dataclasses.replace(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 = dataclasses.replace( + self.request, + exclude_pinned=True, + offset_id=last_message.id if last_message else 0, + offset_date=last_message.date if last_message else None, + 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_peer(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.DialogLike' = _tl.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, +) -> _DialogsIter: + 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', + dialog: 'hints.DialogsLike' = None +) -> _DraftsIter: + limit = None + if dialog: + if not utils.is_list_like(dialog): + dialog = (dialog,) + limit = len(dialog) + + return _DraftsIter(self, limit, entities=dialog) + + +async def delete_dialog( + self: 'TelegramClient', + dialog: 'hints.DialogLike', + *, + 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_peer(dialog) + 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..0f919510 --- /dev/null +++ b/telethon/_client/downloads.py @@ -0,0 +1,771 @@ +import datetime +import io +import os +import pathlib +import typing +import inspect +import asyncio +import dataclasses + +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 = dataclasses.replace(self.request, offset=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 = dataclasses.replace(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 = dataclasses.replace(self.request, offset=self.request.offset - bad) + + done = False + while not done and len(data) - bad < self._chunk_size: + cur = await self._request() + self.request = dataclasses.replace(self.request, offset=self.request.offset - self.request.limit) + + data += cur + done = len(cur) < self.request.limit + + # 1.3 Restore our last desired offset + self.request = dataclasses.replace(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 = dataclasses.replace(self.request, offset=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 = dataclasses.replace(self.request, offset=self.request.offset - self._stride) + + +async def download_profile_photo( + self: 'TelegramClient', + profile: 'hints.DialogLike', + 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) + entity = profile + if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS: + entity = await self.get_profile(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_peer(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_peer(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', + media: '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 + message = media + 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', + media: '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, + media, + 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..9e97a414 --- /dev/null +++ b/telethon/_client/messageparse.py @@ -0,0 +1,175 @@ +import itertools +import re +import typing + +from .._misc import helpers, utils +from ..types import _custom +from ..types._custom.inputmessage import InputMessage +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_peer(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, _ = InputMessage._default_parse_mode + else: + parse, _ = utils.sanitize_parse_mode(parse_mode) + + original_message = message + message, msg_entities = 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)): + 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[message.id] = message + else: + return message + + elif (isinstance(update, _tl.UpdateEditMessage) + and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): + 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[message.id] = message + elif request.id == message.id: + return 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..c2f7ff89 --- /dev/null +++ b/telethon/_client/messages.py @@ -0,0 +1,792 @@ +import inspect +import itertools +import time +import typing +import warnings +import dataclasses +import os + +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_peer(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_peer(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.get_messages( + self.entity, 1, offset_date=offset_date): + self.request = dataclasses.replace(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 = dataclasses.replace(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 = dataclasses.replace(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 = dataclasses.replace(self.request, offset_id=last_message.id) + if self.reverse: + # We want to skip the one we already have + self.request = dataclasses.replace(self.request, offset_id=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 = dataclasses.replace(self.request, max_date=None) + else: + # getHistory, searchGlobal and getReplies call it offset_date + self.request = dataclasses.replace(self.request, offset_date=last_message.date) + + if isinstance(self.request, _tl.fn.messages.SearchGlobal): + if last_message.input_chat: + self.request = dataclasses.replace(self.request, offset_peer=last_message.input_chat) + else: + self.request = dataclasses.replace(self.request, offset_peer=_tl.InputPeerEmpty()) + + self.request = dataclasses.replace(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_peer(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.DialogLike'): + 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', + dialog: 'hints.DialogLike', + 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.DialogLike' = 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=dialog, + ids=ids + ) + + return _MessagesIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=limit, + entity=dialog, + 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.DialogLike', + 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', + dialog: 'hints.DialogLike', + 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.DialogLike' = 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_peer(dialog) + 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, + random_id=int.from_bytes(os.urandom(8), 'big', signed=True), + ) + 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, + random_id=int.from_bytes(os.urandom(8), 'big', signed=True), + ) + + 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', + dialog: 'hints.DialogLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + from_dialog: 'hints.DialogLike' = 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.DialogLike' = 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_peer(dialog) + + if from_dialog: + from_peer = await self._get_input_peer(from_dialog) + from_peer_id = await self._get_peer_id(from_peer) + else: + from_peer = None + 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, + random_id=[int.from_bytes(os.urandom(8), 'big', signed=True) for _ in chunk], + ) + 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', + dialog: 'typing.Union[hints.DialogLike, _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_peer(dialog) + 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', + dialog: 'hints.DialogLike', + 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 dialog: + entity = await self._get_input_peer(dialog) + ty = helpers._entity_type(entity) + else: + # no entity (None), set a value that's not a channel for private delete + entity = None + 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', + dialog: 'hints.DialogLike', + message: 'hints.MessageIDLike' = None, + *, + clear_mentions: bool = False, + clear_reactions: 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_peer(dialog) + if clear_mentions: + await self(_tl.fn.messages.ReadMentions(entity)) + + if clear_reactions: + await self(_tl.fn.messages.ReadReactions(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', + dialog: 'hints.DialogLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False +): + return await _pin(self, dialog, message, unpin=False, notify=notify, pm_oneside=pm_oneside) + +async def unpin_message( + self: 'TelegramClient', + dialog: 'hints.DialogLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False +): + return await _pin(self, dialog, 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_peer(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) + +async def send_reaction( + self: 'TelegramClient', + entity: 'hints.DialogLike', + message: 'hints.MessageIDLike', + reaction: typing.Optional[str] = None, + big: bool = False +): + message = utils.get_message_id(message) or 0 + if not reaction: + get_default_request = _tl.fn.help.GetAppConfig() + app_config = await self(get_default_request) + reaction = ( + next( + ( + y for y in app_config.value + if "reactions_default" in y.key + ) + ) + ).value.value + request = _tl.fn.messages.SendReaction( + big=big, + peer=entity, + msg_id=message, + reaction=reaction + ) + result = await self(request) + for update in result.updates: + if isinstance(update, _tl.UpdateMessageReactions): + return update.reactions + if isinstance(update, _tl.UpdateEditMessage): + return update.message.reactions + +async def set_quick_reaction( + self: 'TelegramClient', + reaction: str +): + request = _tl.fn.messages.SetDefaultReaction( + reaction=reaction + ) + return await self(request) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py new file mode 100644 index 00000000..52e5c392 --- /dev/null +++ b/telethon/_client/telegrambaseclient.py @@ -0,0 +1,474 @@ +import abc +import re +import asyncio +import collections +import logging +import platform +import time +import typing +import ipaddress +import dataclasses +import functools + +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 = _default_session_state() + + # 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._phone_code_hash = None # used during login to prevent exposing the hash to end users + self._tos = None # used during signup and when fetching tos (tos/expiry) + + # 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._update_handlers = [] # sorted list + self._dispatching_update_handlers = False # while dispatching, if add/remove are called, we need to make a copy + 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 docs.telethon.dev 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 = functools.partial( + _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" + ) + + 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) + + +def _default_session_state(): + return SessionState( + user_id=0, + dc_id=DEFAULT_DC_ID, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=None, + ) + + +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 = _default_session_state() + 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. + config = await self._sender.send(_tl.fn.InvokeWithLayer( + _tl.LAYER, self._init_request(query=_tl.fn.help.GetConfig()) + )) + + 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: + return self._sender.is_connected() + + +async def disconnect(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() + + +def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + init_proxy = None + + 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(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)) + req = _tl.fn.InvokeWithLayer(_tl.LAYER, self._init_request( + query=_tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes) + )) + 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..3d32dd3b --- /dev/null +++ b/telethon/_client/telegramclient.py @@ -0,0 +1,3410 @@ +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.base import EventBuilder +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.get_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.get_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.get_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', + *, + code: typing.Union[str, int] = None, + password: str = None, + bot_token: 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 + code (`str` | `int`): + The code that Telegram sent. + + To login to a user account, you must use `client.send_code_request` first. + + The code will expire immediately if you send it through the application itself + as a safety measure. + + 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. + + You do not need to call `client.send_code_request` to login to a bot account. + + Returns + The signed in `User`, if the method did not fail. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.send_code_request(phone) # send code + + code = input('enter code: ') + await client.sign_in(code=code) + """ + + @forward_call(auth.sign_up) + async def sign_up( + self: 'TelegramClient', + first_name: str, + last_name: str = '', + *, + code: typing.Union[str, int]) -> '_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. + + .. important:: + + When creating a new account, you must be sure to show the Terms of Service + to the user, and only after they approve, the code can accept the Terms of + Service. If not, they must be declined, in which case the account **will be + deleted**. + + Make sure to use `client.get_tos` to fetch the Terms of Service, and to + use `tos.accept()` or `tos.decline()` after the user selects an option. + + Arguments + first_name (`str`): + The first name to be used by the new account. + + last_name (`str`, optional) + Optional last name. + + code (`str` | `int`): + The code sent by Telegram + + 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('Anna', 'Banana', code=code) + + # IMPORTANT: you MUST retrieve the Terms of Service and accept + # them, or Telegram has every right to delete the account. + tos = await client.get_tos() + print(tos.html) + + if code('accept (y/n)?: ') == 'y': + await tos.accept() + else: + await tos.decline() # deletes the account! + """ + + @forward_call(auth.send_code_request) + async def send_code_request( + self: 'TelegramClient', + phone: str) -> '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 `SentCode`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + sent = await client.send_code_request(phone) + print(sent.type) + + # Wait before resending sent.next_type, if any + if sent.next_type: + await asyncio.sleep(sent.timeout or 0) + resent = await client.send_code_request(phone) + print(sent.type) + + # Checking the code locally + code = input('Enter code: ') + print('Code looks OK:', resent.check(code)) + """ + + @forward_call(auth.qr_login) + 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. + + Note that the login completes once the context manager exits, + not after the ``wait`` method returns. + + Arguments + ignored_ids (List[`int`]): + List of already logged-in session 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 + + async with client.qr_login() as qr_login: + display_url_as_qr(qr_login.url) + + # Important! You need to wait for the login to complete! + # If the context manager exits before the user logs in, the client won't be logged in. + try: + user = await qr_login.wait() + print('Welcome,', user.first_name) + except asyncio.TimeoutError: + print('User did not login in time') + """ + + @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') + """ + + @forward_call(auth.get_tos) + async def get_tos(self: 'TelegramClient') -> '_custom.TermsOfService': + """ + Fetch `Telegram's Terms of Service`_, which every user must accept in order to use + Telegram, or they must otherwise `delete their account`_. + + This method **must** be called after sign up, and **should** be called again + after it expires (at the risk of having the account terminated otherwise). + + See the documentation of `TermsOfService` for more information. + + The library cannot automate this process because the user must read the Terms of Service. + Automating its usage without reading the terms would be done at the developer's own risk. + + Example + .. code-block:: python + + # Fetch the ToS, forever (this could be a separate task, for example) + while True: + tos = await client.get_tos() + + if tos: + # There's an update or they must be accepted (you could show a popup) + print(tos.html) + if code('accept (y/n)?: ') == 'y': + await tos.accept() + else: + await tos.decline() # deletes the account! + + # after tos.timeout expires, the method should be called again! + await asyncio.sleep(tos.timeout) + + _Telegram's Terms of Service: https://telegram.org/tos + _delete their account: https://core.telegram.org/api/config#terms-of-service + """ + + 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.DialogLike', + query: str, + *, + dialog: 'hints.DialogLike' = 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 user to which the inline query should be made. + + query (`str`): + The query that should be made to the bot. + + dialog (`entity`, optional): + The dialog 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 dialog 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. + + Raises + If the bot does not respond to the inline query in time, + `asyncio.TimeoutError` is raised. The timeout is decided by Telegram. + + 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', + chat: 'hints.DialogLike', + 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 + chat (`entity`): + The chat 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.get_participants(chat): + print(user.id) + + # Search by name + async for user in client.get_participants(chat, search='name'): + print(user.username) + + # Filter by admins + from telethon.tl.types import ChannelParticipantsAdmins + async for user in client.get_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', + chat: 'hints.DialogLike', + limit: float = (), + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.DialogsLike' = 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 + chat (`entity`): + The chat 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.get_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', + profile: 'hints.DialogLike', + 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 + profile (`entity`): + The user or chat profile from which to get the profile 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.get_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', + dialog: 'hints.DialogLike', + 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 + dialog (`entity`): + The dialog 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', + chat: 'hints.DialogLike', + user: 'hints.DialogLike', + *, + 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 + chat (`entity`): + The 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', + chat: 'hints.DialogLike', + user: 'typing.Optional[hints.DialogLike]' = 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 + chat (`entity`): + The chat 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', + chat: 'hints.DialogLike', + user: 'typing.Optional[hints.DialogLike]' + ): + """ + 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 + chat (`entity`): + The 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', + chat: 'hints.DialogLike', + user: 'hints.DialogLike' = 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 + chat (`entity`): + The 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', + chat: 'hints.DialogLike', + 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 + chat (`entity`): + The chat 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 chat is not a broadcast channel ormegagroup, + a `TypeError` is raised. + + If there are not enough members (poorly named) errors such as + ``telethon.errors.ChatAdminRequiredError`` will appear. + + Returns + If both ``chat`` 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.DialogLike' = _tl.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = 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. + Yields + Instances of `Dialog `. + + Example + .. code-block:: python + + # Print all dialog IDs and the title, nicely formatted + async for dialog in client.get_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) + + # Getting only archived dialogs (both equivalent) + archived = await client.get_dialogs(folder=1, limit=None) + """ + + @forward_call(dialogs.get_drafts) + def get_drafts( + self: 'TelegramClient', + dialog: 'hints.DialogsLike' = None + ) -> dialogs._DraftsIter: + """ + Iterator over draft messages. + + The order is unspecified. + + Arguments + dialog (`hints.DialogsLike`, optional): + The dialog or dialogs 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.get_drafts(['bot1', 'bot2']): + print(draft.text) + + # Get the draft in your chat + draft = await client.get_drafts('me') + print(draft.text) + """ + + @forward_call(dialogs.delete_dialog) + async def delete_dialog( + self: 'TelegramClient', + dialog: 'hints.DialogLike', + *, + 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 + dialog (entities): + 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', + profile: 'hints.DialogLike', + 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 + profile (`entity`): + The profile from which to download its photo. + + .. 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', + media: '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 + media (:tl:`Media`): + 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', + media: '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', + dialog: 'hints.DialogLike', + 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.DialogLike' = 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 + dialog (`entity`): + The dialog from which 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 user 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 `dialog` 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 `dialog`. + + Yields + Instances of `Message `. + + Example + .. code-block:: python + + # From most-recent to oldest + async for message in client.get_messages(chat): + print(message.id, message.text) + + # From oldest to most-recent + async for message in client.get_messages(chat, reverse=True): + print(message.id, message.text) + + # Filter by sender, and limit to 10 + async for message in client.get_messages(chat, 10, from_user='me'): + print(message.text) + + # Server-side search with fuzzy text + async for message in client.get_messages(chat, search='hello'): + print(message.id) + + # Filter by message type: + from telethon.tl.types import InputMessagesFilterPhotos + async for message in client.get_messages(chat, filter=InputMessagesFilterPhotos): + print(message.photo) + + # Getting comments from a post in a channel: + async for message in client.get_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', + dialog: 'hints.DialogLike', + 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 + dialog (`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 + from telethon.types import Message + Message.set_default_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 (import Message first) + Message.set_default_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', + dialog: 'hints.DialogLike', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', + from_dialog: 'hints.DialogLike' = 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 dialog. + + 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 + dialog (`entity`): + The target dialog where the message(s) will be forwarded. + + messages (`list`): + The messages to forward, or their integer IDs. + + from_dialog (`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 source dialog 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', + dialog: 'typing.Union[hints.DialogLike, _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 + dialog (`entity` | `Message `): + From which chat to edit the message. This can also be + the message to be edited, and the dialog 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 `dialog` 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 `dialog` + 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 `dialog` 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', + dialog: 'hints.DialogLike', + 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 + dialog (`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', + dialog: 'hints.DialogLike', + 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 + dialog (`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', + dialog: 'hints.DialogLike', + 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 + dialog (`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', + dialog: 'hints.DialogLike', + 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 + dialog (`entity`): + The dialog 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') + """ + + @property + 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 + + # This is a silly example - run_until_disconnected is often better suited + while client.is_connected: + await asyncio.sleep(1) + """ + return telegrambaseclient.is_connected(self) + + @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', *events, priority=0, **filters): + """ + Decorator used to `add_event_handler` more conveniently. + + This decorator should be above other decorators which modify the function. + + Arguments + event (`type` | `tuple`): + The event type(s) you wish to receive, for instance ``events.NewMessage``. + This may also be raw update types. + The same handler is registered multiple times, one per type. + + priority (`int`): + The event priority. Events with higher priority are dispatched first. + The order between events with the same priority is arbitrary. + + filters (any): + Filters passed to `make_filter`. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + # Here we use client.on + @client.on(events.NewMessage, priority=100) + async def handler(event): + ... + + # Both new incoming messages and incoming edits + @client.on(events.NewMessage, events.MessageEdited, incoming=True) + async def handler(event): + ... + """ + + @forward_call(updates.add_event_handler) + def add_event_handler( + self: 'TelegramClient', + callback: updates.Callback = None, + event: EventBuilder = None, + priority=0, + **filters + ): + """ + 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. + + If `None`, the method can be used as a decorator. Note that the handler function + will be replaced with the `EventHandler` instance in this case, but it will still + be callable. + + event (`_EventBuilder` | `type`, optional): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + If left unspecified, it will be inferred from the type hint + used in the handler, or be `telethon.events.raw.Raw` (the + :tl:`Update` objects with no further processing) if there is + none. Note that the type hint must be the desired type. It + cannot be a string, an union, or anything more complex. + + priority (`int`): + The event priority. Events with higher priority are dispatched first. + The order between events with the same priority is arbitrary. + + filters (any): + Filters passed to `make_filter`. + + Returns + An `EventHandler` instance, which can be used + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + # Adding a handler, the "boring" way + async def handler(event): + ... + + client.add_event_handler(handler, events.NewMessage, priority=50) + + # Automatic type + async def handler(event: events.MessageEdited) + ... + + client.add_event_handler(handler, outgoing=False) + + # Streamlined adding + @client.add_event_handler + async def handler(event: events.MessageDeleted): + ... + """ + + @forward_call(updates.remove_event_handler) + def remove_event_handler( + self: 'TelegramClient', + callback: updates.Callback = None, + event: EventBuilder = None, + *, + priority=None, + ) -> int: + """ + Inverse operation of `add_event_handler()`. + + If no event is given, all events for this callback are removed. + Returns a list in arbitrary order with all removed `EventHandler` instances. + + Arguments + callback (`callable`): + The callable function accepting one parameter to be used. + If passed an `EventHandler` instance, both `event` and `priority` are ignored. + + event (`_EventBuilder` | `type`, optional): + The event builder class or instance to be used when searching. + + priority (`int`): + The event priority to be used when searching. + + 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) + + # Remove all handlers with priority 50 + client.remove_event_handler(priority=50) + + # Remove all deleted-message handlers + client.remove_event_handler(event=events.MessageDeleted) + """ + + @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 all registered `EventHandler` in arbitrary order. + + Example + .. code-block:: python + + @client.on(events.NewMessage(pattern='hello')) + async def on_greeting(event): + '''Greets someone''' + await event.reply('Hi') + + for handler in client.list_event_handlers(): + print(id(handler.callback), handler.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', + dialog: 'hints.DialogLike', + 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 + dialog (`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 :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='' + )) + """ + + # 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') \ + -> '_tl.User': + """ + Gets "me", the current :tl:`User` who is logged in. + + If the user has not logged in yet, this method returns `None`. + + 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_profile) + async def get_profile( + self: 'TelegramClient', + profile: 'hints.DialogsLike') -> 'hints.Entity': + """ + Turns the given profile reference into a `User ` + or `Chat ` instance. + + Arguments + profile (`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. + + Using phone numbers with strings will fetch your contact list first. + + Using integer IDs will only work if the ID is in the session cache. + + ``'me'`` is a special-case to the logged-in account (yourself). + + Unsupported types will raise ``TypeError``. + + If the user or chat can't be found, ``ValueError`` will be raised. + + Returns + `User ` or `Chat `, + depending on the profile requested. + + Example + .. code-block:: python + + from telethon import utils + + me = await client.get_profile('me') + print(utils.get_display_name(me)) + + chat = await client.get_profile('username') + async for message in client.get_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_profile if you will reuse it a lot. + async for message in client.get_messages('username'): + ... + + # Note that for this to work the phone number must be in your contacts + some_id = (await client.get_profile('+34123456789')).id + """ + + # 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.DialogLike', + 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 + + @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': + pass + + @forward_call(users._get_input_peer) + async def _get_input_peer(self, *, save=True, **changes): + pass + + @forward_call(users._get_peer_id) + async def _get_peer_id(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..9882cf0a --- /dev/null +++ b/telethon/_client/updates.py @@ -0,0 +1,282 @@ +import asyncio +import inspect +import itertools +import random +import sys +import time +import traceback +import typing +import logging +import inspect +import bisect +import warnings +from collections import deque + +from ..errors._rpcbase import RpcError +from .._events.raw import Raw +from .._events.base import StopPropagation, EventBuilder, EventHandler +from .._events.filters import make_filter, NotResolved +from .._misc import utils +from .. import _tl +from ..types._custom import User, Chat + +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', *events, priority=0, **filters): + def decorator(f): + for event in events: + self.add_event_handler(f, event, priority=priority, **filters) + return f + + return decorator + +def add_event_handler( + self: 'TelegramClient', + callback=None, + event=None, + priority=0, + **filters +): + if callback is None: + return functools.partial(add_event_handler, self, event=event, priority=priority, **filters) + + if event is None: + for param in inspect.signature(callback).parameters.values(): + event = None if param.annotation is inspect.Signature.empty else param.annotation + break # only check the first parameter + + if event is None: + event = Raw + + if not inspect.iscoroutinefunction(callback): + raise TypeError(f'callback was not an async def function: {callback!r}') + + if not isinstance(event, type): + raise TypeError(f'event type was not a type (an instance of something was probably used): {event!r}') + + if not isinstance(priority, int): + raise TypeError(f'priority was not an integer: {priority!r}') + + if not issubclass(event, EventBuilder): + try: + if event.SUBCLASS_OF_ID != 0x9f89304e: + raise TypeError(f'invalid raw update type for the event handler: {event!r}') + + if 'types' in filters: + warnings.warn('"types" filter is ignored when the event type already is a raw update') + + filters['types'] = event + event = Raw + except AttributeError: + raise TypeError(f'unrecognized event handler type: {param.annotation!r}') + + handler = EventHandler(event, callback, priority, make_filter(**filters)) + + if self._dispatching_update_handlers: + # Now that there's a copy, we're no longer dispatching from the old update_handlers, + # so we can modify it. This is why we can turn the flag off. + self._update_handlers = self._update_handlers[:] + self._dispatching_update_handlers = False + + bisect.insort(self._update_handlers, handler) + return handler + +def remove_event_handler( + self: 'TelegramClient', + callback=None, + event=None, + *, + priority=None, +): + if callback is None and event is None and priority is None: + raise ValueError('must specify at least one of callback, event or priority') + + if not self._update_handlers: + return [] # won't be removing anything (some code paths rely on non-empty lists) + + if self._dispatching_update_handlers: + # May be an unnecessary copy if nothing was removed, but that's not a big deal. + self._update_handlers = self._update_handlers[:] + self._dispatching_update_handlers = False + + if isinstance(callback, EventHandler): + if event is not None or priority is not None: + warnings.warn('event and priority are ignored when removing EventHandler instances') + + index = bisect.bisect_left(self._update_handlers, callback) + try: + if self._update_handlers[index] == callback: + return [self._update_handlers.pop(index)] + except IndexError: + pass + return [] + + if priority is not None: + # can binary-search (using a dummy EventHandler) + index = bisect.bisect_right(self._update_handlers, EventHandler(None, None, priority, None)) + try: + while self._update_handlers[index].priority == priority: + index += 1 + except IndexError: + pass + + removed = [] + while index > 0 and self._update_handlers[index - 1].priority == priority: + index -= 1 + if callback is not None and self._update_handlers[index].callback != callback: + continue + if event is not None and self._update_handlers[index].event != event: + continue + removed.append(self._update_handlers.pop(index)) + + return removed + + # slow-path, remove all matching + removed = [] + for index in reversed(range(len(self._update_handlers))): + handler = self._update_handlers[index] + if callback is not None and handler._callback != callback: + continue + if event is not None and handler._event != event: + continue + removed.append(self._update_handlers.pop(index)) + + return removed + +def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + return self._update_handlers[:] + +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: + await _dispatch(self, *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) + updates_to_dispatch.extend(_preprocess_updates(self, updates, users, chats)) + 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) + updates_to_dispatch.extend(_preprocess_updates(self, updates, users, chats)) + 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 = [] + try: + users, chats = self._message_box.process_updates(updates, self._entity_cache, processed) + except GapError: + continue # get(_channel)_difference will start returning requests + + updates_to_dispatch.extend(_preprocess_updates(self, processed, users, chats)) + except Exception: + self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)') + + +def _preprocess_updates(self, updates, users, chats): + self._entity_cache.extend(users, chats) + entities = Entities(self, users, chats) + return ((u, entities) for u in updates) + + +class Entities: + def __init__(self, client, users, chats): + self.self_id = client._session_state.user_id + self._client = client + self._entities = {e.id: e for e in itertools.chain( + (User._new(client, u) for u in users), + (Chat._new(client, c) for u in chats), + )} + + def get(self, peer): + if not peer: + return None + + id = utils.get_peer_id(peer) + try: + return self._entities[id] + except KeyError: + entity = self._client._entity_cache.get(query.user_id) + if not entity: + raise RuntimeError('Update is missing a hash but did not trigger a gap') + + self._entities[entity.id] = User(self._client, entity) if entity.is_user else Chat(self._client, entity) + return self._entities[entity.id] + + +async def _dispatch(self, update, entities): + self._dispatching_update_handlers = True + try: + event_cache = {} + for handler in self._update_handlers: + event = event_cache.get(handler._event) + if not event: + # build can fail if we're missing an access hash; we want this to crash + event_cache[handler._event] = event = handler._event._build(self, update, entities) + + while True: + # filters can be modified at any time, and there can be any amount of them which are not yet resolved + try: + if handler._filter(event): + try: + await handler._callback(event) + except StopPropagation: + return + except Exception: + name = getattr(handler._callback, '__name__', repr(handler._callback)) + self._log[__name__].exception('Unhandled exception on %s (this is likely a bug in your code)', name) + except NotResolved as nr: + try: + await nr.unresolved.resolve() + continue + except Exception as e: + # we cannot really do much about this; it might be a temporary network issue + warnings.warn(f'failed to resolve filter, handler will be skipped: {e}: {nr.unresolved!r}') + except Exception as e: + # invalid filter (e.g. types when types were not used as input) + warnings.warn(f'invalid filter applied, handler will be skipped: {e}: {e.filter!r}') + + # we only want to continue on unresolved filter (to check if there are more unresolved) + break + finally: + self._dispatching_update_handlers = False diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py new file mode 100644 index 00000000..d5b79c81 --- /dev/null +++ b/telethon/_client/uploads.py @@ -0,0 +1,406 @@ +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', + dialog: 'hints.DialogLike', + 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.DialogLike' = None, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, _tl.Message]' = None, +) -> '_tl.Message': + self.send_message( + dialog=dialog, + 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_peer(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': + """ + 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, (_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..42a83de2 --- /dev/null +++ b/telethon/_client/users.py @@ -0,0 +1,398 @@ +import asyncio +import datetime +import itertools +import time +import typing +import dataclasses + +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 ..types import _custom +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, flood_sleep_threshold=flood_sleep_threshold) + + +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,)) + new_requests = [] + for r in requests: + if not isinstance(r, _tl.TLRequest): + raise _NOT_A_REQUEST() + r = 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) + + new_requests.append(r) + request = new_requests if utils.is_list_like(request) else new_requests[0] + + 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') \ + -> 'typing.Union[_tl.User, _tl.InputPeerUser]': + try: + return _custom.User._new(self, (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0]) + 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_profile( + self: 'TelegramClient', + profile: 'hints.DialogsLike') -> 'hints.Entity': + entity = profile + 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_peer(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_peer( + self: 'TelegramClient', + peer: 'hints.DialogLike') -> '_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.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.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 peer 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.DialogLike') -> 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_peer(peer) + except AttributeError: + peer = await self._get_input_peer(peer) + + if isinstance(peer, _tl.InputPeerSelf): + peer = _tl.PeerUser(self._session_state.user_id) + + 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') + return dataclasses.replace(dialog, peer=await self._get_input_peer(dialog.peer)) + elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') + return _tl.InputDialogPeer(dialog) + except AttributeError: + pass + + return _tl.InputDialogPeer(await self._get_input_peer(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): + return dataclasses.replace(notify, peer=await self._get_input_peer(notify.peer)) + return notify + except AttributeError: + pass + + return _tl.InputNotifyPeer(await self._get_input_peer(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..fcfaca48 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('`]): + The list of messages belonging to the same album. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.Album) + async def handler(event): + # Counting how many photos or videos the album has + print('Got an album with', len(event), 'items') + + # Forwarding the album as a whole to some chat + event.forward_to(chat) + + # Printing the caption + print(event.text) + + # Replying to the fifth item in the album + await event.messages[4].reply('Cool!') + """ + + def __init__(self, messages): + message = messages[0] + 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 + else: + chat_peer = message.peer_id + + _custom.chatgetter.ChatGetter.__init__(self, chat_peer=chat_peer, broadcast=bool(message.post)) + _custom.sendergetter.SenderGetter.__init__(self, message.sender_id) + self.messages = messages + + def _build(cls, client, update, entities): + if not others: + return # We only care about albums which come inside the same Updates + + if isinstance(update, + (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)): + if not isinstance(update.message, _tl.Message): + return # We don't care about MessageService's here + + group = update.message.grouped_id + if group is None: + return # It must be grouped + + # Check whether we are supposed to skip this update, and + # if we do also remove it from the ignore list since we + # won't need to check against it again. + if _IGNORE_DICT.pop(id(update), None): + return + + # Check if the ignore list is too big, and if it is clean it + # TODO time could technically go backwards; time is not monotonic + now = time.time() + if len(_IGNORE_DICT) > _IGNORE_MAX_SIZE: + for i in [i for i, t in _IGNORE_DICT.items() if now - t > _IGNORE_MAX_AGE]: + del _IGNORE_DICT[i] + + # Add the other updates to the ignore list + for u in others: + if u is not update: + _IGNORE_DICT[id(u)] = now + + # Figure out which updates share the same group and use those + return cls.Event([ + u.message for u in others + if (isinstance(u, (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)) + and isinstance(u.message, _tl.Message) + and u.message.grouped_id == group) + ]) + + self = cls.__new__(cls) + self._client = client + self._sender = entities.get(_tl.PeerUser(update.user_id)) + self._chat = entities.get(_tl.PeerUser(update.user_id)) + return self + + def _set_client(self, client): + super()._set_client(client) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) + + 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 + hack = client._albums.get(self.grouped_id) + if hack is None: + client._albums[self.grouped_id] = AlbumHack(client, self) + else: + hack.extend(self.messages) + + @property + def grouped_id(self): + """ + The shared ``grouped_id`` between all the messages. + """ + return self.messages[0].grouped_id + + @property + def text(self): + """ + The message text of the first photo with a caption, + formatted using the client's default parse mode. + """ + return next((m.text for m in self.messages if m.text), '') + + @property + def raw_text(self): + """ + The raw message text of the first photo + with a caption, ignoring any formatting. + """ + return next((m.raw_text for m in self.messages if m.raw_text), '') + + @property + def is_reply(self): + """ + `True` if the album is a reply to some other message. + + Remember that you can access the ID of the message + this one is replying to through `reply_to_msg_id`, + and the `Message` object with `get_reply_message()`. + """ + # Each individual message in an album all reply to the same message + return self.messages[0].is_reply + + @property + def forward(self): + """ + 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 + return self.messages[0].forward + + # endregion Public Properties + + # region Public Methods + + async def get_reply_message(self): + """ + The `Message ` + that this album is replying to, or `None`. + + The result will be cached after its first use. + """ + return await self.messages[0].get_reply_message() + + async def respond(self, *args, **kwargs): + """ + Responds to the album (not as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` + with ``entity`` already set. + """ + return await self.messages[0].respond(*args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the first photo in the album (as a reply). Shorthand + for `telethon.client.messages.MessageMethods.send_message` + with both ``entity`` and ``reply_to`` already set. + """ + return await self.messages[0].reply(*args, **kwargs) + + async def forward_to(self, *args, **kwargs): + """ + Forwards the entire album. Shorthand for + `telethon.client.messages.MessageMethods.forward_messages` + with both ``messages`` and ``from_peer`` already set. + """ + if self._client: + kwargs['messages'] = self.messages + kwargs['from_peer'] = await self.get_input_chat() + return await self._client.forward_messages(*args, **kwargs) + + async def edit(self, *args, **kwargs): + """ + Edits the first caption or the message, or the first messages' + caption if no caption is set, iff it's outgoing. Shorthand for + `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 + ` + and **will respect** the previous state of the message. + For example, if the message didn't have a link preview, + the edit won't add one by default, and you should force + it by setting it to `True` if you want it. + + This is generally the most desired and convenient behaviour, + and will work for link previews and message buttons. + """ + for msg in self.messages: + if msg.raw_text: + return await msg.edit(*args, **kwargs) + + return await self.messages[0].edit(*args, **kwargs) + + async def delete(self, *args, **kwargs): + """ + Deletes the entire album. You're responsible for checking whether + you have the permission to do so, or to except the error otherwise. + Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + """ + if self._client: + return await self._client.delete_messages( + await self.get_input_chat(), self.messages, + *args, **kwargs + ) + + async def mark_read(self): + """ + Marks the entire album as read. Shorthand for + `client.mark_read() + ` + with both ``entity`` and ``message`` already set. + """ + if self._client: + await self._client.mark_read( + await self.get_input_chat(), max_id=self.messages[-1].id) + + async def pin(self, *, notify=False): + """ + Pins the first photo in the album. Shorthand for + `telethon.client.messages.MessageMethods.pin_message` + with both ``entity`` and ``message`` already set. + """ + return await self.messages[0].pin(notify=notify) + + def __len__(self): + """ + Return the amount of messages in the album. + + Equivalent to ``len(self.messages)``. + """ + return len(self.messages) + + def __iter__(self): + """ + Iterate over the messages in the album. + + Equivalent to ``iter(self.messages)``. + """ + return iter(self.messages) + + def __getitem__(self, n): + """ + Access the n'th message in the album. + + Equivalent to ``event.messages[n]``. + """ + return self.messages[n] diff --git a/telethon/_events/base.py b/telethon/_events/base.py new file mode 100644 index 00000000..404ff7b6 --- /dev/null +++ b/telethon/_events/base.py @@ -0,0 +1,63 @@ +import abc +import functools + +from .filters import Filter + + +class StopPropagation(Exception): + """ + If this exception is raised in any of the handlers for a given event, + it will stop the execution of all other registered event handlers. + It can be seen as the ``StopIteration`` in a for loop but for events. + + Example usage: + + >>> from telethon import TelegramClient, events + >>> client = TelegramClient(...) + >>> + >>> @client.on(events.NewMessage) + ... async def delete(event): + ... await event.delete() + ... # No other event handler will have a chance to handle this event + ... raise StopPropagation + ... + >>> @client.on(events.NewMessage) + ... async def _(event): + ... # Will never be reached, because it is the second handler + ... pass + """ + # For some reason Sphinx wants the silly >>> or + # it will show warnings and look bad when generated. + pass + + +class EventBuilder(abc.ABC): + @classmethod + @abc.abstractmethod + def _build(cls, client, update, entities): + """ + Builds an event for the given update if possible, or returns None. + + `entities` must have `get(Peer) -> User|Chat` and `self_id`, + which must be the current user's ID. + """ + + +@functools.total_ordering +class EventHandler: + __slots__ = ('_event', '_callback', '_priority', '_filter') + + def __init__(self, event: EventBuilder, callback: callable, priority: int, filter: Filter): + self._event = event + self._callback = callback + self._priority = priority + self._filter = filter + + def __eq__(self, other): + return self is other + + def __lt__(self, other): + return self._priority < other._priority + + def __call__(self, *args, **kwargs): + return self._callback(*args, **kwargs) diff --git a/telethon/_events/callbackquery.py b/telethon/_events/callbackquery.py new file mode 100644 index 00000000..5d36856b --- /dev/null +++ b/telethon/_events/callbackquery.py @@ -0,0 +1,267 @@ +import re +import struct +import asyncio +import functools + +from .base import EventBuilder +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 + + +class CallbackQuery(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter): + """ + Occurs whenever you sign in as a bot and a user + clicks one of the inline buttons on your messages. + + Note that the `chats` parameter will **not** work with normal + IDs or peers if the clicked inline button comes from a "via bot" + message. The `chats` parameter also supports checking against the + `chat_instance` which should be used for inline callbacks. + + Members: + query (:tl:`UpdateBotCallbackQuery`): + The original :tl:`UpdateBotCallbackQuery`. + + data_match (`obj`, optional): + The object returned by the ``data=`` parameter + when creating the event builder, if any. Similar + to ``pattern_match`` for the new message event. + + pattern_match (`obj`, optional): + Alias for ``data_match``. + + Example + .. code-block:: python + + from telethon import events, Button + + # Handle all callback queries and check data inside the handler + @client.on(events.CallbackQuery) + async def handler(event): + if event.data == b'yes': + await event.answer('Correct answer!') + + # Handle only callback queries with data being b'no' + @client.on(events.CallbackQuery(data=b'no')) + async def handler(event): + # Pop-up message with alert + await event.answer('Wrong answer!', alert=True) + + # Send a message with buttons users can click + async def main(): + await client.send_message(user, 'Yes or no?', buttons=[ + Button.inline('Yes!', b'yes'), + Button.inline('Nope', b'no') + ]) + """ + @classmethod + def _build(cls, client, update, entities): + query = update + if isinstance(update, _tl.UpdateBotCallbackQuery): + peer = update.peer + msg_id = update.msg_id + elif isinstance(update, _tl.UpdateInlineBotCallbackQuery): + # See https://github.com/LonamiWebs/Telethon/pull/1005 + # The long message ID is actually just msg_id + peer_id + msg_id, pid = struct.unpack('`, + since the message object is normally not present. + """ + if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID): + return await self._client.edit_message( + None, self.query.msg_id, *args, **kwargs + ) + else: + return await self._client.edit_message( + await self.get_input_chat(), self.query.msg_id, + *args, **kwargs + ) + + @auto_answer + async def delete(self, *args, **kwargs): + """ + Deletes the message. Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + + If you need to delete more than one message at once, don't use + this `delete` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + + This method will also `answer` the callback if necessary. + + This method will likely fail if `via_inline` is `True`. + """ + 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 new file mode 100644 index 00000000..435ab459 --- /dev/null +++ b/telethon/_events/chataction.py @@ -0,0 +1,451 @@ +from .base import EventBuilder +from .._misc import utils +from .. import _tl +from ..types import _custom + + +class ChatAction(EventBuilder): + """ + Occurs on certain chat actions: + + * Whenever a new chat is created. + * Whenever a chat's title or photo is changed or removed. + * Whenever a new message is pinned. + * Whenever a user scores in a game. + * Whenever a user joins or is added to the group. + * Whenever a user is removed or leaves a group if it has + less than 50 members or the removed user was a bot. + + Note that "chat" refers to "small group, megagroup and broadcast + channel", whereas "group" refers to "small group and megagroup" only. + + Members: + action_message (`MessageAction `_): + The message invoked by this Chat Action. + + new_pin (`bool`): + `True` if there is a new pin. + + new_photo (`bool`): + `True` if there's a new chat photo (or it was removed). + + photo (:tl:`Photo`, optional): + The new photo (or `None` if it was removed). + + user_added (`bool`): + `True` if the user was added by some other. + + user_joined (`bool`): + `True` if the user joined on their own. + + user_left (`bool`): + `True` if the user left on their own. + + 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. + + unpin (`bool`): + `True` if the existing pin gets unpinned. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.ChatAction) + async def handler(event): + # Welcome every new user + if event.user_joined: + await event.reply('Welcome to the group!') + """ + + @classmethod + def _build(cls, client, update, entities): + where = None + new_photo = 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 + + # 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, _tl.UpdatePinnedChannelMessages) and not update.pinned: + where = _tl.PeerChannel(update.channel_id) + pin_ids = update.messages + pin = update.pinned + + elif isinstance(update, _tl.UpdatePinnedMessages) and not update.pinned: + where = update.peer + pin_ids = update.messages + pin = update.pinned + + elif isinstance(update, _tl.UpdateChatParticipantAdd): + where = _tl.PeerChat(update.chat_id) + added_by = update.inviter_id or True + users = update.user_id + + elif isinstance(update, _tl.UpdateChatParticipantDelete): + where = _tl.PeerChat(update.chat_id) + kicked_by = True + users = update.user_id + + # UpdateChannel is sent if we leave a channel, and the update._entities + # set by _process_update would let us make some guesses. However it's + # better not to rely on this. Rely only in MessageActionChatDeleteUser. + + elif (isinstance(update, ( + _tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)) + and isinstance(update.message, _tl.MessageService)): + msg = update.message + action = update.message.action + if isinstance(action, _tl.MessageActionChatJoinedByLink): + where = msg + added_by = True + users = msg.from_id + 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 + where = msg + added_by = added_by + users = action.users + elif isinstance(action, _tl.MessageActionChatJoinedByRequest): + # user joined from join request (after getting admin approval) + where = msg + from_approval = True + users = msg.from_id + elif isinstance(action, _tl.MessageActionChatDeleteUser): + where = msg + kicked_by = utils.get_peer_id(msg.from_id) if msg.from_id else True + users = action.user_id + elif isinstance(action, _tl.MessageActionChatCreate): + where = msg + users = action.users + created = True + new_title = action.title + elif isinstance(action, _tl.MessageActionChannelCreate): + where = msg + created = True + users = msg.from_id + new_title = action.title + elif isinstance(action, _tl.MessageActionChatEditTitle): + where = msg + users = msg.from_id + new_title = action.title + elif isinstance(action, _tl.MessageActionChatEditPhoto): + where = msg + users = msg.from_id + new_photo = action.photo + elif isinstance(action, _tl.MessageActionChatDeletePhoto): + where = msg + users = msg.from_id + new_photo = True + elif isinstance(action, _tl.MessageActionPinMessage) and msg.reply_to: + where = msg + pin_ids=[msg.reply_to_msg_id] + elif isinstance(action, _tl.MessageActionGameScore): + where = msg + new_score = action.score + + self = cls.__new__(cls) + self._client = client + + if isinstance(where, _tl.MessageService): + self.action_message = where + where = where.peer_id + else: + self.action_message = None + + self._chat = entities.get(where) + + self.new_pin = pin_ids is not None + self._pin_ids = pin_ids + self._pinned_messages = None + + self.new_photo = new_photo is not None + self.photo = \ + 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 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. + if kicked_by is True or (users is not None and kicked_by == users): + self.user_left = True + elif kicked_by: + self.user_kicked = True + self._kicked_by = kicked_by + + self.created = bool(created) + + if isinstance(users, list): + self._user_ids = [utils.get_peer_id(u) for u in users] + elif users: + self._user_ids = [utils.get_peer_id(users)] + else: + self._user_ids = [] + + self._users = None + self._input_users = None + self.new_title = new_title + self.new_score = new_score + self.unpin = not pin + + return self + + async def respond(self, *args, **kwargs): + """ + Responds to the chat action message (not as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` with + ``entity`` already set. + """ + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the chat action message (as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` with + both ``entity`` and ``reply_to`` already set. + + Has the same effect as `respond` if there is no message. + """ + if not self.action_message: + return await self.respond(*args, **kwargs) + + kwargs['reply_to'] = self.action_message.id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def delete(self, *args, **kwargs): + """ + Deletes the chat action message. You're responsible for checking + whether you have the permission to do so, or to except the error + otherwise. Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + + Does nothing if no message action triggered this event. + """ + if not self.action_message: + return + + return await self._client.delete_messages( + await self.get_input_chat(), [self.action_message], + *args, **kwargs + ) + + async def get_pinned_message(self): + """ + If ``new_pin`` is `True`, this returns the `Message + ` object that was pinned. + """ + if self._pinned_messages is None: + await self.get_pinned_messages() + + if self._pinned_messages: + return self._pinned_messages[0] + + async def get_pinned_messages(self): + """ + If ``new_pin`` is `True`, this returns a `list` of `Message + ` objects that were pinned. + """ + if not self._pin_ids: + return self._pin_ids # either None or empty list + + chat = await self.get_input_chat() + if chat: + self._pinned_messages = await self._client.get_messages( + self._input_chat, ids=self._pin_ids) + + return self._pinned_messages + + @property + def added_by(self): + """ + The user who added ``users``, if applicable (`None` otherwise). + """ + 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 + + return self._added_by + + async def get_added_by(self): + """ + Returns `added_by` but will make an API call if necessary. + """ + if not self.added_by and self._added_by: + self._added_by = await self._client.get_profile(self._added_by) + + return self._added_by + + @property + def kicked_by(self): + """ + The user who kicked ``users``, if applicable (`None` otherwise). + """ + 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 + + return self._kicked_by + + async def get_kicked_by(self): + """ + Returns `kicked_by` but will make an API call if necessary. + """ + if not self.kicked_by and self._kicked_by: + self._kicked_by = await self._client.get_profile(self._kicked_by) + + return self._kicked_by + + @property + def user(self): + """ + The first user that takes part in this action. For example, who joined. + + Might be `None` if the information can't be retrieved or + there is no user taking part. + """ + if self.users: + return self._users[0] + + async def get_user(self): + """ + Returns `user` but will make an API call if necessary. + """ + if self.users or await self.get_users(): + return self._users[0] + + @property + def input_user(self): + """ + Input version of the ``self.user`` property. + """ + if self.input_users: + return self._input_users[0] + + async def get_input_user(self): + """ + Returns `input_user` but will make an API call if necessary. + """ + if self.input_users or await self.get_input_users(): + return self._input_users[0] + + @property + def user_id(self): + """ + Returns the marked signed ID of the first user, if any. + """ + if self._user_ids: + return self._user_ids[0] + + @property + def users(self): + """ + A list of users that take part in this action. For example, who joined. + + Might be empty if the information can't be retrieved or there + are no users taking part. + """ + if not self._user_ids: + return [] + + if self._users is None: + self._users = [ + self._entities[user_id] + for user_id in self._user_ids + if user_id in self._entities + ] + + return self._users + + async def get_users(self): + """ + Returns `users` but will make an API call if necessary. + """ + if not self._user_ids: + return [] + + # Note: we access the property first so that it fills if needed + if (self.users is None or len(self._users) != len(self._user_ids)) and self.action_message: + await self.action_message._reload_message() + self._users = [ + u for u in self.action_message.action_entities + if isinstance(u, (_tl.User, _tl.UserEmpty))] + + return self._users + + @property + def input_users(self): + """ + Input version of the ``self.users`` property. + """ + if self._input_users is None and self._user_ids: + self._input_users = [] + for user_id in self._user_ids: + # 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 + + return self._input_users or [] + + async def get_input_users(self): + """ + Returns `input_users` but will make an API call if necessary. + """ + if not self._user_ids: + return [] + + # Note: we access the property first so that it fills if needed + if (self.input_users is None or len(self._input_users) != len(self._user_ids)) and self.action_message: + self._input_users = [ + utils.get_input_peer(u) + for u in self.action_message.action_entities + if isinstance(u, (_tl.User, _tl.UserEmpty))] + + return self._input_users or [] + + @property + def user_ids(self): + """ + Returns the marked signed ID of the users, if any. + """ + if self._user_ids: + return self._user_ids[:] diff --git a/telethon/_events/filters/__init__.py b/telethon/_events/filters/__init__.py new file mode 100644 index 00000000..0b0df5a3 --- /dev/null +++ b/telethon/_events/filters/__init__.py @@ -0,0 +1,150 @@ +from .base import Filter, And, Or, Not, Identity, Always, Never +from .generic import Types +from .entities import Chats, Senders +from .messages import Incoming, Outgoing, Pattern, Data + + +_sentinel = object() + + +def make_filter( + chats=_sentinel, + blacklist_chats=_sentinel, + func=_sentinel, + types=_sentinel, + incoming=_sentinel, + outgoing=_sentinel, + senders=_sentinel, + blacklist_senders=_sentinel, + forwards=_sentinel, + pattern=_sentinel, + data=_sentinel, +): + """ + Create a new `And` filter joining all the filters specified as input parameters. + + Not all filters may have an effect on all events. + + chats (`entity`, optional): + May be one or more entities (username/peer/etc.), preferably IDs. + By default, only matching chats will be handled. + + blacklist_chats (`bool`, optional): + Whether to treat the chats as a blacklist instead of + as a whitelist (default). This means that every chat + will be handled *except* those specified in ``chats`` + which will be ignored if ``blacklist_chats=True``. + + func (`callable`, optional): + A callable (async or not) function that should accept the event as input + parameter, and return a value indicating whether the event + should be dispatched or not (any truthy value will do, it + does not need to be a `bool`). It works like a custom filter: + + .. code-block:: python + + @client.on(events.NewMessage(func=lambda e: e.is_private)) + async def handler(event): + pass # code here + + incoming (`bool`, optional): + If set to `True`, only **incoming** messages will be handled. + If set to `False`, incoming messages will be ignored. + If both incoming are outgoing are set, whichever is true will be handled. + + outgoing (`bool`, optional): + If set to `True`, only **outgoing** messages will be handled. + If set to `False`, outgoing messages will be ignored. + If both incoming are outgoing are set, whichever is true will be handled. + + senders (`entity`, optional): + Unlike `chats`, this parameter filters the *senders* of the + message. That is, only messages *sent by these users* will be + handled. Use `chats` if you want private messages with this/these + users. `senders` lets you filter by messages sent by *one or + more* users across the desired chats (doesn't need a list). + + blacklist_senders (`bool`): + Whether to treat the senders as a blacklist instead of + as a whitelist (default). This means that every sender + will be handled *except* those specified in ``senders`` + which will be ignored if ``blacklist_senders=True``. + + forwards (`bool`, optional): + Whether forwarded messages should be handled or not. By default, + both forwarded and normal messages are included. If it's `True` + *only* forwards will be handled. If it's `False` only messages + that are *not* forwards will be handled. + + pattern (`str`, `callable`, `Pattern`, optional): + If set, only messages matching this pattern will be handled. + You can specify a regex-like string which will be matched + against the message, a callable function that returns `True` + if a message is acceptable, or a compiled regex pattern. + + data (`bytes`, `str`, `callable`, optional): + If set, the inline button payload data must match this data. + A UTF-8 string can also be given, a regex or a callable. For + instance, to check against ``'data_1'`` and ``'data_2'`` you + can use ``re.compile(b'data_')``. + + types (`list` | `tuple` | `type`, optional): + The type or types that the :tl:`Update` instance must be. + Equivalent to ``if not isinstance(update, types): return``. + """ + filters = [] + + if chats is not _sentinel: + f = Chats(chats) + if blacklist_chats is not _sentinel and blacklist_chats: + f = Not(f) + filters.append(f) + + if func is not _sentinel: + filters.append(Identity(func)) + + if types is not _sentinel: + filters.append(Types(types)) + + if incoming is not _sentinel: + if outgoing is not _sentinel: + if incoming and outgoing: + pass # no need to filter + elif incoming: + filters.append(Incoming()) + elif outgoing: + filters.append(Outgoing()) + else: + return Never() # why? + elif incoming: + filters.append(Incoming()) + else: + filters.append(Outgoing()) + elif outgoing is not _sentinel: + if outgoing: + filters.append(Outgoing()) + else: + filters.append(Incoming()) + + if senders is not _sentinel: + f = Senders(senders) + if blacklist_senders is not _sentinel and blacklist_senders: + f = Not(f) + filters.append(f) + + if forwards is not _sentinel: + filters.append(Forward()) + + if pattern is not _sentinel: + filters.append(Pattern(pattern)) + + if data is not _sentinel: + filters.append(Data(data)) + + return And(*filters) if filters else Always() + + +class NotResolved(ValueError): + def __init__(self, unresolved): + super().__init__() + self.unresolved = unresolved diff --git a/telethon/_events/filters/base.py b/telethon/_events/filters/base.py new file mode 100644 index 00000000..b8a8d9ca --- /dev/null +++ b/telethon/_events/filters/base.py @@ -0,0 +1,78 @@ +import abc + + +class Filter(abc.ABC): + @abc.abstractmethod + def __call__(self, event): + return True + + def __and__(self, other): + return And(self, other) + + def __or__(self, other): + return Or(self, other) + + def __invert__(self): + return Not(self) + + +class And(Filter): + """ + All underlying filters must return `True` for this filter to be `True`. + """ + def __init__(self, *filters): + self._filters = filters + + def __call__(self, event): + return all(f(event) for f in self._filters) + + +class Or(Filter): + """ + At least one underlying filter must return `True` for this filter to be `True`. + """ + def __init__(self, *filters): + self._filters = filters + + def __call__(self, event): + return any(f(event) for f in self._filters) + + +class Not(Filter): + """ + The underlying filter must return `False` for this filter to be `True`. + """ + def __init__(self, filter): + self._filter = filter + + def __call__(self, event): + return not self._filter(event) + + +class Identity(Filter): + """ + Return the value of the underlying filter (or callable) without any modifications. + """ + def __init__(self, filter): + self._filter = filter + + def __call__(self, event): + return self._filter(event) + + +class Always(Filter): + """ + This filter always returns `True`, and is used as the "empty filter". + """ + def __call__(self, event): + return True + + +class Never(Filter): + """ + This filter always returns `False`, and is used when an impossible filter is made + (for example, neither outgoing nor incoming is always false). This can be used to + "turn off" handlers without removing them. + """ + def __call__(self, event): + return False diff --git a/telethon/_events/filters/entities.py b/telethon/_events/filters/entities.py new file mode 100644 index 00000000..4ccb3569 --- /dev/null +++ b/telethon/_events/filters/entities.py @@ -0,0 +1,25 @@ +from .base import Filter + + +class Chats: + """ + The update type must match the specified instances for the filter to return `True`. + This is most useful for raw API. + """ + def __init__(self, types): + self._types = types + + def __call__(self, event): + return isinstance(event, self._types) + + +class Senders: + """ + The update type must match the specified instances for the filter to return `True`. + This is most useful for raw API. + """ + def __init__(self, types): + self._types = types + + def __call__(self, event): + return isinstance(event, self._types) diff --git a/telethon/_events/filters/generic.py b/telethon/_events/filters/generic.py new file mode 100644 index 00000000..c3e010c6 --- /dev/null +++ b/telethon/_events/filters/generic.py @@ -0,0 +1,13 @@ +from .base import Filter + + +class Types: + """ + The update type must match the specified instances for the filter to return `True`. + This is most useful for raw API. + """ + def __init__(self, types): + self._types = types + + def __call__(self, event): + return isinstance(event, self._types) diff --git a/telethon/_events/filters/messages.py b/telethon/_events/filters/messages.py new file mode 100644 index 00000000..e1b1c3a3 --- /dev/null +++ b/telethon/_events/filters/messages.py @@ -0,0 +1,44 @@ +import re +from .base import Filter + + +class Incoming: + """ + The update must be something the client received from another user, + and not something the current user sent. + """ + def __call__(self, event): + return not event.out + + +class Outgoing: + """ + The update must be something the current user sent, + and not something received from another user. + """ + def __call__(self, event): + return event.out + + +class Pattern: + """ + The update type must match the specified instances for the filter to return `True`. + This is most useful for raw API. + """ + def __init__(self, pattern): + self._pattern = re.compile(pattern).match + + def __call__(self, event): + return self._pattern(event.text) + + +class Data: + """ + The update type must match the specified instances for the filter to return `True`. + This is most useful for raw API. + """ + def __init__(self, data): + self._data = re.compile(data).match + + def __call__(self, event): + return self._data(event.data) diff --git a/telethon/_events/inlinequery.py b/telethon/_events/inlinequery.py new file mode 100644 index 00000000..716a9c98 --- /dev/null +++ b/telethon/_events/inlinequery.py @@ -0,0 +1,200 @@ +import inspect +import re + +import asyncio + +from .base import EventBuilder +from .._misc import utils +from .. import _tl +from ..types import _custom + + +class InlineQuery(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter): + """ + Occurs whenever you sign in as a bot and a user + sends an inline query such as ``@bot query``. + + Members: + query (:tl:`UpdateBotInlineQuery`): + The original :tl:`UpdateBotInlineQuery`. + + Make sure to access the `text` property of the query if + you want the text rather than the actual query object. + + pattern_match (`obj`, optional): + The resulting object from calling the passed ``pattern`` + function, which is ``re.compile(...).match`` by default. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.InlineQuery) + async def handler(event): + builder = event.builder + + # Two options (convert user text to UPPERCASE or lowercase) + await event.answer([ + builder.article('UPPERCASE', text=event.text.upper()), + builder.article('lowercase', text=event.text.lower()), + ]) + """ + @classmethod + def _build(cls, client, update, entities): + if not isinstance(update, _tl.UpdateBotInlineQuery): + return None + + self = cls.__new__(cls) + self._client = client + self._sender = entities.get(_tl.PeerUser(update.user_id)) + self._chat = entities.get(_tl.PeerUser(update.user_id)) + self.query = update + self.pattern_match = None + self._answered = False + return self + + @property + def id(self): + """ + Returns the unique identifier for the query ID. + """ + return self.query.query_id + + @property + def text(self): + """ + Returns the text the user used to make the inline query. + """ + return self.query.query + + @property + def offset(self): + """ + The string the user's client used as an offset for the query. + This will either be empty or equal to offsets passed to `answer`. + """ + return self.query.offset + + @property + def geo(self): + """ + If the user location is requested when using inline mode + and the user's device is able to send it, this will return + the :tl:`GeoPoint` with the position of the user. + """ + return self.query.geo + + @property + def builder(self): + """ + Returns a new `InlineBuilder + ` instance. + + See the documentation for `builder` to know what kind of answers + can be given. + """ + return _custom.InlineBuilder(self._client) + + async def answer( + self, results=None, cache_time=0, *, + gallery=False, next_offset=None, private=False, + switch_pm=None, switch_pm_param=''): + """ + Answers the inline query with the given results. + + Args: + results (`list`, optional): + A list of :tl:`InputBotInlineResult` to use. + You should use `builder` to create these: + + .. code-block:: python + + builder = inline.builder + r1 = builder.article('Be nice', text='Have a nice day') + r2 = builder.article('Be bad', text="I don't like you") + await inline.answer([r1, r2]) + + You can send up to 50 results as documented in + https://core.telegram.org/bots/api#answerinlinequery. + Sending more will raise ``ResultsTooMuchError``, + and you should consider using `next_offset` to + paginate them. + + cache_time (`int`, optional): + For how long this result should be cached on + the user's client. Defaults to 0 for no cache. + + gallery (`bool`, optional): + Whether the results should show as a gallery (grid) or not. + + next_offset (`str`, optional): + The offset the client will send when the user scrolls the + results and it repeats the request. + + private (`bool`, optional): + Whether the results should be cached by Telegram + (not private) or by the user's client (private). + + switch_pm (`str`, optional): + If set, this text will be shown in the results + to allow the user to switch to private messages. + + switch_pm_param (`str`, optional): + Optional parameter to start the bot with if + `switch_pm` was used. + + Example: + + .. code-block:: python + + @bot.on(events.InlineQuery) + async def handler(event): + builder = event.builder + + rev_text = event.text[::-1] + await event.answer([ + builder.article('Reverse text', text=rev_text), + builder.photo('/path/to/photo.jpg') + ]) + """ + if self._answered: + return + + if results: + futures = [self._as_future(x) for x in results] + + await asyncio.wait(futures) + + # All futures will be in the `done` *set* that `wait` returns. + # + # Precisely because it's a `set` and not a `list`, it + # will not preserve the order, but since all futures + # completed we can use our original, ordered `list`. + results = [x.result() for x in futures] + else: + results = [] + + if switch_pm: + switch_pm = _tl.InlineBotSwitchPM(switch_pm, switch_pm_param) + + return await self._client( + _tl.fn.messages.SetInlineBotResults( + query_id=self.query.query_id, + results=results, + cache_time=cache_time, + gallery=gallery, + next_offset=next_offset, + private=private, + switch_pm=switch_pm + ) + ) + + @staticmethod + def _as_future(obj): + if inspect.isawaitable(obj): + return asyncio.ensure_future(obj) + + 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 58% rename from telethon/events/messagedeleted.py rename to telethon/_events/messagedeleted.py index f631fd4f..f83bd7dd 100644 --- a/telethon/events/messagedeleted.py +++ b/telethon/_events/messagedeleted.py @@ -1,9 +1,9 @@ -from .common import EventBuilder, EventCommon, name_inner_event -from ..tl import types +from .base import EventBuilder +from .. import _tl +from ..types import _custom -@name_inner_event -class MessageDeleted(EventBuilder): +class MessageDeleted(EventBuilder, _custom.chatgetter.ChatGetter): """ Occurs whenever a message is deleted. Note that this event isn't 100% reliable, since Telegram doesn't always notify the clients that a message @@ -36,22 +36,17 @@ 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): - return cls.Event( - deleted_ids=update.messages, - peer=None - ) - elif isinstance(update, types.UpdateDeleteChannelMessages): - return cls.Event( - deleted_ids=update.messages, - peer=types.PeerChannel(update.channel_id) - ) + def _build(cls, client, update, entities): + if isinstance(update, _tl.UpdateDeleteMessages): + peer = None + elif isinstance(update, _tl.UpdateDeleteChannelMessages): + peer = _tl.PeerChannel(update.channel_id) + else: + return None - class Event(EventCommon): - def __init__(self, deleted_ids, peer): - super().__init__( - chat_peer=peer, msg_id=(deleted_ids or [0])[0] - ) - self.deleted_id = None if not deleted_ids else deleted_ids[0] - self.deleted_ids = deleted_ids + self = cls.__new__(cls) + self._client = client + self._chat = entities.get(peer) + self.deleted_id = None if not update.messages else update.messages[0] + self.deleted_ids = update.messages + return self diff --git a/telethon/events/messageedited.py b/telethon/_events/messageedited.py similarity index 75% rename from telethon/events/messageedited.py rename to telethon/_events/messageedited.py index c4a2b4a7..9ce9f1ed 100644 --- a/telethon/events/messageedited.py +++ b/telethon/_events/messageedited.py @@ -1,10 +1,8 @@ -from .common import name_inner_event -from .newmessage import NewMessage -from ..tl import types +from .base import EventBuilder +from .. import _tl -@name_inner_event -class MessageEdited(NewMessage): +class MessageEdited(EventBuilder): """ Occurs whenever a message is edited. Just like `NewMessage `, you should treat @@ -43,10 +41,7 @@ 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)): - return cls.Event(update.message) - - class Event(NewMessage.Event): - pass # Required if we want a different name for it + def _build(cls, client, update, entities): + if isinstance(update, (_tl.UpdateEditMessage, + _tl.UpdateEditChannelMessage)): + return cls._new(client, update.message, entities, None) diff --git a/telethon/_events/messageread.py b/telethon/_events/messageread.py new file mode 100644 index 00000000..7708ae75 --- /dev/null +++ b/telethon/_events/messageread.py @@ -0,0 +1,136 @@ +from .base import EventBuilder +from .._misc import utils +from .. import _tl + + +class MessageRead(EventBuilder): + """ + Occurs whenever one or more messages are read in a chat. + + Members: + max_id (`int`): + Up to which message ID has been read. Every message + with an ID equal or lower to it have been read. + + outbox (`bool`): + `True` if someone else has read your messages. + + contents (`bool`): + `True` if what was read were the contents of a message. + This will be the case when e.g. you play a voice note. + It may only be set on ``inbox`` events. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.MessageRead) + async def handler(event): + # Log when someone reads your messages + print('Someone has read all your messages until', event.max_id) + + @client.on(events.MessageRead(inbox=True)) + async def handler(event): + # Log when you read message in a chat (from your "inbox") + print('You have read messages until', event.max_id) + """ + def __init__(self, peer=None, max_id=None, out=False, contents=False, + message_ids=None): + self.outbox = out + self.contents = contents + self._message_ids = message_ids or [] + self._messages = None + self.max_id = max_id or max(message_ids or [], default=None) + super().__init__(peer, self.max_id) + + @classmethod + def _build(cls, client, update, entities): + out = False + contents = False + message_ids = None + if isinstance(update, _tl.UpdateReadHistoryInbox): + peer = update.peer + max_id = update.max_id + out = False + elif isinstance(update, _tl.UpdateReadHistoryOutbox): + peer = update.peer + max_id = update.max_id + out = True + elif isinstance(update, _tl.UpdateReadChannelInbox): + peer = _tl.PeerChannel(update.channel_id) + max_id = update.max_id + out = False + elif isinstance(update, _tl.UpdateReadChannelOutbox): + peer = _tl.PeerChannel(update.channel_id) + max_id = update.max_id + out = True + elif isinstance(update, _tl.UpdateReadMessagesContents): + peer = None + message_ids = update.messages + contents = True + elif isinstance(update, _tl.UpdateChannelReadMessagesContents): + peer = _tl.PeerChannel(update.channel_id) + message_ids = update.messages + contents = True + + self = cls.__new__(cls) + self._client = client + self._chat = entities.get(peer) + return self + + @property + def inbox(self): + """ + `True` if you have read someone else's messages. + """ + return not self.outbox + + @property + def message_ids(self): + """ + The IDs of the messages **which contents'** were read. + + Use :meth:`is_read` if you need to check whether a message + was read instead checking if it's in here. + """ + return self._message_ids + + async def get_messages(self): + """ + Returns the list of `Message ` + **which contents'** were read. + + Use :meth:`is_read` if you need to check whether a message + was read instead checking if it's in here. + """ + if self._messages is None: + chat = await self.get_input_chat() + if not chat: + self._messages = [] + else: + self._messages = await self._client.get_messages( + chat, ids=self._message_ids) + + return self._messages + + def is_read(self, message): + """ + Returns `True` if the given message (or its ID) has been read. + + If a list-like argument is provided, this method will return a + list of booleans indicating which messages have been read. + """ + if utils.is_list_like(message): + return [(m if isinstance(m, int) else m.id) <= self.max_id + for m in message] + else: + return (message if isinstance(message, int) + else message.id) <= self.max_id + + def __contains__(self, message): + """`True` if the message(s) are read message.""" + if utils.is_list_like(message): + return all(self.is_read(message)) + else: + return self.is_read(message) diff --git a/telethon/_events/newmessage.py b/telethon/_events/newmessage.py new file mode 100644 index 00000000..d659a8ef --- /dev/null +++ b/telethon/_events/newmessage.py @@ -0,0 +1,104 @@ +import re + +from .base import EventBuilder +from .._misc import utils +from .. import _tl +from ..types import _custom + + +class NewMessage(EventBuilder, _custom.Message): + """ + Represents the event of a new message. This event can be treated + to all effects as a `Message `, + so please **refer to its documentation** to know what you can do + with this event. + + Members: + message (`Message `): + This is the only difference with the received + `Message `, and will + return the `telethon.tl.custom.message.Message` itself, + not the text. + + See `Message ` for + the rest of available members and methods. + + pattern_match (`obj`): + The resulting object from calling the passed ``pattern`` function. + Here's an example using a string (defaults to regex match): + + >>> from telethon import TelegramClient, events + >>> client = TelegramClient(...) + >>> + >>> @client.on(events.NewMessage(pattern=r'hi (\\w+)!')) + ... async def handler(event): + ... # In this case, the result is a ``Match`` object + ... # since the `str` pattern was converted into + ... # the ``re.compile(pattern).match`` function. + ... print('Welcomed', event.pattern_match.group(1)) + ... + >>> + + Example + .. code-block:: python + + import asyncio + from telethon import events + + @client.on(events.NewMessage(pattern='(?i)hello.+')) + async def handler(event): + # Respond whenever someone says "Hello" and something else + await event.reply('Hey!') + + @client.on(events.NewMessage(outgoing=True, pattern='!ping')) + async def handler(event): + # Say "!pong" whenever you send "!ping", then delete both messages + m = await event.respond('!pong') + await asyncio.sleep(5) + await client.delete_messages(event.chat_id, [event.id, m.id]) + """ + @classmethod + def _build(cls, client, update, entities): + if isinstance(update, + (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)): + if not isinstance(update.message, _tl.Message): + return # We don't care about MessageService's here + 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=_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, + via_bot_id=update.via_bot_id, + reply_to=update.reply_to, + entities=update.entities, + ttl_period=update.ttl_period + ) + 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=_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, + via_bot_id=update.via_bot_id, + reply_to=update.reply_to, + entities=update.entities, + ttl_period=update.ttl_period + ) + else: + return + + return cls._new(client, msg, entities, None) diff --git a/telethon/_events/raw.py b/telethon/_events/raw.py new file mode 100644 index 00000000..7faf8e0b --- /dev/null +++ b/telethon/_events/raw.py @@ -0,0 +1,23 @@ +from .base import EventBuilder +from .._misc import utils + + +class Raw(EventBuilder): + """ + Raw events are not actual events. Instead, they are the raw + :tl:`Update` object that Telegram sends. You normally shouldn't + need these. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.Raw) + async def handler(update): + # Print all incoming updates + print(update.stringify()) + """ + @classmethod + def _build(cls, client, update, entities): + return update diff --git a/telethon/_events/userupdate.py b/telethon/_events/userupdate.py new file mode 100644 index 00000000..2ea622bb --- /dev/null +++ b/telethon/_events/userupdate.py @@ -0,0 +1,301 @@ +import datetime +import functools + +from .base import EventBuilder +from .._misc import utils +from .. import _tl +from ..types import _custom + + +# TODO Either the properties are poorly named or they should be +# different events, but that would be a breaking change. +# +# TODO There are more "user updates", but bundling them all up +# in a single place will make it annoying to use (since +# the user needs to check for the existence of `None`). +# +# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUserPhoto + +def _requires_action(function): + @functools.wraps(function) + def wrapped(self): + return None if self.action is None else function(self) + + return wrapped + + +def _requires_status(function): + @functools.wraps(function) + def wrapped(self): + return None if self.status is None else function(self) + + return wrapped + + +class UserUpdate(EventBuilder, _custom.chatgetter.ChatGetter, _custom.sendergetter.SenderGetter): + """ + Occurs whenever a user goes online, starts typing, etc. + + Members: + status (:tl:`UserStatus`, optional): + The user status if the update is about going online or offline. + + You should check this attribute first before checking any + of the seen within properties, since they will all be `None` + if the status is not set. + + action (:tl:`SendMessageAction`, optional): + The "typing" action if any the user is performing if any. + + You should check this attribute first before checking any + of the typing properties, since they will all be `None` + if the action is not set. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.UserUpdate) + async def handler(event): + # If someone is uploading, say something + if event.uploading: + await client.send_message(event.user_id, 'What are you sending?') + """ + @classmethod + def _build(cls, client, update, entities): + chat_peer = None + status = None + if isinstance(update, _tl.UpdateUserStatus): + peer = _tl.PeerUser(update.user_id) + status = update.status + typing = None + elif isinstance(update, _tl.UpdateChannelUserTyping): + peer = update.from_id + chat_peer = _tl.PeerChannel(update.channel_id) + typing = update.action + elif isinstance(update, _tl.UpdateChatUserTyping): + peer = update.from_id + chat_peer = _tl.PeerChat(update.chat_id) + typing = update.action + elif isinstance(update, _tl.UpdateUserTyping): + peer = update.user_id + typing = update.action + else: + return None + + self = cls.__new__(cls) + self._client = client + self._sender = entities.get(peer) + self._chat = entities.get(chat_peer or peer) + self.status = status + self.action = typing + return self + + @property + def user(self): + """Alias for `sender `.""" + return self.sender + + async def get_user(self): + """Alias for `get_sender `.""" + return await self.get_sender() + + @property + def input_user(self): + """Alias for `input_sender `.""" + return self.input_sender + + @property + def user_id(self): + """Alias for `sender_id `.""" + return self.sender_id + + @property + @_requires_action + def typing(self): + """ + `True` if the action is typing a message. + """ + return isinstance(self.action, _tl.SendMessageTypingAction) + + @property + @_requires_action + def uploading(self): + """ + `True` if the action is uploading something. + """ + return isinstance(self.action, ( + _tl.SendMessageChooseContactAction, + _tl.SendMessageChooseStickerAction, + _tl.SendMessageUploadAudioAction, + _tl.SendMessageUploadDocumentAction, + _tl.SendMessageUploadPhotoAction, + _tl.SendMessageUploadRoundAction, + _tl.SendMessageUploadVideoAction + )) + + @property + @_requires_action + def recording(self): + """ + `True` if the action is recording something. + """ + return isinstance(self.action, ( + _tl.SendMessageRecordAudioAction, + _tl.SendMessageRecordRoundAction, + _tl.SendMessageRecordVideoAction + )) + + @property + @_requires_action + def playing(self): + """ + `True` if the action is playing a game. + """ + return isinstance(self.action, _tl.SendMessageGamePlayAction) + + @property + @_requires_action + def cancel(self): + """ + `True` if the action was cancelling other actions. + """ + return isinstance(self.action, _tl.SendMessageCancelAction) + + @property + @_requires_action + def geo(self): + """ + `True` if what's being uploaded is a geo. + """ + return isinstance(self.action, _tl.SendMessageGeoLocationAction) + + @property + @_requires_action + def audio(self): + """ + `True` if what's being recorded/uploaded is an audio. + """ + return isinstance(self.action, ( + _tl.SendMessageRecordAudioAction, + _tl.SendMessageUploadAudioAction + )) + + @property + @_requires_action + def round(self): + """ + `True` if what's being recorded/uploaded is a round video. + """ + return isinstance(self.action, ( + _tl.SendMessageRecordRoundAction, + _tl.SendMessageUploadRoundAction + )) + + @property + @_requires_action + def video(self): + """ + `True` if what's being recorded/uploaded is an video. + """ + return isinstance(self.action, ( + _tl.SendMessageRecordVideoAction, + _tl.SendMessageUploadVideoAction + )) + + @property + @_requires_action + def contact(self): + """ + `True` if what's being uploaded (selected) is a contact. + """ + return isinstance(self.action, _tl.SendMessageChooseContactAction) + + @property + @_requires_action + def document(self): + """ + `True` if what's being uploaded is document. + """ + return isinstance(self.action, _tl.SendMessageUploadDocumentAction) + + @property + @_requires_action + def sticker(self): + """ + `True` if what's being uploaded is a sticker. + """ + return isinstance(self.action, _tl.SendMessageChooseStickerAction) + + @property + @_requires_action + def photo(self): + """ + `True` if what's being uploaded is a photo. + """ + return isinstance(self.action, _tl.SendMessageUploadPhotoAction) + + @property + @_requires_action + def last_seen(self): + """ + Exact `datetime.datetime` when the user was last seen if known. + """ + if isinstance(self.status, _tl.UserStatusOffline): + return self.status.was_online + + @property + @_requires_status + def until(self): + """ + The `datetime.datetime` until when the user should appear online. + """ + if isinstance(self.status, _tl.UserStatusOnline): + return self.status.expires + + def _last_seen_delta(self): + if isinstance(self.status, _tl.UserStatusOffline): + return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online + elif isinstance(self.status, _tl.UserStatusOnline): + return datetime.timedelta(days=0) + elif isinstance(self.status, _tl.UserStatusRecently): + return datetime.timedelta(days=1) + elif isinstance(self.status, _tl.UserStatusLastWeek): + return datetime.timedelta(days=7) + elif isinstance(self.status, _tl.UserStatusLastMonth): + return datetime.timedelta(days=30) + else: + return datetime.timedelta(days=365) + + @property + @_requires_status + def online(self): + """ + `True` if the user is currently online, + """ + return self._last_seen_delta() <= datetime.timedelta(days=0) + + @property + @_requires_status + def recently(self): + """ + `True` if the user was seen within a day. + """ + return self._last_seen_delta() <= datetime.timedelta(days=1) + + @property + @_requires_status + def within_weeks(self): + """ + `True` if the user was seen within 7 days. + """ + return self._last_seen_delta() <= datetime.timedelta(days=7) + + @property + @_requires_status + def within_months(self): + """ + `True` if the user was seen within 30 days. + """ + return self._last_seen_delta() <= datetime.timedelta(days=30) 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 95% rename from telethon/extensions/binaryreader.py rename to telethon/_misc/binaryreader.py index 996f362e..b4690871 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 @@ -139,7 +139,7 @@ class BinaryReader: self.set_position(pos) raise error - return clazz.from_reader(self) + return clazz._from_reader(self) def tgread_vector(self): """Reads a vector (a list) of Telegram objects.""" 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..64dabd28 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) + 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..addce15c --- /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 +Dialog = typing.Union[_tl.User, _tl.Chat, _tl.Channel] +FullDialog = typing.Union[_tl.UserFull, _tl.messages.ChatFull, _tl.ChatFull, _tl.ChannelFull] + +DialogLike = typing.Union[ + Phone, + Username, + PeerID, + _tl.TypePeer, + _tl.TypeInputPeer, + Dialog, + FullDialog +] +DialogsLike = typing.Union[DialogLike, typing.Sequence[DialogLike]] + +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 53% rename from telethon/tl/tlobject.py rename to telethon/_misc/tlobject.py index 4b94e00f..68aa6837 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,78 +33,12 @@ 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): + def _serialize_bytes(data): """Write bytes by using Telegram guidelines""" if not isinstance(data, bytes): if isinstance(data, str): @@ -138,7 +73,7 @@ class TLObject: return b''.join(r) @staticmethod - def serialize_datetime(dt): + def _serialize_datetime(dt): if not dt and not isinstance(dt, timedelta): return b'\0\0\0\0' @@ -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: @@ -197,7 +133,7 @@ class TLObject: # provided) it will try to access `._bytes()`, which will fail # with `AttributeError`. This occurs in fact because the type # was wrong, so raise the correct error type. - raise TypeError('a TLObject was expected but found something else') + raise TypeError(f'a TLObject was expected but found {self!r}') # Custom objects will call `(...)._bytes()` and not `bytes(...)` so that # if the wrong type is used (e.g. `int`) we won't try allocating a huge @@ -206,7 +142,7 @@ class TLObject: raise NotImplementedError @classmethod - def from_reader(cls, reader): + def _from_reader(cls, reader): raise NotImplementedError @@ -215,8 +151,8 @@ class TLRequest(TLObject): Represents a content-related `TLObject` (a request that can be sent). """ @staticmethod - def read_result(reader): + def _read_result(reader): return reader.tgread_object() - async def resolve(self, client, utils): - pass + async def _resolve(self, client, utils): + return self diff --git a/telethon/utils.py b/telethon/_misc/utils.py similarity index 64% rename from telethon/utils.py rename to telethon/_misc/utils.py index 6045506e..9121637a 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 @@ -54,7 +54,7 @@ mimetypes.add_type('audio/flac', '.flac') mimetypes.add_type('application/x-tgsticker', '.tgs') USERNAME_RE = re.compile( - r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|joinchat/)?' + r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|\+|joinchat/)?' ) TG_JOIN_RE = re.compile( r'tg://(join)\?invite=' @@ -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') @@ -429,7 +429,8 @@ def get_input_geo(geo): def get_input_media( media, *, is_photo=False, attributes=None, force_document=False, - voice_note=False, video_note=False, supports_streaming=False + voice_note=False, video_note=False, supports_streaming=False, + ttl=None ): """ Similar to :meth:`get_input_peer`, but for media. @@ -442,37 +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) + return _tl.InputMediaPhoto(media, ttl_seconds=ttl) elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument') - return types.InputMediaDocument(media) + 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=media.ttl_seconds + ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)): - return types.InputMediaPhoto( - id=get_input_photo(media) + 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=media.ttl_seconds + ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Document, types.DocumentEmpty)): - return types.InputMediaDocument( - id=get_input_document(media) + 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) + return _tl.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl) else: attrs, mime = get_attributes( media, @@ -482,28 +485,29 @@ def get_input_media( video_note=video_note, supports_streaming=supports_streaming ) - return types.InputMediaUploadedDocument( - file=media, mime_type=mime, attributes=attrs, force_file=force_document) + 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, @@ -512,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): - return get_input_media(media.media, is_photo=is_photo) + 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. @@ -535,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') @@ -552,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 @@ -569,29 +573,11 @@ 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, - 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 - - return entity, input_entity - - def get_message_id(message): """Similar to :meth:`get_input_peer`, but for message IDs.""" if message is None: @@ -673,8 +659,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) @@ -686,8 +672,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, @@ -698,7 +684,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, @@ -715,22 +701,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 @@ -748,37 +734,26 @@ def get_attributes(file, *, attributes=None, mime_type=None, return list(attr_dict.values()), mime_type -def sanitize_parse_mode(mode): - """ - Converts the given parse mode into an object with - ``parse`` and ``unparse`` callable properties. - """ - if not mode: - return None - - if callable(mode): - class CustomMode: - @staticmethod - def unparse(text, entities): - raise NotImplementedError - - CustomMode.parse = mode - return CustomMode - elif (all(hasattr(mode, x) for x in ('parse', 'unparse')) - and all(callable(x) for x in (mode.parse, mode.unparse))): - return mode +def sanitize_parse_mode(mode, *, _nop_parse=lambda t: (t, []), _nop_unparse=lambda t, e: t): + if mode is None: + mode = (_nop_parse, _nop_unparse) elif isinstance(mode, str): - try: - return { - 'md': markdown, - 'markdown': markdown, - 'htm': html, - 'html': html - }[mode.lower()] - except KeyError: - raise ValueError('Unknown parse mode {}'.format(mode)) + mode = mode.lower() + if mode in ('md', 'markdown'): + mode = (markdown.parse, markdown.unparse) + elif mode in ('htm', 'html'): + mode = (html.parse, 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, _nop_unparse) + elif isinstance(mode, tuple): + if not (len(mode) == 2 and callable(mode[0]) and callable(mode[1])): + raise ValueError(f'mode must be a tuple of exactly two callables') else: - raise TypeError('Invalid parse mode type {}'.format(mode)) + raise TypeError(f'mode must be either a str, callable or tuple, but was {mode!r}') + + return mode def get_input_location(location): @@ -799,23 +774,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, @@ -852,11 +827,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): @@ -961,97 +932,44 @@ 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 :tl:`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] - - # Tell the user to use their client to resolve InputPeerSelf if we got one - if isinstance(peer, types.InputPeerSelf): - _raise_cast_fail(peer, 'int (you might want to use client.get_peer_id)') - - try: - peer = get_peer(peer) - except TypeError: + pid = getattr(peer, 'user_id', None) or getattr(peer, 'channel_id', None) or getattr(peer, 'chat_id', None) + if not isinstance(pid, int): _raise_cast_fail(peer, 'int') - if isinstance(peer, types.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 <= 0x7fffffff): - 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 <= 0x7fffffff): - 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 + return pid def _rle_decode(data): @@ -1115,198 +1033,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 @@ -1322,7 +1048,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..328998f3 --- /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 ``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 ``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..7e87b194 --- /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.hash)) for e in entities) + + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: + try: + ty, hash = self.entities[id] + return Entity(ty, id, 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..791f4818 --- /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 = os.fspath(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, 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, + 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.hash, e.ty) 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, 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 57% rename from telethon/sessions/string.py rename to telethon/_sessions/string.py index fb971d82..eab1b0cc 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', signed=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.dcs[self.state.dc_id].ipv6 is not None: + ip = self.dcs[self.state.dc_id].ipv6.to_bytes(16, 'big', signed=False) + else: + ip = self.dcs[self.state.dc_id].ipv4.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.dcs[self.state.dc_id].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..4c9aa464 --- /dev/null +++ b/telethon/_sessions/types.py @@ -0,0 +1,178 @@ +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 (of type `EntityType`). + * id: 64-bit number uniquely identifying the entity among those of the same type. + * hash: 64-bit signed number needed to use this entity with the API. + + The string representation of this class is considered to be stable, for as long as + Telegram doesn't need to add more fields to the entities. It can also be converted + to bytes with ``bytes(entity)``, for a more compact representation. + """ + __slots__ = ('ty', 'id', 'hash') + + ty: EntityType + id: int + hash: int + + @property + def is_user(self): + """ + ``True`` if the entity is either a user or a bot. + """ + return self.ty in (EntityType.USER, EntityType.BOT) + + @property + def is_group(self): + """ + ``True`` if the entity is a small group chat or `megagroup`_. + + .. _megagroup: https://telegram.org/blog/supergroups5k + """ + return self.ty in (EntityType.GROUP, EntityType.MEGAGROUP) + + @property + def is_broadcast(self): + """ + ``True`` if the entity is a broadcast channel or `broadcast group`_. + + .. _broadcast group: https://telegram.org/blog/autodelete-inv2#groups-with-unlimited-members + """ + return self.ty in (EntityType.CHANNEL, EntityType.GIGAGROUP) + + @classmethod + def from_str(cls, string: str): + """ + Convert the string into an `Entity`. + """ + try: + ty, id, hash = string.split('.') + ty, id, hash = ord(ty), int(id), int(hash) + except AttributeError: + raise TypeError(f'expected str, got {string!r}') from None + except (TypeError, ValueError): + raise ValueError(f'malformed entity str (must be T.id.hash), got {string!r}') from None + + return cls(EntityType(ty), id, hash) + + @classmethod + def from_bytes(cls, blob): + """ + Convert the bytes into an `Entity`. + """ + try: + ty, id, hash = struct.unpack(' (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): + try: + hash, ty = self.hash_map[id] + return Entity(ty, id, hash) + except KeyError: + return 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.hash, entity.ty) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py new file mode 100644 index 00000000..87653103 --- /dev/null +++ b/telethon/_updates/messagebox.py @@ -0,0 +1,578 @@ +""" +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 +import datetime +import time +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: datetime.datetime = datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc) + 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 = datetime.datetime.fromtimestamp(session_state.date).replace(tzinfo=datetime.timezone.utc) + 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=int(self.date.timestamp()), + 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', None) or [] + chats = getattr(updates, 'chats', None) 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=_tl.InputChannel(packed.id, packed.hash), + 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 d712f698..00000000 --- a/telethon/client/chats.py +++ /dev/null @@ -1,1343 +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(), - - '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: - self.total = (await self.client( - functions.channels.GetFullChannelRequest(entity) - )).full_chat.participants_count - - if self.limit <= 0: - 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 - - results = await self.client(self.requests) - for i in reversed(range(len(self.requests))): - participants = results[i] - 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): - 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. - * ``'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 3665c94e..00000000 --- a/telethon/client/dialogs.py +++ /dev/null @@ -1,596 +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. - - 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 62ba6332..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: - 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 6bdb7f5d..00000000 --- a/telethon/client/messages.py +++ /dev/null @@ -1,1413 +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 - ): - # 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 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 - ) -> '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. - - 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 - ) - - 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, - 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. - - 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 - ) - - 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, - reply_to=reply_to, - buttons=markup, - formatting_entities=message.entities, - schedule=schedule - ) - - request = functions.messages.SendMessageRequest( - peer=entity, - message=message.message or '', - silent=silent, - 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, - 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, - *, - 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. - - 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, - 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 - ): - """ - 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. - - 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) - - 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): - 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, - ) - result = await self(request) - - # Unpinning does not produce a service message - if unpin: - return - - # Pinning in User chats (just with yourself really) does not produce a service message - if helpers._entity_type(entity) == helpers._EntityType.USER: - return - - # Pinning a message that doesn't exist would RPC-error earlier - return self._get_response_message(request, result, entity) - - # endregion - - # endregion diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py deleted file mode 100644 index ee40c796..00000000 --- a/telethon/client/telegrambaseclient.py +++ /dev/null @@ -1,858 +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. - """ - - # 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): - 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 = {} - - 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. - - 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 a9d6344e..00000000 --- a/telethon/client/updates.py +++ /dev/null @@ -1,643 +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 - - -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() - - 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: callable, - 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: callable, - 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[callable, 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 544b7087..00000000 --- a/telethon/client/uploads.py +++ /dev/null @@ -1,744 +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, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, types.Message]' = 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. - - 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. - - 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 - ) - 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, - **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 - ) - - # 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 - ) - 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): - """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) - 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 - ) - 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): - 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 - ), 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) - else: - media = types.InputMediaDocumentExternal(file) - else: - bot_file = utils.resolve_bot_file_id(file) - if bot_file: - media = utils.get_input_media(bot_file) - - if media: - pass # Already have media, don't check the rest - 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) - 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 - ) - return file_handle, media, as_image - - # endregion diff --git a/telethon/client/users.py b/telethon/client/users.py deleted file mode 100644 index 646914b2..00000000 --- a/telethon/client/users.py +++ /dev/null @@ -1,606 +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): - return await self._call(self._sender, request, ordered=ordered) - - async def _call(self: 'TelegramClient', sender, request, ordered=False): - requests = (request if utils.is_list_like(request) else (request,)) - for r in requests: - if not isinstance(r, TLRequest): - raise _NOT_A_REQUEST() - await r.resolve(self, utils) - - # Avoid making the request if it's already in a flood wait - if r.CONSTRUCTOR_ID in self._flood_waited_requests: - due = self._flood_waited_requests[r.CONSTRUCTOR_ID] - diff = round(due - time.time()) - if diff <= 3: # Flood waits below 3 seconds are "ignored" - self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) - elif diff <= self.flood_sleep_threshold: - 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) - - 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..cc83f2fe 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -1,46 +1,29 @@ -""" -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, + SignUpRequired, +) +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..b5088bbf 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 ) @@ -161,3 +150,9 @@ class MultiError(Exception): self.results = list(result) self.requests = list(requests) return self + + +class SignUpRequired(Exception): + """ + Occurs when trying to sign in with a phone number that doesn't have an account registered yet. + """ diff --git a/telethon/errors/_rpcbase.py b/telethon/errors/_rpcbase.py new file mode 100644 index 00000000..31691397 --- /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 f6685aeb..00000000 --- a/telethon/errors/rpcbaseerrors.py +++ /dev/null @@ -1,111 +0,0 @@ -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): - return ' (caused by {})'.format(request.__class__.__name__) - - 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..125e6b81 --- /dev/null +++ b/telethon/events.py @@ -0,0 +1,11 @@ +from ._events.base import StopPropagation +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/events/__init__.py b/telethon/events/__init__.py deleted file mode 100644 index 28f85b12..00000000 --- a/telethon/events/__init__.py +++ /dev/null @@ -1,140 +0,0 @@ -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' - - -class StopPropagation(Exception): - """ - If this exception is raised in any of the handlers for a given event, - it will stop the execution of all other registered event handlers. - It can be seen as the ``StopIteration`` in a for loop but for events. - - Example usage: - - >>> from telethon import TelegramClient, events - >>> client = TelegramClient(...) - >>> - >>> @client.on(events.NewMessage) - ... async def delete(event): - ... await event.delete() - ... # No other event handler will have a chance to handle this event - ... raise StopPropagation - ... - >>> @client.on(events.NewMessage) - ... async def _(event): - ... # Will never be reached, because it is the second handler - ... pass - """ - # For some reason Sphinx wants the silly >>> or - # it will show warnings and look bad when generated. - pass - - -def register(event=None): - """ - Decorator method to *register* event handlers. This is the client-less - `add_event_handler() - ` variant. - - Note that this method only registers callbacks as handlers, - and does not attach them to any client. This is useful for - external modules that don't have access to the client, but - still want to define themselves as a handler. Example: - - >>> from telethon import events - >>> @events.register(events.NewMessage) - ... async def handler(event): - ... ... - ... - >>> # (somewhere else) - ... - >>> from telethon import TelegramClient - >>> client = TelegramClient(...) - >>> client.add_event_handler(handler) - - Remember that you can use this as a non-decorator - through ``register(event)(callback)``. - - Args: - event (`_EventBuilder` | `type`): - The event builder class or instance to be used, - for instance ``events.NewMessage``. - """ - if isinstance(event, type): - event = event() - elif not event: - event = Raw() - - def decorator(callback): - handlers = getattr(callback, _HANDLERS_ATTRIBUTE, []) - handlers.append(event) - setattr(callback, _HANDLERS_ATTRIBUTE, handlers) - return callback - - return decorator - - -def unregister(callback, event=None): - """ - Inverse operation of `register` (though not a decorator). Client-less - `remove_event_handler - ` - variant. **Note that this won't remove handlers from the client**, - because it simply can't, so you would generally use this before - adding the handlers to the client. - - This method is here for symmetry. You will rarely need to - unregister events, since you can simply just not add them - to any client. - - If no event is given, all events for this callback are removed. - Returns how many callbacks were removed. - """ - found = 0 - if event and not isinstance(event, type): - event = type(event) - - handlers = getattr(callback, _HANDLERS_ATTRIBUTE, []) - handlers.append((event, callback)) - i = len(handlers) - while i: - i -= 1 - ev = handlers[i] - if not event or isinstance(ev, event): - del handlers[i] - found += 1 - - return found - - -def is_handler(callback): - """ - Returns `True` if the given callback is an - event handler (i.e. you used `register` on it). - """ - return hasattr(callback, _HANDLERS_ATTRIBUTE) - - -def list(callback): - """ - Returns a list containing the registered event - builders inside the specified callback handler. - """ - return getattr(callback, _HANDLERS_ATTRIBUTE, [])[:] - - -def _get_handlers(callback): - """ - Like ``list`` but returns `None` if the callback was never registered. - """ - return getattr(callback, _HANDLERS_ATTRIBUTE, None) diff --git a/telethon/events/album.py b/telethon/events/album.py deleted file mode 100644 index 6e3ce1b3..00000000 --- a/telethon/events/album.py +++ /dev/null @@ -1,349 +0,0 @@ -import asyncio -import time -import weakref - -from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types -from ..tl.custom.sendergetter import SenderGetter - -_IGNORE_MAX_SIZE = 100 # len() -_IGNORE_MAX_AGE = 5 # seconds - -# IDs to ignore, and when they were added. If it grows too large, we will -# remove old entries. Although it should generally not be bigger than 10, -# it may be possible some updates are not processed and thus not removed. -_IGNORE_DICT = {} - - -_HACK_DELAY = 0.5 - - -class AlbumHack: - """ - When receiving an album from a different data-center, they will come in - separate `Updates`, so we need to temporarily remember them for a while - and only after produce the event. - - Of course events are not designed for this kind of wizardy, so this is - a dirty hack that gets the job done. - - When cleaning up the code base we may want to figure out a better way - to do this, or just leave the album problem to the users; the update - handling code is bad enough as it is. - """ - def __init__(self, client, event): - # It's probably silly to use a weakref here because this object is - # very short-lived but might as well try to do "the right thing". - self._client = weakref.ref(client) - self._event = event # parent event - self._due = client.loop.time() + _HACK_DELAY - - client.loop.create_task(self.deliver_event()) - - def extend(self, messages): - client = self._client() - if client: # weakref may be dead - self._event.messages.extend(messages) - self._due = client.loop.time() + _HACK_DELAY - - async def deliver_event(self): - while True: - client = self._client() - if client is None: - return # weakref is dead, nothing to deliver - - diff = self._due - client.loop.time() - if diff <= 0: - # We've hit our due time, deliver event. It won't respect - # sequential updates but fixing that would just worsen this. - await client._dispatch_event(self._event) - return - - del client # Clear ref and sleep until our due time - await asyncio.sleep(diff) - - -@name_inner_event -class Album(EventBuilder): - """ - Occurs whenever you receive an album. This event only exists - to ease dealing with an unknown amount of messages that belong - to the same album. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.Album) - async def handler(event): - # Counting how many photos or videos the album has - print('Got an album with', len(event), 'items') - - # Forwarding the album as a whole to some chat - event.forward_to(chat) - - # Printing the caption - print(event.text) - - # Replying to the fifth item in the album - await event.messages[4].reply('Cool!') - """ - - def __init__( - self, chats=None, *, blacklist_chats=False, func=None): - super().__init__(chats, blacklist_chats=blacklist_chats, func=func) - - @classmethod - def build(cls, update, others=None, self_id=None): - if not others: - return # We only care about albums which come inside the same Updates - - if isinstance(update, - (types.UpdateNewMessage, types.UpdateNewChannelMessage)): - if not isinstance(update.message, types.Message): - return # We don't care about MessageService's here - - group = update.message.grouped_id - if group is None: - return # It must be grouped - - # Check whether we are supposed to skip this update, and - # if we do also remove it from the ignore list since we - # won't need to check against it again. - if _IGNORE_DICT.pop(id(update), None): - return - - # Check if the ignore list is too big, and if it is clean it - # TODO time could technically go backwards; time is not monotonic - now = time.time() - if len(_IGNORE_DICT) > _IGNORE_MAX_SIZE: - for i in [i for i, t in _IGNORE_DICT.items() if now - t > _IGNORE_MAX_AGE]: - del _IGNORE_DICT[i] - - # Add the other updates to the ignore list - for u in others: - if u is not update: - _IGNORE_DICT[id(u)] = now - - # Figure out which updates share the same group and use those - return cls.Event([ - u.message for u in others - if (isinstance(u, (types.UpdateNewMessage, types.UpdateNewChannelMessage)) - and isinstance(u.message, types.Message) - and u.message.grouped_id == group) - ]) - - def filter(self, event): - # Albums with less than two messages require a few hacks to work. - if len(event.messages) > 1: - return super().filter(event) - - class Event(EventCommon, SenderGetter): - """ - Represents the event of a new album. - - Members: - 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): - # 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 - else: - chat_peer = message.peer_id - - super().__init__(chat_peer=chat_peer, - msg_id=message.id, broadcast=bool(message.post)) - - 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) - - for msg in self.messages: - msg._finish_init(client, self._entities, None) - - if len(self.messages) == 1: - # This will require hacks to be a proper album event - hack = client._albums.get(self.grouped_id) - if hack is None: - client._albums[self.grouped_id] = AlbumHack(client, self) - else: - hack.extend(self.messages) - - @property - def grouped_id(self): - """ - The shared ``grouped_id`` between all the messages. - """ - return self.messages[0].grouped_id - - @property - def text(self): - """ - The message text of the first photo with a caption, - formatted using the client's default parse mode. - """ - return next((m.text for m in self.messages if m.text), '') - - @property - def raw_text(self): - """ - The raw message text of the first photo - with a caption, ignoring any formatting. - """ - return next((m.raw_text for m in self.messages if m.raw_text), '') - - @property - def is_reply(self): - """ - `True` if the album is a reply to some other message. - - Remember that you can access the ID of the message - this one is replying to through `reply_to_msg_id`, - and the `Message` object with `get_reply_message()`. - """ - # Each individual message in an album all reply to the same message - return self.messages[0].is_reply - - @property - def forward(self): - """ - 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 - return self.messages[0].forward - - # endregion Public Properties - - # region Public Methods - - async def get_reply_message(self): - """ - The `Message ` - that this album is replying to, or `None`. - - The result will be cached after its first use. - """ - return await self.messages[0].get_reply_message() - - async def respond(self, *args, **kwargs): - """ - Responds to the album (not as a reply). Shorthand for - `telethon.client.messages.MessageMethods.send_message` - with ``entity`` already set. - """ - return await self.messages[0].respond(*args, **kwargs) - - async def reply(self, *args, **kwargs): - """ - Replies to the first photo in the album (as a reply). Shorthand - for `telethon.client.messages.MessageMethods.send_message` - with both ``entity`` and ``reply_to`` already set. - """ - return await self.messages[0].reply(*args, **kwargs) - - async def forward_to(self, *args, **kwargs): - """ - Forwards the entire album. Shorthand for - `telethon.client.messages.MessageMethods.forward_messages` - with both ``messages`` and ``from_peer`` already set. - """ - if self._client: - kwargs['messages'] = self.messages - kwargs['from_peer'] = await self.get_input_chat() - return await self._client.forward_messages(*args, **kwargs) - - async def edit(self, *args, **kwargs): - """ - Edits the first caption or the message, or the first messages' - caption if no caption is set, iff it's outgoing. Shorthand for - `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 - ` - and **will respect** the previous state of the message. - For example, if the message didn't have a link preview, - the edit won't add one by default, and you should force - it by setting it to `True` if you want it. - - This is generally the most desired and convenient behaviour, - and will work for link previews and message buttons. - """ - for msg in self.messages: - if msg.raw_text: - return await msg.edit(*args, **kwargs) - - return await self.messages[0].edit(*args, **kwargs) - - async def delete(self, *args, **kwargs): - """ - Deletes the entire album. You're responsible for checking whether - you have the permission to do so, or to except the error otherwise. - Shorthand for - `telethon.client.messages.MessageMethods.delete_messages` with - ``entity`` and ``message_ids`` already set. - """ - if self._client: - return await self._client.delete_messages( - await self.get_input_chat(), self.messages, - *args, **kwargs - ) - - async def mark_read(self): - """ - Marks the entire album as read. Shorthand for - `client.send_read_acknowledge() - ` - with both ``entity`` and ``message`` already set. - """ - if self._client: - await self._client.send_read_acknowledge( - await self.get_input_chat(), max_id=self.messages[-1].id) - - async def pin(self, *, notify=False): - """ - Pins the first photo in the album. Shorthand for - `telethon.client.messages.MessageMethods.pin_message` - with both ``entity`` and ``message`` already set. - """ - return await self.messages[0].pin(notify=notify) - - def __len__(self): - """ - Return the amount of messages in the album. - - Equivalent to ``len(self.messages)``. - """ - return len(self.messages) - - def __iter__(self): - """ - Iterate over the messages in the album. - - Equivalent to ``iter(self.messages)``. - """ - return iter(self.messages) - - def __getitem__(self, n): - """ - Access the n'th message in the album. - - Equivalent to ``event.messages[n]``. - """ - return self.messages[n] diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py deleted file mode 100644 index d1558d21..00000000 --- a/telethon/events/callbackquery.py +++ /dev/null @@ -1,343 +0,0 @@ -import re -import struct - -from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types, functions -from ..tl.custom.sendergetter import SenderGetter - - -@name_inner_event -class CallbackQuery(EventBuilder): - """ - Occurs whenever you sign in as a bot and a user - clicks one of the inline buttons on your messages. - - Note that the `chats` parameter will **not** work with normal - IDs or peers if the clicked inline button comes from a "via bot" - message. The `chats` parameter also supports checking against the - `chat_instance` which should be used for inline callbacks. - - Args: - data (`bytes`, `str`, `callable`, optional): - If set, the inline button payload data must match this data. - A UTF-8 string can also be given, a regex or a callable. For - instance, to check against ``'data_1'`` and ``'data_2'`` you - can use ``re.compile(b'data_')``. - - pattern (`bytes`, `str`, `callable`, `Pattern`, optional): - If set, only buttons with payload matching this pattern will be handled. - You can specify a regex-like string which will be matched - against the payload data, a callable function that returns `True` - if a the payload data is acceptable, or a compiled regex pattern. - - Example - .. code-block:: python - - from telethon import events, Button - - # Handle all callback queries and check data inside the handler - @client.on(events.CallbackQuery) - async def handler(event): - if event.data == b'yes': - await event.answer('Correct answer!') - - # Handle only callback queries with data being b'no' - @client.on(events.CallbackQuery(data=b'no')) - async def handler(event): - # Pop-up message with alert - await event.answer('Wrong answer!', alert=True) - - # Send a message with buttons users can click - async def main(): - await client.send_message(user, 'Yes or no?', buttons=[ - Button.inline('Yes!', b'yes'), - Button.inline('Nope', b'no') - ]) - """ - def __init__( - self, chats=None, *, blacklist_chats=False, func=None, data=None, pattern=None): - super().__init__(chats, blacklist_chats=blacklist_chats, func=func) - - if data and pattern: - raise ValueError("Only pass either data or pattern not both.") - - if isinstance(data, str): - data = data.encode('utf-8') - if isinstance(pattern, str): - pattern = pattern.encode('utf-8') - - match = data if data else pattern - - if isinstance(match, bytes): - self.match = data if data else re.compile(pattern).match - elif not match or callable(match): - self.match = match - elif hasattr(match, 'match') and callable(match.match): - if not isinstance(getattr(match, 'pattern', b''), bytes): - match = re.compile(match.pattern.encode('utf-8'), - match.flags & (~re.UNICODE)) - - self.match = match.match - else: - raise TypeError('Invalid data or pattern type given') - - self._no_check = all(x is None for x in ( - self.chats, self.func, self.match, - )) - - @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateBotCallbackQuery): - return cls.Event(update, update.peer, update.msg_id) - elif isinstance(update, types.UpdateInlineBotCallbackQuery): - # See https://github.com/LonamiWebs/Telethon/pull/1005 - # The long message ID is actually just msg_id + peer_id - mid, pid = struct.unpack('`, - since the message object is normally not present. - """ - self._client.loop.create_task(self.answer()) - if isinstance(self.query.msg_id, types.InputBotInlineMessageID): - return await self._client.edit_message( - self.query.msg_id, *args, **kwargs - ) - else: - return await self._client.edit_message( - await self.get_input_chat(), self.query.msg_id, - *args, **kwargs - ) - - async def delete(self, *args, **kwargs): - """ - Deletes the message. Shorthand for - `telethon.client.messages.MessageMethods.delete_messages` with - ``entity`` and ``message_ids`` already set. - - If you need to delete more than one message at once, don't use - this `delete` method. Use a - `telethon.client.telegramclient.TelegramClient` instance directly. - - This method also creates a task to `answer` the callback. - - 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 deleted file mode 100644 index dfd361e0..00000000 --- a/telethon/events/chataction.py +++ /dev/null @@ -1,436 +0,0 @@ -from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types - - -@name_inner_event -class ChatAction(EventBuilder): - """ - Occurs on certain chat actions: - - * Whenever a new chat is created. - * Whenever a chat's title or photo is changed or removed. - * Whenever a new message is pinned. - * Whenever a user joins or is added to the group. - * Whenever a user is removed or leaves a group if it has - less than 50 members or the removed user was a bot. - - Note that "chat" refers to "small group, megagroup and broadcast - channel", whereas "group" refers to "small group and megagroup" only. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.ChatAction) - async def handler(event): - # Welcome every new user - if event.user_joined: - await event.reply('Welcome to the group!') - """ - @classmethod - def build(cls, update, others=None, self_id=None): - # 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), - pin_ids=update.messages, - pin=update.pinned) - - elif isinstance(update, types.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), - added_by=update.inviter_id or True, - users=update.user_id) - - elif isinstance(update, types.UpdateChatParticipantDelete): - return cls.Event(types.PeerChat(update.chat_id), - kicked_by=True, - users=update.user_id) - - # UpdateChannel is sent if we leave a channel, and the update._entities - # set by _process_update would let us make some guesses. However it's - # better not to rely on this. Rely only in MessageActionChatDeleteUser. - - elif (isinstance(update, ( - types.UpdateNewMessage, types.UpdateNewChannelMessage)) - and isinstance(update.message, types.MessageService)): - msg = update.message - action = update.message.action - if isinstance(action, types.MessageActionChatJoinedByLink): - return cls.Event(msg, - added_by=True, - users=msg.from_id) - elif isinstance(action, types.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): - 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): - return cls.Event(msg, - users=action.users, - created=True, - new_title=action.title) - elif isinstance(action, types.MessageActionChannelCreate): - return cls.Event(msg, - created=True, - users=msg.from_id, - new_title=action.title) - elif isinstance(action, types.MessageActionChatEditTitle): - return cls.Event(msg, - users=msg.from_id, - new_title=action.title) - elif isinstance(action, types.MessageActionChatEditPhoto): - return cls.Event(msg, - users=msg.from_id, - new_photo=action.photo) - elif isinstance(action, types.MessageActionChatDeletePhoto): - return cls.Event(msg, - users=msg.from_id, - new_photo=True) - elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to: - return cls.Event(msg, - pin_ids=[msg.reply_to_msg_id]) - - class Event(EventCommon): - """ - Represents the event of a new chat action. - - Members: - action_message (`MessageAction `_): - The message invoked by this Chat Action. - - new_pin (`bool`): - `True` if there is a new pin. - - new_photo (`bool`): - `True` if there's a new chat photo (or it was removed). - - photo (:tl:`Photo`, optional): - The new photo (or `None` if it was removed). - - user_added (`bool`): - `True` if the user was added by some other. - - user_joined (`bool`): - `True` if the user joined on their own. - - user_left (`bool`): - `True` if the user left on their own. - - user_kicked (`bool`): - `True` if the user was kicked by some other. - - created (`bool`, optional): - `True` if this chat was just created. - - new_title (`str`, optional): - The new title string for the chat, if applicable. - - unpin (`bool`): - `True` if the existing pin gets unpinned. - """ - def __init__(self, where, new_photo=None, - added_by=None, kicked_by=None, created=None, - users=None, new_title=None, pin_ids=None, pin=None): - if isinstance(where, types.MessageService): - self.action_message = where - where = where.peer_id - else: - self.action_message = None - - # TODO needs some testing (can there be more than one id, and do they follow pin order?) - # same in get_pinned_message - super().__init__(chat_peer=where, msg_id=pin_ids[0] if pin_ids else None) - - self.new_pin = pin_ids is not None - self._pin_ids = pin_ids - self._pinned_messages = None - - self.new_photo = new_photo is not None - self.photo = \ - new_photo if isinstance(new_photo, types.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: - self.user_joined = True - elif added_by: - self.user_added = True - self._added_by = added_by - - # If `from_id` was not present (it's `True`) or the affected - # user was "kicked by itself", then it left. Else it was kicked. - if kicked_by is True or (users is not None and kicked_by == users): - self.user_left = True - elif kicked_by: - self.user_kicked = True - self._kicked_by = kicked_by - - self.created = bool(created) - - if isinstance(users, list): - self._user_ids = [utils.get_peer_id(u) for u in users] - elif users: - self._user_ids = [utils.get_peer_id(users)] - else: - self._user_ids = [] - - self._users = None - self._input_users = None - self.new_title = new_title - 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 - `telethon.client.messages.MessageMethods.send_message` with - ``entity`` already set. - """ - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) - - async def reply(self, *args, **kwargs): - """ - Replies to the chat action message (as a reply). Shorthand for - `telethon.client.messages.MessageMethods.send_message` with - both ``entity`` and ``reply_to`` already set. - - Has the same effect as `respond` if there is no message. - """ - if not self.action_message: - return await self.respond(*args, **kwargs) - - kwargs['reply_to'] = self.action_message.id - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) - - async def delete(self, *args, **kwargs): - """ - Deletes the chat action message. You're responsible for checking - whether you have the permission to do so, or to except the error - otherwise. Shorthand for - `telethon.client.messages.MessageMethods.delete_messages` with - ``entity`` and ``message_ids`` already set. - - Does nothing if no message action triggered this event. - """ - if not self.action_message: - return - - return await self._client.delete_messages( - await self.get_input_chat(), [self.action_message], - *args, **kwargs - ) - - async def get_pinned_message(self): - """ - If ``new_pin`` is `True`, this returns the `Message - ` object that was pinned. - """ - if self._pinned_messages is None: - await self.get_pinned_messages() - - if self._pinned_messages: - return self._pinned_messages[0] - - async def get_pinned_messages(self): - """ - If ``new_pin`` is `True`, this returns a `list` of `Message - ` objects that were pinned. - """ - if not self._pin_ids: - return self._pin_ids # either None or empty list - - chat = await self.get_input_chat() - if chat: - self._pinned_messages = await self._client.get_messages( - self._input_chat, ids=self._pin_ids) - - return self._pinned_messages - - @property - def added_by(self): - """ - The user who added ``users``, if applicable (`None` otherwise). - """ - if self._added_by and not isinstance(self._added_by, types.User): - aby = self._entities.get(utils.get_peer_id(self._added_by)) - if aby: - self._added_by = aby - - return self._added_by - - async def get_added_by(self): - """ - Returns `added_by` but will make an API call if necessary. - """ - if not self.added_by and self._added_by: - self._added_by = await self._client.get_entity(self._added_by) - - return self._added_by - - @property - def kicked_by(self): - """ - The user who kicked ``users``, if applicable (`None` otherwise). - """ - if self._kicked_by and not isinstance(self._kicked_by, types.User): - kby = self._entities.get(utils.get_peer_id(self._kicked_by)) - if kby: - self._kicked_by = kby - - return self._kicked_by - - async def get_kicked_by(self): - """ - Returns `kicked_by` but will make an API call if necessary. - """ - if not self.kicked_by and self._kicked_by: - self._kicked_by = await self._client.get_entity(self._kicked_by) - - return self._kicked_by - - @property - def user(self): - """ - The first user that takes part in this action. For example, who joined. - - Might be `None` if the information can't be retrieved or - there is no user taking part. - """ - if self.users: - return self._users[0] - - async def get_user(self): - """ - Returns `user` but will make an API call if necessary. - """ - if self.users or await self.get_users(): - return self._users[0] - - @property - def input_user(self): - """ - Input version of the ``self.user`` property. - """ - if self.input_users: - return self._input_users[0] - - async def get_input_user(self): - """ - Returns `input_user` but will make an API call if necessary. - """ - if self.input_users or await self.get_input_users(): - return self._input_users[0] - - @property - def user_id(self): - """ - Returns the marked signed ID of the first user, if any. - """ - if self._user_ids: - return self._user_ids[0] - - @property - def users(self): - """ - A list of users that take part in this action. For example, who joined. - - Might be empty if the information can't be retrieved or there - are no users taking part. - """ - if not self._user_ids: - return [] - - if self._users is None: - self._users = [ - self._entities[user_id] - for user_id in self._user_ids - if user_id in self._entities - ] - - return self._users - - async def get_users(self): - """ - Returns `users` but will make an API call if necessary. - """ - if not self._user_ids: - return [] - - # Note: we access the property first so that it fills if needed - if (self.users is None or len(self._users) != len(self._user_ids)) and self.action_message: - await self.action_message._reload_message() - self._users = [ - u for u in self.action_message.action_entities - if isinstance(u, (types.User, types.UserEmpty))] - - return self._users - - @property - def input_users(self): - """ - Input version of the ``self.users`` property. - """ - if self._input_users is None and self._user_ids: - self._input_users = [] - for user_id in self._user_ids: - # First try to get it from our entities - try: - self._input_users.append(utils.get_input_peer(self._entities[user_id])) - continue - except (KeyError, TypeError): - pass - - # If missing, try from the entity cache - try: - self._input_users.append(self._client._entity_cache[user_id]) - continue - except KeyError: - pass - - return self._input_users or [] - - async def get_input_users(self): - """ - Returns `input_users` but will make an API call if necessary. - """ - if not self._user_ids: - return [] - - # Note: we access the property first so that it fills if needed - if (self.input_users is None or len(self._input_users) != len(self._user_ids)) and self.action_message: - self._input_users = [ - utils.get_input_peer(u) - for u in self.action_message.action_entities - if isinstance(u, (types.User, types.UserEmpty))] - - return self._input_users or [] - - @property - def user_ids(self): - """ - Returns the marked signed ID of the users, if any. - """ - if self._user_ids: - return self._user_ids[:] diff --git a/telethon/events/common.py b/telethon/events/common.py deleted file mode 100644 index e8978f31..00000000 --- a/telethon/events/common.py +++ /dev/null @@ -1,186 +0,0 @@ -import abc -import asyncio -import warnings - -from .. import utils -from ..tl import TLObject, types -from ..tl.custom.chatgetter import ChatGetter - - -async def _into_id_set(client, chats): - """Helper util to turn the input chat or chats into a set of IDs.""" - if chats is None: - return None - - if not utils.is_list_like(chats): - chats = (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: - # 0x2d45687 == crc32(b'Peer') - result.add(utils.get_peer_id(chat)) - else: - chat = await client.get_input_entity(chat) - if isinstance(chat, types.InputPeerSelf): - chat = await client.get_me(input_peer=True) - result.add(utils.get_peer_id(chat)) - - return result - - -class EventBuilder(abc.ABC): - """ - The common event builder, with builtin support to filter per chat. - - Args: - chats (`entity`, optional): - May be one or more entities (username/peer/etc.), preferably IDs. - By default, only matching chats will be handled. - - blacklist_chats (`bool`, optional): - Whether to treat the chats as a blacklist instead of - as a whitelist (default). This means that every chat - will be handled *except* those specified in ``chats`` - which will be ignored if ``blacklist_chats=True``. - - func (`callable`, optional): - A callable (async or not) function that should accept the event as input - parameter, and return a value indicating whether the event - should be dispatched or not (any truthy value will do, it - does not need to be a `bool`). It works like a custom filter: - - .. code-block:: python - - @client.on(events.NewMessage(func=lambda e: e.is_private)) - async def handler(event): - pass # code here - """ - def __init__(self, chats=None, *, blacklist_chats=False, func=None): - self.chats = chats - self.blacklist_chats = bool(blacklist_chats) - self.resolved = False - self.func = func - self._resolve_lock = None - - @classmethod - @abc.abstractmethod - def build(cls, update, others=None, self_id=None): - """ - Builds an event for the given update if possible, or returns None. - - `others` are the rest of updates that came in the same container - as the current `update`. - - `self_id` should be the current user's ID, since it is required - for some events which lack this information but still need it. - """ - # TODO So many parameters specific to only some update types seems dirty - - async def resolve(self, client): - """Helper method to allow event builders to be resolved before usage""" - if self.resolved: - return - - if not self._resolve_lock: - self._resolve_lock = asyncio.Lock() - - async with self._resolve_lock: - if not self.resolved: - await self._resolve(client) - self.resolved = True - - async def _resolve(self, client): - self.chats = await _into_id_set(client, self.chats) - - def filter(self, event): - """ - Returns a truthy value if the event passed the filter and should be - used, or falsy otherwise. The return value may need to be awaited. - - The events must have been resolved before this can be called. - """ - if not self.resolved: - return - - if self.chats is not None: - # Note: the `event.chat_id` property checks if it's `None` for us - inside = event.chat_id in self.chats - if inside == self.blacklist_chats: - # If this chat matches but it's a blacklist ignore. - # If it doesn't match but it's a whitelist ignore. - return - - if not self.func: - return True - - # Return the result of func directly as it may need to be awaited - return self.func(event) - - -class EventCommon(ChatGetter, abc.ABC): - """ - Intermediate class with common things to all events. - - Remember that this class implements `ChatGetter - ` which - means you have access to all chat properties and methods. - - In addition, you can access the `original_update` - field which contains the original :tl:`Update`. - """ - _event_name = 'Event' - - def __init__(self, chat_peer=None, msg_id=None, broadcast=None): - super().__init__(chat_peer, broadcast=broadcast) - self._entities = {} - self._client = None - self._message_id = msg_id - self.original_update = None - - def _set_client(self, client): - """ - Setter so subclasses can act accordingly when the client is set. - """ - self._client = client - if self._chat_peer: - self._chat, self._input_chat = utils._get_entity_pair( - self.chat_id, self._entities, client._entity_cache) - else: - self._chat = self._input_chat = None - - @property - def client(self): - """ - The `telethon.TelegramClient` that created this event. - """ - return self._client - - def __str__(self): - return TLObject.pretty_format(self.to_dict()) - - def stringify(self): - return 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] != '_'} - d['_'] = self._event_name - return d - - -def name_inner_event(cls): - """Decorator to rename cls.Event 'Event' as 'cls.Event'""" - if hasattr(cls, 'Event'): - cls.Event._event_name = '{}.Event'.format(cls.__name__) - else: - warnings.warn('Class {} does not have a inner Event'.format(cls)) - return cls diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py deleted file mode 100644 index ad3dbcf6..00000000 --- a/telethon/events/inlinequery.py +++ /dev/null @@ -1,247 +0,0 @@ -import inspect -import re - -import asyncio - -from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types, functions, custom -from ..tl.custom.sendergetter import SenderGetter - - -@name_inner_event -class InlineQuery(EventBuilder): - """ - Occurs whenever you sign in as a bot and a user - sends an inline query such as ``@bot query``. - - Args: - users (`entity`, optional): - May be one or more entities (username/peer/etc.), preferably IDs. - By default, only inline queries from these users will be handled. - - blacklist_users (`bool`, optional): - Whether to treat the users as a blacklist instead of - as a whitelist (default). This means that every chat - will be handled *except* those specified in ``users`` - which will be ignored if ``blacklist_users=True``. - - pattern (`str`, `callable`, `Pattern`, optional): - If set, only queries matching this pattern will be handled. - You can specify a regex-like string which will be matched - against the message, a callable function that returns `True` - if a message is acceptable, or a compiled regex pattern. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.InlineQuery) - async def handler(event): - builder = event.builder - - # Two options (convert user text to UPPERCASE or lowercase) - await event.answer([ - builder.article('UPPERCASE', text=event.text.upper()), - builder.article('lowercase', text=event.text.lower()), - ]) - """ - def __init__( - self, users=None, *, blacklist_users=False, func=None, pattern=None): - super().__init__(users, blacklist_chats=blacklist_users, func=func) - - if isinstance(pattern, str): - self.pattern = re.compile(pattern).match - elif not pattern or callable(pattern): - self.pattern = pattern - elif hasattr(pattern, 'match') and callable(pattern.match): - self.pattern = pattern.match - else: - raise TypeError('Invalid pattern type given') - - @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateBotInlineQuery): - return cls.Event(update) - - def filter(self, event): - if self.pattern: - match = self.pattern(event.text) - if not match: - return - event.pattern_match = match - - return super().filter(event) - - class Event(EventCommon, SenderGetter): - """ - Represents the event of a new callback query. - - Members: - query (:tl:`UpdateBotInlineQuery`): - The original :tl:`UpdateBotInlineQuery`. - - Make sure to access the `text` property of the query if - you want the text rather than the actual query object. - - pattern_match (`obj`, optional): - The resulting object from calling the passed ``pattern`` - 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) - 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) - - @property - def id(self): - """ - Returns the unique identifier for the query ID. - """ - return self.query.query_id - - @property - def text(self): - """ - Returns the text the user used to make the inline query. - """ - return self.query.query - - @property - def offset(self): - """ - The string the user's client used as an offset for the query. - This will either be empty or equal to offsets passed to `answer`. - """ - return self.query.offset - - @property - def geo(self): - """ - If the user location is requested when using inline mode - and the user's device is able to send it, this will return - the :tl:`GeoPoint` with the position of the user. - """ - return - - @property - def builder(self): - """ - Returns a new `InlineBuilder - ` instance. - """ - return custom.InlineBuilder(self._client) - - async def answer( - self, results=None, cache_time=0, *, - gallery=False, next_offset=None, private=False, - switch_pm=None, switch_pm_param=''): - """ - Answers the inline query with the given results. - - See the documentation for `builder` to know what kind of answers - can be given. - - Args: - results (`list`, optional): - A list of :tl:`InputBotInlineResult` to use. - You should use `builder` to create these: - - .. code-block:: python - - builder = inline.builder - r1 = builder.article('Be nice', text='Have a nice day') - r2 = builder.article('Be bad', text="I don't like you") - await inline.answer([r1, r2]) - - You can send up to 50 results as documented in - https://core.telegram.org/bots/api#answerinlinequery. - Sending more will raise ``ResultsTooMuchError``, - and you should consider using `next_offset` to - paginate them. - - cache_time (`int`, optional): - For how long this result should be cached on - the user's client. Defaults to 0 for no cache. - - gallery (`bool`, optional): - Whether the results should show as a gallery (grid) or not. - - next_offset (`str`, optional): - The offset the client will send when the user scrolls the - results and it repeats the request. - - private (`bool`, optional): - Whether the results should be cached by Telegram - (not private) or by the user's client (private). - - switch_pm (`str`, optional): - If set, this text will be shown in the results - to allow the user to switch to private messages. - - switch_pm_param (`str`, optional): - Optional parameter to start the bot with if - `switch_pm` was used. - - Example: - - .. code-block:: python - - @bot.on(events.InlineQuery) - async def handler(event): - builder = event.builder - - rev_text = event.text[::-1] - await event.answer([ - builder.article('Reverse text', text=rev_text), - builder.photo('/path/to/photo.jpg') - ]) - """ - if self._answered: - return - - if results: - futures = [self._as_future(x) for x in results] - - await asyncio.wait(futures) - - # All futures will be in the `done` *set* that `wait` returns. - # - # Precisely because it's a `set` and not a `list`, it - # will not preserve the order, but since all futures - # completed we can use our original, ordered `list`. - results = [x.result() for x in futures] - else: - results = [] - - if switch_pm: - switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param) - - return await self._client( - functions.messages.SetInlineBotResultsRequest( - query_id=self.query.query_id, - results=results, - cache_time=cache_time, - gallery=gallery, - next_offset=next_offset, - private=private, - switch_pm=switch_pm - ) - ) - - @staticmethod - def _as_future(obj): - if inspect.isawaitable(obj): - return asyncio.ensure_future(obj) - - f = asyncio.get_event_loop().create_future() - f.set_result(obj) - return f diff --git a/telethon/events/messageread.py b/telethon/events/messageread.py deleted file mode 100644 index 29f17ab8..00000000 --- a/telethon/events/messageread.py +++ /dev/null @@ -1,143 +0,0 @@ -from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types - - -@name_inner_event -class MessageRead(EventBuilder): - """ - Occurs whenever one or more messages are read in a chat. - - Args: - inbox (`bool`, optional): - If this argument is `True`, then when you read someone else's - messages the event will be fired. By default (`False`) only - when messages you sent are read by someone else will fire it. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.MessageRead) - async def handler(event): - # Log when someone reads your messages - print('Someone has read all your messages until', event.max_id) - - @client.on(events.MessageRead(inbox=True)) - async def handler(event): - # Log when you read message in a chat (from your "inbox") - print('You have read messages until', event.max_id) - """ - def __init__( - self, chats=None, *, blacklist_chats=False, func=None, inbox=False): - super().__init__(chats, blacklist_chats=blacklist_chats, func=func) - self.inbox = inbox - - @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateReadHistoryInbox): - return cls.Event(update.peer, update.max_id, False) - elif isinstance(update, types.UpdateReadHistoryOutbox): - return cls.Event(update.peer, update.max_id, True) - elif isinstance(update, types.UpdateReadChannelInbox): - return cls.Event(types.PeerChannel(update.channel_id), - update.max_id, False) - elif isinstance(update, types.UpdateReadChannelOutbox): - return cls.Event(types.PeerChannel(update.channel_id), - update.max_id, True) - elif isinstance(update, types.UpdateReadMessagesContents): - return cls.Event(message_ids=update.messages, - contents=True) - elif isinstance(update, types.UpdateChannelReadMessagesContents): - return cls.Event(types.PeerChannel(update.channel_id), - message_ids=update.messages, - contents=True) - - def filter(self, event): - if self.inbox == event.outbox: - return - - return super().filter(event) - - class Event(EventCommon): - """ - Represents the event of one or more messages being read. - - Members: - max_id (`int`): - Up to which message ID has been read. Every message - with an ID equal or lower to it have been read. - - outbox (`bool`): - `True` if someone else has read your messages. - - contents (`bool`): - `True` if what was read were the contents of a message. - This will be the case when e.g. you play a voice note. - It may only be set on ``inbox`` events. - """ - def __init__(self, peer=None, max_id=None, out=False, contents=False, - message_ids=None): - self.outbox = out - self.contents = contents - self._message_ids = message_ids or [] - self._messages = None - self.max_id = max_id or max(message_ids or [], default=None) - super().__init__(peer, self.max_id) - - @property - def inbox(self): - """ - `True` if you have read someone else's messages. - """ - return not self.outbox - - @property - def message_ids(self): - """ - The IDs of the messages **which contents'** were read. - - Use :meth:`is_read` if you need to check whether a message - was read instead checking if it's in here. - """ - return self._message_ids - - async def get_messages(self): - """ - Returns the list of `Message ` - **which contents'** were read. - - Use :meth:`is_read` if you need to check whether a message - was read instead checking if it's in here. - """ - if self._messages is None: - chat = await self.get_input_chat() - if not chat: - self._messages = [] - else: - self._messages = await self._client.get_messages( - chat, ids=self._message_ids) - - return self._messages - - def is_read(self, message): - """ - Returns `True` if the given message (or its ID) has been read. - - If a list-like argument is provided, this method will return a - list of booleans indicating which messages have been read. - """ - if utils.is_list_like(message): - return [(m if isinstance(m, int) else m.id) <= self.max_id - for m in message] - else: - return (message if isinstance(message, int) - else message.id) <= self.max_id - - def __contains__(self, message): - """`True` if the message(s) are read message.""" - if utils.is_list_like(message): - return all(self.is_read(message)) - else: - return self.is_read(message) diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py deleted file mode 100644 index d2077a71..00000000 --- a/telethon/events/newmessage.py +++ /dev/null @@ -1,223 +0,0 @@ -import re - -from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set -from .. import utils -from ..tl import types - - -@name_inner_event -class NewMessage(EventBuilder): - """ - Occurs whenever a new text message or a message with media arrives. - - Args: - incoming (`bool`, optional): - If set to `True`, only **incoming** messages will be handled. - Mutually exclusive with ``outgoing`` (can only set one of either). - - outgoing (`bool`, optional): - If set to `True`, only **outgoing** messages will be handled. - Mutually exclusive with ``incoming`` (can only set one of either). - - from_users (`entity`, optional): - Unlike `chats`, this parameter filters the *senders* of the - message. That is, only messages *sent by these users* will be - handled. Use `chats` if you want private messages with this/these - users. `from_users` lets you filter by messages sent by *one or - more* users across the desired chats (doesn't need a list). - - forwards (`bool`, optional): - Whether forwarded messages should be handled or not. By default, - both forwarded and normal messages are included. If it's `True` - *only* forwards will be handled. If it's `False` only messages - that are *not* forwards will be handled. - - pattern (`str`, `callable`, `Pattern`, optional): - If set, only messages matching this pattern will be handled. - You can specify a regex-like string which will be matched - against the message, a callable function that returns `True` - if a message is acceptable, or a compiled regex pattern. - - Example - .. code-block:: python - - import asyncio - from telethon import events - - @client.on(events.NewMessage(pattern='(?i)hello.+')) - async def handler(event): - # Respond whenever someone says "Hello" and something else - await event.reply('Hey!') - - @client.on(events.NewMessage(outgoing=True, pattern='!ping')) - async def handler(event): - # Say "!pong" whenever you send "!ping", then delete both messages - m = await event.respond('!pong') - await asyncio.sleep(5) - await client.delete_messages(event.chat_id, [event.id, m.id]) - """ - def __init__(self, chats=None, *, blacklist_chats=False, func=None, - incoming=None, outgoing=None, - from_users=None, forwards=None, pattern=None): - if incoming and outgoing: - incoming = outgoing = None # Same as no filter - elif incoming is not None and outgoing is None: - outgoing = not incoming - elif outgoing is not None and incoming is None: - incoming = not outgoing - elif all(x is not None and not x for x in (incoming, outgoing)): - raise ValueError("Don't create an event handler if you " - "don't want neither incoming nor outgoing!") - - super().__init__(chats, blacklist_chats=blacklist_chats, func=func) - self.incoming = incoming - self.outgoing = outgoing - self.from_users = from_users - self.forwards = forwards - if isinstance(pattern, str): - self.pattern = re.compile(pattern).match - elif not pattern or callable(pattern): - self.pattern = pattern - elif hasattr(pattern, 'match') and callable(pattern.match): - self.pattern = pattern.match - else: - raise TypeError('Invalid pattern type given') - - # Should we short-circuit? E.g. perform no check at all - self._no_check = all(x is None for x in ( - self.chats, self.incoming, self.outgoing, self.pattern, - self.from_users, self.forwards, self.from_users, self.func - )) - - async def _resolve(self, client): - await super()._resolve(client) - self.from_users = await _into_id_set(client, self.from_users) - - @classmethod - def build(cls, update, others=None, self_id=None): - if isinstance(update, - (types.UpdateNewMessage, types.UpdateNewChannelMessage)): - if not isinstance(update.message, types.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( - 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), - message=update.message, - date=update.date, - fwd_from=update.fwd_from, - via_bot_id=update.via_bot_id, - reply_to=update.reply_to, - entities=update.entities, - ttl_period=update.ttl_period - )) - elif isinstance(update, types.UpdateShortChatMessage): - event = cls.Event(types.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), - message=update.message, - date=update.date, - fwd_from=update.fwd_from, - via_bot_id=update.via_bot_id, - reply_to=update.reply_to, - entities=update.entities, - ttl_period=update.ttl_period - )) - else: - return - - return event - - def filter(self, event): - if self._no_check: - return event - - if self.incoming and event.message.out: - return - if self.outgoing and not event.message.out: - return - if self.forwards is not None: - if bool(self.forwards) != bool(event.message.fwd_from): - return - - if self.from_users is not None: - if event.message.sender_id not in self.from_users: - return - - if self.pattern: - match = self.pattern(event.message.message or '') - if not match: - return - event.pattern_match = match - - return super().filter(event) - - class Event(EventCommon): - """ - Represents the event of a new message. This event can be treated - to all effects as a `Message `, - so please **refer to its documentation** to know what you can do - with this event. - - Members: - message (`Message `): - This is the only difference with the received - `Message `, and will - return the `telethon.tl.custom.message.Message` itself, - not the text. - - See `Message ` for - the rest of available members and methods. - - pattern_match (`obj`): - The resulting object from calling the passed ``pattern`` function. - Here's an example using a string (defaults to regex match): - - >>> from telethon import TelegramClient, events - >>> client = TelegramClient(...) - >>> - >>> @client.on(events.NewMessage(pattern=r'hi (\\w+)!')) - ... async def handler(event): - ... # In this case, the result is a ``Match`` object - ... # since the `str` pattern was converted into - ... # the ``re.compile(pattern).match`` function. - ... print('Welcomed', event.pattern_match.group(1)) - ... - >>> - """ - def __init__(self, message): - self.__dict__['_init'] = False - super().__init__(chat_peer=message.peer_id, - msg_id=message.id, broadcast=bool(message.post)) - - self.pattern_match = None - self.message = message - - 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): - if item in self.__dict__: - return self.__dict__[item] - else: - return getattr(self.message, item) - - def __setattr__(self, name, value): - if not self.__dict__['_init'] or name in self.__dict__: - self.__dict__[name] = value - else: - setattr(self.message, name, value) diff --git a/telethon/events/raw.py b/telethon/events/raw.py deleted file mode 100644 index 84910778..00000000 --- a/telethon/events/raw.py +++ /dev/null @@ -1,53 +0,0 @@ -from .common import EventBuilder -from .. import utils - - -class Raw(EventBuilder): - """ - Raw events are not actual events. Instead, they are the raw - :tl:`Update` object that Telegram sends. You normally shouldn't - need these. - - Args: - types (`list` | `tuple` | `type`, optional): - The type or types that the :tl:`Update` instance must be. - Equivalent to ``if not isinstance(update, types): return``. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.Raw) - async def handler(update): - # Print all incoming updates - print(update.stringify()) - """ - def __init__(self, types=None, *, func=None): - super().__init__(func=func) - if not types: - self.types = None - elif not utils.is_list_like(types): - if not isinstance(types, type): - raise TypeError('Invalid input type given: {}'.format(types)) - - self.types = types - else: - if not all(isinstance(x, type) for x in types): - raise TypeError('Invalid input types given: {}'.format(types)) - - self.types = tuple(types) - - async def resolve(self, client): - self.resolved = True - - @classmethod - def build(cls, update, others=None, self_id=None): - return update - - def filter(self, event): - if not self.types or isinstance(event, self.types): - if self.func: - # Return the result of func directly as it may need to be awaited - return self.func(event) - return event diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py deleted file mode 100644 index c0a07619..00000000 --- a/telethon/events/userupdate.py +++ /dev/null @@ -1,301 +0,0 @@ -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 - - -# TODO Either the properties are poorly named or they should be -# different events, but that would be a breaking change. -# -# TODO There are more "user updates", but bundling them all up -# in a single place will make it annoying to use (since -# the user needs to check for the existence of `None`). -# -# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUserPhoto - -def _requires_action(function): - @functools.wraps(function) - def wrapped(self): - return None if self.action is None else function(self) - - return wrapped - - -def _requires_status(function): - @functools.wraps(function) - def wrapped(self): - return None if self.status is None else function(self) - - return wrapped - - -@name_inner_event -class UserUpdate(EventBuilder): - """ - Occurs whenever a user goes online, starts typing, etc. - - Example - .. code-block:: python - - from telethon import events - - @client.on(events.UserUpdate) - async def handler(event): - # If someone is uploading, say something - if event.uploading: - 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), - status=update.status) - elif isinstance(update, types.UpdateChannelUserTyping): - return cls.Event(update.from_id, - chat_peer=types.PeerChannel(update.channel_id), - typing=update.action) - elif isinstance(update, types.UpdateChatUserTyping): - return cls.Event(update.from_id, - chat_peer=types.PeerChat(update.chat_id), - typing=update.action) - elif isinstance(update, types.UpdateUserTyping): - return cls.Event(update.user_id, - typing=update.action) - - class Event(EventCommon, SenderGetter): - """ - Represents the event of a user update - such as gone online, started typing, etc. - - Members: - status (:tl:`UserStatus`, optional): - The user status if the update is about going online or offline. - - You should check this attribute first before checking any - of the seen within properties, since they will all be `None` - if the status is not set. - - action (:tl:`SendMessageAction`, optional): - The "typing" action if any the user is performing if any. - - You should check this attribute first before checking any - of the typing properties, since they will all be `None` - if the action is not set. - """ - 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)) - - 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) - - @property - def user(self): - """Alias for `sender `.""" - return self.sender - - async def get_user(self): - """Alias for `get_sender `.""" - return await self.get_sender() - - @property - def input_user(self): - """Alias for `input_sender `.""" - return self.input_sender - - async def get_input_user(self): - """Alias for `get_input_sender `.""" - return await self.get_input_sender() - - @property - def user_id(self): - """Alias for `sender_id `.""" - return self.sender_id - - @property - @_requires_action - def typing(self): - """ - `True` if the action is typing a message. - """ - return isinstance(self.action, types.SendMessageTypingAction) - - @property - @_requires_action - def uploading(self): - """ - `True` if the action is uploading something. - """ - return isinstance(self.action, ( - types.SendMessageChooseContactAction, - types.SendMessageUploadAudioAction, - types.SendMessageUploadDocumentAction, - types.SendMessageUploadPhotoAction, - types.SendMessageUploadRoundAction, - types.SendMessageUploadVideoAction - )) - - @property - @_requires_action - def recording(self): - """ - `True` if the action is recording something. - """ - return isinstance(self.action, ( - types.SendMessageRecordAudioAction, - types.SendMessageRecordRoundAction, - types.SendMessageRecordVideoAction - )) - - @property - @_requires_action - def playing(self): - """ - `True` if the action is playing a game. - """ - return isinstance(self.action, types.SendMessageGamePlayAction) - - @property - @_requires_action - def cancel(self): - """ - `True` if the action was cancelling other actions. - """ - return isinstance(self.action, types.SendMessageCancelAction) - - @property - @_requires_action - def geo(self): - """ - `True` if what's being uploaded is a geo. - """ - return isinstance(self.action, types.SendMessageGeoLocationAction) - - @property - @_requires_action - def audio(self): - """ - `True` if what's being recorded/uploaded is an audio. - """ - return isinstance(self.action, ( - types.SendMessageRecordAudioAction, - types.SendMessageUploadAudioAction - )) - - @property - @_requires_action - def round(self): - """ - `True` if what's being recorded/uploaded is a round video. - """ - return isinstance(self.action, ( - types.SendMessageRecordRoundAction, - types.SendMessageUploadRoundAction - )) - - @property - @_requires_action - def video(self): - """ - `True` if what's being recorded/uploaded is an video. - """ - return isinstance(self.action, ( - types.SendMessageRecordVideoAction, - types.SendMessageUploadVideoAction - )) - - @property - @_requires_action - def contact(self): - """ - `True` if what's being uploaded (selected) is a contact. - """ - return isinstance(self.action, types.SendMessageChooseContactAction) - - @property - @_requires_action - def document(self): - """ - `True` if what's being uploaded is document. - """ - return isinstance(self.action, types.SendMessageUploadDocumentAction) - - @property - @_requires_action - def photo(self): - """ - `True` if what's being uploaded is a photo. - """ - return isinstance(self.action, types.SendMessageUploadPhotoAction) - - @property - @_requires_action - def last_seen(self): - """ - Exact `datetime.datetime` when the user was last seen if known. - """ - if isinstance(self.status, types.UserStatusOffline): - return self.status.was_online - - @property - @_requires_status - def until(self): - """ - The `datetime.datetime` until when the user should appear online. - """ - if isinstance(self.status, types.UserStatusOnline): - return self.status.expires - - def _last_seen_delta(self): - if isinstance(self.status, types.UserStatusOffline): - return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online - elif isinstance(self.status, types.UserStatusOnline): - return datetime.timedelta(days=0) - elif isinstance(self.status, types.UserStatusRecently): - return datetime.timedelta(days=1) - elif isinstance(self.status, types.UserStatusLastWeek): - return datetime.timedelta(days=7) - elif isinstance(self.status, types.UserStatusLastMonth): - return datetime.timedelta(days=30) - else: - return datetime.timedelta(days=365) - - @property - @_requires_status - def online(self): - """ - `True` if the user is currently online, - """ - return self._last_seen_delta() <= datetime.timedelta(days=0) - - @property - @_requires_status - def recently(self): - """ - `True` if the user was seen within a day. - """ - return self._last_seen_delta() <= datetime.timedelta(days=1) - - @property - @_requires_status - def within_weeks(self): - """ - `True` if the user was seen within 7 days. - """ - return self._last_seen_delta() <= datetime.timedelta(days=7) - - @property - @_requires_status - def within_months(self): - """ - `True` if the user was seen within 30 days. - """ - return self._last_seen_delta() <= datetime.timedelta(days=30) 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 881622a6..00000000 --- a/telethon/sessions/memory.py +++ /dev/null @@ -1,246 +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, '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/chatgetter.py b/telethon/tl/custom/chatgetter.py deleted file mode 100644 index 60f5c79e..00000000 --- a/telethon/tl/custom/chatgetter.py +++ /dev/null @@ -1,149 +0,0 @@ -import abc - -from ... import errors, utils -from ...tl import types - - -class ChatGetter(abc.ABC): - """ - Helper base class that introduces the `chat`, `input_chat` - and `chat_id` properties and `get_chat` and `get_input_chat` - methods. - """ - def __init__(self, chat_peer=None, *, input_chat=None, chat=None, broadcast=None): - self._chat_peer = chat_peer - self._input_chat = input_chat - self._chat = chat - self._broadcast = broadcast - self._client = None - - @property - def chat(self): - """ - Returns the :tl:`User`, :tl:`Chat` or :tl:`Channel` where this object - belongs to. It may be `None` if Telegram didn't send the chat. - - If you only need the ID, use `chat_id` instead. - - If you need to call a method which needs - this chat, use `input_chat` instead. - - If you're using `telethon.events`, use `get_chat()` instead. - """ - return self._chat - - async def get_chat(self): - """ - Returns `chat`, but will make an API call to find the - chat unless it's already cached. - - If you only need the ID, use `chat_id` instead. - - If you need to call a method which needs - this chat, use `get_input_chat()` instead. - """ - # See `get_sender` for information about 'min'. - if (self._chat is None or getattr(self._chat, 'min', None))\ - and await self.get_input_chat(): - try: - self._chat =\ - await self._client.get_entity(self._input_chat) - except ValueError: - await self._refetch_chat() - return self._chat - - @property - def input_chat(self): - """ - This :tl:`InputPeer` is the input version of the chat where the - message was sent. Similarly to `input_sender - `, this - doesn't have things like username or similar, but still useful in - some cases. - - Note that this might not be available if the library doesn't - have enough information available. - """ - if self._input_chat is None and self._chat_peer 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): - """ - Returns `input_chat`, but will make an API call to find the - input chat unless it's already cached. - """ - if self.input_chat is None and self.chat_id and self._client: - try: - # The chat may be recent, look in dialogs - target = self.chat_id - async for d in self._client.iter_dialogs(100): - if d.id == target: - self._chat = d.entity - self._input_chat = d.input_entity - break - except errors.RPCError: - pass - - return self._input_chat - - @property - def chat_id(self): - """ - Returns the marked chat integer ID. Note that this value **will - be different** from ``peer_id`` for incoming private messages, since - the chat *to* which the messages go is to your own person, but - the *chat* itself is with the one who sent the message. - - TL;DR; this gets the ID that you expect. - - If there is a chat in the object, `chat_id` will *always* be set, - which is why you should use it instead of `chat.id `. - """ - return utils.get_peer_id(self._chat_peer) if self._chat_peer else None - - @property - def is_private(self): - """ - `True` if the message was sent as a private message. - - 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 - - @property - def is_group(self): - """ - True if the message was sent on a group or megagroup. - - Returns `None` if there isn't enough information - (e.g. on `events.MessageDeleted `). - """ - # TODO Cache could tell us more in the future - if self._broadcast is None and hasattr(self.chat, 'broadcast'): - self._broadcast = bool(self.chat.broadcast) - - if isinstance(self._chat_peer, types.PeerChannel): - if self._broadcast is None: - return None - else: - return not self._broadcast - - return isinstance(self._chat_peer, types.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) - - async def _refetch_chat(self): - """ - Re-fetches chat information through other means. - """ 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/custom/qrlogin.py b/telethon/tl/custom/qrlogin.py deleted file mode 100644 index 39585f2f..00000000 --- a/telethon/tl/custom/qrlogin.py +++ /dev/null @@ -1,119 +0,0 @@ -import asyncio -import base64 -import datetime - -from .. import types, functions -from ... import events - - -class QRLogin: - """ - QR login information. - - Most of the time, you will present the `url` as a QR code to the user, - and while it's being shown, call `wait`. - """ - def __init__(self, client, ignored_ids): - self._client = client - self._request = functions.auth.ExportLoginTokenRequest( - self._client.api_id, self._client.api_hash, ignored_ids) - self._resp = None - - async def recreate(self): - """ - Generates a new token and URL for a new QR code, useful if the code - has expired before it was imported. - """ - self._resp = await self._client(self._request) - - @property - def token(self) -> bytes: - """ - The binary data representing the token. - - It can be used by a previously-authorized client in a call to - :tl:`auth.importLoginToken` to log the client that originally - requested the QR login. - """ - return self._resp.token - - @property - def url(self) -> str: - """ - The ``tg://login`` URI with the token. When opened by a Telegram - application where the user is logged in, it will import the login - token. - - If you want to display a QR code to the user, this is the URL that - should be launched when the QR code is scanned (the URL that should - be contained in the QR code image you generate). - - Whether you generate the QR code image or not is up to you, and the - library can't do this for you due to the vast ways of generating and - displaying the QR code that exist. - - The URL simply consists of `token` base64-encoded. - """ - return 'tg://login?token={}'.format(base64.urlsafe_b64encode(self._resp.token).decode('utf-8').rstrip('=')) - - @property - def expires(self) -> datetime.datetime: - """ - The `datetime` at which the QR code will expire. - - If you want to try again, you will need to call `recreate`. - """ - return self._resp.expires - - async def wait(self, timeout: float = None): - """ - Waits for the token to be imported by a previously-authorized client, - either by scanning the QR, launching the URL directly, or calling the - import method. - - This method **must** be called before the QR code is scanned, and - must be executing while the QR code is being scanned. Otherwise, the - login will not complete. - - Will raise `asyncio.TimeoutError` if the login doesn't complete on - time. - - Arguments - timeout (float): - The timeout, in seconds, to wait before giving up. By default - the library will wait until the token expires, which is often - what you want. - - Returns - On success, an instance of :tl:`User`. On failure it will raise. - """ - if timeout is None: - timeout = (self._resp.expires - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds() - - event = asyncio.Event() - - async def handler(_update): - event.set() - - self._client.add_event_handler(handler, events.Raw(types.UpdateLoginToken)) - - try: - # Will raise timeout error if it doesn't complete quick enough, - # which we want to let propagate - await asyncio.wait_for(event.wait(), timeout=timeout) - finally: - self._client.remove_event_handler(handler) - - # We got here without it raising timeout error, so we can proceed - resp = await self._client(self._request) - if isinstance(resp, types.auth.LoginTokenMigrateTo): - await self._client._switch_dc(resp.dc_id) - resp = await self._client(functions.auth.ImportLoginTokenRequest(resp.token)) - # resp should now be auth.loginTokenSuccess - - if isinstance(resp, types.auth.LoginTokenSuccess): - user = resp.authorization.user - self._client._on_login(user) - return user - - raise TypeError('Login token response was unexpected: {}'.format(resp)) diff --git a/telethon/tl/custom/sendergetter.py b/telethon/tl/custom/sendergetter.py deleted file mode 100644 index 673cab25..00000000 --- a/telethon/tl/custom/sendergetter.py +++ /dev/null @@ -1,97 +0,0 @@ -import abc - - -class SenderGetter(abc.ABC): - """ - Helper base class that introduces the `sender`, `input_sender` - and `sender_id` properties and `get_sender` and `get_input_sender` - methods. - """ - def __init__(self, sender_id=None, *, sender=None, input_sender=None): - self._sender_id = sender_id - self._sender = sender - self._input_sender = input_sender - self._client = None - - @property - def sender(self): - """ - Returns the :tl:`User` or :tl:`Channel` that sent this object. - It may be `None` if Telegram didn't send the sender. - - If you only need the ID, use `sender_id` instead. - - If you need to call a method which needs - this chat, use `input_sender` instead. - - If you're using `telethon.events`, use `get_sender()` instead. - """ - return self._sender - - async def get_sender(self): - """ - Returns `sender`, but will make an API call to find the - sender unless it's already cached. - - If you only need the ID, use `sender_id` instead. - - If you need to call a method which needs - this sender, use `get_input_sender()` instead. - """ - # ``sender.min`` is present both in :tl:`User` and :tl:`Channel`. - # It's a flag that will be set if only minimal information is - # available (such as display name, but username may be missing), - # in which case we want to force fetch the entire thing because - # the user explicitly called a method. If the user is okay with - # cached information, they may use the property instead. - if (self._sender is None or getattr(self._sender, 'min', None)) \ - and await self.get_input_sender(): - try: - self._sender =\ - await self._client.get_entity(self._input_sender) - except ValueError: - await self._refetch_sender() - return self._sender - - @property - def input_sender(self): - """ - This :tl:`InputPeer` is the input version of the user/channel who - sent the message. Similarly to `input_chat - `, this doesn't - have things like username or similar, but still useful in some cases. - - Note that this might not be available if the library can't - find the input chat, or if the message a broadcast on a channel. - """ - if self._input_sender is None and self._sender_id 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): - """ - Returns `input_sender`, but will make an API call to find the - input sender unless it's already cached. - """ - if self.input_sender is None and self._sender_id and self._client: - await self._refetch_sender() - return self._input_sender - - @property - def sender_id(self): - """ - Returns the marked sender integer ID, if present. - - If there is a sender in the object, `sender_id` will *always* be set, - which is why you should use it instead of `sender.id `. - """ - return self._sender_id - - async def _refetch_sender(self): - """ - Re-fetches sender information through other means. - """ 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..334e4161 --- /dev/null +++ b/telethon/types/__init__.py @@ -0,0 +1,21 @@ +from .._misc.tlobject import TLObject, TLRequest +from ._custom import ( + CodeType, + SentCode, + AdminLogEvent, + Draft, + Dialog, + InputSizedFile, + MessageButton, + Forward, + Message, + Button, + InlineBuilder, + InlineResult, + InlineResults, + QrLogin, + ParticipantPermissions, + Chat, + User, + TermsOfService, +) 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 86% rename from telethon/tl/core/gzippacked.py rename to telethon/types/_core/gzippacked.py index fb4094e4..ff805df4 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/types/_custom/auth.py b/telethon/types/_custom/auth.py new file mode 100644 index 00000000..bfd1ad09 --- /dev/null +++ b/telethon/types/_custom/auth.py @@ -0,0 +1,120 @@ +import asyncio +import re +from enum import Enum, auto +from ... import _tl + + +class CodeType(Enum): + """ + The type of the login code sent. + + When resending the code, it won't be APP a second time. + """ + + APP = auto() + SMS = auto() + CALL = auto() + FLASH_CALL = auto() + MISSED_CALL = auto() + + +class SentCode: + """ + Information about the login code request, returned by `client.send_code_request`. + """ + + @classmethod + def _new(cls, code): + self = cls.__new__(cls) + self._code = code + self._start = asyncio.get_running_loop().time() + return self + + @property + def type(self): + """ + The `CodeType` which was sent. + """ + return { + _tl.auth.SentCodeTypeApp: CodeType.APP, + _tl.auth.SentCodeTypeSms: CodeType.SMS, + _tl.auth.SentCodeTypeCall: CodeType.CALL, + _tl.auth.SentCodeTypeFlashCall: CodeType.FLASH_CALL, + _tl.auth.SentCodeTypeMissedCall: CodeType.MISSED_CALL, + }[type(self._code.type)] + + @property + def next_type(self): + """ + The `CodeType` which will be sent if `client.send_code_request` + is used again after `timeout` seconds have elapsed. It may be `None`. + """ + if not self._code.next_type: + return None + + return { + _tl.auth.CodeTypeSms: CodeType.SMS, + _tl.auth.CodeTypeCall: CodeType.CALL, + _tl.auth.CodeTypeFlashCall: CodeType.FLASH_CALL, + _tl.auth.CodeTypeMissedCall: CodeType.MISSED_CALL, + }[type(self._code.next_type)] + + @property + def timeout(self): + """ + How many seconds are left before `client.send_code_request` can be used to resend the code. + Resending the code before this many seconds have elapsed may or may not work. + + This value can be `None`. + + This value is a positive floating point number, and is monotically decreasing. + The value will reach zero after enough seconds have elapsed. This lets you do some work + and call sleep on the value and still wait just long enough. + + If you need the original timeout, call `round` on the value as soon as possible. + """ + if not self._code.timeout: + return None + + return max(0.0, (self._start + self._code.timeout) - asyncio.get_running_loop().time()) + + @property + def length(self): + """ + The length of the sent code. + + If the length is unknown (it could be any length), `None` is returned. + This can be true for `CodeType.FLASH_CALL`. + """ + if isinstance(self._code.type, _tl.auth.SentCodeTypeFlashCall): + return None if self._code.type.pattern in ('', '*') else len(self._code.type.pattern) + else: + return self._code.type.length + + def check(self, code): + """ + Check if the user's input code is valid. + + This can be used to implement a client-side validation before actually trying to login + (mostly useful with a graphic interface, to hint the user the code is not yet correct). + """ + if not isinstance(code, str): + raise TypeError(f'code must be str, but was {type(code)}') + + if isinstance(self._code.type, _tl.auth.SentCodeTypeFlashCall): + if self._code.type.pattern in ('', '*'): + return True + + if not all(c.isdigit() or c == '*' for c in self._code.type.pattern): + # Potentially unsafe to use this pattern in a regex + raise RuntimeError(f'Unrecognised code pattern: {self._code.type.pattern!r}') + + pattern = self._code.type.pattern.replace('*', r'\d*') + numbers = ''.join(c for c in code if c.isdigit()) + return re.match(f'^{pattern}$', numbers) is not None + + if isinstance(self._code.type, _tl.auth.SentCodeTypeMissedCall): + if not code.startswith(self._code.type.prefix): + return False + + return len(code) == self._code.type.length diff --git a/telethon/tl/custom/button.py b/telethon/types/_custom/button.py similarity index 56% rename from telethon/tl/custom/button.py rename to telethon/types/_custom/button.py index 134fbec7..243f13be 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: @@ -20,7 +23,7 @@ class Button: instances instead making them yourself (i.e. don't do ``Button(...)`` but instead use methods line `Button.inline(...) ` etc. - You can use `inline`, `switch_inline`, `url` and `auth` + You can use `inline`, `switch_inline`, `url`, `auth`, `buy` and `game` together to create inline buttons (under the message). You can use `text`, `request_location`, `request_phone` and `request_poll` @@ -49,10 +52,13 @@ class Button: Returns `True` if the button belongs to an inline keyboard. """ return isinstance(button, ( - types.KeyboardButtonCallback, - types.KeyboardButtonSwitchInline, - types.KeyboardButtonUrl, - types.InputKeyboardButtonUrlAuth + _tl.KeyboardButtonBuy, + _tl.KeyboardButtonCallback, + _tl.KeyboardButtonGame, + _tl.KeyboardButtonSwitchInline, + _tl.KeyboardButtonUrl, + _tl.InputKeyboardButtonUrlAuth, + _tl.InputKeyboardButtonUserProfile )) @staticmethod @@ -81,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): @@ -99,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): @@ -115,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): @@ -132,7 +138,7 @@ class Button: If no `url` is specified, it will default to `text`. Args: - bot (`hints.EntityLike`): + bot (`hints.DialogLike`): The bot that requires this authorization. By default, this is the bot that is currently logged in (itself), although you may pass a different input peer. @@ -155,14 +161,80 @@ 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 inline_mention(text, input_entity=None): + """ + Creates a new inline button linked to the profile of user. + + This will only work in Telegram versions released after December 7, 2021. + + Older clients will display unsupported message. + + Args: + text: + Label text on the button + + 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()) + ) + + + @staticmethod + def mention(text, input_entity=None): + """ + Creates a text mentioning the user. + + This will only work in Telegram versions (only Telegram Desktop and Telegram X) released after December 7, 2021. + + Older clients will display unsupported message. + + Args: + text: + Label text on the button + + 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 types.KeyboardButtonUserProfile( + text, + ( + utils.get_input_user( + input_entity or _tl.InputUserSelf() + ).id + ) + ) + + @classmethod def text(cls, text, *, resize=None, single_use=None, selective=None): """ @@ -189,7 +261,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 @@ -204,7 +276,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 @@ -219,7 +291,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 @@ -241,21 +313,148 @@ 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 - def clear(): + def clear(selective=None): """ Clears all keyboard buttons after sending a message with this markup. When used, no other button should be present or it will be ignored. + + ``selective`` is as documented in `text`. + """ - return types.ReplyKeyboardHide() + return _tl.ReplyKeyboardHide(selective=selective) @staticmethod - def force_reply(): + def force_reply(single_use=None, selective=None, placeholder=None): """ Forces a reply to the message with this markup. If used, no other button should be present or it will be ignored. + + ``single_use`` and ``selective`` are as documented in `text`. + + Args: + placeholder (str): + text to show the user at typing place of message. + + If the placeholder is too long, Telegram applications will + 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) + + @staticmethod + def buy(text): + """ + Creates a new inline button to buy a product. + + This can only be used when sending files of type + :tl:`InputMediaInvoice`, and must be the first button. + + If the button is not specified, Telegram will automatically + add the button to the message. See the + `Payments API `__ + documentation for more information. + """ + return _tl.KeyboardButtonBuy(text) + + @staticmethod + def game(text): + """ + Creates a new inline button to start playing a game. + + This should be used when sending files of type + :tl:`InputMediaGame`, and must be the first button. + + See the + `Games `__ + documentation for more information on using games. + """ + 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) + diff --git a/telethon/types/_custom/chat.py b/telethon/types/_custom/chat.py new file mode 100644 index 00000000..f5056eff --- /dev/null +++ b/telethon/types/_custom/chat.py @@ -0,0 +1,290 @@ +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime +from dataclasses import dataclass +import mimetypes +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from .messagebutton import MessageButton +from .forward import Forward +from .file import File +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup +from ..._misc import utils, helpers, tlobject, markdown, html +from ... import _tl, _misc + + +if TYPE_CHECKING: + from ..._misc import hints + + +def _fwd(field, doc): + def fget(self): + return getattr(self._chat, field, None) + + def fset(self, value): + object.__setattr__(self._chat, field, value) + + return property(fget, fset, None, doc) + + +@dataclass(frozen=True) +class _TinyChat: + __slots__ = ('id', 'access_hash') + + id: int + access_hash: int + + +@dataclass(frozen=True) +class _TinyChannel: + __slots__ = ('id', 'access_hash', 'megagroup') + + id: int + access_hash: int + megagroup: bool # gigagroup is not present in channelForbidden but megagroup is + + +class Chat: + """ + Represents a :tl:`Chat` or :tl:`Channel` (or their empty and forbidden variants) from the API. + """ + + id = _fwd('id', """ + The chat identifier. This is the only property which will **always** be present. + """) + + title = _fwd('title', """ + The chat title. It will be `None` for empty chats. + """) + + username = _fwd('username', """ + The public `username` of the chat. + """) + + participants_count = _fwd('participants_count', """ + The number of participants who are currently joined to the chat. + It will be `None` for forbidden and empty chats or if the information isn't known. + """) + + broadcast = _fwd('broadcast', """ + `True` if the chat is a broadcast channel. + """) + + megagroup = _fwd('megagroup', """ + `True` if the chat is a supergroup. + """) + + gigagroup = _fwd('gigagroup', """ + `True` if the chat used to be a `megagroup` but is now a broadcast group. + """) + + verified = _fwd('verified', """ + `True` if the chat has been verified as official by Telegram. + """) + + scam = _fwd('scam', """ + `True` if the chat has been flagged as scam. + """) + + fake = _fwd('fake', """ + `True` if the chat has been flagged as fake. + """) + + creator = _fwd('creator', """ + `True` if the logged-in account is the creator of the chat. + """) + + kicked = _fwd('kicked', """ + `True` if the logged-in account was kicked from the chat. + """) + + left = _fwd('left', """ + `True` if the logged-in account has left the chat. + """) + + restricted = _fwd('restricted', """ + `True` if the logged-in account cannot write in the chat. + """) + + slowmode_enabled = _fwd('slowmode_enabled', """ + `True` if the chat currently has slowmode enabled. + """) + + signatures = _fwd('signatures', """ + `True` if signatures are enabled in a broadcast channel. + """) + + admin_rights = _fwd('admin_rights', """ + Administrator rights the logged-in account has in the chat. + """) + + banned_rights = _fwd('banned_rights', """ + Banned rights the logged-in account has in the chat. + """) + + default_banned_rights = _fwd('default_banned_rights', """ + The default banned rights for every non-admin user in the chat. + """) + + @property + def forbidden(self): + """ + `True` if access to this channel is forbidden. + """ + return isinstance(self._chat, (_tl.ChatForbidden, _tl.ChannelForbidden)) + + @property + def forbidden_until(self): + """ + If access to the chat is only temporarily `forbidden`, returns when access will be regained. + """ + try: + return self._chat.until_date + except AttributeError: + return None + + @property + def restriction_reasons(self): + """ + Returns a possibly-empty list of reasons why the chat is restricted to some platforms. + """ + try: + return self._chat.restriction_reason or [] + except AttributeError: + return [] + + @property + def migrated_to(self): + """ + If the current chat has migrated to a larger group, returns the new `Chat`. + """ + try: + migrated = self._chat.migrated_to + except AttributeError: + migrated = None + + if migrated is None: + return migrated + + # Small chats don't migrate to other small chats, nor do they migrate to broadcast channels + return type(self)._new(self._client, _TinyChannel(migrated.channel_id, migrated.access_hash, True)) + + def __init__(self): + raise TypeError('You cannot create Chat instances by hand!') + + @classmethod + def _new(cls, client, chat): + self = cls.__new__(cls) + self._client = client + + self._chat = chat + if isinstance(cls, Entity): + if chat.is_user: + raise TypeError('Tried to construct Chat with non-chat Entity') + elif chat.ty == EntityType.GROUP: + self._chat = _TinyChat(chat.id) + else: + self._chat = _TinyChannel(chat.id, chat.hash, chat.is_group) + else: + self._chat = chat + + self._full = None + return self + + async def fetch(self, *, full=False): + """ + Perform an API call to fetch fresh information about this chat. + + Returns itself, but with the information fetched (allowing you to chain the call). + + If ``full`` is ``True``, the full information about the user will be fetched, + which will include things like ``about``. + """ + return self + + def compact(self): + """ + Return a compact representation of this user, useful for storing for later use. + """ + raise RuntimeError('TODO') + + @property + def client(self): + """ + Returns the `TelegramClient ` + which returned this user from a friendly method. + """ + return self._client + + def to_dict(self): + return self._user.to_dict() + + 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) + + @property + def is_user(self): + """ + Returns `False`. + + This property also exists in `User`, where it returns `True`. + + .. code-block:: python + + if message.chat.is_user: + ... # do stuff + """ + return False + + @property + def is_group(self): + """ + Returns `True` if the chat is a small group chat or `megagroup`_. + + This property also exists in `User`, where it returns `False`. + + .. code-block:: python + + if message.chat.is_group: + ... # do stuff + + .. _megagroup: https://telegram.org/blog/supergroups5k + """ + return isinstance(self._chat, (_tl.Chat, _TinyChat, _tl.ChatForbidden, _tl.ChatEmpty)) or self._chat.megagroup + + @property + def is_broadcast(self): + """ + Returns `True` if the chat is a broadcast channel group chat or `broadcast group`_. + + This property also exists in `User`, where it returns `False`. + + .. code-block:: python + + if message.chat.is_broadcast: + ... # do stuff + + .. _broadcast group: https://telegram.org/blog/autodelete-inv2#groups-with-unlimited-members + """ + return not self.is_group + + @property + def full_name(self): + """ + Returns `title`. + + This property also exists in `User`, where it returns the first name and last name + concatenated. + + .. code-block:: python + + print(message.chat.full_name): + """ + return self.title diff --git a/telethon/types/_custom/chatgetter.py b/telethon/types/_custom/chatgetter.py new file mode 100644 index 00000000..ff06d642 --- /dev/null +++ b/telethon/types/_custom/chatgetter.py @@ -0,0 +1,68 @@ +import abc + +from ..._misc import utils +from ... import errors, _tl + + +class ChatGetter(abc.ABC): + """ + Helper base class that introduces the chat-related properties and methods. + + The parent class must set both ``_chat`` and ``_client``. + """ + @property + def chat(self): + """ + Returns the `User` or `Chat` who sent this object, or `None` if there is no chat. + + The chat of an event is only guaranteed to include the ``id``. + If you need the chat to at least have basic information, use `get_chat` instead. + + Chats obtained through friendly methods (not events) will always have complete + information (so there is no need to use `get_chat` or ``chat.fetch()``). + """ + return self._chat + + async def get_chat(self): + """ + Returns `chat`, but will make an API call to find the chat unless it's already cached. + + If you only need the ID, use `chat_id` instead. + + If you need to call a method which needs this chat, prefer `chat` instead. + + Telegram may send a "minimal" version of the chat to save on bandwidth when using events. + If you need all the information about the chat upfront, you can use ``chat.fetch()``. + + .. code-block:: python + + @client.on(events.NewMessage) + async def handler(event): + # I only need the ID -> use chat_id + chat_id = event.chat_id + + # I'm going to use the chat in a method -> use chat + await client.send_message(event.chat, 'Hi!') + + # I need the chat's title -> use get_chat + chat = await event.get_chat() + print(chat.title) + + # I want to see all the information about the chat -> use fetch + chat = await event.chat.fetch() + print(chat.stringify()) + + # ... + + async for message in client.get_messages(chat): + # Here there's no need to fetch the chat - get_messages already did + print(message.chat.stringify()) + """ + raise RuntimeError('TODO fetch if it is tiny') + + @property + def chat_id(self): + """ + Alias for ``self.chat.id``, but checking if ``chat is not None`` first. + """ + return self._chat.id if self._chat else None diff --git a/telethon/tl/custom/dialog.py b/telethon/types/_custom/dialog.py similarity index 76% rename from telethon/tl/custom/dialog.py rename to telethon/types/_custom/dialog.py index 79ef1131..2cbf3dad 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: @@ -20,9 +20,6 @@ class Dialog: folder_id (`folder_id`): The folder ID that this dialog belongs to. - archived (`bool`): - Whether this dialog is archived or not (``folder_id is None``). - message (`Message `): The last message sent on this dialog. Note that this member will not be updated when new messages arrive, it's only set @@ -55,6 +52,10 @@ class Dialog: How many mentions are currently unread in this dialog. Note that this value won't update when new messages arrive. + unread_reactions_count (`int`): + How many reactions are currently unread in this dialog. Note that + this value won't update when new messages arrive. + draft (`Draft `): The draft object in this dialog. It will not be `None`, so you can call ``draft.set_message(...)``. @@ -75,7 +76,6 @@ class Dialog: self.dialog = dialog self.pinned = bool(dialog.pinned) self.folder_id = dialog.folder_id - self.archived = dialog.folder_id is not None self.message = message self.date = getattr(self.message, 'date', None) @@ -86,15 +86,16 @@ class Dialog: self.unread_count = dialog.unread_count self.unread_mentions_count = dialog.unread_mentions_count + self.unread_reactions_count = dialog.unread_reactions_count 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): """ @@ -117,33 +118,6 @@ class Dialog: # or it would raise `PEER_ID_INVALID`). await self._client.delete_dialog(self.entity, revoke=revoke) - async def archive(self, folder=1): - """ - Archives (or un-archives) this dialog. - - Args: - folder (`int`, optional): - The folder to which the dialog should be archived to. - - If you want to "un-archive" it, use ``folder=0``. - - Returns: - The :tl:`Updates` object that the request produces. - - Example: - - .. code-block:: python - - # Archiving - dialog.archive() - - # Un-archiving - dialog.archive(0) - """ - return await self._client(functions.folders.EditPeerFoldersRequest([ - types.InputFolderPeer(self.input_entity, folder_id=folder) - ])) - def to_dict(self): return { '_': 'Dialog', @@ -155,7 +129,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 84% rename from telethon/tl/custom/draft.py rename to telethon/types/_custom/draft.py index a44986b7..60aabdaf 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): @@ -66,7 +58,7 @@ class Draft: if not self.entity and await self.get_input_entity(): try: self._entity =\ - await self._client.get_entity(self._input_entity) + await self._client.get_profile(self._input_entity) except ValueError: pass @@ -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, @@ -169,22 +161,17 @@ class Draft: return await self.set_message(text='') def to_dict(self): - try: - entity = self.entity - except RPCError as e: - entity = e - return { '_': 'Draft', 'text': self.text, - 'entity': entity, + 'entity': self.entity, 'date': self.date, 'link_preview': self.link_preview, 'reply_to_msg_id': self.reply_to_msg_id } 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 91% rename from telethon/tl/custom/inlinebuilder.py rename to telethon/types/_custom/inlinebuilder.py index f82b7e67..9d69eb1f 100644 --- a/telethon/tl/custom/inlinebuilder.py +++ b/telethon/types/_custom/inlinebuilder.py @@ -1,7 +1,9 @@ import hashlib +import dataclasses -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 +128,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( @@ -143,7 +145,7 @@ class InlineBuilder: content=content ) if id is None: - result.id = hashlib.sha256(bytes(result)).hexdigest() + result = dataclasses.replace(result, id=hashlib.sha256(bytes(result)).hexdigest()) return result @@ -194,15 +196,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, @@ -225,7 +227,7 @@ class InlineBuilder: # noinspection PyIncorrectDocstring async def document( - self, file, title=None, *, description=None, type=None, + self, file, title, *, description=None, type=None, mime_type=None, attributes=None, force_document=False, voice_note=False, video_note=False, use_cache=True, id=None, text=None, parse_mode=(), link_preview=True, @@ -244,7 +246,7 @@ class InlineBuilder: Same as ``file`` for `client.send_file() `. - title (`str`, optional): + title (`str`): The title to be shown for this result. description (`str`, optional): @@ -314,15 +316,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 +363,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 +393,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 +402,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 +436,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 +445,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 76% rename from telethon/tl/custom/inlineresult.py rename to telethon/types/_custom/inlineresult.py index f189068c..0a659f8e 100644 --- a/telethon/tl/custom/inlineresult.py +++ b/telethon/types/_custom/inlineresult.py @@ -1,5 +1,6 @@ -from .. import types, functions -from ... import utils +from ... import _tl +from ..._misc import utils +import os class InlineResult: @@ -77,7 +78,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 +87,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,13 +98,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, - silent=False, clear_draft=False, hide_via=False): + async def click(self, entity=None, reply_to=None, comment_to=None, + silent=False, clear_draft=False, hide_via=False, + background=None, send_as=None): """ Clicks this result and sends the associated `message`. @@ -114,6 +116,11 @@ class InlineResult: reply_to (`int` | `Message `, optional): If present, the sent message will reply to this ID or message. + 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). + silent (`bool`, optional): Whether the message should notify people with sound or not. Defaults to `False` (send with a notification sound unless @@ -123,27 +130,41 @@ class InlineResult: clear_draft (`bool`, optional): Whether the draft should be removed after sending the message from this result or not. Defaults to `False`. - + hide_via (`bool`, optional): Whether the "via @bot" should be hidden or not. Only works with certain bots (like @bing or @gif). + + 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) + entity = await self._client.get_input_peer(entity) elif self._entity: entity = self._entity else: raise ValueError('You must provide the entity where the result should be sent to') - reply_id = None if reply_to is None else utils.get_message_id(reply_to) - req = functions.messages.SendInlineBotResultRequest( + if comment_to: + entity, reply_id = await self._client._get_comment_data(entity, comment_to) + else: + reply_id = None if reply_to is None else utils.get_message_id(reply_to) + + req = _tl.fn.messages.SendInlineBotResult( peer=entity, query_id=self._query_id, id=self.result.id, silent=silent, + 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, + random_id=int.from_bytes(os.urandom(8), 'big', signed=True), ) 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 52% rename from telethon/tl/custom/message.py rename to telethon/types/_custom/message.py index 3d069bf7..8a8460fa 100644 --- a/telethon/tl/custom/message.py +++ b/telethon/types/_custom/message.py @@ -1,240 +1,440 @@ 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, markdown, html +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): + object.__setattr__(self._message, field, value) + + return property(fget, fset, None, doc) + + +class _UninitClient: + def __getattribute__(self, attr): + raise ValueError('this Message instance does not come from a chat and cannot be used') # 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). + 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). - 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. + Manually-created instances of this message cannot be responded to, edited, + and so on, because the message needs to first be sent for those to make sense. """ + # 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, + ) + self._client = _UninitClient() + + @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) + + self = cls.__new__(cls) + self._client = client + self._sender = entities.get(_tl.PeerUser(update.user_id)) + self._chat = entities.get(_tl.PeerUser(update.user_id)) + 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 self._buttons = None @@ -244,76 +444,76 @@ 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)``. Obtaining formatted text from a message in + this setting is not supported and will instead return the plain text. + * 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. + """ + InputMessage._default_parse_mode = utils.sanitize_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 @@ -323,44 +523,30 @@ class Message(ChatGetter, SenderGetter, TLObject): def client(self): """ Returns the `TelegramClient ` - that *patched* this message. This will only be present if you - **use the friendly methods**, it won't be there if you invoke - raw API methods manually, in which case you should only access - members, not properties. + which returned this message from a friendly method. It won't be there if you + invoke raw API methods manually (because those return the original :tl:`Message`, + not this class). """ return self._client @property def text(self): """ - The message text, formatted using the client's default - parse mode. Will be `None` for :tl:`MessageService`. + The message text, formatted using the default parse mode. + Will be `None` for :tl:`MessageService`. """ - if self._text is None and self._client: - if not self._client.parse_mode: - self._text = self.message - else: - self._text = self._client.parse_mode.unparse( - self.message, self.entities) - - return self._text + return InputMessage._default_parse_mode[1](self.message, self.entities) @text.setter def text(self, value): - self._text = value - if self._client and self._client.parse_mode: - self.message, self.entities = self._client.parse_mode.parse(value) - else: - self.message, self.entities = value, [] + self.message, self.entities = InputMessage._default_parse_mode[0](value) @property def raw_text(self): """ - The raw message text, ignoring any formatting. - Will be `None` for :tl:`MessageService`. + The plain message text, ignoring any formatting. Will be `None` for :tl:`MessageService`. - Setting a value to this field will erase the - `entities`, unlike changing the `message` member. + Setting a value to this field will erase the `entities`, unlike changing the `message` member. """ return self.message @@ -368,7 +554,28 @@ class Message(ChatGetter, SenderGetter, TLObject): def raw_text(self, value): self.message = value self.entities = [] - self._text = None + + @property + def markdown(self): + """ + The message text, formatted using markdown. Will be `None` for :tl:`MessageService`. + """ + return markdown.unparse(self.message, self.entities) + + @markdown.setter + def markdown(self, value): + self.message, self.entities = markdown.parse(value) + + @property + def html(self): + """ + The message text, formatted using HTML. Will be `None` for :tl:`MessageService`. + """ + return html.unparse(self.message, self.entities) + + @html.setter + def html(self, value): + self.message, self.entities = html.parse(value) @property def is_reply(self): @@ -435,7 +642,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 +678,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 +693,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 +706,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 +715,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 +723,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 +731,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 +750,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 +772,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 +780,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 +790,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 +798,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 +806,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 +814,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 +823,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`. @@ -659,8 +866,8 @@ 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) + if not self.out and self.chat.is_user: + return _tl.PeerUser(self._client._session_state.user_id) return self.peer_id @@ -714,15 +921,15 @@ class Message(ChatGetter, SenderGetter, TLObject): The result will be cached after its first use. """ - if self._reply_message is None and self._client: + if self._reply_message is None: if not self.reply_to: return None # Bots cannot access other bots' messages by their ID. # 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) + self.chat, + ids=_tl.InputMessageReplyTo(self.id) ) if not self._reply_message: # ...unless the current message got deleted. @@ -730,7 +937,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # If that's the case, give it a second chance accessing # directly by its ID. self._reply_message = await self._client.get_messages( - self._input_chat if self.is_channel else None, + self.chat, ids=self.reply_to.reply_to_msg_id ) @@ -742,9 +949,8 @@ class Message(ChatGetter, SenderGetter, TLObject): `telethon.client.messages.MessageMethods.send_message` with ``entity`` already set. """ - if self._client: - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) async def reply(self, *args, **kwargs): """ @@ -752,10 +958,9 @@ class Message(ChatGetter, SenderGetter, TLObject): `telethon.client.messages.MessageMethods.send_message` with both ``entity`` and ``reply_to`` already set. """ - if self._client: - kwargs['reply_to'] = self.id - return await self._client.send_message( - await self.get_input_chat(), *args, **kwargs) + kwargs['reply_to'] = self.id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) async def forward_to(self, *args, **kwargs): """ @@ -767,10 +972,9 @@ class Message(ChatGetter, SenderGetter, TLObject): this `forward_to` method. Use a `telethon.client.telegramclient.TelegramClient` instance directly. """ - if self._client: - kwargs['messages'] = self.id - kwargs['from_peer'] = await self.get_input_chat() - return await self._client.forward_messages(*args, **kwargs) + kwargs['messages'] = self.id + kwargs['from_peer'] = await self.get_input_chat() + return await self._client.forward_messages(*args, **kwargs) async def edit(self, *args, **kwargs): """ @@ -778,9 +982,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 +994,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) @@ -819,11 +1017,10 @@ class Message(ChatGetter, SenderGetter, TLObject): this `delete` method. Use a `telethon.client.telegramclient.TelegramClient` instance directly. """ - if self._client: - return await self._client.delete_messages( - await self.get_input_chat(), [self.id], - *args, **kwargs - ) + return await self._client.delete_messages( + await self.get_input_chat(), [self.id], + *args, **kwargs + ) async def download_media(self, *args, **kwargs): """ @@ -831,10 +1028,9 @@ class Message(ChatGetter, SenderGetter, TLObject): for `telethon.client.downloads.DownloadMethods.download_media` with the ``message`` already set. """ - if self._client: - # Passing the entire message is important, in case it has to be - # refetched for a fresh file reference. - return await self._client.download_media(self, *args, **kwargs) + # Passing the entire message is important, in case it has to be + # refetched for a fresh file reference. + return await self._client.download_media(self, *args, **kwargs) async def click(self, i=None, j=None, *, text=None, filter=None, data=None, share_phone=None, @@ -883,7 +1079,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. @@ -942,15 +1138,12 @@ class Message(ChatGetter, SenderGetter, TLObject): # Click on a button requesting a phone await message.click(0, share_phone=True) """ - if not self._client: - return - if data: chat = await self.get_input_chat() 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 +1179,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,15 +1223,14 @@ 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.get_input_chat(), max_id=self.id) + await self._client.mark_read( + await self.get_input_chat(), max_id=self.id) - async def pin(self, *, notify=False): + async def pin(self, *, notify=False, pm_oneside=False): """ Pins the message. Shorthand for `telethon.client.messages.MessageMethods.pin_message` @@ -1047,9 +1239,8 @@ class Message(ChatGetter, SenderGetter, TLObject): # TODO Constantly checking if client is a bit annoying, # maybe just make it illegal to call messages from raw API? # That or figure out a way to always set it directly. - if self._client: - return await self._client.pin_message( - await self.get_input_chat(), self.id, notify=notify) + return await self._client.pin_message( + await self.get_input_chat(), self.id, notify=notify, pm_oneside=pm_oneside) async def unpin(self): """ @@ -1057,25 +1248,45 @@ class Message(ChatGetter, SenderGetter, TLObject): `telethon.client.messages.MessageMethods.unpin_message` with both ``entity`` and ``message`` already set. """ + return await self._client.unpin_message( + await self.get_input_chat(), self.id) + + async def react(self, reaction=None): + """ + Reacts on the given message. Shorthand for + `telethon.client.messages.MessageMethods.send_reaction` + with both ``entity`` and ``message`` already set. + """ if self._client: - return await self._client.unpin_message( - await self.get_input_chat(), self.id) + return await self._client.send_reaction( + await self.get_input_chat(), + self.id, + reaction + ) # endregion Public Methods # 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, along with their input versions. """ - if not self._client: - return - try: - chat = await self.get_input_chat() if self.is_channel else None - msg = await self._client.get_messages(chat, ids=self.id) + msg = await self._client.get_messages(self.chat, ids=self.id) except ValueError: return # We may not have the input chat/get message failed if not msg: @@ -1090,15 +1301,12 @@ class Message(ChatGetter, SenderGetter, TLObject): self._forward = msg._forward self._action_entities = msg._action_entities - async def _refetch_sender(self): - await self._reload_message() - def _set_buttons(self, chat, bot): """ Helper methods to set the buttons given the input sender and chat. """ - if self._client and isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + if isinstance(self.reply_markup, ( + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): self._buttons = [[ MessageButton(self._client, button, chat, bot, self.id) for button in row.buttons @@ -1113,13 +1321,13 @@ class Message(ChatGetter, SenderGetter, TLObject): to know what bot we want to start. Raises ``ValueError`` if the bot cannot be found but is needed. Returns `None` if it's not needed. """ - if self._client and not isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + if not isinstance(self.reply_markup, ( + _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 +1335,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 +1351,15 @@ class Message(ChatGetter, SenderGetter, TLObject): return None # endregion Private Methods + + def to_dict(self): + return self._message.to_dict() + + 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) diff --git a/telethon/tl/custom/messagebutton.py b/telethon/types/_custom/messagebutton.py similarity index 74% rename from telethon/tl/custom/messagebutton.py rename to telethon/types/_custom/messagebutton.py index 7f6490b2..9286c042 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_profile` 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,30 @@ class MessageButton: return await self._client(req) except BotResponseTimeoutError: return None - elif isinstance(self.button, types.KeyboardButtonSwitchInline): - return await self._client(functions.messages.StartBotRequest( - bot=self._bot, peer=self._chat, start_param=self.button.query + elif isinstance(self.button, _tl.KeyboardButtonUserProfile): + return await self._client.get_profile(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, + random_id=int.from_bytes(os.urandom(8), 'big', signed=True), )) - 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 +142,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 86% rename from telethon/tl/custom/participantpermissions.py rename to telethon/types/_custom/participantpermissions.py index c59a05ed..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,24 +77,27 @@ 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): """ Whether the administrator can add new administrators with the same or less permissions than them. """ - if not self.is_admin or (self.is_chat and not self.is_creator): + if not self.is_admin: return False + if self.is_chat: + return self.is_creator + return self.participant.admin_rights.add_admins ban_users = property(**_admin_prop('ban_users', """ @@ -129,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/types/_custom/qrlogin.py b/telethon/types/_custom/qrlogin.py new file mode 100644 index 00000000..be1fec55 --- /dev/null +++ b/telethon/types/_custom/qrlogin.py @@ -0,0 +1,169 @@ +import asyncio +import base64 +import time +import functools + +from ... import _tl +from ..._events.raw import Raw + + +class QrLoginManager: + def __init__(self, client, ignored_ids): + self._client = client + self._request = _tl.fn.auth.ExportLoginToken(client._api_id, client._api_hash, ignored_ids or []) + self._event = None + self._handler = None + self._login = None + + async def __aenter__(self): + self._event = asyncio.Event() + self._handler = self._client.add_event_handler(self._callback, Raw) + + try: + qr = await self._client(self._request) + except: + self._cleanup() + raise + + self._login = QrLogin._new(self._client, self._request, qr, self._event) + return self._login + + async def __aexit__(self, *args): + try: + # The logic to complete the login is in wait so the user can retrieve the logged-in user + await self._login.wait(timeout=0) + # User logged-in in time + except asyncio.TimeoutError: + pass # User did not login in time + finally: + self._cleanup() + + async def _callback(self, update): + if isinstance(update, _tl.UpdateLoginToken): + self._event.set() + + def _cleanup(self): + # Users technically could remove all raw handlers during the procedure but it's unlikely to happen + self._client.remove_event_handler(self._handler) + self._event = None + self._handler = None + self._login = None + + +class QrLogin: + """ + QR login information. + + Most of the time, you will present the `url` as a QR code to the user, + and while it's being shown, call `wait`. + """ + def __init__(self): + raise TypeError('You cannot create QrLogin instances by hand!') + + @classmethod + def _new(cls, client, request, qr, event): + self = cls.__new__(cls) + self._client = client + self._request = request + self._qr = qr + self._expiry = asyncio.get_running_loop().time() + qr.expires.timestamp() - time.time() + self._event = event + self._user = None + return self + + @property + def token(self) -> bytes: + """ + The binary data representing the token. + + It can be used by a previously-authorized client in a call to + :tl:`auth.importLoginToken` to log the client that originally + requested the QR login. + """ + return self._qr.token + + @property + def url(self) -> str: + """ + The ``tg://login`` URI with the token. When opened by a Telegram + application where the user is logged in, it will import the login + token. + + If you want to display a QR code to the user, this is the URL that + should be launched when the QR code is scanned (the URL that should + be contained in the QR code image you generate). + + Whether you generate the QR code image or not is up to you, and the + library can't do this for you due to the vast ways of generating and + displaying the QR code that exist. + + The URL simply consists of `token` base64-encoded. + """ + return 'tg://login?token={}'.format(base64.urlsafe_b64encode(self._qr.token).decode('utf-8').rstrip('=')) + + @property + def timeout(self): + """ + How many seconds are left before `client.qr_login` should be used again. + + This value is a positive floating point number, and is monotically decreasing. + The value will reach zero after enough seconds have elapsed. This lets you do some work + and call sleep on the value and still wait just long enough. + """ + return max(0.0, self._expiry - asyncio.get_running_loop().time()) + + @property + def expired(self): + """ + Returns `True` if this instance of the QR login has expired and should be re-created. + + .. code-block:: python + + if qr.expired: + qr = await client.qr_login() + """ + return asyncio.get_running_loop().time() >= self._expiry + + async def wait(self, timeout: float = None): + """ + Waits for the token to be imported by a previously-authorized client, + either by scanning the QR, launching the URL directly, or calling the + import method. + + Will raise `asyncio.TimeoutError` if the login doesn't complete on + time. + + Note that the login can complete even if `wait` isn't used (if the + context-manager is kept alive for long enough and the users logs in). + + Arguments + timeout (float): + The timeout, in seconds, to wait before giving up. By default + the library will wait until the token expires, which is often + what you want. + + Returns + On success, an instance of `User`. On failure it will raise. + """ + if self._user: + return self._user + + if timeout is None: + timeout = self.timeout + + # Will raise timeout error if it doesn't complete quick enough, + # which we want to let propagate + await asyncio.wait_for(self._event.wait(), timeout=timeout) + + resp = await self._client(self._request) + if isinstance(resp, _tl.auth.LoginTokenMigrateTo): + await self._client._switch_dc(resp.dc_id) + resp = await self._client(_tl.fn.auth.ImportLoginToken(resp.token)) + # resp should now be auth.loginTokenSuccess + + if isinstance(resp, _tl.auth.LoginTokenSuccess): + user = resp.authorization.user + self._user = self._client._update_session_state(user) + return self._user + + raise RuntimeError(f'Unexpected login token response: {resp}') diff --git a/telethon/types/_custom/sendergetter.py b/telethon/types/_custom/sendergetter.py new file mode 100644 index 00000000..71f39580 --- /dev/null +++ b/telethon/types/_custom/sendergetter.py @@ -0,0 +1,65 @@ +import abc + + +class SenderGetter(abc.ABC): + """ + Helper base class that introduces the sender-related properties and methods. + + The parent class must set both ``_sender`` and ``_client``. + """ + @property + def sender(self): + """ + Returns the `User` or `Chat` who sent this object, or `None` if there is no sender. + + The sender of an event is only guaranteed to include the ``id``. + If you need the sender to at least have basic information, use `get_sender` instead. + + Senders obtained through friendly methods (not events) will always have complete + information (so there is no need to use `get_sender` or ``sender.fetch()``). + """ + return self._sender + + async def get_sender(self): + """ + Returns `sender`, but will make an API call to find the sender unless it's already cached. + + If you only need the ID, use `sender_id` instead. + + If you need to call a method which needs this sender, prefer `sender` instead. + + Telegram may send a "minimal" version of the sender to save on bandwidth when using events. + If you need all the information about the sender upfront, you can use ``sender.fetch()``. + + .. code-block:: python + + @client.on(events.NewMessage) + async def handler(event): + # I only need the ID -> use sender_id + sender_id = event.sender_id + + # I'm going to use the sender in a method -> use sender + await client.send_message(event.sender, 'Hi!') + + # I need the sender's first name -> use get_sender + sender = await event.get_sender() + print(sender.first_name) + + # I want to see all the information about the sender -> use fetch + sender = await event.sender.fetch() + print(sender.stringify()) + + # ... + + async for message in client.get_messages(chat): + # Here there's no need to fetch the sender - get_messages already did + print(message.sender.stringify()) + """ + raise RuntimeError('TODO fetch if it is tiny') + + @property + def sender_id(self): + """ + Alias for ``self.sender.id``, but checking if ``sender is not None`` first. + """ + return self._sender.id if sender else None diff --git a/telethon/types/_custom/tos.py b/telethon/types/_custom/tos.py new file mode 100644 index 00000000..390d8aa0 --- /dev/null +++ b/telethon/types/_custom/tos.py @@ -0,0 +1,155 @@ +import sys +import asyncio + +from ..._misc import markdown, html +from ... import _tl + + +_DEFAULT_TIMEOUT = 24 * 60 * 60 + + +class TermsOfService: + """ + Represents `Telegram's Terms of Service`_, which every user must accept in order to use + Telegram, or they must otherwise `delete their account`_. + + This is not the same as the `API's Terms of Service`_, which every developer must accept + before creating applications for Telegram. + + You must make sure to check for the terms text (or markdown, or HTML), as well as confirm + the user's age if required. + + This class implements `__bool__`, meaning it will be truthy if there are terms to display, + and falsey otherwise. + + .. code-block:: python + + tos = await client.get_tos() + if tos: + print(tos.html) # there's something to read and accept or decline + ... + else: + await asyncio.sleep(tos.timeout) # nothing to read, but still has tos.timeout + + _Telegram's Terms of Service: https://telegram.org/tos + _delete their account: https://core.telegram.org/api/config#terms-of-service + _API's Terms of Service: https://core.telegram.org/api/terms + """ + + @property + def text(self): + """Plain-text version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and self._tos.text + + @property + def markdown(self): + """Markdown-formatted version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and markdown.unparse(self._tos.text, self._tos.entities) + + @property + def html(self): + """HTML-formatted version of the Terms of Service, or `None` if there is no ToS update.""" + return self._tos and html.unparse(self._tos.text, self._tos.entities) + + @property + def popup(self): + """`True` a popup should be shown to the user.""" + return self._tos and self._tos.popup + + @property + def minimum_age(self): + """The minimum age the user must be to accept the terms, or `None` if there's no requirement.""" + return self._tos and self._tos.min_age_confirm + + @property + def timeout(self): + """ + How many seconds are left before `client.get_tos` should be used again. + + This value is a positive floating point number, and is monotically decreasing. + The value will reach zero after enough seconds have elapsed. This lets you do some work + and call sleep on the value and still wait just long enough. + """ + return max(0.0, self._expiry - asyncio.get_running_loop().time()) + + @property + def expired(self): + """ + Returns `True` if this instance of the Terms of Service has expired and should be re-fetched. + + .. code-block:: python + + if tos.expired: + tos = await client.get_tos() + """ + return asyncio.get_running_loop().time() >= self._expiry + + def __init__(self): + raise TypeError('You cannot create TermsOfService instances by hand!') + + @classmethod + def _new(cls, client, tos, expiry): + self = cls.__new__(cls) + self._client = client + self._tos = tos + self._expiry = expiry or asyncio.get_running_loop().time() + _DEFAULT_TIMEOUT + return self + + async def accept(self, *, age=None): + """ + Accept the Terms of Service. + + Does nothing if there is nothing to accept. + + If `minimum_age` is not `None`, the `age` parameter must be provided, + and be greater than or equal to `minimum_age`. Otherwise, the function will fail. + + .. code-example: + + if tos.minimum_age: + age = int(input('age: ')) + else: + age = None + + print(tos.html) + if input('accept (y/n)?: ') == 'y': + await tos.accept(age=age) + """ + if not self._tos: + return + + if age < (self.minimum_age or 0): + raise ValueError('User is not old enough to accept the Terms of Service') + + if age > 122: + # This easter egg may be out of date by 2025 + print('Lying is done at your own risk!', file=sys.stderr) + + await self._client(_tl.fn.help.AcceptTermsOfService(self._tos.id)) + + async def decline(self): + """ + Decline the Terms of Service. + + Does nothing if there is nothing to decline. + + .. danger:: + + Declining the Terms of Service will result in the `termination of your account`_. + **Your account will be deleted**. + + _termination of your account: https://core.telegram.org/api/config#terms-of-service + """ + if not self._tos: + return + + await self._client(_tl.fn.account.DeleteAccount('Decline ToS update')) + + def __str__(self): + return self.markdown or '(empty ToS)' + + def __repr__(self): + return f'TermsOfService({self.markdown!r})' + + def __bool__(self): + return self._tos is not None diff --git a/telethon/types/_custom/user.py b/telethon/types/_custom/user.py new file mode 100644 index 00000000..7a636dfd --- /dev/null +++ b/telethon/types/_custom/user.py @@ -0,0 +1,292 @@ +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime +from dataclasses import dataclass +import mimetypes +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from .messagebutton import MessageButton +from .forward import Forward +from .file import File +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup +from ..._misc import utils, helpers, tlobject, markdown, html +from ..._sessions.types import Entity + + +if TYPE_CHECKING: + from ..._misc import hints + + +def _fwd(field, doc): + def fget(self): + return getattr(self._user, field, None) + + def fset(self, value): + object.__setattr__(self._user, field, value) + + return property(fget, fset, None, doc) + + +class BotInfo: + @property + def version(self): + """ + Version number of this information, incremented whenever it changes. + """ + return self._user.bot_info_version + + @property + def chat_history_access(self): + """ + `True` if the bot has privacy mode disabled via @BotFather and can see *all* messages of the group. + """ + return self._user.bot_chat_history + + @property + def private_only(self): + """ + `True` if the bot cannot be added to group and can only be used in private messages. + """ + return self._user.bot_nochats + + @property + def inline_geo(self): + """ + `True` if the bot can request the user's geolocation when used in @bot inline mode. + """ + return self._user.bot_inline_geo + + @property + def inline_placeholder(self): + """ + The placeholder to show when using the @bot inline mode. + """ + return self._user.bot_inline_placeholder + + def __init__(self, user): + self._user = user + + +@dataclass(frozen=True) +class _TinyUser: + __slots__ = ('id', 'access_hash') + + id: int + access_hash: int + + +class User: + """ + Represents a :tl:`User` (or :tl:`UserEmpty`, or :tl:`UserFull`) from the API. + """ + + id = _fwd('id', """ + The user identifier. This is the only property which will **always** be present. + """) + + first_name = _fwd('first_name', """ + The user's first name. It will be ``None`` for deleted accounts. + """) + + last_name = _fwd('last_name', """ + The user's last name. It can be ``None``. + """) + + username = _fwd('username', """ + The user's @username. It can be ``None``. + """) + + phone = _fwd('phone', """ + The user's phone number. It can be ``None`` if the user is not in your contacts or their + privacy setting does not allow you to view the phone number. + """) + + is_self = _fwd('is_self', """ + ``True`` if this user represents the logged-in account. + """) + + bot = _fwd('bot', """ + ``True`` if this user is a bot created via @BotFather. + """) + + contact = _fwd('contact', """ + ``True`` if this user is in the contact list of the logged-in account. + """) + + mutual_contact = _fwd('mutual_contact', """ + ``True`` if this user is in the contact list of the logged-in account, + and the user also has the logged-in account in their contact list. + """) + + deleted = _fwd('deleted', """ + ``True`` if this user belongs to a deleted account. + """) + + verified = _fwd('verified', """ + ``True`` if this user represents an official account verified by Telegram. + """) + + restricted = _fwd('restricted', """ + `True` if the user has been restricted for some reason. + """) + + support = _fwd('support', """ + ``True`` if this user belongs to an official account from Telegram Support. + """) + + scam = _fwd('scam', """ + ``True`` if this user has been flagged as spam. + """) + + fake = _fwd('fake', """ + ``True`` if this user has been flagged as fake. + """) + + lang_code = _fwd('lang_code', """ + Language code of the user, if it's known. + """) + + @property + def restriction_reasons(self): + """ + Returns a possibly-empty list of reasons why the chat is restricted to some platforms. + """ + try: + return self._user.restriction_reason or [] + except AttributeError: + return [] + + @property + def bot_info(self): + """ + Additional information about the user if it's a bot, `None` otherwise. + """ + return BotInfo(self._user) if self.bot else None + + def __init__(self): + raise TypeError('You cannot create User instances by hand!') + + @classmethod + def _new(cls, client, user): + self = cls.__new__(cls) + self._client = client + + if isinstance(user, Entity): + if not user.is_user: + raise TypeError('Tried to construct User with non-user Entity') + + self._user = _TinyUser(user.id, user.hash) + else: + self._user = user + + self._full = None + + return self + + async def fetch(self, *, full=False): + """ + Perform an API call to fetch fresh information about this user. + + Returns itself, but with the information fetched (allowing you to chain the call). + + If ``full`` is ``True``, the full information about the user will be fetched, + which will include things like ``about``. + """ + + # sender - might just be hash + # get sender - might be min + # sender fetch - never min + + return self + + def compact(self): + """ + Return a compact representation of this user, useful for storing for later use. + """ + raise RuntimeError('TODO') + + @property + def client(self): + """ + Returns the `TelegramClient ` + which returned this user from a friendly method. + """ + return self._client + + def to_dict(self): + return self._user.to_dict() + + 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) + + def download_profile_photo(): + # why'd you want to access photo? just do this + pass + + def get_profile_photos(): + # this i can understand as you can pick other photos... sadly exposing raw api + pass + + # TODO status, photo, and full properties + + @property + def is_user(self): + """ + Returns `True`. + + This property also exists in `Chat`, where it returns `False`. + + .. code-block:: python + + if message.sender.is_user: + ... # do stuff + """ + return True + + @property + def is_group(self): + """ + Returns `False`. + + This property also exists in `Chat`, where it can return `True`. + + .. code-block:: python + + if message.sender.is_group: + ... # do stuff + """ + return False + + @property + def is_broadcast(self): + """ + Returns `False`. + + This property also exists in `Chat`, where it can return `True`. + + .. code-block:: python + + if message.sender.is_broadcast: + ... # do stuff + """ + return False + + @property + def full_name(self): + """ + Returns the user's full name (first name and last name concatenated). + + This property also exists in `Chat`, where it returns the title. + + .. code-block:: python + + print(message.sender.full_name): + """ + return f'{self.first_name} {self.last_name}' if self.last_name else self.first_name diff --git a/telethon/version.py b/telethon/version.py index 1b916db8..68e59657 100644 --- a/telethon/version.py +++ b/telethon/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '1.22.0' +__version__ = '1.24.0' diff --git a/telethon_examples/gui.py b/telethon_examples/gui.py index bd241f60..fb369c2f 100644 --- a/telethon_examples/gui.py +++ b/telethon_examples/gui.py @@ -53,7 +53,7 @@ def callback(func): def wrapped(*args, **kwargs): result = func(*args, **kwargs) if inspect.iscoroutine(result): - aio_loop.create_task(result) + asyncio.create_task(result) return wrapped @@ -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): """ @@ -228,7 +228,7 @@ class App(tkinter.Tk): """ Sends a message. Does nothing if the client is not connected. """ - if not self.cl.is_connected(): + if not self.cl.is_connected: return # The user needs to configure a chat where the message should be sent. @@ -322,7 +322,7 @@ class App(tkinter.Tk): try: old = self.chat_id # Valid chat ID, set it and configure the colour back to white - self.chat_id = await self.cl.get_peer_id(chat) + self.chat_id = (await self.cl.get_profile(chat)).id self.chat.configure(bg='white') # If the chat ID changed, clear the @@ -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..293890f5 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""" @@ -375,7 +371,7 @@ class InteractiveTelegramClient(TelegramClient): # with events. Since they are methods, you know they may make an API # call, which can be expensive. chat = await event.get_chat() - if event.is_group: + if chat.is_group: if event.out: sprint('>> sent "{}" to chat {}'.format( event.text, get_display_name(chat) @@ -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..5f5f1b00 100644 --- a/telethon_examples/quart_login.py +++ b/telethon_examples/quart_login.py @@ -5,6 +5,7 @@ import hypercorn.asyncio from quart import Quart, render_template_string, request from telethon import TelegramClient, utils +from telethon.types import Message from telethon.errors import SessionPasswordNeededError @@ -51,9 +52,11 @@ SESSION = os.environ.get('TG_SESSION', 'quart') API_ID = int(get_env('TG_API_ID', 'Enter your API ID: ')) API_HASH = get_env('TG_API_HASH', 'Enter your API hash: ') +# Render things nicely (global setting) +Message.set_default_parse_mode('html') + # Telethon client client = TelegramClient(SESSION, API_ID, API_HASH) -client.parse_mode = 'html' # <- Render things nicely phone = None # Quart app @@ -69,7 +72,7 @@ async def format_message(message): message.raw_text ) else: - # client.parse_mode = 'html', so bold etc. will work! + # The Message parse_mode is 'html', so bold etc. will work! content = (message.text or '(action message)').replace('\n', '
') return '

{}: {}{}

'.format( @@ -116,7 +119,7 @@ async def root(): # They are logged in, show them some messages from their first dialog dialog = (await client.get_dialogs())[0] result = '

{}

'.format(dialog.title) - async for m in client.iter_messages(dialog, 10): + async for m in client.get_messages(dialog, 10): result += await(format_message(m)) return await render_template_string(BASE_TEMPLATE, content=result) @@ -134,12 +137,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 +153,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_examples/replier.py b/telethon_examples/replier.py index e498bad9..59d17de1 100755 --- a/telethon_examples/replier.py +++ b/telethon_examples/replier.py @@ -78,7 +78,7 @@ async def handler(event): # and we said "save pic" in the message if event.out and event.is_reply and 'save pic' in event.raw_text: reply_msg = await event.get_reply_message() - replied_to_user = await reply_msg.get_input_sender() + replied_to_user = reply_msg.sender message = await event.reply('Downloading your profile photo...') file = await client.download_profile_photo(replied_to_user) diff --git a/telethon_generator/data/api.tl b/telethon_generator/data/api.tl index 1db76ca1..de179160 100644 --- a/telethon_generator/data/api.tl +++ b/telethon_generator/data/api.tl @@ -41,16 +41,16 @@ null#56730bcc = Null; inputPeerEmpty#7f3b18ea = InputPeer; inputPeerSelf#7da07ec9 = InputPeer; -inputPeerChat#179be863 chat_id:int = InputPeer; -inputPeerUser#7b8e7de6 user_id:int access_hash:long = InputPeer; -inputPeerChannel#20adaef8 channel_id:int access_hash:long = InputPeer; -inputPeerUserFromMessage#17bae2e6 peer:InputPeer msg_id:int user_id:int = InputPeer; -inputPeerChannelFromMessage#9c95f7bb peer:InputPeer msg_id:int channel_id:int = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; inputUserEmpty#b98886cf = InputUser; inputUserSelf#f7c1b13f = InputUser; -inputUser#d8292816 user_id:int access_hash:long = InputUser; -inputUserFromMessage#2d117597 peer:InputPeer msg_id:int user_id:int = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; inputPhoneContact#f392b7f4 client_id:long phone:string first_name:string last_name:string = InputContact; @@ -92,11 +92,11 @@ inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes th inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; -inputGroupCallStream#bba51639 call:InputGroupCall time_ms:long scale:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; -peerUser#9db1bc6d user_id:int = Peer; -peerChat#bad0e5bb chat_id:int = Peer; -peerChannel#bddde532 channel_id:int = Peer; +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; storage.fileUnknown#aa963b05 = storage.FileType; storage.filePartial#40bc6f52 = storage.FileType; @@ -109,8 +109,8 @@ storage.fileMov#4b09ebbc = storage.FileType; storage.fileMp4#b3cea0e4 = storage.FileType; storage.fileWebp#1081464c = storage.FileType; -userEmpty#200250ba id:int = User; -user#938458c1 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true id:int access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string = User; +userEmpty#d3bc4b7a id:long = User; +user#3ff6ecb0 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string = User; userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; userProfilePhoto#82d1f706 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; @@ -122,33 +122,33 @@ userStatusRecently#e26f42f1 = UserStatus; userStatusLastWeek#7bf09fc = UserStatus; userStatusLastMonth#77ebc742 = UserStatus; -chatEmpty#9ba2d800 id:int = Chat; -chat#3bda1bde 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:int 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#7328bdb id:int title:string = Chat; -channel#d31a961e 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:int access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int version: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#289da732 flags:# broadcast:flags.5?true megagroup:flags.8?true id:int access_hash:long title:string until_date:flags.16?int = Chat; +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 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 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#8a1e2983 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:int about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite: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 = ChatFull; -channelFull#548c3f93 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:int about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?int migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?int location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer = 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#c8d7493e user_id:int inviter_id:int date:int = ChatParticipant; -chatParticipantCreator#da13538a user_id:int = ChatParticipant; -chatParticipantAdmin#e2d6e436 user_id:int inviter_id:int date:int = ChatParticipant; +chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; +chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; +chatParticipantAdmin#a0933f5b user_id:long inviter_id:long date:int = ChatParticipant; -chatParticipantsForbidden#fc900c2b flags:# chat_id:int self_participant:flags.0?ChatParticipant = ChatParticipants; -chatParticipants#3f460fed chat_id:int participants:Vector version:int = ChatParticipants; +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; 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#bce383d2 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?int 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; messageMediaPhoto#695150d7 flags:# photo:flags.0?Photo ttl_seconds:flags.2?int = MessageMedia; messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; -messageMediaContact#cbf24940 phone_number:string first_name:string last_name:string vcard:string user_id:int = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; messageMediaUnsupported#9f84f49e = MessageMedia; messageMediaDocument#9cb070d7 flags:# document:flags.0?Document ttl_seconds:flags.2?int = MessageMedia; messageMediaWebPage#a32dd600 webpage:WebPage = MessageMedia; @@ -160,16 +160,16 @@ messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; messageMediaDice#3f7ee58b value:int emoticon:string = MessageMedia; messageActionEmpty#b6aef7b0 = MessageAction; -messageActionChatCreate#a6638b9a title:string users:Vector = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; messageActionChatDeletePhoto#95e3fbef = MessageAction; -messageActionChatAddUser#488a7337 users:Vector = MessageAction; -messageActionChatDeleteUser#b2ae9b0c user_id:int = MessageAction; -messageActionChatJoinedByLink#f89cf5e8 inviter_id:int = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; messageActionChannelCreate#95d2ac92 title:string = MessageAction; -messageActionChatMigrateTo#51bdb021 channel_id:int = MessageAction; -messageActionChannelMigrateFrom#b055eaee title:string chat_id:int = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; messageActionPinMessage#94bd38ed = MessageAction; messageActionHistoryClear#9fbab604 = MessageAction; messageActionGameScore#92a72876 game_id:long score:int = MessageAction; @@ -184,11 +184,13 @@ messageActionSecureValuesSent#d95c6154 types:Vector = MessageAc messageActionContactSignUp#f3f25f76 = MessageAction; messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; -messageActionInviteToGroupCall#76b9f11a call:InputGroupCall users:Vector = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; 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; +dialog#a8edd0f5 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 unread_reactions_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; photoEmpty#2331b22d id:long = Photo; @@ -206,10 +208,10 @@ 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#df969c2d id:int bytes:bytes = auth.ExportedAuthorization; +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; inputNotifyUsers#193b4417 = InputNotifyPeer; @@ -220,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; @@ -233,14 +235,16 @@ inputReportReasonOther#c1e4a2b1 = ReportReason; inputReportReasonCopyright#9b89f93a = ReportReason; inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; -userFull#139a9a77 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 = 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#f911c994 user_id:int mutual:Bool = Contact; +contact#145ade0b user_id:long mutual:Bool = Contact; -importedContact#d0028438 user_id:int client_id:long = ImportedContact; +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; -contactStatus#d3680c61 user_id:int status:UserStatus = ContactStatus; +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; @@ -287,64 +291,64 @@ inputMessagesFilterPinned#1bb00451 = MessagesFilter; updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; updateMessageID#4e90bfd6 id:int random_id:long = Update; updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; -updateUserTyping#5c486927 user_id:int action:SendMessageAction = Update; -updateChatUserTyping#86cadb6c chat_id:int from_id:Peer action:SendMessageAction = Update; +updateUserTyping#c01e857f user_id:long action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; updateChatParticipants#7761198 participants:ChatParticipants = Update; -updateUserStatus#1bfbd823 user_id:int status:UserStatus = Update; -updateUserName#a7332b73 user_id:int first_name:string last_name:string username:string = Update; -updateUserPhoto#95313b0c user_id:int date:int photo:UserProfilePhoto previous:Bool = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#c3f202e0 user_id:long first_name:string last_name:string username:string = Update; +updateUserPhoto#f227868c user_id:long date:int photo:UserProfilePhoto previous:Bool = Update; updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; updateEncryptedChatTyping#1710f156 chat_id:int = Update; updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; -updateChatParticipantAdd#ea4b0e5c chat_id:int user_id:int inviter_id:int date:int version:int = Update; -updateChatParticipantDelete#6e5f8c22 chat_id:int user_id:int version:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; updateDcOptions#8e5e9873 dc_options:Vector = Update; updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; updateServiceNotification#ebe46819 flags:# popup:flags.0?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; -updateUserPhone#12b9417b user_id:int phone:string = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; updateReadHistoryInbox#9c974fdf flags:# folder_id:flags.0?int peer:Peer max_id:int still_unread_count:int pts:int pts_count:int = Update; updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; updateReadMessagesContents#68c13933 messages:Vector pts:int pts_count:int = Update; -updateChannelTooLong#eb0467fb flags:# channel_id:int pts:flags.0?int = Update; -updateChannel#b6d45656 channel_id:int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; -updateReadChannelInbox#330b5424 flags:# folder_id:flags.0?int channel_id:int max_id:int still_unread_count:int pts:int = Update; -updateDeleteChannelMessages#c37521c9 channel_id:int messages:Vector pts:int pts_count:int = Update; -updateChannelMessageViews#98a12b4b channel_id:int id:int views:int = Update; -updateChatParticipantAdmin#b6901959 chat_id:int user_id:int is_admin:Bool version:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true order:Vector = Update; updateStickerSets#43ae3dec = Update; updateSavedGifs#9375341e = Update; -updateBotInlineQuery#3f2038db flags:# query_id:long user_id:int query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; -updateBotInlineSend#e48f964 flags:# user_id:int query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; -updateBotCallbackQuery#e73547e1 flags:# query_id:long user_id:int peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; -updateInlineBotCallbackQuery#f9d27a5a flags:# query_id:long user_id:int msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; -updateReadChannelOutbox#25d6c9c7 channel_id:int max_id:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; updateDraftMessage#ee2bb969 peer:Peer draft:DraftMessage = Update; updateReadFeaturedStickers#571d2742 = Update; updateRecentStickers#9a422c20 = Update; updateConfig#a229dd06 = Update; updatePtsChanged#3354678f = Update; -updateChannelWebPage#40771900 channel_id:int webpage:WebPage pts:int pts_count:int = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; -updateBotShippingQuery#e0cdc940 query_id:long user_id:int payload:bytes shipping_address:PostAddress = Update; -updateBotPrecheckoutQuery#5d2f3aa9 flags:# query_id:long user_id:int payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; updateLangPackTooLong#46560264 lang_code:string = Update; updateLangPack#56022f4d difference:LangPackDifference = Update; updateFavedStickers#e511996d = Update; -updateChannelReadMessagesContents#89893b45 channel_id:int messages:Vector = Update; +updateChannelReadMessagesContents#44bdd535 channel_id:long messages:Vector = Update; updateContactsReset#7084a7be = Update; -updateChannelAvailableMessages#70db6837 channel_id:int available_min_id:int = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; updateDialogUnreadMark#e16459c3 flags:# unread:flags.0?true peer:DialogPeer = Update; updateMessagePoll#aca1657b flags:# poll_id:long poll:flags.0?Poll results:PollResults = Update; updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; @@ -356,26 +360,30 @@ updateDeleteScheduledMessages#90866cee peer:Peer messages:Vector = Update; updateTheme#8216fba3 theme:Theme = Update; updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; updateLoginToken#564fe691 = Update; -updateMessagePollVote#37f69f0b poll_id:long user_id:int options:Vector qts:int = Update; +updateMessagePollVote#106395c9 poll_id:long user_id:long options:Vector qts:int = Update; updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; updateDialogFilterOrder#a5d72105 order:Vector = Update; updateDialogFilters#3504914f = Update; updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; -updateChannelMessageForwards#6e8a84df channel_id:int id:int forwards:int = Update; -updateReadChannelDiscussionInbox#1cc7de54 flags:# channel_id:int top_msg_id:int read_max_id:int broadcast_id:flags.0?int broadcast_post:flags.0?int = Update; -updateReadChannelDiscussionOutbox#4638a26c channel_id:int top_msg_id:int read_max_id:int = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; updatePeerBlocked#246a4b22 peer_id:Peer blocked:Bool = Update; -updateChannelUserTyping#6b171718 flags:# channel_id:int top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; -updatePinnedChannelMessages#8588878b flags:# pinned:flags.0?true channel_id:int messages:Vector pts:int pts_count:int = Update; -updateChat#1330a196 chat_id:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; -updateGroupCall#a45eb99b chat_id:int call:GroupCall = Update; +updateGroupCall#14b24500 chat_id:long call:GroupCall = Update; updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; -updateChatParticipant#f3b3781f flags:# chat_id:int date:int actor_id:int user_id:int prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; -updateChannelParticipant#7fecb1ec flags:# channel_id:int date:int actor_id:int user_id:int prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; -updateBotStopped#7f9488a user_id:int date:int stopped:Bool qts:int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +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; @@ -385,8 +393,8 @@ updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_mess updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; updatesTooLong#e317af7e = Updates; -updateShortMessage#faeff833 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:int message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?int reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; -updateShortChatMessage#1157b858 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:int chat_id:int message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?int reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; updateShort#78d4dec1 update:Update date:int = Updates; updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; @@ -412,9 +420,9 @@ help.noAppUpdate#c45a6536 = help.AppUpdate; help.inviteText#18cb9f78 message:string = help.InviteText; encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; -encryptedChatWaiting#3bf703dc id:int access_hash:long date:int admin_id:int participant_id:int = EncryptedChat; -encryptedChatRequested#62718a82 flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:int participant_id:int g_a:bytes = EncryptedChat; -encryptedChat#fa56ce36 id:int access_hash:long date:int admin_id:int participant_id:int g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; @@ -464,6 +472,9 @@ sendMessageRecordRoundAction#88f27fbc = SendMessageAction; sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; speakingInGroupCallAction#d92c2285 = SendMessageAction; sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; @@ -491,17 +502,17 @@ inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; -inputPrivacyValueAllowChatParticipants#4c81c1ba chats:Vector = InputPrivacyRule; -inputPrivacyValueDisallowChatParticipants#d82363af chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; privacyValueAllowContacts#fffe1bac = PrivacyRule; privacyValueAllowAll#65427b82 = PrivacyRule; -privacyValueAllowUsers#4d5bbe0c users:Vector = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; privacyValueDisallowContacts#f888fa1a = PrivacyRule; privacyValueDisallowAll#8b73e763 = PrivacyRule; -privacyValueDisallowUsers#c7f49b7 users:Vector = PrivacyRule; -privacyValueAllowChatParticipants#18be796b chats:Vector = PrivacyRule; -privacyValueDisallowChatParticipants#acae0690 chats:Vector = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; @@ -516,12 +527,12 @@ documentAttributeFilename#15590068 file_name:string = DocumentAttribute; documentAttributeHasStickers#9801d2f7 = DocumentAttribute; messages.stickersNotModified#f1749a22 = messages.Stickers; -messages.stickers#e4599bbd hash:int stickers:Vector = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; messages.allStickersNotModified#e86602c3 = messages.AllStickers; -messages.allStickers#edfd405f hash:int sets:Vector = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; @@ -530,11 +541,11 @@ 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#ad2641f8 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 = account.Password; +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; account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; @@ -544,10 +555,10 @@ auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; -chatInviteExported#6e24fc9d flags:# revoked:flags.0?true permanent:flags.5?true link:string admin_id:int 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; @@ -555,14 +566,16 @@ inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +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; +stickerSet#d7df217a flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true animated:flags.5?true videos:flags.6?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; -botInfo#98e81d3a user_id:int description:string commands:Vector = BotInfo; +botInfo#1b74b335 user_id:long description:string commands:Vector = BotInfo; keyboardButton#a2fa4880 text:string = KeyboardButton; keyboardButtonUrl#258aff05 text:string url:string = KeyboardButton; @@ -575,12 +588,14 @@ 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; replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; -replyKeyboardForceReply#f4108aa0 flags:# single_use:flags.1?true selective:flags.2?true = ReplyMarkup; -replyKeyboardMarkup#3502758c flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true rows:Vector = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true rows:Vector placeholder:flags.3?string = ReplyMarkup; replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; @@ -594,7 +609,7 @@ messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; messageEntityCode#28a20571 offset:int length:int = MessageEntity; messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; -messageEntityMentionName#352dca58 offset:int length:int user_id:int = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; @@ -602,10 +617,11 @@ 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#afeb712e channel_id:int access_hash:long = InputChannel; -inputChannelFromMessage#2a286531 peer:InputPeer msg_id:int channel_id:int = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; @@ -618,11 +634,11 @@ updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:fl channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; -channelParticipant#15ebac1d user_id:int date:int = ChannelParticipant; -channelParticipantSelf#a3289a6d user_id:int inviter_id:int date:int = ChannelParticipant; -channelParticipantCreator#447dca4b flags:# user_id:int admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; -channelParticipantAdmin#ccbebbaf flags:# can_edit:flags.0?true self:flags.1?true user_id:int inviter_id:flags.1?int promoted_by:int date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; -channelParticipantBanned#50a1dfd6 flags:# left:flags.0?true peer:Peer kicked_by:int date:int banned_rights:ChatBannedRights = ChannelParticipant; +channelParticipant#c00c07c0 user_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; channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; @@ -642,7 +658,7 @@ channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; -messages.savedGifs#2e0709a5 hash:int gifs:Vector = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; inputBotInlineMessageMediaAuto#3380c786 flags:# message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; @@ -676,17 +692,20 @@ 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; messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; @@ -713,10 +732,10 @@ draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; draftMessage#fd8e711f flags:# no_webpage:flags.1?true reply_to_msg_id:flags.0?int message:string entities:flags.3?Vector date:int = DraftMessage; messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; -messages.featuredStickers#b6abc341 hash:int count:int sets:Vector unread:Vector = messages.FeaturedStickers; +messages.featuredStickers#84c02310 hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; messages.recentStickersNotModified#b17f890 = messages.RecentStickers; -messages.recentStickers#22f3afb3 hash:int packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; @@ -736,7 +755,7 @@ game#bdf9653b flags:# id:long access_hash:long short_name:string title:string de inputGameID#32c3e77 id:long access_hash:long = InputGame; inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; -highScore#58fffcd0 pos:int user_id:int score:int = HighScore; +highScore#73a379eb pos:int user_id:long score:int = HighScore; messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; @@ -816,14 +835,14 @@ inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; -payments.paymentForm#8d0b2415 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:int invoice:Invoice provider_id:int url:string native_provider:flags.4?string native_params:flags.4?DataJSON saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?PaymentSavedCredentials users:Vector = payments.PaymentForm; +payments.paymentForm#1694761b flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?PaymentSavedCredentials users:Vector = payments.PaymentForm; payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; -payments.paymentReceipt#10b555d0 flags:# date:int bot_id:int provider_id:int title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; @@ -841,10 +860,10 @@ inputStickerSetItem#ffa0a496 flags:# document:InputDocument emoji:string mask_co inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; phoneCallEmpty#5366c915 id:long = PhoneCall; -phoneCallWaiting#1b8f4ad1 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; -phoneCallRequested#87eabb53 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; -phoneCallAccepted#997c454a flags:# video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_b:bytes protocol:PhoneCallProtocol = PhoneCall; -phoneCall#8742ae7f flags:# p2p_allowed:flags.5?true video:flags.6?true id:long access_hash:long date:int admin_id:int participant_id:int g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#967f7c67 flags:# p2p_allowed:flags.5?true video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int = PhoneCall; phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; phoneConnection#9d4c17c0 id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; @@ -887,7 +906,7 @@ channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputSticker channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; -channelAdminLogEventActionChangeLinkedChat#a26f881b prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; @@ -901,21 +920,25 @@ 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#3b5a3e40 id:long date:int user_id:int action:ChannelAdminLogEventAction = ChannelAdminLogEvent; +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; messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; -messages.favedStickers#f37f2f16 hash:int packs:Vector stickers:Vector = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; -recentMeUrlUser#8dbc3336 url:string user_id:int = RecentMeUrl; -recentMeUrlChat#a01b22f9 url:string chat_id:int = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; @@ -923,7 +946,7 @@ help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vect inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; -webAuthorization#cac943f2 hash:long bot_id:int domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; @@ -939,7 +962,7 @@ dialogPeer#e56dbf05 peer:Peer = DialogPeer; dialogPeerFolder#514519e2 folder_id:int = DialogPeer; messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; -messages.foundStickerSets#5108d648 hash:int sets:Vector = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; fileHash#6242c773 offset:int limit:int hash:bytes = FileHash; @@ -1058,7 +1081,7 @@ poll#86e18161 id:long flags:# closed:flags.0?true public_voters:flags.1?true mul pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; -pollResults#badcc1a3 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; +pollResults#dcb82ea3 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; chatOnlines#f041e250 onlines:int = ChatOnlines; @@ -1073,9 +1096,9 @@ inputWallPaperSlug#72091c80 slug:string = InputWallPaper; inputWallPaperNoFile#967a462e id:long = InputWallPaper; account.wallPapersNotModified#1c199183 = account.WallPapers; -account.wallPapers#702b65a9 hash:int wallpapers:Vector = 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; @@ -1115,10 +1138,10 @@ restrictionReason#d072acb4 platform:string reason:string text:string = Restricti inputTheme#3c5693e9 id:long access_hash:long = InputTheme; inputThemeSlug#f5890df1 slug:string = InputTheme; -theme#28f1114 flags:# creator:flags.0?true default:flags.1?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?ThemeSettings installs_count: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#7f676421 hash:int themes:Vector = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; @@ -1134,15 +1157,15 @@ baseThemeNight#b7b31ea8 = BaseTheme; baseThemeTinted#6d5f77ee = BaseTheme; baseThemeArctic#5b11125a = BaseTheme; -inputThemeSettings#bd507cd1 flags:# base_theme:BaseTheme accent_color:int message_top_color:flags.0?int message_bottom_color:flags.0?int wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; -themeSettings#9c14984a flags:# base_theme:BaseTheme accent_color:int message_top_color:flags.0?int message_bottom_color:flags.0?int wallpaper:flags.1?WallPaper = ThemeSettings; +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; -messageUserVote#a28e5559 user_id:int option:bytes date:int = MessageUserVote; -messageUserVoteInputOption#36377430 user_id:int date:int = MessageUserVote; -messageUserVoteMultiple#e8fe0de user_id:int options:Vector date:int = MessageUserVote; +messageUserVote#34d247b4 user_id:long option:bytes date:int = MessageUserVote; +messageUserVoteInputOption#3ca5b0ec user_id:long date:int = MessageUserVote; +messageUserVoteMultiple#8a65e557 user_id:long options:Vector date:int = MessageUserVote; messages.votesList#823f649 flags:# count:int votes:Vector users:Vector next_offset:flags.0?string = messages.VotesList; @@ -1173,11 +1196,11 @@ help.promoData#8c39793f flags:# proxy:flags.0?true expires:int peer:Peer chats:V videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; -statsGroupTopPoster#18f3d0f7 user_id:int messages:int avg_chars:int = StatsGroupTopPoster; +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; -statsGroupTopAdmin#6014f412 user_id:int deleted:int kicked:int banned:int = StatsGroupTopAdmin; +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; -statsGroupTopInviter#31962a4c user_id:int invitations:int = StatsGroupTopInviter; +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; @@ -1194,18 +1217,18 @@ messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:fla messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; -messages.discussionMessage#f5dd8f9d flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int chats:Vector users:Vector = messages.DiscussionMessage; +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; messageReplyHeader#a6d57763 flags:# reply_to_msg_id:int reply_to_peer_id:flags.0?Peer reply_to_top_id:flags.1?int = MessageReplyHeader; -messageReplies#4128faac flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?int max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; stats.messageStats#8999f295 views_graph:StatsGraph = stats.MessageStats; groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; -groupCall#653dbaad flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int version:int = GroupCall; +groupCall#d597650c flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int = GroupCall; inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; @@ -1227,7 +1250,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#1e3e6680 user_id:int 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; @@ -1236,7 +1259,7 @@ messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invit messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; -chatAdminWithInvites#dfd2330f admin_id:int invites_count:int revoked_invites_count:int = ChatAdminWithInvites; +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; @@ -1248,7 +1271,63 @@ phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvi groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; -groupCallParticipantVideo#78e41663 flags:# paused:flags.0?true endpoint:string source_groups:Vector = GroupCallParticipantVideo; +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +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#4f2b9479 flags:# min:flags.0?true can_see_list:flags.2?true results:Vector recent_reactions:flags.1?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats: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; + +messages.translateNoResult#67ca4737 = messages.TranslatedText; +messages.translateResultText#a214f7d0 text:string = messages.TranslatedText; + +messagePeerReaction#51b67eff flags:# big:flags.0?true unread:flags.1?true peer_id:Peer reaction:string = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; ---functions--- @@ -1263,30 +1342,31 @@ 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#e3ef9613 id:int bytes:bytes = auth.Authorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; -auth.recoverPassword#4ea56e92 code:string = auth.Authorization; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; auth.resendCode#3ef1a9bf phone_number:string phone_code_hash:string = auth.SentCode; auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; -auth.exportLoginToken#b1b41517 api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; -account.registerDevice#68976c6f flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; -account.unregisterDevice#3076c4bf token_type:int token:string other_uids:Vector = Bool; +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; account.resetNotifySettings#db7e1747 = Bool; account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; account.updateStatus#6628562c offline:Bool = Bool; -account.getWallPapers#aabb1763 hash:int = account.WallPapers; +account.getWallPapers#7967d36 hash:long = account.WallPapers; account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; account.checkUsername#2714d86c username:string = Bool; account.updateUsername#3e0bdd7c username:string = User; @@ -1313,8 +1393,8 @@ account.getAllSecureValues#b288bc7d = Vector; account.getSecureValue#73665bc2 types:Vector = Vector; account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; account.deleteSecureValue#b880bc4b types:Vector = Bool; -account.getAuthorizationForm#b86ba8e1 bot_id:int scope:string public_key:string = account.AuthorizationForm; -account.acceptAuthorization#e7027c94 bot_id:int scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; account.sendVerifyEmailCode#7011509f email:string = account.SentEmailCode; @@ -1335,26 +1415,31 @@ 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#285946f8 format:string hash:int = account.Themes; +account.getThemes#7206e458 format:string hash:long = account.Themes; account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; account.getContentSettings#8b9b4dae = account.ContentSettings; account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +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#2caa4a42 hash:int = Vector; +contacts.getContactIDs#7adc669d hash:long = Vector; contacts.getStatuses#c4a353ee = Vector; -contacts.getContacts#c023849f hash:int = contacts.Contacts; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; contacts.deleteContacts#96a0e00 id:Vector = Updates; contacts.deleteByPhones#1013fd9e phones:Vector = Bool; @@ -1363,7 +1448,7 @@ contacts.unblock#bea65d50 id:InputPeer = Bool; contacts.getBlocked#f57c350f offset:int limit:int = contacts.Blocked; contacts.search#11f812d8 q:string limit:int = contacts.Found; contacts.resolveUsername#f93ccba3 username:string = contacts.ResolvedPeer; -contacts.getTopPeers#d4982db5 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true offset:int limit:int hash:int = contacts.TopPeers; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true offset:int limit:int hash:long = contacts.TopPeers; contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; contacts.resetSaved#879537f1 = Bool; contacts.getSaved#82f1e39f = Vector; @@ -1372,28 +1457,29 @@ contacts.addContact#e8f463d0 flags:# add_phone_privacy_exception:flags.0?true id contacts.acceptContact#f831a20f id:InputUser = Updates; contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; messages.getMessages#63c66506 id:Vector = messages.Messages; -messages.getDialogs#a0ee3b73 flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:int = messages.Dialogs; -messages.getHistory#dcbb8260 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:int = messages.Messages; -messages.search#c352eec 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:int = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +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 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#3c6aa187 id:Vector = messages.Chats; -messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; -messages.editChatTitle#dc452855 chat_id:int title:string = Updates; -messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates; -messages.addChatUser#f9a0aa09 chat_id:int user_id:InputUser fwd_limit:int = Updates; -messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#f24753e3 chat_id:long user_id:InputUser fwd_limit:int = Updates; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; messages.createChat#9cb126e users:Vector title:string = Updates; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; @@ -1407,27 +1493,27 @@ messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long da messages.receivedQueue#55a5bb66 max_qts:int = Vector; messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; -messages.getStickers#43d4f2c emoticon:string hash:int = messages.Stickers; -messages.getAllStickers#1c9618b1 hash:int = messages.AllStickers; +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; messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; -messages.editChatAdmin#a9e69f2e chat_id:int user_id:InputUser is_admin:Bool = Bool; -messages.migrateChat#15a3b8e3 chat_id:int = Updates; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; messages.searchGlobal#4bc6589a flags:# folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; messages.reorderStickerSets#78337739 flags:# masks:flags.0?true order:Vector = Bool; messages.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document; -messages.getSavedGifs#83bf3d52 hash:int = messages.SavedGifs; +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; @@ -1436,20 +1522,20 @@ messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; messages.saveDraft#bc39e14b flags:# no_webpage:flags.1?true reply_to_msg_id:flags.0?int peer:InputPeer message:string entities:flags.3?Vector = Bool; messages.getAllDrafts#6a3f8d65 = Updates; -messages.getFeaturedStickers#2dacca4f hash:int = messages.FeaturedStickers; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; messages.readFeaturedStickers#5b118126 id:Vector = Bool; -messages.getRecentStickers#5ea192c9 flags:# attached:flags.0?true hash:int = messages.RecentStickers; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true offset_id:long limit:int = messages.ArchivedStickers; -messages.getMaskStickers#65b8c79f hash:int = messages.AllStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; -messages.getCommonChats#d0a48c4 user_id:InputUser max_id:int limit:int = messages.Chats; -messages.getAllChats#eba80ff0 except_ids:Vector = messages.Chats; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getAllChats#875f74be except_ids:Vector = messages.Chats; messages.getWebPage#32ca8f91 url:string hash:int = WebPage; messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; @@ -1458,14 +1544,14 @@ messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?stri messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia; messages.sendScreenshotNotification#c97df020 peer:InputPeer reply_to_msg_id:int random_id:long = Updates; -messages.getFavedStickers#21ce0b0e hash:int = messages.FavedStickers; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; 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#bbc45b09 peer:InputPeer limit:int hash:int = 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.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +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#c2b7d08b flags:# exclude_featured:flags.0?true q:string hash:int = messages.FoundStickerSets; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; messages.getSplitRanges#1cff7e08 = Vector; messages.markDialogUnread#c286d98f flags:# unread:flags.0?true peer:InputDialogPeer = Bool; messages.getDialogUnreadMarks#22e24e22 = Vector; @@ -1474,7 +1560,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; @@ -1485,7 +1570,7 @@ messages.getSearchCounters#732eef00 peer:InputPeer filters:Vector = messages.Messages; messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; @@ -1495,12 +1580,12 @@ messages.getDialogFilters#f19ed96d = Vector; messages.getSuggestedDialogFilters#a29cd42c = Vector; messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; -messages.getOldFeaturedStickers#5fe7025b offset:int limit:int hash:int = messages.FeaturedStickers; -messages.getReplies#24b581ba peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:int = messages.Messages; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; -messages.deleteChat#83247d11 chat_id:int = Bool; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; @@ -1508,13 +1593,31 @@ 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:# big:flags.1?true 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; +messages.translateText#24ce6dee flags:# peer:flags.0?InputPeer msg_id:flags.0?int text:flags.1?string from_lang:flags.2?string to_lang:string = messages.TranslatedText; +messages.getUnreadReactions#e85bae1a peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#82e251d7 peer:InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1559,10 +1662,9 @@ 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#123e05e9 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:int = channels.ChannelParticipants; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; channels.getChannels#a7f6bbb id:Vector = messages.Chats; channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; @@ -1593,10 +1695,16 @@ channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint addr channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; 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; -bots.setBotCommands#805d46f6 commands:Vector = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; payments.getPaymentForm#8a333c8d flags:# peer:InputPeer msg_id:int theme_params:flags.0?DataJSON = payments.PaymentForm; payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; @@ -1606,11 +1714,13 @@ payments.getSavedInfo#227d824b = payments.SavedInfo; payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; payments.getBankCardData#2e79d779 number:string = payments.BankCardData; -stickers.createStickerSet#f1036780 flags:# masks:flags.0?true animated:flags.1?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector = messages.StickerSet; +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true animated:flags.1?true videos:flags.4?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; stickers.setStickerSetThumb#9a364e30 stickerset:InputStickerSet thumb:InputDocument = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; phone.getCallConfig#55451fa9 = DataJSON; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; @@ -1621,16 +1731,16 @@ phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall durati phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; -phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates; phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; phone.toggleGroupCallSettings#74bbb43d flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool = Updates; -phone.getGroupCall#c7cb017 call:InputGroupCall = phone.GroupCall; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; -phone.toggleGroupCallRecord#c02a66d7 flags:# start:flags.0?true call:InputGroupCall title:flags.1?string = Updates; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; @@ -1640,6 +1750,8 @@ phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#deb3abbf peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; @@ -1656,4 +1768,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 129 +// LAYER 139 diff --git a/telethon_generator/data/errors.csv b/telethon_generator/data/errors.csv index 85571513..5584b049 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 @@ -27,6 +27,7 @@ BANK_CARD_NUMBER_INVALID,400,Incorrect credit card number BASE_PORT_LOC_INVALID,400,Base port location invalid BANNED_RIGHTS_INVALID,400,"You cannot use that set of permissions in this request, i.e. restricting view_messages as a default" BOTS_TOO_MUCH,400,There are too many bots in this chat/channel +BOT_ONESIDE_NOT_AVAIL,400, BOT_CHANNELS_NA,400,Bots can't edit admin privileges BOT_COMMAND_DESCRIPTION_INVALID,400,"The command description was empty, too long or had invalid characters used" BOT_COMMAND_INVALID,400, @@ -40,6 +41,7 @@ BOT_MISSING,400,This method can only be run by a bot BOT_PAYMENTS_DISABLED,400,This method can only be run by a bot BOT_POLLS_DISABLED,400,You cannot create polls under a bot account BOT_RESPONSE_TIMEOUT,400,The bot did not answer to the callback query in time +BOT_SCORE_NOT_MODIFIED,400, BROADCAST_CALLS_DISABLED,400, BROADCAST_FORBIDDEN,403,The request cannot be used in broadcast channels BROADCAST_ID_INVALID,400,The channel is invalid @@ -55,16 +57,20 @@ CALL_PEER_INVALID,400,The provided call peer object is invalid CALL_PROTOCOL_FLAGS_INVALID,400,Call protocol flags invalid CDN_METHOD_INVALID,400,This method cannot be invoked on a CDN server. Refer to https://core.telegram.org/cdn#schema for available methods CHANNELS_ADMIN_PUBLIC_TOO_MUCH,400,"You're admin of too many public channels, make some channels private to change the username of this channel" +CHANNELS_ADMIN_LOCATED_TOO_MUCH,400, CHANNELS_TOO_MUCH,400,You have joined too many channels/supergroups +CHANNEL_ADD_INVALID,400, CHANNEL_BANNED,400,The channel is banned CHANNEL_INVALID,400,"Invalid channel object. Make sure to pass the right types, for instance making sure that the request is designed for channels or otherwise look for a different one more suited" CHANNEL_PRIVATE,400,The channel specified is private and you lack permission to access it. Another reason may be that you were banned from it CHANNEL_PUBLIC_GROUP_NA,403,channel/supergroup not available +CHANNEL_TOO_LARGE,406, CHAT_ABOUT_NOT_MODIFIED,400,About text has not changed 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 @@ -76,6 +82,7 @@ CHAT_SEND_INLINE_FORBIDDEN,400,You cannot send inline results in this chat CHAT_SEND_MEDIA_FORBIDDEN,403,You can't send media in this chat CHAT_SEND_STICKERS_FORBIDDEN,403,You can't send stickers in this chat CHAT_TITLE_EMPTY,400,No chat title provided +CHAT_TOO_BIG,400, CHAT_WRITE_FORBIDDEN,403,You can't write in this chat CHP_CALL_FAIL,500,The statistics cannot be retrieved at this time CODE_EMPTY,400,The provided code is empty @@ -90,6 +97,7 @@ CONNECTION_SYSTEM_EMPTY,400,Connection system empty CONNECTION_SYSTEM_LANG_CODE_EMPTY,400,The system language string was empty during connection CONTACT_ID_INVALID,400,The provided contact ID is invalid CONTACT_NAME_EMPTY,400,The provided contact name cannot be empty +CURRENCY_TOTAL_AMOUNT_INVALID,400, DATA_INVALID,400,Encrypted data invalid DATA_JSON_INVALID,400,The provided JSON data is invalid DATE_EMPTY,400,Date empty @@ -98,7 +106,9 @@ 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 EMOTICON_INVALID,400,The specified emoticon cannot be used or was not a emoticon EMOTICON_STICKERPACK_MISSING,400,The emoticon sticker pack you are trying to get is missing @@ -111,28 +121,31 @@ 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 FIELD_NAME_EMPTY,400,The field with the name FIELD_NAME is missing 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 +FILTER_NOT_SUPPORTED,400, +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 @@ -144,7 +157,10 @@ GIF_CONTENT_TYPE_INVALID,400, GIF_ID_INVALID,400,The provided GIF ID is invalid GRAPH_INVALID_RELOAD,400, GRAPH_OUTDATED_RELOAD,400,"Data can't be used for the channel statistics, graphs outdated" +GROUPCALL_ADD_PARTICIPANTS_FAILED,500, +GROUPCALL_ALREADY_DISCARDED,400, GROUPCALL_FORBIDDEN,403, +GROUPCALL_INVALID,400, GROUPCALL_JOIN_MISSING,400, GROUPCALL_SSRC_DUPLICATE_MUCH,400, GROUPCALL_NOT_MODIFIED,400, @@ -166,11 +182,13 @@ 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 INVITE_HASH_INVALID,400,The invite hash is invalid +LANG_CODE_INVALID,400, LANG_PACK_INVALID,400,The provided language pack is invalid LASTNAME_INVALID,400,The last name is invalid LIMIT_INVALID,400,An invalid limit was provided. See https://core.telegram.org/api/files#downloading-files @@ -208,7 +226,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 @@ -221,12 +239,15 @@ 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 PASSWORD_MISSING,400,The account must have 2-factor authentication enabled (a password) before this method can be used +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)" @@ -238,7 +259,8 @@ 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_NOT_OCCUPIED,400, 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. @@ -251,25 +273,31 @@ PHOTO_CONTENT_TYPE_INVALID,400, PHOTO_CONTENT_URL_EMPTY,400,The content from the URL used as a photo appears to be empty or has caused another HTTP error PHOTO_CROP_SIZE_SMALL,400,Photo is too small PHOTO_EXT_INVALID,400,The extension of the photo is invalid +PHOTO_FILE_MISSING,400, PHOTO_ID_INVALID,400,Photo id is invalid PHOTO_INVALID,400,Photo invalid PHOTO_INVALID_DIMENSIONS,400,The photo dimensions are invalid (hint: `pip install pillow` for `send_file` to resize images) PHOTO_SAVE_FILE_INVALID,400,The photo you tried to send cannot be saved by Telegram. A reason may be that it exceeds 10MB. Try resizing it locally PHOTO_THUMB_URL_EMPTY,400,The URL used as a thumbnail appears to be empty or has caused another HTTP error PIN_RESTRICTED,400,You can't pin messages in private chats with other people +PINNED_DIALOGS_TOO_MUCH,400, +POLL_ANSWER_INVALID,400, POLL_ANSWERS_INVALID,400,The poll did not have enough answers or had too many POLL_OPTION_DUPLICATE,400,A duplicate option was sent in the same poll POLL_OPTION_INVALID,400,A poll option used invalid data (the data may be too long) 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 -PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN,406,"Similar to a flood wait, must wait {minutes} minutes" +POLL_VOTE_REQUIRED,403, +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 PTS_CHANGE_EMPTY,500,No PTS change +PUBLIC_KEY_REQUIRED,400, QUERY_ID_EMPTY,400,The query ID is empty QUERY_ID_INVALID,400,The query ID is invalid QUERY_TOO_SHORT,400,The query string is too short +QUIZ_ANSWER_MISSING,400, QUIZ_CORRECT_ANSWERS_EMPTY,400,A quiz must specify one correct answer QUIZ_CORRECT_ANSWERS_TOO_MUCH,400,There can only be one correct answer QUIZ_CORRECT_ANSWER_INVALID,400,The correct answer is not an existing answer @@ -279,16 +307,18 @@ RANDOM_ID_INVALID,400,A provided random ID is invalid RANDOM_LENGTH_INVALID,400,Random length invalid RANGES_INVALID,400,Invalid range provided REACTION_EMPTY,400,No reaction provided -REACTION_INVALID,400,Invalid reaction provided (only emoji are allowed) +REACTION_INVALID,400,Invalid reaction provided (only emoji are allowed) or you cannot use the reaction in the specified chat REFLECTOR_NOT_AVAILABLE,400,Invalid call reflector server REG_ID_GENERATE_FAILED,500,Failure while generating registration ID REPLY_MARKUP_GAME_EMPTY,400,The provided reply markup for the game is empty REPLY_MARKUP_INVALID,400,The provided reply markup is invalid REPLY_MARKUP_TOO_LONG,400,The data embedded in the reply markup buttons was too much +RESET_REQUEST_MISSING,400, RESULTS_TOO_MUCH,400,"You sent too many results, see https://core.telegram.org/bots/api#answerinlinequery for the current limit" RESULT_ID_DUPLICATE,400,Duplicated IDs on the sent results. Make sure to use unique IDs RESULT_ID_INVALID,400,The given result cannot be used to send the selection to the bot RESULT_TYPE_INVALID,400,Result type invalid +REVOTE_NOT_ALLOWED,400, RIGHT_FORBIDDEN,403,Either your admin rights do not allow you to do this or you passed the wrong rights combination (some rights only apply to channels and vice versa) RPC_CALL_FAIL,500,"Telegram is having internal issues, please try again later." RPC_MCGET_FAIL,500,"Telegram is having internal issues, please try again later." @@ -298,28 +328,37 @@ SCHEDULE_DATE_INVALID,400, SCHEDULE_DATE_TOO_LATE,400,The date you tried to schedule is too far in the future (last known limit of 1 year and a few hours) SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes online if their privacy does not show this information SCHEDULE_TOO_MUCH,400,You cannot schedule more messages in this chat (last known limit of 100 per chat) +SCORE_INVALID,400, 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 -SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat +SHORT_NAME_INVALID,400, +SHORT_NAME_OCCUPIED,400, +SLOWMODE_MULTI_MSGS_DISABLED,400, +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} +STICKERPACK_STICKERS_TOO_MUCH,400, 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 +STICKERS_TOO_MUCH,400, STICKER_DOCUMENT_INVALID,400,"The sticker file was invalid (this file has failed Telegram internal checks, make sure to use the correct format and comply with https://core.telegram.org/animated_stickers)" STICKER_EMOJI_INVALID,400,Sticker emoji invalid STICKER_FILE_INVALID,400,Sticker file invalid +STICKER_GIF_DIMENSIONS,400, STICKER_ID_INVALID,400,The provided sticker ID is invalid STICKER_INVALID,400,The provided sticker is invalid STICKER_PNG_DIMENSIONS,400,Sticker png dimensions invalid @@ -328,25 +367,32 @@ STICKER_TGS_NODOC,400, STICKER_TGS_NOTGS,400,Stickers must be a tgs file but the used file was not a tgs STICKER_THUMB_PNG_NOPNG,400,Stickerset thumb must be a png file but the used file was not png STICKER_THUMB_TGS_NOTGS,400,Stickerset thumb must be a tgs file but the used file was not tgs +STICKER_VIDEO_NOWEBM,400, +STICKER_VIDEO_BIG,400, 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 TIMEOUT,500,A timeout occurred while fetching data from the worker +TITLE_INVALID,400, THEME_INVALID,400,Theme invalid THEME_MIME_INVALID,400,"You cannot create this theme, the mime-type is invalid" TMP_PASSWORD_DISABLED,400,The temporary password is disabled TMP_PASSWORD_INVALID,400,Password auth needs to be regenerated TOKEN_INVALID,400,The provided token is invalid TTL_DAYS_INVALID,400,The provided TTL is invalid +TTL_MEDIA_INVALID,400,The provided media cannot be used with a TTL TTL_PERIOD_INVALID,400,The provided TTL Period is invalid TYPES_EMPTY,400,The types field is empty 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) +USAGE_LIMIT_INVALID,400, 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]""" USERNAME_NOT_MODIFIED,400,The username is not different from the current username @@ -355,6 +401,7 @@ USERNAME_OCCUPIED,400,The username is already taken USERS_TOO_FEW,400,"Not enough users (to create a chat, for example)" USERS_TOO_MUCH,400,"The maximum number of users has been exceeded (to create a chat, for example)" USER_ADMIN_INVALID,400,Either you're not an admin or you tried to ban an admin that you didn't promote +USER_ALREADY_INVITED,400, USER_ALREADY_PARTICIPANT,400,The authenticated user is already a participant of the chat USER_BANNED_IN_CHANNEL,400,You're banned from sending messages in supergroups/channels USER_BLOCKED,400,User blocked @@ -370,7 +417,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 @@ -388,4 +435,4 @@ WEBDOCUMENT_URL_INVALID,400,The given URL cannot be used WEBPAGE_CURL_FAILED,400,Failure while fetching the webpage with cURL WEBPAGE_MEDIA_EMPTY,400,Webpage media empty WORKER_BUSY_TOO_LONG_RETRY,500,Telegram workers are too busy to respond immediately -YOU_BLOCKED_USER,400,You blocked this user \ No newline at end of file +YOU_BLOCKED_USER,400,You blocked this user diff --git a/telethon_generator/data/friendly.csv b/telethon_generator/data/friendly.csv index 950a8bd7..524746d5 100644 --- a/telethon_generator/data/friendly.csv +++ b/telethon_generator/data/friendly.csv @@ -23,5 +23,4 @@ messages.MessageMethods,delete_messages,channels.deleteMessages messages.deleteM messages.MessageMethods,send_read_acknowledge,messages.readMentions channels.readHistory messages.readHistory updates.UpdateMethods,catch_up,updates.getDifference updates.getChannelDifference uploads.UploadMethods,send_file,messages.sendMedia messages.sendMultiMedia messages.uploadMedia -uploads.UploadMethods,upload_file,upload.saveFilePart upload.saveBigFilePart -users.UserMethods,get_entity,users.getUsers messages.getChats channels.getChannels contacts.resolveUsername +users.UserMethods,get_profile,users.getUsers messages.getChats channels.getChannels contacts.resolveUsername diff --git a/telethon_generator/data/methods.csv b/telethon_generator/data/methods.csv index 2e96877d..0d595987 100644 --- a/telethon_generator/data/methods.csv +++ b/telethon_generator/data/methods.csv @@ -6,12 +6,13 @@ account.checkUsername,user,USERNAME_INVALID account.confirmPasswordEmail,user, account.confirmPhone,user,CODE_HASH_INVALID PHONE_CODE_EMPTY account.createTheme,user,THEME_MIME_INVALID -account.deleteAccount,user,2FA_CONFIRM_WAIT_X +account.declinePasswordReset,user,RESET_REQUEST_MISSING +account.deleteAccount,user,2FA_CONFIRM_WAIT_0 account.deleteSecureValue,user, account.finishTakeoutSession,user, account.getAccountTTL,user, account.getAllSecureValues,user, -account.getAuthorizationForm,user, +account.getAuthorizationForm,user,PUBLIC_KEY_REQUIRED account.getAuthorizations,user, account.getAutoDownloadSettings,user, account.getContactSignUpNotification,user, @@ -56,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 @@ -69,6 +70,7 @@ auth.acceptLoginToken,user, auth.bindTempAuthKey,both,ENCRYPTED_MESSAGE_INVALID INPUT_REQUEST_TOO_LONG TEMP_AUTH_KEY_EMPTY TIMEOUT auth.cancelCode,user,PHONE_NUMBER_INVALID auth.checkPassword,user,PASSWORD_HASH_INVALID +auth.checkRecoveryPassword,user,PASSWORD_RECOVERY_EXPIRED auth.dropTempAuthKeys,both, auth.exportAuthorization,both,DC_ID_INVALID auth.exportLoginToken,user, @@ -76,25 +78,26 @@ auth.importAuthorization,both,AUTH_BYTES_INVALID USER_ID_INVALID auth.importBotAuthorization,both,ACCESS_TOKEN_EXPIRED ACCESS_TOKEN_INVALID API_ID_INVALID auth.importLoginToken,user,AUTH_TOKEN_ALREADY_ACCEPTED AUTH_TOKEN_EXPIRED AUTH_TOKEN_INVALID auth.logOut,both, -auth.recoverPassword,user,CODE_EMPTY -auth.requestPasswordRecovery,user,PASSWORD_EMPTY -auth.resendCode,user,PHONE_NUMBER_INVALID +auth.recoverPassword,user,CODE_EMPTY NEW_SETTINGS_INVALID +auth.requestPasswordRecovery,user,PASSWORD_EMPTY PASSWORD_RECOVERY_NA +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 auth.signUp,user,FIRSTNAME_INVALID MEMBER_OCCUPY_PRIMARY_LOC_FAILED PHONE_CODE_EMPTY PHONE_CODE_EXPIRED PHONE_CODE_INVALID PHONE_NUMBER_FLOOD PHONE_NUMBER_INVALID PHONE_NUMBER_OCCUPIED REG_ID_GENERATE_FAILED bots.answerWebhookJSONQuery,bot,QUERY_ID_INVALID USER_BOT_INVALID bots.sendCustomRequest,bot,USER_BOT_INVALID -bots.setBotCommands,bot,BOT_COMMAND_DESCRIPTION_INVALID BOT_COMMAND_INVALID +bots.setBotCommands,bot,BOT_COMMAND_DESCRIPTION_INVALID BOT_COMMAND_INVALID LANG_CODE_INVALID channels.checkUsername,user,CHANNEL_INVALID CHAT_ID_INVALID USERNAME_INVALID -channels.createChannel,user,CHAT_TITLE_EMPTY USER_RESTRICTED -channels.deleteChannel,user,CHANNEL_INVALID CHANNEL_PRIVATE +channels.convertToGigagroup,user,PARTICIPANTS_TOO_FEW +channels.createChannel,user,CHANNELS_ADMIN_LOCATED_TOO_MUCH CHAT_TITLE_EMPTY USER_RESTRICTED +channels.deleteChannel,user,CHANNEL_INVALID CHANNEL_PRIVATE CHANNEL_TOO_LARGE channels.deleteHistory,user, channels.deleteMessages,both,CHANNEL_INVALID CHANNEL_PRIVATE MESSAGE_DELETE_FORBIDDEN 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.editBanned,both,CHANNEL_INVALID CHANNEL_ADD_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED USER_ADMIN_INVALID USER_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 @@ -121,6 +124,7 @@ channels.togglePreHistoryHidden,user,CHAT_LINK_EXISTS channels.toggleSignatures,user,CHANNEL_INVALID channels.toggleSlowMode,user,SECONDS_INVALID channels.updateUsername,user,CHANNELS_ADMIN_PUBLIC_TOO_MUCH CHANNEL_INVALID CHAT_ADMIN_REQUIRED USERNAME_INVALID USERNAME_OCCUPIED +channels.viewSponsoredMessage,user,UNKNOWN_ERROR contacts.acceptContact,user, contacts.addContact,user,CONTACT_NAME_EMPTY contacts.block,user,CONTACT_ID_INVALID @@ -136,6 +140,7 @@ contacts.getTopPeers,user,TYPES_EMPTY contacts.importContacts,user, contacts.resetSaved,user, contacts.resetTopPeerRating,user,PEER_ID_INVALID +contacts.resolvePhone,user,PHONE_NOT_OCCUPIED contacts.resolveUsername,both,AUTH_KEY_PERM_EMPTY SESSION_PASSWORD_NEEDED USERNAME_INVALID USERNAME_NOT_OCCUPIED contacts.search,user,QUERY_TOO_SHORT SEARCH_QUERY_EMPTY TIMEOUT contacts.toggleTopPeers,user, @@ -193,9 +198,9 @@ 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 USAGE_LIMIT_INVALID messages.faveSticker,user,STICKER_ID_INVALID -messages.forwardMessages,both,BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_SEND_GIFS_FORBIDDEN CHAT_SEND_MEDIA_FORBIDDEN CHAT_SEND_STICKERS_FORBIDDEN CHAT_WRITE_FORBIDDEN GROUPED_MEDIA_INVALID INPUT_USER_DEACTIVATED MEDIA_EMPTY MESSAGE_IDS_EMPTY MESSAGE_ID_INVALID PEER_ID_INVALID PTS_CHANGE_EMPTY RANDOM_ID_DUPLICATE RANDOM_ID_INVALID SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER +messages.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 SLOWMODE_MULTI_MSGS_DISABLED TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER messages.getAllChats,user, messages.getAllDrafts,user, messages.getAllStickers,user, @@ -223,19 +228,21 @@ messages.getInlineGameHighScores,bot,MESSAGE_ID_INVALID USER_BOT_REQUIRED messages.getMaskStickers,user, messages.getMessageEditData,user,MESSAGE_AUTHOR_REQUIRED PEER_ID_INVALID messages.getMessages,both, +messages.getMessagesReadParticipants,user,CHAT_TOO_BIG MESSAGE_ID_INVALID messages.getMessagesViews,user,CHANNEL_PRIVATE CHAT_ID_INVALID PEER_ID_INVALID messages.getOnlines,user, messages.getPeerDialogs,user,CHANNEL_PRIVATE PEER_ID_INVALID messages.getPeerSettings,user,CHANNEL_INVALID PEER_ID_INVALID messages.getPinnedDialogs,user, messages.getPollResults,user, -messages.getPollVotes,user,BROADCAST_FORBIDDEN +messages.getPollVotes,user,BROADCAST_FORBIDDEN POLL_VOTE_REQUIRED messages.getRecentLocations,user, messages.getRecentStickers,user, messages.getSavedGifs,user, messages.getScheduledHistory,user, messages.getScheduledMessages,user, messages.getSearchCounters,user, +messages.getSearchResultsCalendar,user,FILTER_NOT_SUPPORTED messages.getSplitRanges,user, messages.getStatsURL,user, messages.getStickerSet,both,EMOTICON_STICKERPACK_MISSING STICKERSET_INVALID @@ -246,7 +253,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 @@ -275,28 +282,29 @@ messages.sendEncrypted,user,CHAT_ID_INVALID DATA_INVALID ENCRYPTION_DECLINED MSG messages.sendEncryptedFile,user,MSG_WAIT_FAILED messages.sendEncryptedService,user,DATA_INVALID ENCRYPTION_DECLINED MSG_WAIT_FAILED USER_IS_BLOCKED messages.sendInlineBotResult,user,CHAT_SEND_INLINE_FORBIDDEN CHAT_WRITE_FORBIDDEN INLINE_RESULT_EXPIRED PEER_ID_INVALID QUERY_ID_EMPTY SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY -messages.sendMedia,both,BOT_PAYMENTS_DISABLED BOT_POLLS_DISABLED BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_SEND_MEDIA_FORBIDDEN CHAT_WRITE_FORBIDDEN EMOTICON_INVALID EXTERNAL_URL_INVALID FILE_PARTS_INVALID FILE_PART_LENGTH_INVALID FILE_REFERENCE_EMPTY FILE_REFERENCE_EXPIRED GAME_BOT_INVALID INPUT_USER_DEACTIVATED MEDIA_CAPTION_TOO_LONG MEDIA_EMPTY PAYMENT_PROVIDER_INVALID PEER_ID_INVALID PHOTO_EXT_INVALID PHOTO_INVALID_DIMENSIONS PHOTO_SAVE_FILE_INVALID POLL_ANSWERS_INVALID POLL_OPTION_DUPLICATE POLL_QUESTION_INVALID QUIZ_CORRECT_ANSWERS_EMPTY QUIZ_CORRECT_ANSWERS_TOO_MUCH QUIZ_CORRECT_ANSWER_INVALID QUIZ_MULTIPLE_INVALID RANDOM_ID_DUPLICATE SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH STORAGE_CHECK_FAILED TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT VIDEO_CONTENT_TYPE_INVALID WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY +messages.sendMedia,both,BOT_PAYMENTS_DISABLED BOT_POLLS_DISABLED BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_SEND_MEDIA_FORBIDDEN CHAT_WRITE_FORBIDDEN CURRENCY_TOTAL_AMOUNT_INVALID EMOTICON_INVALID EXTERNAL_URL_INVALID FILE_PARTS_INVALID FILE_PART_LENGTH_INVALID FILE_REFERENCE_EMPTY FILE_REFERENCE_EXPIRED GAME_BOT_INVALID INPUT_USER_DEACTIVATED MEDIA_CAPTION_TOO_LONG MEDIA_EMPTY PAYMENT_PROVIDER_INVALID PEER_ID_INVALID PHOTO_EXT_INVALID PHOTO_INVALID_DIMENSIONS PHOTO_SAVE_FILE_INVALID POLL_ANSWER_INVALID POLL_ANSWERS_INVALID POLL_OPTION_DUPLICATE POLL_QUESTION_INVALID QUIZ_CORRECT_ANSWERS_EMPTY QUIZ_CORRECT_ANSWERS_TOO_MUCH QUIZ_CORRECT_ANSWER_INVALID QUIZ_MULTIPLE_INVALID RANDOM_ID_DUPLICATE SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH STORAGE_CHECK_FAILED TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT VIDEO_CONTENT_TYPE_INVALID WEBPAGE_CURL_FAILED WEBPAGE_MEDIA_EMPTY messages.sendMessage,both,AUTH_KEY_DUPLICATED BOT_DOMAIN_INVALID BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_RESTRICTED CHAT_WRITE_FORBIDDEN ENTITIES_TOO_LONG ENTITY_MENTION_USER_INVALID INPUT_USER_DEACTIVATED MESSAGE_EMPTY MESSAGE_TOO_LONG MSG_ID_INVALID PEER_ID_INVALID POLL_OPTION_INVALID RANDOM_ID_DUPLICATE REPLY_MARKUP_INVALID REPLY_MARKUP_TOO_LONG SCHEDULE_BOT_NOT_ALLOWED SCHEDULE_DATE_TOO_LATE SCHEDULE_STATUS_PRIVATE SCHEDULE_TOO_MUCH TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER messages.sendMultiMedia,both,MULTI_MEDIA_TOO_LONG SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH messages.sendScheduledMessages,user, -messages.sendVote,user,MESSAGE_POLL_CLOSED OPTION_INVALID +messages.sendVote,user,MESSAGE_POLL_CLOSED OPTIONS_TOO_MUCH OPTION_INVALID REVOTE_NOT_ALLOWED messages.setBotCallbackAnswer,both,QUERY_ID_INVALID URL_INVALID messages.setBotPrecheckoutResults,both,ERROR_TEXT_EMPTY messages.setBotShippingResults,both,QUERY_ID_INVALID +messages.setChatTheme,user,EMOJI_INVALID EMOJI_NOT_MODIFIED PEER_ID_INVALID messages.setEncryptedTyping,user,CHAT_ID_INVALID -messages.setGameScore,bot,PEER_ID_INVALID USER_BOT_REQUIRED +messages.setGameScore,bot,BOT_SCORE_NOT_MODIFIED PEER_ID_INVALID SCORE_INVALID USER_BOT_REQUIRED messages.setHistoryTTL,user,CHAT_NOT_MODIFIED TTL_PERIOD_INVALID -messages.setInlineBotResults,bot,ARTICLE_TITLE_EMPTY AUDIO_CONTENT_URL_EMPTY AUDIO_TITLE_EMPTY BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID DOCUMENT_INVALID GIF_CONTENT_TYPE_INVALID MESSAGE_EMPTY NEXT_OFFSET_INVALID PHOTO_CONTENT_TYPE_INVALID PHOTO_CONTENT_URL_EMPTY PHOTO_THUMB_URL_EMPTY QUERY_ID_INVALID REPLY_MARKUP_INVALID RESULT_TYPE_INVALID SEND_MESSAGE_MEDIA_INVALID SEND_MESSAGE_TYPE_INVALID START_PARAM_INVALID STICKER_DOCUMENT_INVALID USER_BOT_INVALID VIDEO_TITLE_EMPTY WEBDOCUMENT_MIME_INVALID WEBDOCUMENT_URL_INVALID +messages.setInlineBotResults,bot,ARTICLE_TITLE_EMPTY AUDIO_CONTENT_URL_EMPTY AUDIO_TITLE_EMPTY BUTTON_DATA_INVALID BUTTON_TYPE_INVALID BUTTON_URL_INVALID DOCUMENT_INVALID FILE_CONTENT_TYPE_INVALID FILE_TITLE_EMPTY GIF_CONTENT_TYPE_INVALID MESSAGE_EMPTY NEXT_OFFSET_INVALID PHOTO_CONTENT_TYPE_INVALID PHOTO_CONTENT_URL_EMPTY PHOTO_THUMB_URL_EMPTY QUERY_ID_INVALID REPLY_MARKUP_INVALID RESULT_TYPE_INVALID SEND_MESSAGE_MEDIA_INVALID SEND_MESSAGE_TYPE_INVALID START_PARAM_INVALID STICKER_DOCUMENT_INVALID USER_BOT_INVALID VIDEO_TITLE_EMPTY WEBDOCUMENT_MIME_INVALID WEBDOCUMENT_URL_INVALID messages.setInlineGameScore,bot,MESSAGE_ID_INVALID USER_BOT_REQUIRED messages.setTyping,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ID_INVALID CHAT_WRITE_FORBIDDEN PEER_ID_INVALID USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT messages.startBot,user,BOT_INVALID PEER_ID_INVALID START_PARAM_EMPTY START_PARAM_INVALID messages.startHistoryImport,user,IMPORT_ID_INVALID -messages.toggleDialogPin,user,PEER_ID_INVALID +messages.toggleDialogPin,user,PEER_ID_INVALID PINNED_DIALOGS_TOO_MUCH messages.toggleStickerSets,user, messages.uninstallStickerSet,user,STICKERSET_INVALID messages.updateDialogFilter,user, messages.updateDialogFiltersOrder,user, -messages.updatePinnedMessage,both, +messages.updatePinnedMessage,both,BOT_ONESIDE_NOT_AVAIL messages.uploadEncryptedFile,user, messages.uploadMedia,both,BOT_MISSING MEDIA_INVALID PEER_ID_INVALID payments.clearSavedInfo,user, @@ -310,10 +318,12 @@ phone.acceptCall,user,CALL_ALREADY_ACCEPTED CALL_ALREADY_DECLINED CALL_OCCUPY_FA phone.confirmCall,user,CALL_ALREADY_DECLINED CALL_PEER_INVALID phone.createGroupCall,user,SCHEDULE_DATE_INVALID phone.discardCall,user,CALL_ALREADY_ACCEPTED CALL_PEER_INVALID +phone.discardGroupCallRequest,user,GROUPCALL_ALREADY_DISCARDED phone.editGroupCallParticipant,user,USER_VOLUME_INVALID phone.getCallConfig,user, -phone.inviteToGroupCall,user,GROUPCALL_FORBIDDEN -phone.joinGroupCall,user,GROUPCALL_SSRC_DUPLICATE_MUCH +phone.getGroupCallStreamChannels,user,GROUPCALL_INVALID +phone.inviteToGroupCall,user,GROUPCALL_FORBIDDEN USER_ALREADY_INVITED INVITE_FORBIDDEN_WITH_JOINAS +phone.joinGroupCall,user,GROUPCALL_ADD_PARTICIPANTS_FAILED GROUPCALL_SSRC_DUPLICATE_MUCH phone.joinGroupCallPresentation,user, PARTICIPANT_JOIN_MISSING phone.receivedCall,user,CALL_ALREADY_DECLINED CALL_PEER_INVALID phone.requestCall,user,CALL_PROTOCOL_FLAGS_INVALID PARTICIPANT_CALL_FAILED PARTICIPANT_VERSION_OUTDATED USER_ID_INVALID USER_IS_BLOCKED USER_PRIVACY_RESTRICTED @@ -323,21 +333,23 @@ phone.toggleGroupCallSettings,user,GROUPCALL_NOT_MODIFIED photos.deletePhotos,user, photos.getUserPhotos,both,MAX_ID_INVALID USER_ID_INVALID photos.updateProfilePhoto,user,PHOTO_ID_INVALID -photos.uploadProfilePhoto,user,ALBUM_PHOTOS_TOO_MANY FILE_PARTS_INVALID IMAGE_PROCESS_FAILED PHOTO_CROP_SIZE_SMALL PHOTO_EXT_INVALID VIDEO_FILE_INVALID +photos.uploadProfilePhoto,user,ALBUM_PHOTOS_TOO_MANY FILE_PARTS_INVALID IMAGE_PROCESS_FAILED PHOTO_CROP_SIZE_SMALL PHOTO_EXT_INVALID PHOTO_FILE_MISSING VIDEO_FILE_INVALID ping,both, reqDHParams,both, 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.addStickerToSet,bot,BOT_MISSING STICKERS_TOO_MUCH STICKERSET_INVALID STICKERPACK_STICKERS_TOO_MUCH STICKER_PNG_NOPNG STICKER_TGS_NOTGS stickers.changeStickerPosition,bot,BOT_MISSING STICKER_INVALID -stickers.createStickerSet,bot,BOT_MISSING PACK_SHORT_NAME_INVALID PACK_SHORT_NAME_OCCUPIED PEER_ID_INVALID SHORTNAME_OCCUPY_FAILED STICKERS_EMPTY STICKER_EMOJI_INVALID STICKER_FILE_INVALID STICKER_PNG_DIMENSIONS STICKER_PNG_NOPNG STICKER_TGS_NOTGS STICKER_THUMB_PNG_NOPNG STICKER_THUMB_TGS_NOTGS USER_ID_INVALID +stickers.checkShortName,user,SHORT_NAME_INVALID SHORT_NAME_OCCUPIED +stickers.createStickerSet,bot,BOT_MISSING PACK_SHORT_NAME_INVALID PACK_SHORT_NAME_OCCUPIED PEER_ID_INVALID SHORTNAME_OCCUPY_FAILED STICKERS_EMPTY STICKER_EMOJI_INVALID STICKER_FILE_INVALID STICKER_GIF_DIMENSIONS STICKER_PNG_DIMENSIONS STICKER_PNG_NOPNG STICKER_TGS_NOTGS STICKER_THUMB_PNG_NOPNG STICKER_THUMB_TGS_NOTGS STICKER_VIDEO_BIG STICKER_VIDEO_NOWEBM USER_ID_INVALID stickers.removeStickerFromSet,bot,BOT_MISSING STICKER_INVALID stickers.setStickerSetThumb,bot,STICKER_THUMB_PNG_NOPNG STICKER_THUMB_TGS_NOTGS +stickers.suggestShortName,user,TITLE_INVALID updates.getChannelDifference,both,CHANNEL_INVALID CHANNEL_PRIVATE CHANNEL_PUBLIC_GROUP_NA HISTORY_GET_FAILED PERSISTENT_TIMESTAMP_EMPTY PERSISTENT_TIMESTAMP_INVALID PERSISTENT_TIMESTAMP_OUTDATED RANGES_INVALID TIMEOUT updates.getDifference,both,AUTH_KEY_PERM_EMPTY CDN_METHOD_INVALID DATE_EMPTY NEED_MEMBER_INVALID PERSISTENT_TIMESTAMP_EMPTY PERSISTENT_TIMESTAMP_INVALID SESSION_PASSWORD_NEEDED STORE_INVALID_SCALAR_TYPE TIMEOUT updates.getState,both,AUTH_KEY_DUPLICATED MSGID_DECREASE_RETRY SESSION_PASSWORD_NEEDED TIMEOUT @@ -351,4 +363,4 @@ upload.saveBigFilePart,both,FILE_PARTS_INVALID FILE_PART_EMPTY FILE_PART_INVALID upload.saveFilePart,both,FILE_PART_EMPTY FILE_PART_INVALID INPUT_FETCH_FAIL SESSION_PASSWORD_NEEDED users.getFullUser,both,TIMEOUT USER_ID_INVALID users.getUsers,both,AUTH_KEY_PERM_EMPTY MEMBER_NO_LOCATION NEED_MEMBER_INVALID SESSION_PASSWORD_NEEDED TIMEOUT -users.setSecureValueErrors,bot, \ No newline at end of file +users.setSecureValueErrors,bot, 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..f52c497b 100644
--- a/telethon_generator/generators/tlobject.py
+++ b/telethon_generator/generators/tlobject.py
@@ -1,3 +1,4 @@
+import builtins
 import functools
 import os
 import re
@@ -15,11 +16,11 @@ AUTO_GEN_NOTICE = \
 
 AUTO_CASTS = {
     'InputPeer':
-        'utils.get_input_peer(await client.get_input_entity({}))',
+        'utils.get_input_peer(await client._get_input_peer({}))',
     'InputChannel':
-        'utils.get_input_channel(await client.get_input_entity({}))',
+        'utils.get_input_channel(await client._get_input_peer({}))',
     'InputUser':
-        'utils.get_input_user(await client.get_input_entity({}))',
+        'utils.get_input_user(await client._get_input_peer({}))',
 
     'InputDialogPeer': 'await client._get_input_dialog({})',
     'InputNotifyPeer': 'await client._get_input_notify({})',
@@ -32,7 +33,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 +53,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 +61,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')
@@ -83,6 +85,9 @@ def _write_modules(
             # Import struct for the .__bytes__(self) serialization
             builder.writeln('import struct')
 
+            # Import dataclasses in order to freeze the instances
+            builder.writeln('import dataclasses')
+
             # Import datetime for type hinting
             builder.writeln('from datetime import datetime')
 
@@ -124,7 +129,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 +167,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 +182,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)
@@ -179,28 +190,9 @@ def _write_source_code(tlobject, kind, builder, type_constructors):
 def _write_class_init(tlobject, kind, type_constructors, builder):
     builder.writeln()
     builder.writeln()
+    builder.writeln('@dataclasses.dataclass(init=False, frozen=True)')
     builder.writeln('class {}({}):', tlobject.class_name, kind)
 
-    # Class-level variable to store its Telegram's constructor ID
-    builder.writeln('CONSTRUCTOR_ID = {:#x}', tlobject.id)
-    builder.writeln('SUBCLASS_OF_ID = {:#x}',
-                    crc32(tlobject.result.encode('ascii')))
-    builder.writeln()
-
-    # Convert the args to string parameters, flags having =None
-    args = ['{}: {}{}'.format(
-        a.name, a.type_hint(), '=None' if a.is_flag or a.can_be_inferred else '')
-        for a in tlobject.real_args
-    ]
-
-    # Write the __init__ function if it has any argument
-    if not tlobject.real_args:
-        return
-
-    if any(a.name in __builtins__ for a in tlobject.real_args):
-        builder.writeln('# noinspection PyShadowingBuiltins')
-
-    builder.writeln("def __init__({}):", ', '.join(['self'] + args))
     builder.writeln('"""')
     if tlobject.is_function:
         builder.write(':returns {}: ', tlobject.result)
@@ -219,47 +211,83 @@ def _write_class_init(tlobject, kind, type_constructors, builder):
 
     builder.writeln('"""')
 
-    # Set the arguments
+    # 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:
-        if not arg.can_be_inferred:
-            builder.writeln('self.{0} = {0}', arg.name)
+        builder.write('{}{!r},', sep, arg.name)
+        sep = ' '
+    builder.writeln(')')
 
-        # Currently the only argument that can be
-        # inferred are those called 'random_id'
-        elif arg.name == 'random_id':
-            # Endianness doesn't really matter, and 'big' is shorter
-            code = "int.from_bytes(os.urandom({}), 'big', signed=True)" \
-                .format(8 if arg.type == 'long' else 4)
+    # Class-level variable to store its Telegram's constructor ID
+    builder.writeln('CONSTRUCTOR_ID = {:#x}', tlobject.id)
+    builder.writeln('SUBCLASS_OF_ID = {:#x}',
+                    crc32(tlobject.result.encode('ascii')))
+    builder.writeln()
 
-            if arg.is_vector:
-                # Currently for the case of "messages.forwardMessages"
-                # Ensure we can infer the length from id:Vector<>
-                if not next(a for a in tlobject.real_args
-                            if a.name == 'id').is_vector:
-                    raise ValueError(
-                        'Cannot infer list of random ids for ', tlobject
-                    )
-                code = '[{} for _ in range(len(id))]'.format(code)
+    # Because we're using __slots__ and frozen instances, we cannot have flags = None directly.
+    # See https://stackoverflow.com/q/50180735 (Python 3.10 does offer a solution).
+    # Write the __init__ function if it has any argument.
+    if tlobject.real_args:
+        # Convert the args to string parameters
+        for a in tlobject.real_args:
+            builder.writeln('{}: {}', a.name, a.type_hint())
 
-            builder.writeln(
-                "self.random_id = random_id if random_id "
-                "is not None else {}", code
-            )
-        else:
-            raise ValueError('Cannot infer a value for ', arg)
+        # Convert the args to string parameters, flags having =None
+        args = ['{}: {}{}'.format(
+            a.name, a.type_hint(), '=None' if a.is_flag or a.can_be_inferred else '')
+            for a in tlobject.real_args
+        ]
 
-    builder.end_block()
+        if any(a.name in dir(builtins) for a in tlobject.real_args):
+            builder.writeln('# noinspection PyShadowingBuiltins')
+
+        builder.writeln("def __init__({}):", ', '.join(['self'] + args))
+
+        # Set the arguments
+        for arg in tlobject.real_args:
+            builder.writeln("object.__setattr__(self, '{0}', {0})", arg.name)
+
+        builder.end_block()
 
 
 def _write_resolve(tlobject, builder):
     if tlobject.is_function and any(
-            (arg.type in AUTO_CASTS
-             or ((arg.name, arg.type) in NAMED_AUTO_CASTS
-                 and tlobject.fullname not in NAMED_BLACKLIST))
-            for arg in tlobject.real_args
+        (arg.can_be_inferred
+            or arg.type in AUTO_CASTS
+            or ((arg.name, arg.type) in NAMED_AUTO_CASTS and tlobject.fullname not in NAMED_BLACKLIST))
+        for arg in tlobject.real_args
     ):
-        builder.writeln('async def resolve(self, client, utils):')
+        builder.writeln('async def _resolve(self, client, utils):')
+        builder.writeln('r = {}')  # hold replacements
+
         for arg in tlobject.real_args:
+            if arg.can_be_inferred:
+                builder.writeln('if self.{} is None:', arg.name)
+
+                # Currently the only argument that can be
+                # inferred are those called 'random_id'
+                if arg.name == 'random_id':
+                    # Endianness doesn't really matter, and 'big' is shorter
+                    code = "int.from_bytes(os.urandom({}), 'big', signed=True)" \
+                        .format(8 if arg.type == 'long' else 4)
+
+                    if arg.is_vector:
+                        # Currently for the case of "messages.forwardMessages"
+                        # Ensure we can infer the length from id:Vector<>
+                        if not next(a for a in tlobject.real_args if a.name == 'id').is_vector:
+                            raise ValueError('Cannot infer list of random ids for ', tlobject)
+
+                        code = '[{} for _ in range(len(self.id))]'.format(code)
+
+                    builder.writeln("r['{}'] = {}", arg.name, code)
+                else:
+                    raise ValueError('Cannot infer a value for ', arg)
+
+                builder.end_block()
+                continue
+
             ac = AUTO_CASTS.get(arg.type)
             if not ac:
                 ac = NAMED_AUTO_CASTS.get((arg.name, arg.type))
@@ -270,56 +298,20 @@ def _write_resolve(tlobject, builder):
                 builder.writeln('if self.{}:', arg.name)
 
             if arg.is_vector:
-                builder.writeln('_tmp = []')
-                builder.writeln('for _x in self.{0}:', arg.name)
-                builder.writeln('_tmp.append({})', ac.format('_x'))
+                builder.writeln("r['{}'] = []", arg.name)
+                builder.writeln('for x in self.{0}:', arg.name)
+                builder.writeln("r['{}'].append({})", arg.name, ac.format('x'))
                 builder.end_block()
-                builder.writeln('self.{} = _tmp', arg.name)
             else:
-                builder.writeln('self.{} = {}', arg.name,
-                              ac.format('self.' + arg.name))
+                builder.writeln("r['{}'] = {}", arg.name, ac.format('self.' + arg.name))
 
             if arg.is_flag:
                 builder.end_block()
+
+        builder.writeln('return dataclasses.replace(self, **r)')
         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):')
 
@@ -360,7 +352,7 @@ def _write_to_bytes(tlobject, builder):
 
 def _write_from_reader(tlobject, builder):
     builder.writeln('@classmethod')
-    builder.writeln('def from_reader(cls, reader):')
+    builder.writeln('def _from_reader(cls, reader):')
     for arg in tlobject.args:
         _write_arg_read_code(builder, arg, tlobject, name='_' + arg.name)
 
@@ -390,7 +382,7 @@ def _write_read_result(tlobject, builder):
 
     builder.end_block()
     builder.writeln('@staticmethod')
-    builder.writeln('def read_result(reader):')
+    builder.writeln('def _read_result(reader):')
     builder.writeln('reader.read_int()  # Vector ID')
     builder.writeln('return [reader.read_{}() '
                     'for _ in range(reader.read_int())]', m.group(1))
@@ -495,7 +487,7 @@ def _write_arg_to_bytes(builder, arg, tlobject, name=None):
         builder.write("struct.pack('