mirror of
https://github.com/LonamiWebs/Telethon.git
synced 2024-11-29 12:53:44 +03:00
Merge branch 'v2'
v2 is still not complete. A lot of cleanup still needs to be done. In particular, entities still need some care. However, most of it is there, and keeping up with two branches is annoying. This also lets me close a lot of issues to reduce noise and focus on the important ones. Closes #354 (input entities have been reworked). Closes #902 (sessions were overhauled). Closes #1125, #3253, #1589, #1634, #3150, #3668 (updates are reworked, gaps are properly handled now). Closes #1169 (2.0 is now merged). Closes #1311 (proper usage should not trigger this issue on the reworked connection code). Closes #1327 (there have been some stringify changes). Closes #1330 (gaps are now detected). Closes #1366 (sessions are now async). Closes #1476, #1484 (asyncio open connection is no longer used). Closes #1529 (commonmark is now used). Closes #1721 (update gaps are now properly handled). Closes #1724 (a gap that fixes this will eventually trigger). Closes #3006 (force_sms is gone). Closes #3041 (a clean implementation to get difference now exists). Closes #3049 (commonmark is now used). Closes #3111 (to_dict has changed). Closes #3117 (SMS is no longer an option). Closes #3171 (connectivity bug is unlikely to be a bug in the library). Closes #3206 (Telethon cannot really fix broken SSL). Closes #3214, #3257, #3661 (not enough information). Closes #3215 (this had already been fixed). Closes #3230, #3674 (entities were reworked). Closes #3234, #3238, #3245, #3258, #3264 (the layer has been updated). Closes #3242 (bot-API file IDs have been removed). Closes #3244 (the error is now documented). Closes #3249 (errors have been reworked).
This commit is contained in:
commit
ed70991bf3
2
.github/ISSUE_TEMPLATE/bug-report.md
vendored
2
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -14,7 +14,7 @@ assignees: ''
|
|||
|
||||
**Code that causes the issue**
|
||||
```python
|
||||
from telethon.sync import TelegramClient
|
||||
from telethon import TelegramClient
|
||||
...
|
||||
|
||||
```
|
||||
|
|
28
.github/workflows/python.yml
vendored
28
.github/workflows/python.yml
vendored
|
@ -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
|
11
.gitignore
vendored
11
.gitignore
vendored
|
@ -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
|
||||
|
|
20
README.rst
20
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'
|
||||
|
||||
async def main():
|
||||
client = TelegramClient('session_name', api_id, api_hash)
|
||||
client.start()
|
||||
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):
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -8,14 +8,15 @@ 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():
|
||||
async with TelegramClient('anon', api_id, api_hash).start() as client:
|
||||
# Getting information about yourself
|
||||
me = await client.get_me()
|
||||
|
||||
|
@ -70,8 +71,7 @@ use these if possible.
|
|||
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.
|
||||
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())
|
||||
|
|
|
@ -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'
|
||||
|
||||
async def main():
|
||||
# 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 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
|
||||
with bot:
|
||||
...
|
||||
async with TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) as bot:
|
||||
... # bot is your client
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
To get a bot account, you need to talk
|
||||
|
@ -116,11 +121,9 @@ with `@BotFather <https://t.me/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:
|
||||
|
|
|
@ -58,84 +58,6 @@ What are asyncio basics?
|
|||
loop.run_until_complete(main())
|
||||
|
||||
|
||||
What does telethon.sync do?
|
||||
===========================
|
||||
|
||||
The moment you import any of these:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from telethon import sync, ...
|
||||
# or
|
||||
from telethon.sync import ...
|
||||
# or
|
||||
import telethon.sync
|
||||
|
||||
The ``sync`` module rewrites most ``async def``
|
||||
methods in Telethon to something similar to this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def new_method():
|
||||
result = original_method()
|
||||
if loop.is_running():
|
||||
# the loop is already running, return the await-able to the user
|
||||
return result
|
||||
else:
|
||||
# the loop is not running yet, so we can run it for the user
|
||||
return loop.run_until_complete(result)
|
||||
|
||||
|
||||
That means you can do this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
print(client.get_me().username)
|
||||
|
||||
Instead of this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
me = client.loop.run_until_complete(client.get_me())
|
||||
print(me.username)
|
||||
|
||||
# or, using asyncio's default loop (it's the same)
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop() # == client.loop
|
||||
me = loop.run_until_complete(client.get_me())
|
||||
print(me.username)
|
||||
|
||||
|
||||
As you can see, it's a lot of boilerplate and noise having to type
|
||||
``run_until_complete`` all the time, so you can let the magic module
|
||||
to rewrite it for you. But notice the comment above: it won't run
|
||||
the loop if it's already running, because it can't. That means this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def main():
|
||||
# 3. the loop is running here
|
||||
print(
|
||||
client.get_me() # 4. this will return a coroutine!
|
||||
.username # 5. this fails, coroutines don't have usernames
|
||||
)
|
||||
|
||||
loop.run_until_complete( # 2. run the loop and the ``main()`` coroutine
|
||||
main() # 1. calling ``async def`` "returns" a coroutine
|
||||
)
|
||||
|
||||
|
||||
Will fail. So if you're inside an ``async def``, then the loop is
|
||||
running, and if the loop is running, you must ``await`` things yourself:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def main():
|
||||
print((await client.get_me()).username)
|
||||
|
||||
loop.run_until_complete(main())
|
||||
|
||||
|
||||
What are async, await and coroutines?
|
||||
=====================================
|
||||
|
||||
|
@ -275,7 +197,7 @@ in it. So if you want to run *other* code, create tasks for it:
|
|||
|
||||
loop.create_task(clock())
|
||||
...
|
||||
client.run_until_disconnected()
|
||||
await client.run_until_disconnected()
|
||||
|
||||
This creates a task for a clock that prints the time every second.
|
||||
You don't need to use `client.run_until_disconnected()
|
||||
|
@ -344,19 +266,6 @@ When you use a library, you're not limited to use only its methods. You can
|
|||
combine all the libraries you want. People seem to forget this simple fact!
|
||||
|
||||
|
||||
Why does client.start() work outside async?
|
||||
===========================================
|
||||
|
||||
Because it's so common that it's really convenient to offer said
|
||||
functionality by default. This means you can set up all your event
|
||||
handlers and start the client without worrying about loops at all.
|
||||
|
||||
Using the client in a ``with`` block, `start
|
||||
<telethon.client.auth.AuthMethods.start>`, `run_until_disconnected
|
||||
<telethon.client.updates.UpdateMethods.run_until_disconnected>`, and
|
||||
`disconnect <telethon.client.telegrambaseclient.TelegramBaseClient.disconnect>`
|
||||
all support this.
|
||||
|
||||
Where can I read more?
|
||||
======================
|
||||
|
||||
|
|
|
@ -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::
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
================================
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.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::
|
||||
|
|
|
@ -13,6 +13,14 @@ it can take advantage of new goodies!
|
|||
|
||||
.. contents:: List of All Versions
|
||||
|
||||
|
||||
Complete overhaul of the library (v2.0)
|
||||
=======================================
|
||||
|
||||
(inc and link all of migration guide)
|
||||
properly-typed enums for filters and actions
|
||||
|
||||
|
||||
Rushed release to fix login (v1.24)
|
||||
===================================
|
||||
|
||||
|
|
775
readthedocs/misc/v2-migration-guide.rst
Normal file
775
readthedocs/misc/v2-migration-guide.rst
Normal file
|
@ -0,0 +1,775 @@
|
|||
=========================
|
||||
Version 2 Migration Guide
|
||||
=========================
|
||||
|
||||
Version 2 represents the second major version change, breaking compatibility
|
||||
with old code beyond the usual raw API changes in order to clean up a lot of
|
||||
the technical debt that has grown on the project.
|
||||
|
||||
This document documents all the things you should be aware of when migrating from Telethon version
|
||||
1.x to 2.0 onwards. It is sorted roughly from the "likely most impactful changes" to "there's a
|
||||
good chance you were not relying on this to begin with".
|
||||
|
||||
**Please read this document in full before upgrading your code to Telethon 2.0.**
|
||||
|
||||
|
||||
Python 3.5 is no longer supported
|
||||
---------------------------------
|
||||
|
||||
The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.7.
|
||||
|
||||
This also means workarounds for 3.6 and below have been dropped.
|
||||
|
||||
|
||||
User, chat and channel identifiers are now 64-bit numbers
|
||||
---------------------------------------------------------
|
||||
|
||||
`Layer 133 <https://diff.telethon.dev/?from=132&to=133>`__ changed *a lot* of identifiers from
|
||||
``int`` to ``long``, meaning they will no longer fit in 32 bits, and instead require 64 bits.
|
||||
|
||||
If you were storing these identifiers somewhere size did matter (for example, a database), you
|
||||
will need to migrate that to support the new size requirement of 8 bytes.
|
||||
|
||||
For the full list of types changed, please review the above link.
|
||||
|
||||
|
||||
Peer IDs, including chat_id and sender_id, no longer follow bot API conventions
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Both the ``utils.get_peer_id`` and ``client.get_peer_id`` methods no longer have an ``add_mark``
|
||||
parameter. Both will always return the original ID as given by Telegram. This should lead to less
|
||||
confusion. However, it also means that an integer ID on its own no longer embeds the information
|
||||
about the type (did it belong to a user, chat, or channel?), so ``utils.get_peer`` can no longer
|
||||
guess the type from just a number.
|
||||
|
||||
Because it's not possible to know what other changes Telegram will do with identifiers, it's
|
||||
probably best to get used to transparently storing whatever value they send along with the type
|
||||
separatedly.
|
||||
|
||||
As far as I can tell, user, chat and channel identifiers are globally unique, meaning a channel
|
||||
and a user cannot share the same identifier. The library currently makes this assumption. However,
|
||||
this is merely an observation (I have never heard of such a collision exist), and Telegram could
|
||||
change at any time. If you want to be on the safe side, you're encouraged to save a pair of type
|
||||
and identifier, rather than just the number.
|
||||
|
||||
// TODO we DEFINITELY need to provide a way to "upgrade" old ids
|
||||
// TODO and storing type+number by hand is a pain, provide better alternative
|
||||
|
||||
|
||||
Synchronous compatibility mode has been removed
|
||||
-----------------------------------------------
|
||||
|
||||
The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been
|
||||
removed. This implies:
|
||||
|
||||
* The ``telethon.sync`` module is gone.
|
||||
* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported.
|
||||
Most notably, you can no longer do ``with client``. It must be ``async with client`` now.
|
||||
* The "smart" behaviour of the following methods has been removed and now they no longer work in
|
||||
a synchronous context when the ``asyncio`` event loop was not running. This means they now need
|
||||
to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``):
|
||||
* ``start``
|
||||
* ``disconnect``
|
||||
* ``run_until_disconnected``
|
||||
|
||||
// TODO provide standalone alternative for this?
|
||||
|
||||
|
||||
Complete overhaul of session files
|
||||
----------------------------------
|
||||
|
||||
If you were using third-party libraries to deal with sessions, you will need to wait for those to
|
||||
be updated. The library will automatically upgrade the SQLite session files to the new version,
|
||||
and the ``StringSession`` remains backward-compatible. The sessions can now be async.
|
||||
|
||||
In case you were relying on the tables used by SQLite (even though these should have been, and
|
||||
will still need to be, treated as an implementation detail), here are the changes:
|
||||
|
||||
* The ``sessions`` table is now correctly split into ``datacenter`` and ``session``.
|
||||
``datacenter`` contains information about a Telegram datacenter, along with its corresponding
|
||||
authorization key, and ``session`` contains information about the update state and user.
|
||||
* The ``entities`` table is now called ``entity`` and stores the ``type`` separatedly.
|
||||
* The ``update_state`` table is now split into ``session`` and ``channel``, which can contain
|
||||
a per-channel ``pts``.
|
||||
|
||||
Because **the new version does not cache usernames, phone numbers and display names**, using these
|
||||
in method calls is now quite expensive. You *should* migrate your code to do the Right Thing and
|
||||
start using identifiers rather than usernames, phone numbers or invite links. This is both simpler
|
||||
and more reliable, because while a user identifier won't change, their username could.
|
||||
|
||||
You can use the following snippet to make a JSON backup (alternatively, you could just copy the
|
||||
``.session`` file and keep it around) in case you want to preserve the cached usernames:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import sqlite, json
|
||||
with sqlite3.connect('your.session') as conn, open('entities.json', 'w', encoding='utf-8') as fp:
|
||||
json.dump([
|
||||
{'id': id, 'hash': hash, 'username': username, 'phone': phone, 'name': name, 'date': date}
|
||||
for (id, hash, username, phone, name, date)
|
||||
in conn.execute('select id, hash, username, phone, name, date from entities')
|
||||
], fp)
|
||||
|
||||
The following public methods or properties have also been removed from ``SQLiteSession`` because
|
||||
they no longer make sense:
|
||||
|
||||
* ``list_sessions``. You can ``glob.glob('*.session')`` instead.
|
||||
* ``clone``.
|
||||
|
||||
And the following, which were inherited from ``MemorySession``:
|
||||
|
||||
* ``delete``. You can ``os.remove`` the file instead (preferably after ``client.log_out()``).
|
||||
``client.log_out()`` also no longer deletes the session file (it can't as there's no method).
|
||||
* ``set_dc``.
|
||||
* ``dc_id``.
|
||||
* ``server_address``.
|
||||
* ``port``.
|
||||
* ``auth_key``.
|
||||
* ``takeout_id``.
|
||||
* ``get_update_state``.
|
||||
* ``set_update_state``.
|
||||
* ``process_entities``.
|
||||
* ``get_entity_rows_by_phone``.
|
||||
* ``get_entity_rows_by_username``.
|
||||
* ``get_entity_rows_by_name``.
|
||||
* ``get_entity_rows_by_id``.
|
||||
* ``get_input_entity``.
|
||||
* ``cache_file``.
|
||||
* ``get_file``.
|
||||
|
||||
You also can no longer set ``client.session.save_entities = False``. The entities must be saved
|
||||
for the library to work properly. If you still don't want it, you should subclass the session and
|
||||
override the methods to do nothing.
|
||||
|
||||
|
||||
Complete overhaul of errors
|
||||
---------------------------
|
||||
|
||||
The following error name have changed to follow a better naming convention (clearer acronyms):
|
||||
|
||||
* ``RPCError`` is now ``RpcError``.
|
||||
* ``InvalidDCError`` is now ``InvalidDcError`` (lowercase ``c``).
|
||||
|
||||
The base errors no longer have a ``.message`` field at the class-level. Instead, it is now an
|
||||
attribute at the instance level (meaning you cannot do ``BadRequestError.message``, it must be
|
||||
``bad_request_err.message`` where ``isinstance(bad_request_err, BadRequestError)``).
|
||||
|
||||
The ``.message`` will gain its value at the time the error is constructed, rather than being
|
||||
known beforehand.
|
||||
|
||||
The parameter order for ``RpcError`` and all its subclasses are now ``(code, message, request)``,
|
||||
as opposed to ``(message, request, code)``.
|
||||
|
||||
Because Telegram errors can be added at any time, the library no longer generate a fixed set of
|
||||
them. This means you can no longer use ``dir`` to get a full list of them. Instead, the errors
|
||||
are automatically generated depending on the name you use for the error, with the following rules:
|
||||
|
||||
* Numbers are removed from the name. The Telegram error ``FLOOD_WAIT_42`` is transformed into
|
||||
``FLOOD_WAIT_``.
|
||||
* Underscores are removed from the name. ``FLOOD_WAIT_`` becomes ``FLOODWAIT``.
|
||||
* Everything is lowercased. ``FLOODWAIT`` turns into ``floodwait``.
|
||||
* While the name ends with ``error``, this suffix is removed.
|
||||
|
||||
The only exception to this rule is ``2FA_CONFIRM_WAIT_0``, which is transformed as
|
||||
``twofaconfirmwait`` (read as ``TwoFaConfirmWait``).
|
||||
|
||||
What all this means is that, if Telegram raises a ``FLOOD_WAIT_42``, you can write the following:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from telethon.errors import FloodWaitError
|
||||
|
||||
try:
|
||||
await client.send_message(chat, message)
|
||||
except FloodWaitError as e:
|
||||
print(f'Flood! wait for {e.seconds} seconds')
|
||||
|
||||
Essentially, old code will keep working, but now you have the freedom to define even yet-to-be
|
||||
discovered errors. This makes use of `PEP 562 <https://www.python.org/dev/peps/pep-0562/>`__ 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 `<markdown-it-py https://github.com/executablebooks/markdown-it-py>`__
|
||||
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 ``<u>`` for underlining text).
|
||||
* Line breaks behave properly now. For a single-line break, end your line with ``\\``.
|
||||
* Inline links should no longer behave in a strange manner.
|
||||
* Pre-blocks can now have a language. Official clients don't syntax highlight code yet, though.
|
||||
|
||||
Furthermore, the parse mode is no longer client-dependant. It is now configured through ``Message``.
|
||||
|
||||
// TODO provide a way to get back the old behaviour?
|
||||
|
||||
|
||||
The "iter" variant of the client methods have been removed
|
||||
----------------------------------------------------------
|
||||
|
||||
Instead, you can now use the result of the ``get_*`` variant. For instance, where before you had:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async for message in client.iter_messages(...):
|
||||
pass
|
||||
|
||||
You would now do:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async for message in client.get_messages(...):
|
||||
pass # ^^^ now it's get, not iter
|
||||
|
||||
You can still use ``await`` on the ``get_`` methods to retrieve the list.
|
||||
|
||||
The removed methods are:
|
||||
|
||||
* iter_messages
|
||||
* iter_dialogs
|
||||
* iter_participants
|
||||
* iter_admin_log
|
||||
* iter_profile_photos
|
||||
* iter_drafts
|
||||
|
||||
The only exception to this rule is ``iter_download``.
|
||||
|
||||
Additionally, when using ``await``, if the method was called with a limit of 1 (either through
|
||||
setting just one value to fetch, or setting the limit to one), either ``None`` or a single item
|
||||
(outside of a ``list``) will be returned. This used to be the case only for ``get_messages``,
|
||||
but now all methods behave in the same way for consistency.
|
||||
|
||||
When using ``async for``, the default limit will be ``None``, meaning all items will be fetched.
|
||||
When using ``await``, the default limit will be ``1``, meaning the latest item will be fetched.
|
||||
If you want to use ``await`` but still get a list, use the ``.collect()`` method to collect the
|
||||
results into a list:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
chat = ...
|
||||
|
||||
# will iterate over all (default limit=None)
|
||||
async for message in client.get_messages(chat):
|
||||
...
|
||||
|
||||
# will return either a single Message or None if there is not any (limit=1)
|
||||
message = await client.get_messages(chat)
|
||||
|
||||
# will collect all messages into a list (default limit=None). will also take long!
|
||||
all_messages = await client.get_messages(chat).collect()
|
||||
|
||||
|
||||
// TODO keep providing the old ``iter_`` versions? it doesn't really hurt, even if the recommended way changed
|
||||
// TODO does the download really need to be special? get download is kind of weird though
|
||||
|
||||
|
||||
Raw API has been renamed and is now considered private
|
||||
------------------------------------------------------
|
||||
|
||||
The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to
|
||||
signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``).
|
||||
|
||||
Because in Python "we're all adults", you *can* use this private module if you need to. However,
|
||||
you *are* also acknowledging that this is a private module prone to change (and indeed, it will
|
||||
change on layer upgrades across minor version bumps).
|
||||
|
||||
The ``Request`` suffix has been removed from the classes inside ``tl.functions``.
|
||||
|
||||
The ``tl.types`` is now simply ``_tl``, and the ``tl.functions`` is now ``_tl.fn``.
|
||||
|
||||
Some examples:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Before
|
||||
from telethon.tl import types, functions
|
||||
|
||||
await client(functions.messages.SendMessageRequest(...))
|
||||
message: types.Message = ...
|
||||
|
||||
# After
|
||||
from telethon import _tl
|
||||
await client(_tl.fn.messages.SendMessage(...))
|
||||
message: _tl.Message
|
||||
|
||||
This serves multiple goals:
|
||||
|
||||
* It removes redundant parts from the names. The "recommended" way of using the raw API is through
|
||||
the subpackage namespace, which already contains a mention to "functions" in it. In addition,
|
||||
some requests were awkward, such as ``SendCustomRequestRequest``.
|
||||
* It makes it easier to search for code that is using the raw API, so that you can quickly
|
||||
identify which parts are making use of it.
|
||||
* The name is shorter, but remains recognizable.
|
||||
|
||||
Because *a lot* of these objects are created, they now define ``__slots__``. This means you can
|
||||
no longer monkey-patch them to add new attributes at runtime. You have to create a subclass if you
|
||||
want to define new attributes.
|
||||
|
||||
This also means that the updates from ``events.Raw`` **no longer have** ``update._entities``.
|
||||
|
||||
``tlobject.to_dict()`` has changed and is now generated dynamically based on the ``__slots__`.
|
||||
This may incur a small performance hit (but you shouldn't really be using ``.to_dict()`` when
|
||||
you can just use attribute access and ``getattr``). In general, this should handle ill-defined
|
||||
objects more gracefully (for instance, those where you're using a ``tuple`` and not a ``list``
|
||||
or using a list somewhere it shouldn't be), and have no other observable effects. As an extra
|
||||
benefit, this slightly cuts down on the amount of bloat.
|
||||
|
||||
In ``tlobject.to_dict()``, the special ``_`` key is now also contains the module (so you can
|
||||
actually distinguish between equally-named classes). If you want the old behaviour, use
|
||||
``tlobject.__class__.__name__` instead (and add ``Request`` for functions).
|
||||
|
||||
Because the string representation of an object used ``tlobject.to_dict()``, it is now also
|
||||
affected by these changes.
|
||||
|
||||
// TODO this definitely generated files mapping from the original name to this new one...
|
||||
// TODO what's the alternative to update._entities? and update._client??
|
||||
|
||||
|
||||
Many subpackages and modules are now private
|
||||
--------------------------------------------
|
||||
|
||||
There were a lot of things which were public but should not have been. From now on, you should
|
||||
only rely on things that are either publicly re-exported or defined. That is, as soon as anything
|
||||
starts with an underscore (``_``) on its name, you're acknowledging that the functionality may
|
||||
change even across minor version changes, and thus have your code break.
|
||||
|
||||
The following subpackages are now considered private:
|
||||
|
||||
* ``client`` is now ``_client``.
|
||||
* ``crypto`` is now ``_crypto``.
|
||||
* ``extensions`` is now ``_misc``.
|
||||
* ``tl`` is now ``_tl``.
|
||||
|
||||
The following modules have been moved inside ``_misc``:
|
||||
|
||||
* ``entitycache.py``
|
||||
* ``helpers.py``
|
||||
* ``hints.py``
|
||||
* ``password.py``
|
||||
* ``requestiter.py`
|
||||
* ``statecache.py``
|
||||
* ``utils.py``
|
||||
|
||||
// TODO review telethon/__init__.py isn't exposing more than it should
|
||||
|
||||
|
||||
Using the client in a context-manager no longer calls start automatically
|
||||
-------------------------------------------------------------------------
|
||||
|
||||
The following code no longer automatically calls ``client.start()``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with TelegramClient(...) as client:
|
||||
...
|
||||
|
||||
# or
|
||||
|
||||
async with client:
|
||||
...
|
||||
|
||||
|
||||
This means the context-manager will only call ``client.connect()`` and ``client.disconnect()``.
|
||||
The rationale for this change is that it could be strange for this to ask for the login code if
|
||||
the session ever was invalid. If you want the old behaviour, you now need to be explicit:
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with TelegramClient(...).start() as client:
|
||||
... # ++++++++
|
||||
|
||||
|
||||
Note that you do not need to ``await`` the call to ``.start()`` if you are going to use the result
|
||||
in a context-manager (but it's okay if you put the ``await``).
|
||||
|
||||
|
||||
Several methods have been removed from the client
|
||||
-------------------------------------------------
|
||||
|
||||
``client.download_file`` has been removed. Instead, ``client.download_media`` should be used.
|
||||
The now-removed ``client.download_file`` method was a lower level implementation which should
|
||||
have not been exposed at all.
|
||||
|
||||
``client.build_reply_markup`` has been removed. Manually calling this method was purely an
|
||||
optimization (the buttons won't need to be transformed into a reply markup every time they're
|
||||
used). This means you can just remove any calls to this method and things will continue to work.
|
||||
|
||||
|
||||
Support for bot-API style file_id has been removed
|
||||
--------------------------------------------------
|
||||
|
||||
They have been half-broken for a while now, so this is just making an existing reality official.
|
||||
See `issue #1613 <https://github.com/LonamiWebs/Telethon/issues/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 <https://github.com/LonamiWebs/Telethon/issues/1723>`__).
|
||||
* ``utils.resolve_id``. Marked IDs are no longer used thorough the library. The removal of this
|
||||
method also means ``utils.get_peer`` can no longer get a ``Peer`` from just a number, as the
|
||||
type is no longer embedded inside the ID.
|
||||
|
||||
// TODO provide the new clean utils
|
||||
|
||||
|
||||
Changes on how to configure filters for certain client methods
|
||||
--------------------------------------------------------------
|
||||
|
||||
Before, ``client.iter_participants`` (and ``get_participants``) would expect a type or instance
|
||||
of the raw Telegram definition as a ``filter``. Now, this ``filter`` expects a string.
|
||||
The supported values are:
|
||||
|
||||
* ``'admin'``
|
||||
* ``'bot'``
|
||||
* ``'kicked'``
|
||||
* ``'banned'``
|
||||
* ``'contact'``
|
||||
|
||||
If you prefer to avoid hardcoding strings, you may use ``telethon.enums.Participant``.
|
||||
|
||||
// TODO maintain support for the old way of doing it?
|
||||
// TODO now that there's a custom filter, filter client-side for small chats?
|
||||
|
||||
|
||||
The custom.Message class and the way it is used has changed
|
||||
-----------------------------------------------------------
|
||||
|
||||
It no longer inherits ``TLObject``, and rather than trying to mimick Telegram's ``Message``
|
||||
constructor, it now takes two parameters: a ``TelegramClient`` instance and a ``_tl.Message``.
|
||||
As a benefit, you can now more easily reconstruct instances of this type from a previously-stored
|
||||
``_tl.Message`` instance.
|
||||
|
||||
There are no public attributes. Instead, they are now properties which forward the values into and
|
||||
from the private ``_message`` field. As a benefit, the documentation will now be easier to follow.
|
||||
However, you can no longer use ``del`` on these.
|
||||
|
||||
The ``_tl.Message.media`` attribute will no longer be ``None`` when using raw API if the media was
|
||||
``messageMediaEmpty``. As a benefit, you can now actually distinguish between no media and empty
|
||||
media. The ``Message.media`` property as returned by friendly methods will still be ``None`` on
|
||||
empty media.
|
||||
|
||||
The ``telethon.tl.patched`` hack has been removed.
|
||||
|
||||
The message sender no longer is the channel when no sender is provided by Telegram. Telethon used
|
||||
to patch this value for channels to be the same as the chat, but now it will be faithful to
|
||||
Telegram's value.
|
||||
|
||||
In order to avoid breaking more code than strictly necessary, ``.raw_text`` will remain a synonym
|
||||
of ``.message``, and ``.text`` will still be the text formatted through the ``client.parse_mode``.
|
||||
However, you're encouraged to change uses of ``.raw_text`` with ``.message``, and ``.text`` with
|
||||
either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` and ``.raw_text``
|
||||
may disappear in future versions, and their behaviour is not immediately obvious.
|
||||
|
||||
// TODO actually provide the things mentioned here
|
||||
|
||||
|
||||
Using a flat list to define buttons will now create rows and not columns
|
||||
------------------------------------------------------------------------
|
||||
|
||||
When sending a message with buttons under a bot account, passing a flat list such as the following:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bot.send_message(chat, message, buttons=[
|
||||
Button.inline('top'),
|
||||
Button.inline('middle'),
|
||||
Button.inline('bottom'),
|
||||
])
|
||||
|
||||
Will now send a message with 3 rows of buttons, instead of a message with 3 columns (old behaviour).
|
||||
If you still want the old behaviour, wrap the list inside another list:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bot.send_message(chat, message, buttons=[[
|
||||
# +
|
||||
Button.inline('top'),
|
||||
Button.inline('middle'),
|
||||
Button.inline('bottom'),
|
||||
]])
|
||||
#+
|
||||
|
||||
|
||||
Changes to the string and to_dict representation
|
||||
------------------------------------------------
|
||||
|
||||
The string representation of raw API objects will now have its "printing depth" limited, meaning
|
||||
very large and nested objects will be easier to read.
|
||||
|
||||
If you want to see the full object's representation, you should instead use Python's builtin
|
||||
``repr`` method.
|
||||
|
||||
The ``.stringify`` method remains unchanged.
|
||||
|
||||
Here's a comparison table for a convenient overview:
|
||||
|
||||
+-------------------+---------------------------------------------+---------------------------------------------+
|
||||
| | Telethon v1.x | Telethon v2.x |
|
||||
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
|
||||
| | ``__str__`` | ``__repr__`` | ``.stringify`` | ``__str__`` | ``__repr__`` | ``.stringify`` |
|
||||
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
|
||||
| Useful? | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
|
||||
| Multiline? | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
|
||||
| Shows everything? | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
+-------------------+-------------+--------------+----------------+-------------+--------------+----------------+
|
||||
|
||||
Both of the string representations may still change in the future without warning, as Telegram
|
||||
adds, changes or removes fields. It should only be used for debugging. If you need a persistent
|
||||
string representation, it is your job to decide which fields you care about and their format.
|
||||
|
||||
The ``Message`` representation now contains different properties, which should be more useful and
|
||||
less confusing.
|
||||
|
||||
|
||||
Changes on how to configure a different connection mode
|
||||
-------------------------------------------------------
|
||||
|
||||
The ``connection`` parameter of the ``TelegramClient`` now expects a string, and not a type.
|
||||
The supported values are:
|
||||
|
||||
* ``'full'``
|
||||
* ``'intermediate'``
|
||||
* ``'abridged'``
|
||||
* ``'obfuscated'``
|
||||
* ``'http'``
|
||||
|
||||
The value chosen by the library is left as an implementation detail which may change. However,
|
||||
you can force a certain mode by explicitly configuring it. If you don't want to hardcode the
|
||||
string, you can import these values from the new ``telethon.enums`` module:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
client = TelegramClient(..., connection='tcp')
|
||||
|
||||
# or
|
||||
|
||||
from telethon.enums import ConnectionMode
|
||||
client = TelegramClient(..., connection=ConnectionMode.TCP)
|
||||
|
||||
You may have noticed there's currently no alternative for ``TcpMTProxy``. This mode has been
|
||||
broken for some time now (see `issue #1319 <https://github.com/LonamiWebs/Telethon/issues/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 <https://stackoverflow.com/a/62246569/>`__ inside your handlers will do
|
||||
just fine This approach can also be easily persisted, and you can adjust it to your needs and
|
||||
your handlers much more easily.
|
||||
|
||||
// TODO provide standalone alternative for this?
|
||||
|
||||
|
||||
Deleting messages now returns a more useful value
|
||||
-------------------------------------------------
|
||||
|
||||
It used to return a list of :tl:`messages.affectedMessages` which I expect very little people were
|
||||
actually using. Now it returns an ``int`` value indicating the number of messages that did exist
|
||||
and were deleted.
|
||||
|
||||
|
||||
Changes to the methods to retrieve participants
|
||||
-----------------------------------------------
|
||||
|
||||
The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now gone.
|
||||
It was not reliable, and was a cause of flood wait errors.
|
||||
|
||||
The ``search`` parameter is no longer ignored when ``filter`` is specified.
|
||||
|
||||
|
||||
The total value when getting participants has changed
|
||||
-----------------------------------------------------
|
||||
|
||||
Before, it used to always be the total amount of people inside the chat. Now the filter is also
|
||||
considered. If you were running ``client.get_participants`` with a ``filter`` other than the
|
||||
default and accessing the ``list.total``, you will now get a different result. You will need to
|
||||
perform a separate request with no filter to fetch the total without filter (this is what the
|
||||
library used to do).
|
||||
|
||||
|
||||
Changes to editing messages
|
||||
---------------------------
|
||||
|
||||
Before, calling ``message.edit()`` would completely ignore your attempt to edit a message if the
|
||||
message had a forward header or was not outgoing. This is no longer the case. It is now the user's
|
||||
responsibility to check for this.
|
||||
|
||||
However, most likely, you were already doing the right thing (or else you would've experienced a
|
||||
"why is this not being edited", which you would most likely consider a bug rather than a feature).
|
||||
|
||||
When using ``client.edit_message``, you now must always specify the chat and the message (or
|
||||
message identifier). This should be less "magic". As an example, if you were doing this before:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
await client.edit_message(message, 'new text')
|
||||
|
||||
You now have to do the following:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
await client.edit_message(message.input_chat, message.id, 'new text')
|
||||
|
||||
# or
|
||||
|
||||
await message.edit('new text')
|
||||
|
||||
|
||||
Signing in no longer sends the code
|
||||
-----------------------------------
|
||||
|
||||
``client.sign_in()`` used to run ``client.send_code_request()`` if you only provided the phone and
|
||||
not the code. It no longer does this. If you need that convenience, use ``client.start()`` instead.
|
||||
|
||||
|
||||
The client.disconnected property has been removed
|
||||
-------------------------------------------------
|
||||
|
||||
``client.run_until_disconnected()`` should be used instead.
|
||||
|
||||
|
||||
The TelegramClient is no longer made out of mixins
|
||||
--------------------------------------------------
|
||||
|
||||
If you were relying on any of the individual mixins that made up the client, such as
|
||||
``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone.
|
||||
There is a single ``TelegramClient`` class now, containing everything you need.
|
||||
|
||||
|
||||
The takeout context-manager has changed
|
||||
---------------------------------------
|
||||
|
||||
It no longer has a finalize. All the requests made by the client in the same task will be wrapped,
|
||||
not only those made through the proxy client returned by the context-manager.
|
||||
|
||||
This cleans up the (rather hacky) implementation, making use of Python's ``contextvar``. If you
|
||||
still need the takeout session to persist, you should manually use the ``begin_takeout`` and
|
||||
``end_takeout`` method.
|
||||
|
||||
If you want to ignore the currently-active takeout session in a task, toggle the following context
|
||||
variable:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
telethon.ignore_takeout.set(True)
|
||||
|
||||
|
||||
CdnDecrypter has been removed
|
||||
-----------------------------
|
||||
|
||||
It was not really working and was more intended to be an implementation detail than anything else.
|
||||
|
||||
|
||||
URL buttons no longer open the web-browser
|
||||
------------------------------------------
|
||||
|
||||
Now the URL is returned. You can still use ``webbrowser.open`` to get the old behaviour.
|
||||
|
||||
|
||||
---
|
||||
|
||||
you can no longer pass an attributes list because the constructor is now nice.
|
||||
use raw api if you really need it.
|
||||
goal is to hide raw api from high level api. sorry.
|
||||
|
||||
no parsemode. use the correct parameter. it's more convenient than setting two.
|
||||
|
||||
formatting_entities stays because otherwise it's the only feasible way to manually specify it.
|
||||
|
||||
todo update send_message and send_file docs (well review all functions)
|
||||
|
||||
album overhaul. use a list of Message instead.
|
||||
|
||||
size selector for download_profile_photo and download_media is now different
|
||||
|
||||
still thumb because otherwise documents are weird.
|
||||
|
||||
keep support for explicit size instance?
|
||||
|
||||
renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read?
|
||||
|
||||
force sms removed as it was broken anyway and not very reliable
|
||||
|
||||
you can now await client.action for a one-off any action not just cancel
|
||||
|
||||
fwd msg and delete msg now mandate a list rather than a single int or msg
|
||||
(since there's msg.delete and msg.forward_to this should be no issue).
|
||||
they are meant to work on lists.
|
||||
|
||||
also mark read only supports single now. a list would just be max anyway.
|
||||
removed max id since it's not really of much use.
|
||||
|
||||
client loop has been removed. embrace implicit loop as asyncio does now
|
||||
|
||||
renamed some client params, and made other privates
|
||||
timeout -> connect_timeout
|
||||
connection_retries -> connect_retries
|
||||
retry_delay -> connect_retry_delay
|
||||
|
||||
sequential_updates is gone
|
||||
connection type is gone
|
||||
|
||||
raise_last_call_error is now the default rather than ValueError
|
||||
|
||||
self-produced updates like getmessage now also trigger a handler
|
|
@ -1,65 +0,0 @@
|
|||
=============
|
||||
Wall of Shame
|
||||
=============
|
||||
|
||||
|
||||
This project has an
|
||||
`issues <https://github.com/LonamiWebs/Telethon/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 <https://tl.telethon.dev/>`__,
|
||||
you will end up on the `Wall of
|
||||
Shame <https://github.com/LonamiWebs/Telethon/issues?q=is%3Aissue+label%3ARTFM+is%3Aclosed>`__,
|
||||
i.e. all issues labeled
|
||||
`"RTFM" <http://www.urbandictionary.com/define.php?term=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 <https://github.com/LonamiWebs/Telethon/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20label%3Aquestion%20>`__
|
||||
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 <https://github.com/LonamiWebs/Telethon/issues/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
|
|
@ -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...
|
||||
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.
|
||||
|
|
|
@ -46,15 +46,6 @@ ChatGetter
|
|||
:show-inheritance:
|
||||
|
||||
|
||||
Conversation
|
||||
============
|
||||
|
||||
.. automodule:: telethon.tl.custom.conversation
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Dialog
|
||||
======
|
||||
|
||||
|
|
|
@ -107,7 +107,6 @@ Dialogs
|
|||
iter_drafts
|
||||
get_drafts
|
||||
delete_dialog
|
||||
conversation
|
||||
|
||||
Users
|
||||
-----
|
||||
|
|
|
@ -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``
|
||||
|
|
|
@ -155,33 +155,6 @@ its name, bot-API style file ID, etc.
|
|||
sticker_set
|
||||
|
||||
|
||||
Conversation
|
||||
============
|
||||
|
||||
The `Conversation <telethon.tl.custom.conversation.Conversation>` object
|
||||
is returned by the `client.conversation()
|
||||
<telethon.client.dialogs.DialogMethods.conversation>` method to easily
|
||||
send and receive responses like a normal conversation.
|
||||
|
||||
It bases `ChatGetter <telethon.tl.custom.chatgetter.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
|
||||
=============
|
||||
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
pyaes
|
||||
rsa
|
||||
markdown-it-py~=1.1.0
|
||||
pyaes~=1.6.1
|
||||
rsa~=4.7.2
|
||||
|
|
14
setup.py
14
setup.py
|
@ -47,7 +47,7 @@ GENERATOR_DIR = Path('telethon_generator')
|
|||
LIBRARY_DIR = Path('telethon')
|
||||
|
||||
ERRORS_IN = GENERATOR_DIR / 'data/errors.csv'
|
||||
ERRORS_OUT = LIBRARY_DIR / 'errors/rpcerrorlist.py'
|
||||
ERRORS_OUT = LIBRARY_DIR / 'errors/_generated.py'
|
||||
|
||||
METHODS_IN = GENERATOR_DIR / 'data/methods.csv'
|
||||
|
||||
|
@ -55,8 +55,8 @@ METHODS_IN = GENERATOR_DIR / 'data/methods.csv'
|
|||
FRIENDLY_IN = GENERATOR_DIR / 'data/friendly.csv'
|
||||
|
||||
TLOBJECT_IN_TLS = [Path(x) for x in GENERATOR_DIR.glob('data/*.tl')]
|
||||
TLOBJECT_OUT = LIBRARY_DIR / 'tl'
|
||||
IMPORT_DEPTH = 2
|
||||
TLOBJECT_OUT = LIBRARY_DIR / '_tl'
|
||||
TLOBJECT_MOD = 'telethon._tl'
|
||||
|
||||
DOCS_IN_RES = GENERATOR_DIR / 'data/html'
|
||||
DOCS_OUT = Path('docs')
|
||||
|
@ -94,7 +94,7 @@ def generate(which, action='gen'):
|
|||
if clean:
|
||||
clean_tlobjects(TLOBJECT_OUT)
|
||||
else:
|
||||
generate_tlobjects(tlobjects, layer, IMPORT_DEPTH, TLOBJECT_OUT)
|
||||
generate_tlobjects(tlobjects, layer, TLOBJECT_MOD, TLOBJECT_OUT)
|
||||
|
||||
if 'errors' in which:
|
||||
which.remove('errors')
|
||||
|
@ -208,7 +208,7 @@ def main(argv):
|
|||
# See https://stackoverflow.com/a/40300957/4759433
|
||||
# -> https://www.python.org/dev/peps/pep-0345/#requires-python
|
||||
# -> http://setuptools.readthedocs.io/en/latest/setuptools.html
|
||||
python_requires='>=3.5',
|
||||
python_requires='>=3.7',
|
||||
|
||||
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||
classifiers=[
|
||||
|
@ -223,10 +223,10 @@ def main(argv):
|
|||
'License :: OSI Approved :: MIT License',
|
||||
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
],
|
||||
keywords='telegram api chat client library messaging mtproto',
|
||||
packages=find_packages(exclude=[
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
|
|
4
telethon/_client/__init__.py
Normal file
4
telethon/_client/__init__.py
Normal file
|
@ -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.
|
||||
"""
|
73
telethon/_client/account.py
Normal file
73
telethon/_client/account.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
import functools
|
||||
import inspect
|
||||
import typing
|
||||
import dataclasses
|
||||
from contextvars import ContextVar
|
||||
|
||||
from .._misc import helpers, utils
|
||||
from .. import _tl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
ignore_takeout = ContextVar('ignore_takeout', default=False)
|
||||
|
||||
|
||||
# TODO Make use of :tl:`InvokeWithMessagesRange` somehow
|
||||
# For that, we need to use :tl:`GetSplitRanges` first.
|
||||
class _Takeout:
|
||||
def __init__(self, client, kwargs):
|
||||
self._client = client
|
||||
self._kwargs = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
await self._client.begin_takeout(**kwargs)
|
||||
return self._client
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self._client.end_takeout(success=exc_type is None)
|
||||
|
||||
|
||||
def takeout(self: 'TelegramClient', **kwargs):
|
||||
return _Takeout(self, kwargs)
|
||||
|
||||
|
||||
async def begin_takeout(
|
||||
self: 'TelegramClient',
|
||||
*,
|
||||
contacts: bool = None,
|
||||
users: bool = None,
|
||||
chats: bool = None,
|
||||
megagroups: bool = None,
|
||||
channels: bool = None,
|
||||
files: bool = None,
|
||||
max_file_size: bool = None,
|
||||
) -> 'TelegramClient':
|
||||
if takeout_active():
|
||||
raise ValueError('a previous takeout session was already active')
|
||||
|
||||
await self._replace_session_state(takeout_id=(await client(
|
||||
contacts=contacts,
|
||||
message_users=users,
|
||||
message_chats=chats,
|
||||
message_megagroups=megagroups,
|
||||
message_channels=channels,
|
||||
files=files,
|
||||
file_max_size=max_file_size
|
||||
)).id)
|
||||
|
||||
|
||||
def takeout_active(self: 'TelegramClient') -> bool:
|
||||
return self._session_state.takeout_id is not None
|
||||
|
||||
|
||||
async def end_takeout(self: 'TelegramClient', success: bool) -> bool:
|
||||
if not takeout_active():
|
||||
raise ValueError('no previous takeout session was active')
|
||||
|
||||
result = await self(_tl.fn.account.FinishTakeoutSession(success))
|
||||
if not result:
|
||||
raise ValueError("could not end the active takeout session")
|
||||
|
||||
await self._replace_session_state(takeout_id=None)
|
431
telethon/_client/auth.py
Normal file
431
telethon/_client/auth.py
Normal file
|
@ -0,0 +1,431 @@
|
|||
import getpass
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
import functools
|
||||
import dataclasses
|
||||
|
||||
from .._misc import utils, helpers, password as pwd_mod
|
||||
from .. import errors, _tl
|
||||
from ..types import _custom
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
class StartingClient:
|
||||
def __init__(self, client, start_fn):
|
||||
self.client = client
|
||||
self.start_fn = start_fn
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.start_fn()
|
||||
return self.client
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.client.__aexit__(*args)
|
||||
|
||||
def __await__(self):
|
||||
return self.__aenter__().__await__()
|
||||
|
||||
|
||||
def start(
|
||||
self: 'TelegramClient',
|
||||
phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '),
|
||||
password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '),
|
||||
*,
|
||||
bot_token: str = None,
|
||||
code_callback: typing.Callable[[], typing.Union[str, int]] = None,
|
||||
first_name: str = 'New User',
|
||||
last_name: str = '',
|
||||
max_attempts: int = 3) -> 'TelegramClient':
|
||||
if code_callback is None:
|
||||
def code_callback():
|
||||
return input('Please enter the code you received: ')
|
||||
elif not callable(code_callback):
|
||||
raise ValueError(
|
||||
'The code_callback parameter needs to be a callable '
|
||||
'function that returns the code you received by Telegram.'
|
||||
)
|
||||
|
||||
if not phone and not bot_token:
|
||||
raise ValueError('No phone number or bot token provided.')
|
||||
|
||||
if phone and bot_token and not callable(phone):
|
||||
raise ValueError('Both a phone and a bot token provided, '
|
||||
'must only provide one of either')
|
||||
|
||||
return StartingClient(self, functools.partial(_start,
|
||||
self=self,
|
||||
phone=phone,
|
||||
password=password,
|
||||
bot_token=bot_token,
|
||||
code_callback=code_callback,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
max_attempts=max_attempts
|
||||
))
|
||||
|
||||
async def _start(
|
||||
self: 'TelegramClient', phone, password, bot_token,
|
||||
code_callback, first_name, last_name, max_attempts):
|
||||
if not self.is_connected():
|
||||
await self.connect()
|
||||
|
||||
# Rather than using `is_user_authorized`, use `get_me`. While this is
|
||||
# more expensive and needs to retrieve more data from the server, it
|
||||
# enables the library to warn users trying to login to a different
|
||||
# account. See #1172.
|
||||
me = await self.get_me()
|
||||
if me is not None:
|
||||
# The warnings here are on a best-effort and may fail.
|
||||
if bot_token:
|
||||
# bot_token's first part has the bot ID, but it may be invalid
|
||||
# so don't try to parse as int (instead cast our ID to string).
|
||||
if bot_token[:bot_token.find(':')] != str(me.id):
|
||||
warnings.warn(
|
||||
'the session already had an authorized user so it did '
|
||||
'not login to the bot account using the provided '
|
||||
'bot_token (it may not be using the user you expect)'
|
||||
)
|
||||
elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone:
|
||||
warnings.warn(
|
||||
'the session already had an authorized user so it did '
|
||||
'not login to the user account using the provided '
|
||||
'phone (it may not be using the user you expect)'
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
if not bot_token:
|
||||
# Turn the callable into a valid phone number (or bot token)
|
||||
while callable(phone):
|
||||
value = phone()
|
||||
if inspect.isawaitable(value):
|
||||
value = await value
|
||||
|
||||
if ':' in value:
|
||||
# Bot tokens have 'user_id:access_hash' format
|
||||
bot_token = value
|
||||
break
|
||||
|
||||
phone = utils.parse_phone(value) or phone
|
||||
|
||||
if bot_token:
|
||||
await self.sign_in(bot_token=bot_token)
|
||||
return self
|
||||
|
||||
me = None
|
||||
attempts = 0
|
||||
two_step_detected = False
|
||||
|
||||
await self.send_code_request(phone)
|
||||
sign_up = False # assume login
|
||||
while attempts < max_attempts:
|
||||
try:
|
||||
value = code_callback()
|
||||
if inspect.isawaitable(value):
|
||||
value = await value
|
||||
|
||||
# Since sign-in with no code works (it sends the code)
|
||||
# we must double-check that here. Else we'll assume we
|
||||
# logged in, and it will return None as the User.
|
||||
if not value:
|
||||
raise errors.PhoneCodeEmptyError(request=None)
|
||||
|
||||
if sign_up:
|
||||
me = await self.sign_up(value, first_name, last_name)
|
||||
else:
|
||||
# Raises SessionPasswordNeededError if 2FA enabled
|
||||
me = await self.sign_in(phone, code=value)
|
||||
break
|
||||
except errors.SessionPasswordNeededError:
|
||||
two_step_detected = True
|
||||
break
|
||||
except errors.PhoneNumberOccupiedError:
|
||||
sign_up = False
|
||||
except errors.PhoneNumberUnoccupiedError:
|
||||
sign_up = True
|
||||
except (errors.PhoneCodeEmptyError,
|
||||
errors.PhoneCodeExpiredError,
|
||||
errors.PhoneCodeHashEmptyError,
|
||||
errors.PhoneCodeInvalidError):
|
||||
print('Invalid code. Please try again.', file=sys.stderr)
|
||||
|
||||
attempts += 1
|
||||
else:
|
||||
raise RuntimeError(
|
||||
'{} consecutive sign-in attempts failed. Aborting'
|
||||
.format(max_attempts)
|
||||
)
|
||||
|
||||
if two_step_detected:
|
||||
if not password:
|
||||
raise ValueError(
|
||||
"Two-step verification is enabled for this account. "
|
||||
"Please provide the 'password' argument to 'start()'."
|
||||
)
|
||||
|
||||
if callable(password):
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
value = password()
|
||||
if inspect.isawaitable(value):
|
||||
value = await value
|
||||
|
||||
me = await self.sign_in(phone=phone, password=value)
|
||||
break
|
||||
except errors.PasswordHashInvalidError:
|
||||
print('Invalid password. Please try again',
|
||||
file=sys.stderr)
|
||||
else:
|
||||
raise errors.PasswordHashInvalidError(request=None)
|
||||
else:
|
||||
me = await self.sign_in(phone=phone, password=password)
|
||||
|
||||
# We won't reach here if any step failed (exit by exception)
|
||||
signed, name = 'Signed in successfully as', utils.get_display_name(me)
|
||||
try:
|
||||
print(signed, name)
|
||||
except UnicodeEncodeError:
|
||||
# Some terminals don't support certain characters
|
||||
print(signed, name.encode('utf-8', errors='ignore')
|
||||
.decode('ascii', errors='ignore'))
|
||||
|
||||
return self
|
||||
|
||||
def _parse_phone_and_hash(self, phone, phone_hash):
|
||||
"""
|
||||
Helper method to both parse and validate phone and its hash.
|
||||
"""
|
||||
phone = utils.parse_phone(phone) or self._phone
|
||||
if not phone:
|
||||
raise ValueError(
|
||||
'Please make sure to call send_code_request first.'
|
||||
)
|
||||
|
||||
phone_hash = phone_hash or self._phone_code_hash.get(phone, None)
|
||||
if not phone_hash:
|
||||
raise ValueError('You also need to provide a phone_code_hash.')
|
||||
|
||||
return phone, phone_hash
|
||||
|
||||
async def sign_in(
|
||||
self: 'TelegramClient',
|
||||
phone: str = None,
|
||||
code: typing.Union[str, int] = None,
|
||||
*,
|
||||
password: str = None,
|
||||
bot_token: str = None,
|
||||
phone_code_hash: str = None) -> 'typing.Union[_tl.User, _tl.auth.SentCode]':
|
||||
me = await self.get_me()
|
||||
if me:
|
||||
return me
|
||||
|
||||
if phone and code:
|
||||
phone, phone_code_hash = \
|
||||
_parse_phone_and_hash(self, phone, phone_code_hash)
|
||||
|
||||
# May raise PhoneCodeEmptyError, PhoneCodeExpiredError,
|
||||
# PhoneCodeHashEmptyError or PhoneCodeInvalidError.
|
||||
request = _tl.fn.auth.SignIn(
|
||||
phone, phone_code_hash, str(code)
|
||||
)
|
||||
elif password:
|
||||
pwd = await self(_tl.fn.account.GetPassword())
|
||||
request = _tl.fn.auth.CheckPassword(
|
||||
pwd_mod.compute_check(pwd, password)
|
||||
)
|
||||
elif bot_token:
|
||||
request = _tl.fn.auth.ImportBotAuthorization(
|
||||
flags=0, bot_auth_token=bot_token,
|
||||
api_id=self._api_id, api_hash=self._api_hash
|
||||
)
|
||||
else:
|
||||
raise ValueError('You must provide either phone and code, password, or bot_token.')
|
||||
|
||||
result = await self(request)
|
||||
if isinstance(result, _tl.auth.AuthorizationSignUpRequired):
|
||||
# Emulate pre-layer 104 behaviour
|
||||
self._tos = result.terms_of_service
|
||||
raise errors.PhoneNumberUnoccupiedError(request=request)
|
||||
|
||||
return await _update_session_state(self, result.user)
|
||||
|
||||
async def sign_up(
|
||||
self: 'TelegramClient',
|
||||
code: typing.Union[str, int],
|
||||
first_name: str,
|
||||
last_name: str = '',
|
||||
*,
|
||||
phone: str = None,
|
||||
phone_code_hash: str = None) -> '_tl.User':
|
||||
me = await self.get_me()
|
||||
if me:
|
||||
return me
|
||||
|
||||
# To prevent abuse, one has to try to sign in before signing up. This
|
||||
# is the current way in which Telegram validates the code to sign up.
|
||||
#
|
||||
# `sign_in` will set `_tos`, so if it's set we don't need to call it
|
||||
# because the user already tried to sign in.
|
||||
#
|
||||
# We're emulating pre-layer 104 behaviour so except the right error:
|
||||
if not self._tos:
|
||||
try:
|
||||
return await self.sign_in(
|
||||
phone=phone,
|
||||
code=code,
|
||||
phone_code_hash=phone_code_hash,
|
||||
)
|
||||
except errors.PhoneNumberUnoccupiedError:
|
||||
pass # code is correct and was used, now need to sign in
|
||||
|
||||
if self._tos and self._tos.text:
|
||||
if self.parse_mode:
|
||||
t = self.parse_mode.unparse(self._tos.text, self._tos.entities)
|
||||
else:
|
||||
t = self._tos.text
|
||||
sys.stderr.write("{}\n".format(t))
|
||||
sys.stderr.flush()
|
||||
|
||||
phone, phone_code_hash = \
|
||||
_parse_phone_and_hash(self, phone, phone_code_hash)
|
||||
|
||||
result = await self(_tl.fn.auth.SignUp(
|
||||
phone_number=phone,
|
||||
phone_code_hash=phone_code_hash,
|
||||
first_name=first_name,
|
||||
last_name=last_name
|
||||
))
|
||||
|
||||
if self._tos:
|
||||
await self(
|
||||
_tl.fn.help.AcceptTermsOfService(self._tos.id))
|
||||
|
||||
return await _update_session_state(self, result.user)
|
||||
|
||||
|
||||
async def _update_session_state(self, user, save=True):
|
||||
"""
|
||||
Callback called whenever the login or sign up process completes.
|
||||
Returns the input user parameter.
|
||||
"""
|
||||
state = await self(_tl.fn.updates.GetState())
|
||||
await _replace_session_state(
|
||||
self,
|
||||
save=save,
|
||||
user_id=user.id,
|
||||
bot=user.bot,
|
||||
pts=state.pts,
|
||||
qts=state.qts,
|
||||
date=int(state.date.timestamp()),
|
||||
seq=state.seq,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def _replace_session_state(self, *, save=True, **changes):
|
||||
new = dataclasses.replace(self._session_state, **changes)
|
||||
await self._session.set_state(new)
|
||||
self._session_state = new
|
||||
|
||||
if save:
|
||||
await self._session.save()
|
||||
|
||||
|
||||
async def send_code_request(
|
||||
self: 'TelegramClient',
|
||||
phone: str) -> '_tl.auth.SentCode':
|
||||
result = None
|
||||
phone = utils.parse_phone(phone) or self._phone
|
||||
phone_hash = self._phone_code_hash.get(phone)
|
||||
|
||||
if phone_hash:
|
||||
result = await self(
|
||||
_tl.fn.auth.ResendCode(phone, phone_hash))
|
||||
|
||||
self._phone_code_hash[phone] = result.phone_code_hash
|
||||
else:
|
||||
try:
|
||||
result = await self(_tl.fn.auth.SendCode(
|
||||
phone, self._api_id, self._api_hash, _tl.CodeSettings()))
|
||||
except errors.AuthRestartError:
|
||||
return await self.send_code_request(phone)
|
||||
|
||||
# phone_code_hash may be empty, if it is, do not save it (#1283)
|
||||
if result.phone_code_hash:
|
||||
self._phone_code_hash[phone] = phone_hash = result.phone_code_hash
|
||||
|
||||
self._phone = phone
|
||||
|
||||
return result
|
||||
|
||||
async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QRLogin:
|
||||
qr_login = _custom.QRLogin(self, ignored_ids or [])
|
||||
await qr_login.recreate()
|
||||
return qr_login
|
||||
|
||||
async def log_out(self: 'TelegramClient') -> bool:
|
||||
try:
|
||||
await self(_tl.fn.auth.LogOut())
|
||||
except errors.RPCError:
|
||||
return False
|
||||
|
||||
await self.disconnect()
|
||||
return True
|
||||
|
||||
async def edit_2fa(
|
||||
self: 'TelegramClient',
|
||||
current_password: str = None,
|
||||
new_password: str = None,
|
||||
*,
|
||||
hint: str = '',
|
||||
email: str = None,
|
||||
email_code_callback: typing.Callable[[int], str] = None) -> bool:
|
||||
if new_password is None and current_password is None:
|
||||
return False
|
||||
|
||||
if email and not callable(email_code_callback):
|
||||
raise ValueError('email present without email_code_callback')
|
||||
|
||||
pwd = await self(_tl.fn.account.GetPassword())
|
||||
pwd.new_algo.salt1 += os.urandom(32)
|
||||
assert isinstance(pwd, _tl.account.Password)
|
||||
if not pwd.has_password and current_password:
|
||||
current_password = None
|
||||
|
||||
if current_password:
|
||||
password = pwd_mod.compute_check(pwd, current_password)
|
||||
else:
|
||||
password = _tl.InputCheckPasswordEmpty()
|
||||
|
||||
if new_password:
|
||||
new_password_hash = pwd_mod.compute_digest(
|
||||
pwd.new_algo, new_password)
|
||||
else:
|
||||
new_password_hash = b''
|
||||
|
||||
try:
|
||||
await self(_tl.fn.account.UpdatePasswordSettings(
|
||||
password=password,
|
||||
new_settings=_tl.account.PasswordInputSettings(
|
||||
new_algo=pwd.new_algo,
|
||||
new_password_hash=new_password_hash,
|
||||
hint=hint,
|
||||
email=email,
|
||||
new_secure_settings=None
|
||||
)
|
||||
))
|
||||
except errors.EmailUnconfirmedError as e:
|
||||
code = email_code_callback(e.code_length)
|
||||
if inspect.isawaitable(code):
|
||||
code = await code
|
||||
|
||||
code = str(code)
|
||||
await self(_tl.fn.account.ConfirmPasswordEmail(code))
|
||||
|
||||
return True
|
33
telethon/_client/bots.py
Normal file
33
telethon/_client/bots.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
import typing
|
||||
|
||||
from ..types import _custom
|
||||
from .._misc import hints
|
||||
from .. import _tl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
async def inline_query(
|
||||
self: 'TelegramClient',
|
||||
bot: 'hints.EntityLike',
|
||||
query: str,
|
||||
*,
|
||||
entity: 'hints.EntityLike' = None,
|
||||
offset: str = None,
|
||||
geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults:
|
||||
bot = await self.get_input_entity(bot)
|
||||
if entity:
|
||||
peer = await self.get_input_entity(entity)
|
||||
else:
|
||||
peer = _tl.InputPeerEmpty()
|
||||
|
||||
result = await self(_tl.fn.messages.GetInlineBotResults(
|
||||
bot=bot,
|
||||
peer=peer,
|
||||
query=query,
|
||||
offset=offset or '',
|
||||
geo_point=geo_point
|
||||
))
|
||||
|
||||
return _custom.InlineResults(self, result, entity=peer if entity else None)
|
700
telethon/_client/chats.py
Normal file
700
telethon/_client/chats.py
Normal file
|
@ -0,0 +1,700 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import string
|
||||
import typing
|
||||
|
||||
from .. import errors, _tl
|
||||
from .._misc import helpers, utils, requestiter, tlobject, enums, hints
|
||||
from ..types import _custom
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
_MAX_PARTICIPANTS_CHUNK_SIZE = 200
|
||||
_MAX_ADMIN_LOG_CHUNK_SIZE = 100
|
||||
_MAX_PROFILE_PHOTO_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
class _ChatAction:
|
||||
def __init__(self, client, chat, action, *, delay, auto_cancel):
|
||||
self._client = client
|
||||
self._chat = chat
|
||||
self._action = action
|
||||
self._delay = delay
|
||||
self._auto_cancel = auto_cancel
|
||||
self._request = None
|
||||
self._task = None
|
||||
self._running = False
|
||||
|
||||
def __await__(self):
|
||||
return self._once().__await__()
|
||||
|
||||
async def __aenter__(self):
|
||||
self._chat = await self._client.get_input_entity(self._chat)
|
||||
|
||||
# Since `self._action` is passed by reference we can avoid
|
||||
# recreating the request all the time and still modify
|
||||
# `self._action.progress` directly in `progress`.
|
||||
self._request = _tl.fn.messages.SetTyping(
|
||||
self._chat, self._action)
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._update())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._task = None
|
||||
|
||||
async def _once(self):
|
||||
self._chat = await self._client.get_input_entity(self._chat)
|
||||
await self._client(_tl.fn.messages.SetTyping(self._chat, self._action))
|
||||
|
||||
async def _update(self):
|
||||
try:
|
||||
while self._running:
|
||||
await self._client(self._request)
|
||||
await asyncio.sleep(self._delay)
|
||||
except ConnectionError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
if self._auto_cancel:
|
||||
await self._client(_tl.fn.messages.SetTyping(
|
||||
self._chat, _tl.SendMessageCancelAction()))
|
||||
|
||||
@staticmethod
|
||||
def _parse(action):
|
||||
if isinstance(action, tlobject.TLObject) and action.SUBCLASS_OF_ID != 0x20b2cc21:
|
||||
return action
|
||||
|
||||
return {
|
||||
enums.Action.TYPING: _tl.SendMessageTypingAction(),
|
||||
enums.Action.CONTACT: _tl.SendMessageChooseContactAction(),
|
||||
enums.Action.GAME: _tl.SendMessageGamePlayAction(),
|
||||
enums.Action.LOCATION: _tl.SendMessageGeoLocationAction(),
|
||||
enums.Action.STICKER: _tl.SendMessageChooseStickerAction(),
|
||||
enums.Action.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(),
|
||||
enums.Action.RECORD_ROUND: _tl.SendMessageRecordRoundAction(),
|
||||
enums.Action.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(),
|
||||
enums.Action.AUDIO: _tl.SendMessageUploadAudioAction(1),
|
||||
enums.Action.ROUND: _tl.SendMessageUploadRoundAction(1),
|
||||
enums.Action.VIDEO: _tl.SendMessageUploadVideoAction(1),
|
||||
enums.Action.PHOTO: _tl.SendMessageUploadPhotoAction(1),
|
||||
enums.Action.DOCUMENT: _tl.SendMessageUploadDocumentAction(1),
|
||||
enums.Action.CANCEL: _tl.SendMessageCancelAction(),
|
||||
}[enums.Action(action)]
|
||||
|
||||
def progress(self, current, total):
|
||||
if hasattr(self._action, 'progress'):
|
||||
self._action.progress = 100 * round(current / total)
|
||||
|
||||
|
||||
class _ParticipantsIter(requestiter.RequestIter):
|
||||
async def _init(self, entity, filter, search):
|
||||
if not filter:
|
||||
if search:
|
||||
filter = _tl.ChannelParticipantsSearch(search)
|
||||
else:
|
||||
filter = _tl.ChannelParticipantsRecent()
|
||||
else:
|
||||
filter = enums.Participant(filter)
|
||||
if filter == enums.Participant.ADMIN:
|
||||
filter = _tl.ChannelParticipantsAdmins()
|
||||
elif filter == enums.Participant.BOT:
|
||||
filter = _tl.ChannelParticipantsBots()
|
||||
elif filter == enums.Participant.KICKED:
|
||||
filter = _tl.ChannelParticipantsKicked(search)
|
||||
elif filter == enums.Participant.BANNED:
|
||||
filter = _tl.ChannelParticipantsBanned(search)
|
||||
elif filter == enums.Participant.CONTACT:
|
||||
filter = _tl.ChannelParticipantsContacts(search)
|
||||
else:
|
||||
raise RuntimeError('unhandled enum variant')
|
||||
|
||||
entity = await self.client.get_input_entity(entity)
|
||||
ty = helpers._entity_type(entity)
|
||||
if search and (filter or ty != helpers._EntityType.CHANNEL):
|
||||
# We need to 'search' ourselves unless we have a PeerChannel
|
||||
search = search.casefold()
|
||||
|
||||
self.filter_entity = lambda ent: (
|
||||
search in utils.get_display_name(ent).casefold() or
|
||||
search in (getattr(ent, 'username', None) or '').casefold()
|
||||
)
|
||||
else:
|
||||
self.filter_entity = lambda ent: True
|
||||
|
||||
if ty == helpers._EntityType.CHANNEL:
|
||||
if self.limit <= 0:
|
||||
# May not have access to the channel, but getFull can get the .total.
|
||||
self.total = (await self.client(
|
||||
_tl.fn.channels.GetFullChannel(entity)
|
||||
)).full_chat.participants_count
|
||||
raise StopAsyncIteration
|
||||
|
||||
self.seen = set()
|
||||
self.request = _tl.fn.channels.GetParticipants(
|
||||
channel=entity,
|
||||
filter=filter or _tl.ChannelParticipantsSearch(search),
|
||||
offset=0,
|
||||
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
|
||||
hash=0
|
||||
)
|
||||
|
||||
elif ty == helpers._EntityType.CHAT:
|
||||
full = await self.client(
|
||||
_tl.fn.messages.GetFullChat(entity.chat_id))
|
||||
if not isinstance(
|
||||
full.full_chat.participants, _tl.ChatParticipants):
|
||||
# ChatParticipantsForbidden won't have ``.participants``
|
||||
self.total = 0
|
||||
raise StopAsyncIteration
|
||||
|
||||
self.total = len(full.full_chat.participants.participants)
|
||||
|
||||
users = {user.id: user for user in full.users}
|
||||
for participant in full.full_chat.participants.participants:
|
||||
if isinstance(participant, _tl.ChannelParticipantBanned):
|
||||
user_id = participant.peer.user_id
|
||||
else:
|
||||
user_id = participant.user_id
|
||||
user = users[user_id]
|
||||
if not self.filter_entity(user):
|
||||
continue
|
||||
|
||||
user = users[user_id]
|
||||
self.buffer.append(user)
|
||||
|
||||
return True
|
||||
else:
|
||||
self.total = 1
|
||||
if self.limit != 0:
|
||||
user = await self.client.get_entity(entity)
|
||||
if self.filter_entity(user):
|
||||
self.buffer.append(user)
|
||||
|
||||
return True
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
# Only care about the limit for the first request
|
||||
# (small amount of people).
|
||||
#
|
||||
# Most people won't care about getting exactly 12,345
|
||||
# members so it doesn't really matter not to be 100%
|
||||
# precise with being out of the offset/limit here.
|
||||
self.request.limit = min(
|
||||
self.limit - self.request.offset, _MAX_PARTICIPANTS_CHUNK_SIZE)
|
||||
|
||||
if self.request.offset > self.limit:
|
||||
return True
|
||||
|
||||
participants = await self.client(self.request)
|
||||
self.total = participants.count
|
||||
|
||||
self.request.offset += len(participants.participants)
|
||||
users = {user.id: user for user in participants.users}
|
||||
for participant in participants.participants:
|
||||
if isinstance(participant, _tl.ChannelParticipantBanned):
|
||||
if not isinstance(participant.peer, _tl.PeerUser):
|
||||
# May have the entire channel banned. See #3105.
|
||||
continue
|
||||
user_id = participant.peer.user_id
|
||||
else:
|
||||
user_id = participant.user_id
|
||||
|
||||
user = users[user_id]
|
||||
if not self.filter_entity(user) or user.id in self.seen:
|
||||
continue
|
||||
self.seen.add(user_id)
|
||||
user = users[user_id]
|
||||
self.buffer.append(user)
|
||||
|
||||
|
||||
class _AdminLogIter(requestiter.RequestIter):
|
||||
async def _init(
|
||||
self, entity, admins, search, min_id, max_id,
|
||||
join, leave, invite, restrict, unrestrict, ban, unban,
|
||||
promote, demote, info, settings, pinned, edit, delete,
|
||||
group_call
|
||||
):
|
||||
if any((join, leave, invite, restrict, unrestrict, ban, unban,
|
||||
promote, demote, info, settings, pinned, edit, delete,
|
||||
group_call)):
|
||||
events_filter = _tl.ChannelAdminLogEventsFilter(
|
||||
join=join, leave=leave, invite=invite, ban=restrict,
|
||||
unban=unrestrict, kick=ban, unkick=unban, promote=promote,
|
||||
demote=demote, info=info, settings=settings, pinned=pinned,
|
||||
edit=edit, delete=delete, group_call=group_call
|
||||
)
|
||||
else:
|
||||
events_filter = None
|
||||
|
||||
self.entity = await self.client.get_input_entity(entity)
|
||||
|
||||
admin_list = []
|
||||
if admins:
|
||||
if not utils.is_list_like(admins):
|
||||
admins = (admins,)
|
||||
|
||||
for admin in admins:
|
||||
admin_list.append(await self.client.get_input_entity(admin))
|
||||
|
||||
self.request = _tl.fn.channels.GetAdminLog(
|
||||
self.entity, q=search or '', min_id=min_id, max_id=max_id,
|
||||
limit=0, events_filter=events_filter, admins=admin_list or None
|
||||
)
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
self.request.limit = min(self.left, _MAX_ADMIN_LOG_CHUNK_SIZE)
|
||||
r = await self.client(self.request)
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)}
|
||||
|
||||
self.request.max_id = min((e.id for e in r.events), default=0)
|
||||
for ev in r.events:
|
||||
if isinstance(ev.action,
|
||||
_tl.ChannelAdminLogEventActionEditMessage):
|
||||
ev.action.prev_message = _custom.Message._new(
|
||||
self.client, ev.action.prev_message, entities, self.entity)
|
||||
|
||||
ev.action.new_message = _custom.Message._new(
|
||||
self.client, ev.action.new_message, entities, self.entity)
|
||||
|
||||
elif isinstance(ev.action,
|
||||
_tl.ChannelAdminLogEventActionDeleteMessage):
|
||||
ev.action.message = _custom.Message._new(
|
||||
self.client, ev.action.message, entities, self.entity)
|
||||
|
||||
self.buffer.append(_custom.AdminLogEvent(ev, entities))
|
||||
|
||||
if len(r.events) < self.request.limit:
|
||||
return True
|
||||
|
||||
|
||||
class _ProfilePhotoIter(requestiter.RequestIter):
|
||||
async def _init(
|
||||
self, entity, offset, max_id
|
||||
):
|
||||
entity = await self.client.get_input_entity(entity)
|
||||
ty = helpers._entity_type(entity)
|
||||
if ty == helpers._EntityType.USER:
|
||||
self.request = _tl.fn.photos.GetUserPhotos(
|
||||
entity,
|
||||
offset=offset,
|
||||
max_id=max_id,
|
||||
limit=1
|
||||
)
|
||||
else:
|
||||
self.request = _tl.fn.messages.Search(
|
||||
peer=entity,
|
||||
q='',
|
||||
filter=_tl.InputMessagesFilterChatPhotos(),
|
||||
min_date=None,
|
||||
max_date=None,
|
||||
offset_id=0,
|
||||
add_offset=offset,
|
||||
limit=1,
|
||||
max_id=max_id,
|
||||
min_id=0,
|
||||
hash=0
|
||||
)
|
||||
|
||||
if self.limit == 0:
|
||||
self.request.limit = 1
|
||||
result = await self.client(self.request)
|
||||
if isinstance(result, _tl.photos.Photos):
|
||||
self.total = len(result.photos)
|
||||
elif isinstance(result, _tl.messages.Messages):
|
||||
self.total = len(result.messages)
|
||||
else:
|
||||
# Luckily both photosSlice and messages have a count for total
|
||||
self.total = getattr(result, 'count', None)
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
self.request.limit = min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE)
|
||||
result = await self.client(self.request)
|
||||
|
||||
if isinstance(result, _tl.photos.Photos):
|
||||
self.buffer = result.photos
|
||||
self.left = len(self.buffer)
|
||||
self.total = len(self.buffer)
|
||||
elif isinstance(result, _tl.messages.Messages):
|
||||
self.buffer = [x.action.photo for x in result.messages
|
||||
if isinstance(x.action, _tl.MessageActionChatEditPhoto)]
|
||||
|
||||
self.left = len(self.buffer)
|
||||
self.total = len(self.buffer)
|
||||
elif isinstance(result, _tl.photos.PhotosSlice):
|
||||
self.buffer = result.photos
|
||||
self.total = result.count
|
||||
if len(self.buffer) < self.request.limit:
|
||||
self.left = len(self.buffer)
|
||||
else:
|
||||
self.request.offset += len(result.photos)
|
||||
else:
|
||||
# Some broadcast channels have a photo that this request doesn't
|
||||
# retrieve for whatever random reason the Telegram server feels.
|
||||
#
|
||||
# This means the `total` count may be wrong but there's not much
|
||||
# that can be done around it (perhaps there are too many photos
|
||||
# and this is only a partial result so it's not possible to just
|
||||
# use the len of the result).
|
||||
self.total = getattr(result, 'count', None)
|
||||
|
||||
# Unconditionally fetch the full channel to obtain this photo and
|
||||
# yield it with the rest (unless it's a duplicate).
|
||||
seen_id = None
|
||||
if isinstance(result, _tl.messages.ChannelMessages):
|
||||
channel = await self.client(_tl.fn.channels.GetFullChannel(self.request.peer))
|
||||
photo = channel.full_chat.chat_photo
|
||||
if isinstance(photo, _tl.Photo):
|
||||
self.buffer.append(photo)
|
||||
seen_id = photo.id
|
||||
|
||||
self.buffer.extend(
|
||||
x.action.photo for x in result.messages
|
||||
if isinstance(x.action, _tl.MessageActionChatEditPhoto)
|
||||
and x.action.photo.id != seen_id
|
||||
)
|
||||
|
||||
if len(result.messages) < self.request.limit:
|
||||
self.left = len(self.buffer)
|
||||
elif result.messages:
|
||||
self.request.add_offset = 0
|
||||
self.request.offset_id = result.messages[-1].id
|
||||
|
||||
|
||||
def get_participants(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
limit: float = (),
|
||||
*,
|
||||
search: str = '',
|
||||
filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter:
|
||||
return _ParticipantsIter(
|
||||
self,
|
||||
limit,
|
||||
entity=entity,
|
||||
filter=filter,
|
||||
search=search
|
||||
)
|
||||
|
||||
|
||||
def get_admin_log(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
limit: float = (),
|
||||
*,
|
||||
max_id: int = 0,
|
||||
min_id: int = 0,
|
||||
search: str = None,
|
||||
admins: 'hints.EntitiesLike' = None,
|
||||
join: bool = None,
|
||||
leave: bool = None,
|
||||
invite: bool = None,
|
||||
restrict: bool = None,
|
||||
unrestrict: bool = None,
|
||||
ban: bool = None,
|
||||
unban: bool = None,
|
||||
promote: bool = None,
|
||||
demote: bool = None,
|
||||
info: bool = None,
|
||||
settings: bool = None,
|
||||
pinned: bool = None,
|
||||
edit: bool = None,
|
||||
delete: bool = None,
|
||||
group_call: bool = None) -> _AdminLogIter:
|
||||
return _AdminLogIter(
|
||||
self,
|
||||
limit,
|
||||
entity=entity,
|
||||
admins=admins,
|
||||
search=search,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
join=join,
|
||||
leave=leave,
|
||||
invite=invite,
|
||||
restrict=restrict,
|
||||
unrestrict=unrestrict,
|
||||
ban=ban,
|
||||
unban=unban,
|
||||
promote=promote,
|
||||
demote=demote,
|
||||
info=info,
|
||||
settings=settings,
|
||||
pinned=pinned,
|
||||
edit=edit,
|
||||
delete=delete,
|
||||
group_call=group_call
|
||||
)
|
||||
|
||||
|
||||
def get_profile_photos(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
limit: int = (),
|
||||
*,
|
||||
offset: int = 0,
|
||||
max_id: int = 0) -> _ProfilePhotoIter:
|
||||
return _ProfilePhotoIter(
|
||||
self,
|
||||
limit,
|
||||
entity=entity,
|
||||
offset=offset,
|
||||
max_id=max_id
|
||||
)
|
||||
|
||||
|
||||
def action(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
action: 'typing.Union[str, _tl.TypeSendMessageAction]',
|
||||
*,
|
||||
delay: float = 4,
|
||||
auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]':
|
||||
action = _ChatAction._parse(action)
|
||||
|
||||
return _ChatAction(
|
||||
self, entity, action, delay=delay, auto_cancel=auto_cancel)
|
||||
|
||||
async def edit_admin(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
user: 'hints.EntityLike',
|
||||
*,
|
||||
change_info: bool = None,
|
||||
post_messages: bool = None,
|
||||
edit_messages: bool = None,
|
||||
delete_messages: bool = None,
|
||||
ban_users: bool = None,
|
||||
invite_users: bool = None,
|
||||
pin_messages: bool = None,
|
||||
add_admins: bool = None,
|
||||
manage_call: bool = None,
|
||||
anonymous: bool = None,
|
||||
is_admin: bool = None,
|
||||
title: str = None) -> _tl.Updates:
|
||||
entity = await self.get_input_entity(entity)
|
||||
user = await self.get_input_entity(user)
|
||||
ty = helpers._entity_type(user)
|
||||
if ty != helpers._EntityType.USER:
|
||||
raise ValueError('You must pass a user entity')
|
||||
|
||||
perm_names = (
|
||||
'change_info', 'post_messages', 'edit_messages', 'delete_messages',
|
||||
'ban_users', 'invite_users', 'pin_messages', 'add_admins',
|
||||
'anonymous', 'manage_call',
|
||||
)
|
||||
|
||||
ty = helpers._entity_type(entity)
|
||||
if ty == helpers._EntityType.CHANNEL:
|
||||
# If we try to set these permissions in a megagroup, we
|
||||
# would get a RIGHT_FORBIDDEN. However, it makes sense
|
||||
# that an admin can post messages, so we want to avoid the error
|
||||
if post_messages or edit_messages:
|
||||
# TODO get rid of this once sessions cache this information
|
||||
if entity.channel_id not in self._megagroup_cache:
|
||||
full_entity = await self.get_entity(entity)
|
||||
self._megagroup_cache[entity.channel_id] = full_entity.megagroup
|
||||
|
||||
if self._megagroup_cache[entity.channel_id]:
|
||||
post_messages = None
|
||||
edit_messages = None
|
||||
|
||||
perms = locals()
|
||||
return await self(_tl.fn.channels.EditAdmin(entity, user, _tl.ChatAdminRights(**{
|
||||
# A permission is its explicit (not-None) value or `is_admin`.
|
||||
# This essentially makes `is_admin` be the default value.
|
||||
name: perms[name] if perms[name] is not None else is_admin
|
||||
for name in perm_names
|
||||
}), rank=title or ''))
|
||||
|
||||
elif ty == helpers._EntityType.CHAT:
|
||||
# If the user passed any permission in a small
|
||||
# group chat, they must be a full admin to have it.
|
||||
if is_admin is None:
|
||||
is_admin = any(locals()[x] for x in perm_names)
|
||||
|
||||
return await self(_tl.fn.messages.EditChatAdmin(
|
||||
entity, user, is_admin=is_admin))
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
'You can only edit permissions in groups and channels')
|
||||
|
||||
async def edit_permissions(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
user: 'typing.Optional[hints.EntityLike]' = None,
|
||||
until_date: 'hints.DateLike' = None,
|
||||
*,
|
||||
view_messages: bool = True,
|
||||
send_messages: bool = True,
|
||||
send_media: bool = True,
|
||||
send_stickers: bool = True,
|
||||
send_gifs: bool = True,
|
||||
send_games: bool = True,
|
||||
send_inline: bool = True,
|
||||
embed_link_previews: bool = True,
|
||||
send_polls: bool = True,
|
||||
change_info: bool = True,
|
||||
invite_users: bool = True,
|
||||
pin_messages: bool = True) -> _tl.Updates:
|
||||
entity = await self.get_input_entity(entity)
|
||||
ty = helpers._entity_type(entity)
|
||||
if ty != helpers._EntityType.CHANNEL:
|
||||
raise ValueError('You must pass either a channel or a supergroup')
|
||||
|
||||
rights = _tl.ChatBannedRights(
|
||||
until_date=until_date,
|
||||
view_messages=not view_messages,
|
||||
send_messages=not send_messages,
|
||||
send_media=not send_media,
|
||||
send_stickers=not send_stickers,
|
||||
send_gifs=not send_gifs,
|
||||
send_games=not send_games,
|
||||
send_inline=not send_inline,
|
||||
embed_links=not embed_link_previews,
|
||||
send_polls=not send_polls,
|
||||
change_info=not change_info,
|
||||
invite_users=not invite_users,
|
||||
pin_messages=not pin_messages
|
||||
)
|
||||
|
||||
if user is None:
|
||||
return await self(_tl.fn.messages.EditChatDefaultBannedRights(
|
||||
peer=entity,
|
||||
banned_rights=rights
|
||||
))
|
||||
|
||||
user = await self.get_input_entity(user)
|
||||
ty = helpers._entity_type(user)
|
||||
if ty != helpers._EntityType.USER:
|
||||
raise ValueError('You must pass a user entity')
|
||||
|
||||
if isinstance(user, _tl.InputPeerSelf):
|
||||
raise ValueError('You cannot restrict yourself')
|
||||
|
||||
return await self(_tl.fn.channels.EditBanned(
|
||||
channel=entity,
|
||||
participant=user,
|
||||
banned_rights=rights
|
||||
))
|
||||
|
||||
async def kick_participant(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
user: 'typing.Optional[hints.EntityLike]'
|
||||
):
|
||||
entity = await self.get_input_entity(entity)
|
||||
user = await self.get_input_entity(user)
|
||||
if helpers._entity_type(user) != helpers._EntityType.USER:
|
||||
raise ValueError('You must pass a user entity')
|
||||
|
||||
ty = helpers._entity_type(entity)
|
||||
if ty == helpers._EntityType.CHAT:
|
||||
resp = await self(_tl.fn.messages.DeleteChatUser(entity.chat_id, user))
|
||||
elif ty == helpers._EntityType.CHANNEL:
|
||||
if isinstance(user, _tl.InputPeerSelf):
|
||||
# Despite no longer being in the channel, the account still
|
||||
# seems to get the service message.
|
||||
resp = await self(_tl.fn.channels.LeaveChannel(entity))
|
||||
else:
|
||||
resp = await self(_tl.fn.channels.EditBanned(
|
||||
channel=entity,
|
||||
participant=user,
|
||||
banned_rights=_tl.ChatBannedRights(
|
||||
until_date=None, view_messages=True)
|
||||
))
|
||||
await asyncio.sleep(0.5)
|
||||
await self(_tl.fn.channels.EditBanned(
|
||||
channel=entity,
|
||||
participant=user,
|
||||
banned_rights=_tl.ChatBannedRights(until_date=None)
|
||||
))
|
||||
else:
|
||||
raise ValueError('You must pass either a channel or a chat')
|
||||
|
||||
return self._get_response_message(None, resp, entity)
|
||||
|
||||
async def get_permissions(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
user: 'hints.EntityLike' = None
|
||||
) -> 'typing.Optional[custom.ParticipantPermissions]':
|
||||
entity = await self.get_entity(entity)
|
||||
|
||||
if not user:
|
||||
if helpers._entity_type(entity) != helpers._EntityType.USER:
|
||||
return entity.default_banned_rights
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
user = await self.get_input_entity(user)
|
||||
if helpers._entity_type(user) != helpers._EntityType.USER:
|
||||
raise ValueError('You must pass a user entity')
|
||||
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
|
||||
participant = await self(_tl.fn.channels.GetParticipant(
|
||||
entity,
|
||||
user
|
||||
))
|
||||
return _custom.ParticipantPermissions(participant.participant, False)
|
||||
elif helpers._entity_type(entity) == helpers._EntityType.CHAT:
|
||||
chat = await self(_tl.fn.messages.GetFullChat(
|
||||
entity
|
||||
))
|
||||
if isinstance(user, _tl.InputPeerSelf):
|
||||
user = await self.get_me(input_peer=True)
|
||||
for participant in chat.full_chat.participants.participants:
|
||||
if participant.user_id == user.user_id:
|
||||
return _custom.ParticipantPermissions(participant, True)
|
||||
raise errors.USER_NOT_PARTICIPANT(400, 'USER_NOT_PARTICIPANT')
|
||||
|
||||
raise ValueError('You must pass either a channel or a chat')
|
||||
|
||||
async def get_stats(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'typing.Union[int, _tl.Message]' = None,
|
||||
):
|
||||
entity = await self.get_input_entity(entity)
|
||||
if helpers._entity_type(entity) != helpers._EntityType.CHANNEL:
|
||||
raise TypeError('You must pass a channel entity')
|
||||
|
||||
message = utils.get_message_id(message)
|
||||
if message is not None:
|
||||
try:
|
||||
req = _tl.fn.stats.GetMessageStats(entity, message)
|
||||
return await self(req)
|
||||
except errors.STATS_MIGRATE as e:
|
||||
dc = e.dc
|
||||
else:
|
||||
# Don't bother fetching the Channel entity (costs a request), instead
|
||||
# try to guess and if it fails we know it's the other one (best case
|
||||
# no extra request, worst just one).
|
||||
try:
|
||||
req = _tl.fn.stats.GetBroadcastStats(entity)
|
||||
return await self(req)
|
||||
except errors.STATS_MIGRATE as e:
|
||||
dc = e.dc
|
||||
except errors.BROADCAST_REQUIRED:
|
||||
req = _tl.fn.stats.GetMegagroupStats(entity)
|
||||
try:
|
||||
return await self(req)
|
||||
except errors.STATS_MIGRATE as e:
|
||||
dc = e.dc
|
||||
|
||||
sender = await self._borrow_exported_sender(dc)
|
||||
try:
|
||||
# req will be resolved to use the right types inside by now
|
||||
return await sender.send(req)
|
||||
finally:
|
||||
await self._return_exported_sender(sender)
|
244
telethon/_client/dialogs.py
Normal file
244
telethon/_client/dialogs.py
Normal file
|
@ -0,0 +1,244 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import typing
|
||||
|
||||
from .. import errors, _tl
|
||||
from .._misc import helpers, utils, requestiter, hints
|
||||
from ..types import _custom
|
||||
|
||||
_MAX_CHUNK_SIZE = 100
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
def _dialog_message_key(peer, message_id):
|
||||
"""
|
||||
Get the key to get messages from a dialog.
|
||||
|
||||
We cannot just use the message ID because channels share message IDs,
|
||||
and the peer ID is required to distinguish between them. But it is not
|
||||
necessary in small group chats and private chats.
|
||||
"""
|
||||
return (peer.channel_id if isinstance(peer, _tl.PeerChannel) else None), message_id
|
||||
|
||||
|
||||
class _DialogsIter(requestiter.RequestIter):
|
||||
async def _init(
|
||||
self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder
|
||||
):
|
||||
self.request = _tl.fn.messages.GetDialogs(
|
||||
offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
offset_peer=offset_peer,
|
||||
limit=1,
|
||||
hash=0,
|
||||
exclude_pinned=ignore_pinned,
|
||||
folder_id=folder
|
||||
)
|
||||
|
||||
if self.limit <= 0:
|
||||
# Special case, get a single dialog and determine count
|
||||
dialogs = await self.client(self.request)
|
||||
self.total = getattr(dialogs, 'count', len(dialogs.dialogs))
|
||||
raise StopAsyncIteration
|
||||
|
||||
self.seen = set()
|
||||
self.offset_date = offset_date
|
||||
self.ignore_migrated = ignore_migrated
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
self.request.limit = min(self.left, _MAX_CHUNK_SIZE)
|
||||
r = await self.client(self.request)
|
||||
|
||||
self.total = getattr(r, 'count', len(r.dialogs))
|
||||
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)
|
||||
if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))}
|
||||
|
||||
messages = {
|
||||
_dialog_message_key(m.peer_id, m.id): _custom.Message._new(self.client, m, entities, None)
|
||||
for m in r.messages
|
||||
}
|
||||
|
||||
for d in r.dialogs:
|
||||
# We check the offset date here because Telegram may ignore it
|
||||
message = messages.get(_dialog_message_key(d.peer, d.top_message))
|
||||
if self.offset_date:
|
||||
date = getattr(message, 'date', None)
|
||||
if not date or date.timestamp() > self.offset_date.timestamp():
|
||||
continue
|
||||
|
||||
peer_id = utils.get_peer_id(d.peer)
|
||||
if peer_id not in self.seen:
|
||||
self.seen.add(peer_id)
|
||||
if peer_id not in entities:
|
||||
# > In which case can a UserEmpty appear in the list of banned members?
|
||||
# > In a very rare cases. This is possible but isn't an expected behavior.
|
||||
# Real world example: https://t.me/TelethonChat/271471
|
||||
continue
|
||||
|
||||
cd = _custom.Dialog(self.client, d, entities, message)
|
||||
if cd.dialog.pts:
|
||||
self.client._channel_pts[cd.id] = cd.dialog.pts
|
||||
|
||||
if not self.ignore_migrated or getattr(
|
||||
cd.entity, 'migrated_to', None) is None:
|
||||
self.buffer.append(cd)
|
||||
|
||||
if len(r.dialogs) < self.request.limit\
|
||||
or not isinstance(r, _tl.messages.DialogsSlice):
|
||||
# Less than we requested means we reached the end, or
|
||||
# we didn't get a DialogsSlice which means we got all.
|
||||
return True
|
||||
|
||||
# We can't use `messages[-1]` as the offset ID / date.
|
||||
# Why? Because pinned dialogs will mess with the order
|
||||
# in this list. Instead, we find the last dialog which
|
||||
# has a message, and use it as an offset.
|
||||
last_message = next(filter(None, (
|
||||
messages.get(_dialog_message_key(d.peer, d.top_message))
|
||||
for d in reversed(r.dialogs)
|
||||
)), None)
|
||||
|
||||
self.request.exclude_pinned = True
|
||||
self.request.offset_id = last_message.id if last_message else 0
|
||||
self.request.offset_date = last_message.date if last_message else None
|
||||
self.request.offset_peer = self.buffer[-1].input_entity
|
||||
|
||||
|
||||
class _DraftsIter(requestiter.RequestIter):
|
||||
async def _init(self, entities, **kwargs):
|
||||
if not entities:
|
||||
r = await self.client(_tl.fn.messages.GetAllDrafts())
|
||||
items = r.updates
|
||||
else:
|
||||
peers = []
|
||||
for entity in entities:
|
||||
peers.append(_tl.InputDialogPeer(
|
||||
await self.client.get_input_entity(entity)))
|
||||
|
||||
r = await self.client(_tl.fn.messages.GetPeerDialogs(peers))
|
||||
items = r.dialogs
|
||||
|
||||
# TODO Maybe there should be a helper method for this?
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)}
|
||||
|
||||
self.buffer.extend(
|
||||
_custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
|
||||
for d in items
|
||||
)
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
return []
|
||||
|
||||
|
||||
def get_dialogs(
|
||||
self: 'TelegramClient',
|
||||
limit: float = (),
|
||||
*,
|
||||
offset_date: 'hints.DateLike' = None,
|
||||
offset_id: int = 0,
|
||||
offset_peer: 'hints.EntityLike' = _tl.InputPeerEmpty(),
|
||||
ignore_pinned: bool = False,
|
||||
ignore_migrated: bool = False,
|
||||
folder: int = None,
|
||||
archived: bool = None
|
||||
) -> _DialogsIter:
|
||||
if archived is not None:
|
||||
folder = 1 if archived else 0
|
||||
|
||||
return _DialogsIter(
|
||||
self,
|
||||
limit,
|
||||
offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
offset_peer=offset_peer,
|
||||
ignore_pinned=ignore_pinned,
|
||||
ignore_migrated=ignore_migrated,
|
||||
folder=folder
|
||||
)
|
||||
|
||||
|
||||
def get_drafts(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntitiesLike' = None
|
||||
) -> _DraftsIter:
|
||||
limit = None
|
||||
if entity:
|
||||
if not utils.is_list_like(entity):
|
||||
entity = (entity,)
|
||||
limit = len(entity)
|
||||
|
||||
return _DraftsIter(self, limit, entities=entity)
|
||||
|
||||
|
||||
async def edit_folder(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntitiesLike' = None,
|
||||
folder: typing.Union[int, typing.Sequence[int]] = None,
|
||||
*,
|
||||
unpack=None
|
||||
) -> _tl.Updates:
|
||||
if (entity is None) == (unpack is None):
|
||||
raise ValueError('You can only set either entities or unpack, not both')
|
||||
|
||||
if unpack is not None:
|
||||
return await self(_tl.fn.folders.DeleteFolder(
|
||||
folder_id=unpack
|
||||
))
|
||||
|
||||
if not utils.is_list_like(entity):
|
||||
entities = [await self.get_input_entity(entity)]
|
||||
else:
|
||||
entities = await asyncio.gather(
|
||||
*(self.get_input_entity(x) for x in entity))
|
||||
|
||||
if folder is None:
|
||||
raise ValueError('You must specify a folder')
|
||||
elif not utils.is_list_like(folder):
|
||||
folder = [folder] * len(entities)
|
||||
elif len(entities) != len(folder):
|
||||
raise ValueError('Number of folders does not match number of entities')
|
||||
|
||||
return await self(_tl.fn.folders.EditPeerFolders([
|
||||
_tl.InputFolderPeer(x, folder_id=y)
|
||||
for x, y in zip(entities, folder)
|
||||
]))
|
||||
|
||||
async def delete_dialog(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
*,
|
||||
revoke: bool = False
|
||||
):
|
||||
# If we have enough information (`Dialog.delete` gives it to us),
|
||||
# then we know we don't have to kick ourselves in deactivated chats.
|
||||
if isinstance(entity, _tl.Chat):
|
||||
deactivated = entity.deactivated
|
||||
else:
|
||||
deactivated = False
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
ty = helpers._entity_type(entity)
|
||||
if ty == helpers._EntityType.CHANNEL:
|
||||
return await self(_tl.fn.channels.LeaveChannel(entity))
|
||||
|
||||
if ty == helpers._EntityType.CHAT and not deactivated:
|
||||
try:
|
||||
result = await self(_tl.fn.messages.DeleteChatUser(
|
||||
entity.chat_id, _tl.InputUserSelf(), revoke_history=revoke
|
||||
))
|
||||
except errors.PEER_ID_INVALID:
|
||||
# Happens if we didn't have the deactivated information
|
||||
result = None
|
||||
else:
|
||||
result = None
|
||||
|
||||
if not await self.is_bot():
|
||||
await self(_tl.fn.messages.DeleteHistory(entity, 0, revoke=revoke))
|
||||
|
||||
return result
|
768
telethon/_client/downloads.py
Normal file
768
telethon/_client/downloads.py
Normal file
|
@ -0,0 +1,768 @@
|
|||
import datetime
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import typing
|
||||
import inspect
|
||||
import asyncio
|
||||
|
||||
from .._crypto import AES
|
||||
from .._misc import utils, helpers, requestiter, tlobject, hints, enums
|
||||
from .. import errors, _tl
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
aiohttp = None
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
# Chunk sizes for upload.getFile must be multiples of the smallest size
|
||||
MIN_CHUNK_SIZE = 4096
|
||||
MAX_CHUNK_SIZE = 512 * 1024
|
||||
|
||||
# 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files.
|
||||
TIMED_OUT_SLEEP = 1
|
||||
|
||||
class _DirectDownloadIter(requestiter.RequestIter):
|
||||
async def _init(
|
||||
self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data
|
||||
):
|
||||
self.request = _tl.fn.upload.GetFile(
|
||||
file, offset=offset, limit=request_size)
|
||||
|
||||
self.total = file_size
|
||||
self._stride = stride
|
||||
self._chunk_size = chunk_size
|
||||
self._last_part = None
|
||||
self._msg_data = msg_data
|
||||
self._timed_out = False
|
||||
|
||||
self._exported = dc_id and self.client._session_state.dc_id != dc_id
|
||||
if not self._exported:
|
||||
# The used sender will also change if ``FileMigrateError`` occurs
|
||||
self._sender = self.client._sender
|
||||
else:
|
||||
# If this raises DcIdInvalidError, it means we tried exporting the same DC we're in.
|
||||
# This should not happen, but if it does, it's a bug.
|
||||
self._sender = await self.client._borrow_exported_sender(dc_id)
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
cur = await self._request()
|
||||
self.buffer.append(cur)
|
||||
if len(cur) < self.request.limit:
|
||||
self.left = len(self.buffer)
|
||||
await self.close()
|
||||
else:
|
||||
self.request.offset += self._stride
|
||||
|
||||
async def _request(self):
|
||||
try:
|
||||
result = await self.client._call(self._sender, self.request)
|
||||
self._timed_out = False
|
||||
if isinstance(result, _tl.upload.FileCdnRedirect):
|
||||
raise NotImplementedError # TODO Implement
|
||||
else:
|
||||
return result.bytes
|
||||
|
||||
except errors.TimeoutError as e:
|
||||
if self._timed_out:
|
||||
self.client._log[__name__].warning('Got two timeouts in a row while downloading file')
|
||||
raise
|
||||
|
||||
self._timed_out = True
|
||||
self.client._log[__name__].info('Got timeout while downloading file, retrying once')
|
||||
await asyncio.sleep(TIMED_OUT_SLEEP)
|
||||
return await self._request()
|
||||
|
||||
except errors.FileMigrateError as e:
|
||||
self.client._log[__name__].info('File lives in another DC')
|
||||
self._sender = await self.client._borrow_exported_sender(e.new_dc)
|
||||
self._exported = True
|
||||
return await self._request()
|
||||
|
||||
except errors.FilerefUpgradeNeededError as e:
|
||||
# Only implemented for documents which are the ones that may take that long to download
|
||||
if not self._msg_data \
|
||||
or not isinstance(self.request.location, _tl.InputDocumentFileLocation) \
|
||||
or self.request.location.thumb_size != '':
|
||||
raise
|
||||
|
||||
self.client._log[__name__].info('File ref expired during download; refetching message')
|
||||
chat, msg_id = self._msg_data
|
||||
msg = await self.client.get_messages(chat, ids=msg_id)
|
||||
|
||||
if not isinstance(msg.media, _tl.MessageMediaDocument):
|
||||
raise
|
||||
|
||||
document = msg.media.document
|
||||
|
||||
# Message media may have been edited for something else
|
||||
if document.id != self.request.location.id:
|
||||
raise
|
||||
|
||||
self.request.location.file_reference = document.file_reference
|
||||
return await self._request()
|
||||
|
||||
async def close(self):
|
||||
if not self._sender:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._exported:
|
||||
await self.client._return_exported_sender(self._sender)
|
||||
elif self._sender != self.client._sender:
|
||||
await self._sender.disconnect()
|
||||
finally:
|
||||
self._sender = None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.close()
|
||||
|
||||
|
||||
class _GenericDownloadIter(_DirectDownloadIter):
|
||||
async def _load_next_chunk(self):
|
||||
# 1. Fetch enough for one chunk
|
||||
data = b''
|
||||
|
||||
# 1.1. ``bad`` is how much into the data we have we need to offset
|
||||
bad = self.request.offset % self.request.limit
|
||||
before = self.request.offset
|
||||
|
||||
# 1.2. We have to fetch from a valid offset, so remove that bad part
|
||||
self.request.offset -= bad
|
||||
|
||||
done = False
|
||||
while not done and len(data) - bad < self._chunk_size:
|
||||
cur = await self._request()
|
||||
self.request.offset += self.request.limit
|
||||
|
||||
data += cur
|
||||
done = len(cur) < self.request.limit
|
||||
|
||||
# 1.3 Restore our last desired offset
|
||||
self.request.offset = before
|
||||
|
||||
# 2. Fill the buffer with the data we have
|
||||
# 2.1. Slicing `bytes` is expensive, yield `memoryview` instead
|
||||
mem = memoryview(data)
|
||||
|
||||
# 2.2. The current chunk starts at ``bad`` offset into the data,
|
||||
# and each new chunk is ``stride`` bytes apart of the other
|
||||
for i in range(bad, len(data), self._stride):
|
||||
self.buffer.append(mem[i:i + self._chunk_size])
|
||||
|
||||
# 2.3. We will yield this offset, so move to the next one
|
||||
self.request.offset += self._stride
|
||||
|
||||
# 2.4. If we are in the last chunk, we will return the last partial data
|
||||
if done:
|
||||
self.left = len(self.buffer)
|
||||
await self.close()
|
||||
return
|
||||
|
||||
# 2.5. If we are not done, we can't return incomplete chunks.
|
||||
if len(self.buffer[-1]) != self._chunk_size:
|
||||
self._last_part = self.buffer.pop().tobytes()
|
||||
|
||||
# 3. Be careful with the offsets. Re-fetching a bit of data
|
||||
# is fine, since it greatly simplifies things.
|
||||
# TODO Try to not re-fetch data
|
||||
self.request.offset -= self._stride
|
||||
|
||||
|
||||
async def download_profile_photo(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
file: 'hints.FileLike' = None,
|
||||
*,
|
||||
thumb,
|
||||
progress_callback) -> typing.Optional[str]:
|
||||
# hex(crc32(x.encode('ascii'))) for x in
|
||||
# ('User', 'Chat', 'UserFull', 'ChatFull')
|
||||
ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697)
|
||||
# ('InputPeer', 'InputUser', 'InputChannel')
|
||||
INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd)
|
||||
if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS:
|
||||
entity = await self.get_entity(entity)
|
||||
|
||||
possible_names = []
|
||||
if entity.SUBCLASS_OF_ID not in ENTITIES:
|
||||
photo = entity
|
||||
else:
|
||||
if not hasattr(entity, 'photo'):
|
||||
# Special case: may be a ChatFull with photo:Photo
|
||||
# This is different from a normal UserProfilePhoto and Chat
|
||||
if not hasattr(entity, 'chat_photo'):
|
||||
return None
|
||||
|
||||
return await _download_photo(
|
||||
self, entity.chat_photo, file, date=None,
|
||||
thumb=thumb, progress_callback=progress_callback
|
||||
)
|
||||
|
||||
for attr in ('username', 'first_name', 'title'):
|
||||
possible_names.append(getattr(entity, attr, None))
|
||||
|
||||
photo = entity.photo
|
||||
|
||||
if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)):
|
||||
thumb = enums.Size.ORIGINAL if thumb == () else enums.Size(thumb)
|
||||
|
||||
dc_id = photo.dc_id
|
||||
loc = _tl.InputPeerPhotoFileLocation(
|
||||
peer=await self.get_input_entity(entity),
|
||||
photo_id=photo.photo_id,
|
||||
big=thumb >= enums.Size.LARGE
|
||||
)
|
||||
else:
|
||||
# It doesn't make any sense to check if `photo` can be used
|
||||
# as input location, because then this method would be able
|
||||
# to "download the profile photo of a message", i.e. its
|
||||
# media which should be done with `download_media` instead.
|
||||
return None
|
||||
|
||||
file = _get_proper_filename(
|
||||
file, 'profile_photo', '.jpg',
|
||||
possible_names=possible_names
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _download_file(
|
||||
self=self,
|
||||
input_location=loc,
|
||||
file=file,
|
||||
dc_id=dc_id
|
||||
)
|
||||
return result if file is bytes else file
|
||||
except errors.LocationInvalidError:
|
||||
# See issue #500, Android app fails as of v4.6.0 (1155).
|
||||
# The fix seems to be using the full channel chat photo.
|
||||
ie = await self.get_input_entity(entity)
|
||||
ty = helpers._entity_type(ie)
|
||||
if ty == helpers._EntityType.CHANNEL:
|
||||
full = await self(_tl.fn.channels.GetFullChannel(ie))
|
||||
return await _download_photo(
|
||||
self, full.full_chat.chat_photo, file,
|
||||
date=None, progress_callback=progress_callback,
|
||||
thumb=thumb
|
||||
)
|
||||
else:
|
||||
# Until there's a report for chats, no need to.
|
||||
return None
|
||||
|
||||
async def download_media(
|
||||
self: 'TelegramClient',
|
||||
message: 'hints.MessageLike',
|
||||
file: 'hints.FileLike' = None,
|
||||
*,
|
||||
size = (),
|
||||
progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]:
|
||||
# Downloading large documents may be slow enough to require a new file reference
|
||||
# to be obtained mid-download. Store (input chat, message id) so that the message
|
||||
# can be re-fetched.
|
||||
msg_data = None
|
||||
|
||||
# TODO This won't work for messageService
|
||||
if isinstance(message, _tl.Message):
|
||||
date = message.date
|
||||
media = message.media
|
||||
msg_data = (message.input_chat, message.id) if message.input_chat else None
|
||||
else:
|
||||
date = datetime.datetime.now()
|
||||
media = message
|
||||
|
||||
if isinstance(media, _tl.MessageService):
|
||||
if isinstance(message.action,
|
||||
_tl.MessageActionChatEditPhoto):
|
||||
media = media.photo
|
||||
|
||||
if isinstance(media, _tl.MessageMediaWebPage):
|
||||
if isinstance(media.webpage, _tl.WebPage):
|
||||
media = media.webpage.document or media.webpage.photo
|
||||
|
||||
if isinstance(media, (_tl.MessageMediaPhoto, _tl.Photo)):
|
||||
return await _download_photo(
|
||||
self, media, file, date, thumb, progress_callback
|
||||
)
|
||||
elif isinstance(media, (_tl.MessageMediaDocument, _tl.Document)):
|
||||
return await _download_document(
|
||||
self, media, file, date, thumb, progress_callback, msg_data
|
||||
)
|
||||
elif isinstance(media, _tl.MessageMediaContact):
|
||||
return _download_contact(
|
||||
self, media, file
|
||||
)
|
||||
elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)):
|
||||
return await _download_web_document(
|
||||
self, media, file, progress_callback
|
||||
)
|
||||
|
||||
async def _download_file(
|
||||
self: 'TelegramClient',
|
||||
input_location: 'hints.FileLike',
|
||||
file: 'hints.OutFileLike' = None,
|
||||
*,
|
||||
part_size_kb: float = None,
|
||||
file_size: int = None,
|
||||
progress_callback: 'hints.ProgressCallback' = None,
|
||||
dc_id: int = None,
|
||||
key: bytes = None,
|
||||
iv: bytes = None,
|
||||
msg_data: tuple = None) -> typing.Optional[bytes]:
|
||||
"""
|
||||
Low-level method to download files from their input location.
|
||||
|
||||
Arguments
|
||||
input_location (:tl:`InputFileLocation`):
|
||||
The file location from which the file will be downloaded.
|
||||
See `telethon.utils.get_input_location` source for a complete
|
||||
list of supported _tl.
|
||||
|
||||
file (`str` | `file`, optional):
|
||||
The output file path, directory, or stream-like object.
|
||||
If the path exists and is a file, it will be overwritten.
|
||||
|
||||
If the file path is `None` or `bytes`, then the result
|
||||
will be saved in memory and returned as `bytes`.
|
||||
|
||||
part_size_kb (`int`, optional):
|
||||
Chunk size when downloading files. The larger, the less
|
||||
requests will be made (up to 512KB maximum).
|
||||
|
||||
file_size (`int`, optional):
|
||||
The file size that is about to be downloaded, if known.
|
||||
Only used if ``progress_callback`` is specified.
|
||||
|
||||
progress_callback (`callable`, optional):
|
||||
A callback function accepting two parameters:
|
||||
``(downloaded bytes, total)``. Note that the
|
||||
``total`` is the provided ``file_size``.
|
||||
|
||||
dc_id (`int`, optional):
|
||||
The data center the library should connect to in order
|
||||
to download the file. You shouldn't worry about this.
|
||||
|
||||
key ('bytes', optional):
|
||||
In case of an encrypted upload (secret chats) a key is supplied
|
||||
|
||||
iv ('bytes', optional):
|
||||
In case of an encrypted upload (secret chats) an iv is supplied
|
||||
"""
|
||||
|
||||
if not part_size_kb:
|
||||
if not file_size:
|
||||
part_size_kb = 64 # Reasonable default
|
||||
else:
|
||||
part_size_kb = utils.get_appropriated_part_size(file_size)
|
||||
|
||||
part_size = int(part_size_kb * 1024)
|
||||
if part_size % MIN_CHUNK_SIZE != 0:
|
||||
raise ValueError(
|
||||
'The part size must be evenly divisible by 4096.')
|
||||
|
||||
if isinstance(file, pathlib.Path):
|
||||
file = str(file.absolute())
|
||||
|
||||
in_memory = file is None or file is bytes
|
||||
if in_memory:
|
||||
f = io.BytesIO()
|
||||
elif isinstance(file, str):
|
||||
# Ensure that we'll be able to download the media
|
||||
helpers.ensure_parent_dir_exists(file)
|
||||
f = open(file, 'wb')
|
||||
else:
|
||||
f = file
|
||||
|
||||
try:
|
||||
async for chunk in _iter_download(
|
||||
self, input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data):
|
||||
if iv and key:
|
||||
chunk = AES.decrypt_ige(chunk, key, iv)
|
||||
r = f.write(chunk)
|
||||
if inspect.isawaitable(r):
|
||||
await r
|
||||
|
||||
if progress_callback:
|
||||
r = progress_callback(f.tell(), file_size)
|
||||
if inspect.isawaitable(r):
|
||||
await r
|
||||
|
||||
# Not all IO objects have flush (see #1227)
|
||||
if callable(getattr(f, 'flush', None)):
|
||||
f.flush()
|
||||
|
||||
if in_memory:
|
||||
return f.getvalue()
|
||||
finally:
|
||||
if isinstance(file, str) or in_memory:
|
||||
f.close()
|
||||
|
||||
def iter_download(
|
||||
self: 'TelegramClient',
|
||||
file: 'hints.FileLike',
|
||||
*,
|
||||
offset: int = 0,
|
||||
stride: int = None,
|
||||
limit: int = (),
|
||||
chunk_size: int = None,
|
||||
request_size: int = MAX_CHUNK_SIZE,
|
||||
file_size: int = None,
|
||||
dc_id: int = None
|
||||
):
|
||||
return _iter_download(
|
||||
self,
|
||||
file,
|
||||
offset=offset,
|
||||
stride=stride,
|
||||
limit=limit,
|
||||
chunk_size=chunk_size,
|
||||
request_size=request_size,
|
||||
file_size=file_size,
|
||||
dc_id=dc_id,
|
||||
)
|
||||
|
||||
def _iter_download(
|
||||
self: 'TelegramClient',
|
||||
file: 'hints.FileLike',
|
||||
*,
|
||||
offset: int = 0,
|
||||
stride: int = None,
|
||||
limit: int = None,
|
||||
chunk_size: int = None,
|
||||
request_size: int = MAX_CHUNK_SIZE,
|
||||
file_size: int = None,
|
||||
dc_id: int = None,
|
||||
msg_data: tuple = None
|
||||
):
|
||||
info = utils._get_file_info(file)
|
||||
if info.dc_id is not None:
|
||||
dc_id = info.dc_id
|
||||
|
||||
if file_size is None:
|
||||
file_size = info.size
|
||||
|
||||
file = info.location
|
||||
|
||||
if chunk_size is None:
|
||||
chunk_size = request_size
|
||||
|
||||
if limit is None and file_size is not None:
|
||||
limit = (file_size + chunk_size - 1) // chunk_size
|
||||
|
||||
if stride is None:
|
||||
stride = chunk_size
|
||||
elif stride < chunk_size:
|
||||
raise ValueError('stride must be >= chunk_size')
|
||||
|
||||
request_size -= request_size % MIN_CHUNK_SIZE
|
||||
if request_size < MIN_CHUNK_SIZE:
|
||||
request_size = MIN_CHUNK_SIZE
|
||||
elif request_size > MAX_CHUNK_SIZE:
|
||||
request_size = MAX_CHUNK_SIZE
|
||||
|
||||
if chunk_size == request_size \
|
||||
and offset % MIN_CHUNK_SIZE == 0 \
|
||||
and stride % MIN_CHUNK_SIZE == 0 \
|
||||
and (limit is None or offset % limit == 0):
|
||||
cls = _DirectDownloadIter
|
||||
self._log[__name__].info('Starting direct file download in chunks of '
|
||||
'%d at %d, stride %d', request_size, offset, stride)
|
||||
else:
|
||||
cls = _GenericDownloadIter
|
||||
self._log[__name__].info('Starting indirect file download in chunks of '
|
||||
'%d at %d, stride %d', request_size, offset, stride)
|
||||
|
||||
return cls(
|
||||
self,
|
||||
limit,
|
||||
file=file,
|
||||
dc_id=dc_id,
|
||||
offset=offset,
|
||||
stride=stride,
|
||||
chunk_size=chunk_size,
|
||||
request_size=request_size,
|
||||
file_size=file_size,
|
||||
msg_data=msg_data,
|
||||
)
|
||||
|
||||
|
||||
def _get_thumb(thumbs, thumb):
|
||||
if isinstance(thumb, tlobject.TLObject):
|
||||
return thumb
|
||||
|
||||
thumb = enums.Size(thumb)
|
||||
return min(
|
||||
thumbs,
|
||||
default=None,
|
||||
key=lambda t: abs(thumb - enums.Size(t.type))
|
||||
)
|
||||
|
||||
def _download_cached_photo_size(self: 'TelegramClient', size, file):
|
||||
# No need to download anything, simply write the bytes
|
||||
if isinstance(size, _tl.PhotoStrippedSize):
|
||||
data = utils.stripped_photo_to_jpg(size.bytes)
|
||||
else:
|
||||
data = size.bytes
|
||||
|
||||
if file is bytes:
|
||||
return data
|
||||
elif isinstance(file, str):
|
||||
helpers.ensure_parent_dir_exists(file)
|
||||
f = open(file, 'wb')
|
||||
else:
|
||||
f = file
|
||||
|
||||
try:
|
||||
f.write(data)
|
||||
finally:
|
||||
if isinstance(file, str):
|
||||
f.close()
|
||||
return file
|
||||
|
||||
async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback):
|
||||
"""Specialized version of .download_media() for photos"""
|
||||
# Determine the photo and its largest size
|
||||
if isinstance(photo, _tl.MessageMediaPhoto):
|
||||
photo = photo.photo
|
||||
if not isinstance(photo, _tl.Photo):
|
||||
return
|
||||
|
||||
# Include video sizes here (but they may be None so provide an empty list)
|
||||
size = _get_thumb(photo.sizes + (photo.video_sizes or []), thumb)
|
||||
if not size or isinstance(size, _tl.PhotoSizeEmpty):
|
||||
return
|
||||
|
||||
if isinstance(size, _tl.VideoSize):
|
||||
file = _get_proper_filename(file, 'video', '.mp4', date=date)
|
||||
else:
|
||||
file = _get_proper_filename(file, 'photo', '.jpg', date=date)
|
||||
|
||||
if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
|
||||
return _download_cached_photo_size(self, size, file)
|
||||
|
||||
if isinstance(size, _tl.PhotoSizeProgressive):
|
||||
file_size = max(size.sizes)
|
||||
else:
|
||||
file_size = size.size
|
||||
|
||||
result = await _download_file(
|
||||
self=self,
|
||||
input_location=_tl.InputPhotoFileLocation(
|
||||
id=photo.id,
|
||||
access_hash=photo.access_hash,
|
||||
file_reference=photo.file_reference,
|
||||
thumb_size=size.type
|
||||
),
|
||||
file=file,
|
||||
file_size=file_size,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
return result if file is bytes else file
|
||||
|
||||
def _get_kind_and_names(attributes):
|
||||
"""Gets kind and possible names for :tl:`DocumentAttribute`."""
|
||||
kind = 'document'
|
||||
possible_names = []
|
||||
for attr in attributes:
|
||||
if isinstance(attr, _tl.DocumentAttributeFilename):
|
||||
possible_names.insert(0, attr.file_name)
|
||||
|
||||
elif isinstance(attr, _tl.DocumentAttributeAudio):
|
||||
kind = 'audio'
|
||||
if attr.performer and attr.title:
|
||||
possible_names.append('{} - {}'.format(
|
||||
attr.performer, attr.title
|
||||
))
|
||||
elif attr.performer:
|
||||
possible_names.append(attr.performer)
|
||||
elif attr.title:
|
||||
possible_names.append(attr.title)
|
||||
elif attr.voice:
|
||||
kind = 'voice'
|
||||
|
||||
return kind, possible_names
|
||||
|
||||
async def _download_document(
|
||||
self, document, file, date, thumb, progress_callback, msg_data):
|
||||
"""Specialized version of .download_media() for documents."""
|
||||
if isinstance(document, _tl.MessageMediaDocument):
|
||||
document = document.document
|
||||
if not isinstance(document, _tl.Document):
|
||||
return
|
||||
|
||||
if thumb == ():
|
||||
kind, possible_names = _get_kind_and_names(document.attributes)
|
||||
file = _get_proper_filename(
|
||||
file, kind, utils.get_extension(document),
|
||||
date=date, possible_names=possible_names
|
||||
)
|
||||
size = None
|
||||
else:
|
||||
file = _get_proper_filename(file, 'photo', '.jpg', date=date)
|
||||
size = _get_thumb(document.thumbs, thumb)
|
||||
if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
|
||||
return _download_cached_photo_size(self, size, file)
|
||||
|
||||
result = await _download_file(
|
||||
self=self,
|
||||
input_location=_tl.InputDocumentFileLocation(
|
||||
id=document.id,
|
||||
access_hash=document.access_hash,
|
||||
file_reference=document.file_reference,
|
||||
thumb_size=size.type if size else ''
|
||||
),
|
||||
file=file,
|
||||
file_size=size.size if size else document.size,
|
||||
progress_callback=progress_callback,
|
||||
msg_data=msg_data,
|
||||
)
|
||||
|
||||
return result if file is bytes else file
|
||||
|
||||
def _download_contact(cls, mm_contact, file):
|
||||
"""
|
||||
Specialized version of .download_media() for contacts.
|
||||
Will make use of the vCard 4.0 format.
|
||||
"""
|
||||
first_name = mm_contact.first_name
|
||||
last_name = mm_contact.last_name
|
||||
phone_number = mm_contact.phone_number
|
||||
|
||||
# Remove these pesky characters
|
||||
first_name = first_name.replace(';', '')
|
||||
last_name = (last_name or '').replace(';', '')
|
||||
result = (
|
||||
'BEGIN:VCARD\n'
|
||||
'VERSION:4.0\n'
|
||||
'N:{f};{l};;;\n'
|
||||
'FN:{f} {l}\n'
|
||||
'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n'
|
||||
'END:VCARD\n'
|
||||
).format(f=first_name, l=last_name, p=phone_number).encode('utf-8')
|
||||
|
||||
if file is bytes:
|
||||
return result
|
||||
elif isinstance(file, str):
|
||||
file = cls._get_proper_filename(
|
||||
file, 'contact', '.vcard',
|
||||
possible_names=[first_name, phone_number, last_name]
|
||||
)
|
||||
f = open(file, 'wb')
|
||||
else:
|
||||
f = file
|
||||
|
||||
try:
|
||||
f.write(result)
|
||||
finally:
|
||||
# Only close the stream if we opened it
|
||||
if isinstance(file, str):
|
||||
f.close()
|
||||
|
||||
return file
|
||||
|
||||
async def _download_web_document(cls, web, file, progress_callback):
|
||||
"""
|
||||
Specialized version of .download_media() for web documents.
|
||||
"""
|
||||
if not aiohttp:
|
||||
raise ValueError(
|
||||
'Cannot download web documents without the aiohttp '
|
||||
'dependency install it (pip install aiohttp)'
|
||||
)
|
||||
|
||||
# TODO Better way to get opened handles of files and auto-close
|
||||
in_memory = file is bytes
|
||||
if in_memory:
|
||||
f = io.BytesIO()
|
||||
elif isinstance(file, str):
|
||||
kind, possible_names = cls._get_kind_and_names(web.attributes)
|
||||
file = cls._get_proper_filename(
|
||||
file, kind, utils.get_extension(web),
|
||||
possible_names=possible_names
|
||||
)
|
||||
f = open(file, 'wb')
|
||||
else:
|
||||
f = file
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# TODO Use progress_callback; get content length from response
|
||||
# https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319
|
||||
async with session.get(web.url) as response:
|
||||
while True:
|
||||
chunk = await response.content.read(128 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
finally:
|
||||
if isinstance(file, str) or file is bytes:
|
||||
f.close()
|
||||
|
||||
return f.getvalue() if in_memory else file
|
||||
|
||||
def _get_proper_filename(file, kind, extension,
|
||||
date=None, possible_names=None):
|
||||
"""Gets a proper filename for 'file', if this is a path.
|
||||
|
||||
'kind' should be the kind of the output file (photo, document...)
|
||||
'extension' should be the extension to be added to the file if
|
||||
the filename doesn't have any yet
|
||||
'date' should be when this file was originally sent, if known
|
||||
'possible_names' should be an ordered list of possible names
|
||||
|
||||
If no modification is made to the path, any existing file
|
||||
will be overwritten.
|
||||
If any modification is made to the path, this method will
|
||||
ensure that no existing file will be overwritten.
|
||||
"""
|
||||
if isinstance(file, pathlib.Path):
|
||||
file = str(file.absolute())
|
||||
|
||||
if file is not None and not isinstance(file, str):
|
||||
# Probably a stream-like object, we cannot set a filename here
|
||||
return file
|
||||
|
||||
if file is None:
|
||||
file = ''
|
||||
elif os.path.isfile(file):
|
||||
# Make no modifications to valid existing paths
|
||||
return file
|
||||
|
||||
if os.path.isdir(file) or not file:
|
||||
try:
|
||||
name = None if possible_names is None else next(
|
||||
x for x in possible_names if x
|
||||
)
|
||||
except StopIteration:
|
||||
name = None
|
||||
|
||||
if not name:
|
||||
if not date:
|
||||
date = datetime.datetime.now()
|
||||
name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format(
|
||||
kind,
|
||||
date.year, date.month, date.day,
|
||||
date.hour, date.minute, date.second,
|
||||
)
|
||||
file = os.path.join(file, name)
|
||||
|
||||
directory, name = os.path.split(file)
|
||||
name, ext = os.path.splitext(name)
|
||||
if not ext:
|
||||
ext = extension
|
||||
|
||||
result = os.path.join(directory, name + ext)
|
||||
if not os.path.isfile(result):
|
||||
return result
|
||||
|
||||
i = 1
|
||||
while True:
|
||||
result = os.path.join(directory, '{} ({}){}'.format(name, i, ext))
|
||||
if not os.path.isfile(result):
|
||||
return result
|
||||
i += 1
|
177
telethon/_client/messageparse.py
Normal file
177
telethon/_client/messageparse.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
import itertools
|
||||
import re
|
||||
import typing
|
||||
|
||||
from .._misc import helpers, utils
|
||||
from ..types import _custom
|
||||
from .. import _tl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
async def _replace_with_mention(self: 'TelegramClient', entities, i, user):
|
||||
"""
|
||||
Helper method to replace ``entities[i]`` to mention ``user``,
|
||||
or do nothing if it can't be found.
|
||||
"""
|
||||
try:
|
||||
entities[i] = _tl.InputMessageEntityMentionName(
|
||||
entities[i].offset, entities[i].length,
|
||||
await self.get_input_entity(user)
|
||||
)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
async def _parse_message_text(self: 'TelegramClient', message, parse_mode):
|
||||
"""
|
||||
Returns a (parsed message, entities) tuple depending on ``parse_mode``.
|
||||
"""
|
||||
if parse_mode == ():
|
||||
parse_mode = self._parse_mode
|
||||
else:
|
||||
parse_mode = utils.sanitize_parse_mode(parse_mode)
|
||||
|
||||
if not parse_mode:
|
||||
return message, []
|
||||
|
||||
original_message = message
|
||||
message, msg_entities = parse_mode.parse(message)
|
||||
if original_message and not message and not msg_entities:
|
||||
raise ValueError("Failed to parse message")
|
||||
|
||||
for i in reversed(range(len(msg_entities))):
|
||||
e = msg_entities[i]
|
||||
if isinstance(e, _tl.MessageEntityTextUrl):
|
||||
m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url)
|
||||
if m:
|
||||
user = int(m.group(1)) if m.group(1) else e.url
|
||||
is_mention = await _replace_with_mention(self, msg_entities, i, user)
|
||||
if not is_mention:
|
||||
del msg_entities[i]
|
||||
elif isinstance(e, (_tl.MessageEntityMentionName,
|
||||
_tl.InputMessageEntityMentionName)):
|
||||
is_mention = await _replace_with_mention(self, msg_entities, i, e.user_id)
|
||||
if not is_mention:
|
||||
del msg_entities[i]
|
||||
|
||||
return message, msg_entities
|
||||
|
||||
def _get_response_message(self: 'TelegramClient', request, result, input_chat):
|
||||
"""
|
||||
Extracts the response message known a request and Update result.
|
||||
The request may also be the ID of the message to match.
|
||||
|
||||
If ``request is None`` this method returns ``{id: message}``.
|
||||
|
||||
If ``request.random_id`` is a list, this method returns a list too.
|
||||
"""
|
||||
if isinstance(result, _tl.UpdateShort):
|
||||
updates = [result.update]
|
||||
entities = {}
|
||||
elif isinstance(result, (_tl.Updates, _tl.UpdatesCombined)):
|
||||
updates = result.updates
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in
|
||||
itertools.chain(result.users, result.chats)}
|
||||
else:
|
||||
return None
|
||||
|
||||
random_to_id = {}
|
||||
id_to_message = {}
|
||||
for update in updates:
|
||||
if isinstance(update, _tl.UpdateMessageID):
|
||||
random_to_id[update.random_id] = update.id
|
||||
|
||||
elif isinstance(update, (
|
||||
_tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)):
|
||||
update.message = _custom.Message._new(self, update.message, entities, input_chat)
|
||||
|
||||
# Pinning a message with `updatePinnedMessage` seems to
|
||||
# always produce a service message we can't map so return
|
||||
# it directly. The same happens for kicking users.
|
||||
#
|
||||
# It could also be a list (e.g. when sending albums).
|
||||
#
|
||||
# TODO this method is getting messier and messier as time goes on
|
||||
if hasattr(request, 'random_id') or utils.is_list_like(request):
|
||||
id_to_message[update.message.id] = update.message
|
||||
else:
|
||||
return update.message
|
||||
|
||||
elif (isinstance(update, _tl.UpdateEditMessage)
|
||||
and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL):
|
||||
update.message = _custom.Message._new(self, update.message, entities, input_chat)
|
||||
|
||||
# Live locations use `sendMedia` but Telegram responds with
|
||||
# `updateEditMessage`, which means we won't have `id` field.
|
||||
if hasattr(request, 'random_id'):
|
||||
id_to_message[update.message.id] = update.message
|
||||
elif request.id == update.message.id:
|
||||
return update.message
|
||||
|
||||
elif (isinstance(update, _tl.UpdateEditChannelMessage)
|
||||
and utils.get_peer_id(request.peer) ==
|
||||
utils.get_peer_id(update.message.peer_id)):
|
||||
if request.id == update.message.id:
|
||||
return _custom.Message._new(self, update.message, entities, input_chat)
|
||||
|
||||
elif isinstance(update, _tl.UpdateNewScheduledMessage):
|
||||
# Scheduled IDs may collide with normal IDs. However, for a
|
||||
# single request there *shouldn't* be a mix between "some
|
||||
# scheduled and some not".
|
||||
id_to_message[update.message.id] = _custom.Message._new(self, update.message, entities, input_chat)
|
||||
|
||||
elif isinstance(update, _tl.UpdateMessagePoll):
|
||||
if request.media.poll.id == update.poll_id:
|
||||
return _custom.Message._new(self, _tl.Message(
|
||||
id=request.id,
|
||||
peer_id=utils.get_peer(request.peer),
|
||||
media=_tl.MessageMediaPoll(
|
||||
poll=update.poll,
|
||||
results=update.results
|
||||
),
|
||||
date=None,
|
||||
message=''
|
||||
), entities, input_chat)
|
||||
|
||||
if request is None:
|
||||
return id_to_message
|
||||
|
||||
random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None)
|
||||
if random_id is None:
|
||||
# Can happen when pinning a message does not actually produce a service message.
|
||||
self._log[__name__].warning(
|
||||
'No random_id in %s to map to, returning None message for %s', request, result)
|
||||
return None
|
||||
|
||||
if not utils.is_list_like(random_id):
|
||||
msg = id_to_message.get(random_to_id.get(random_id))
|
||||
|
||||
if not msg:
|
||||
self._log[__name__].warning(
|
||||
'Request %s had missing message mapping %s', request, result)
|
||||
|
||||
return msg
|
||||
|
||||
try:
|
||||
return [id_to_message[random_to_id[rnd]] for rnd in random_id]
|
||||
except KeyError:
|
||||
# Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets
|
||||
# deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at
|
||||
# Telegram), in which case we get some "missing" message mappings.
|
||||
# Log them with the hope that we can better work around them.
|
||||
#
|
||||
# This also happens when trying to forward messages that can't
|
||||
# be forwarded because they don't exist (0, service, deleted)
|
||||
# among others which could be (like deleted or existing).
|
||||
self._log[__name__].warning(
|
||||
'Request %s had missing message mappings %s', request, result)
|
||||
|
||||
return [
|
||||
id_to_message.get(random_to_id[rnd])
|
||||
if rnd in random_to_id
|
||||
else None
|
||||
for rnd in random_id
|
||||
]
|
740
telethon/_client/messages.py
Normal file
740
telethon/_client/messages.py
Normal file
|
@ -0,0 +1,740 @@
|
|||
import inspect
|
||||
import itertools
|
||||
import time
|
||||
import typing
|
||||
import warnings
|
||||
|
||||
from .._misc import helpers, utils, requestiter, hints
|
||||
from ..types import _custom
|
||||
from ..types._custom.inputmessage import InputMessage
|
||||
from .. import errors, _tl
|
||||
|
||||
_MAX_CHUNK_SIZE = 100
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
class _MessagesIter(requestiter.RequestIter):
|
||||
"""
|
||||
Common factor for all requests that need to iterate over messages.
|
||||
"""
|
||||
async def _init(
|
||||
self, entity, offset_id, min_id, max_id,
|
||||
from_user, offset_date, add_offset, filter, search, reply_to,
|
||||
scheduled
|
||||
):
|
||||
# Note that entity being `None` will perform a global search.
|
||||
if entity:
|
||||
self.entity = await self.client.get_input_entity(entity)
|
||||
else:
|
||||
self.entity = None
|
||||
if self.reverse:
|
||||
raise ValueError('Cannot reverse global search')
|
||||
|
||||
# Telegram doesn't like min_id/max_id. If these IDs are low enough
|
||||
# (starting from last_id - 100), the request will return nothing.
|
||||
#
|
||||
# We can emulate their behaviour locally by setting offset = max_id
|
||||
# and simply stopping once we hit a message with ID <= min_id.
|
||||
if self.reverse:
|
||||
offset_id = max(offset_id, min_id)
|
||||
if offset_id and max_id:
|
||||
if max_id - offset_id <= 1:
|
||||
raise StopAsyncIteration
|
||||
|
||||
if not max_id:
|
||||
max_id = float('inf')
|
||||
else:
|
||||
offset_id = max(offset_id, max_id)
|
||||
if offset_id and min_id:
|
||||
if offset_id - min_id <= 1:
|
||||
raise StopAsyncIteration
|
||||
|
||||
if self.reverse:
|
||||
if offset_id:
|
||||
offset_id += 1
|
||||
elif not offset_date:
|
||||
# offset_id has priority over offset_date, so don't
|
||||
# set offset_id to 1 if we want to offset by date.
|
||||
offset_id = 1
|
||||
|
||||
if from_user:
|
||||
from_user = await self.client.get_input_entity(from_user)
|
||||
self.from_id = await self.client.get_peer_id(from_user)
|
||||
else:
|
||||
self.from_id = None
|
||||
|
||||
# `messages.searchGlobal` only works with text `search` or `filter` queries.
|
||||
# If we want to perform global a search with `from_user` we have to perform
|
||||
# a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`.
|
||||
if not self.entity and from_user:
|
||||
self.entity = _tl.InputPeerEmpty()
|
||||
|
||||
if filter is None:
|
||||
filter = _tl.InputMessagesFilterEmpty()
|
||||
else:
|
||||
filter = filter() if isinstance(filter, type) else filter
|
||||
|
||||
if not self.entity:
|
||||
self.request = _tl.fn.messages.SearchGlobal(
|
||||
q=search or '',
|
||||
filter=filter,
|
||||
min_date=None,
|
||||
max_date=offset_date,
|
||||
offset_rate=0,
|
||||
offset_peer=_tl.InputPeerEmpty(),
|
||||
offset_id=offset_id,
|
||||
limit=1
|
||||
)
|
||||
elif scheduled:
|
||||
self.request = _tl.fn.messages.GetScheduledHistory(
|
||||
peer=entity,
|
||||
hash=0
|
||||
)
|
||||
elif reply_to is not None:
|
||||
self.request = _tl.fn.messages.GetReplies(
|
||||
peer=self.entity,
|
||||
msg_id=reply_to,
|
||||
offset_id=offset_id,
|
||||
offset_date=offset_date,
|
||||
add_offset=add_offset,
|
||||
limit=1,
|
||||
max_id=0,
|
||||
min_id=0,
|
||||
hash=0
|
||||
)
|
||||
elif search is not None or not isinstance(filter, _tl.InputMessagesFilterEmpty) or from_user:
|
||||
# Telegram completely ignores `from_id` in private chats
|
||||
ty = helpers._entity_type(self.entity)
|
||||
if ty == helpers._EntityType.USER:
|
||||
# Don't bother sending `from_user` (it's ignored anyway),
|
||||
# but keep `from_id` defined above to check it locally.
|
||||
from_user = None
|
||||
else:
|
||||
# Do send `from_user` to do the filtering server-side,
|
||||
# and set `from_id` to None to avoid checking it locally.
|
||||
self.from_id = None
|
||||
|
||||
self.request = _tl.fn.messages.Search(
|
||||
peer=self.entity,
|
||||
q=search or '',
|
||||
filter=filter,
|
||||
min_date=None,
|
||||
max_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
add_offset=add_offset,
|
||||
limit=0, # Search actually returns 0 items if we ask it to
|
||||
max_id=0,
|
||||
min_id=0,
|
||||
hash=0,
|
||||
from_id=from_user
|
||||
)
|
||||
|
||||
# Workaround issue #1124 until a better solution is found.
|
||||
# Telegram seemingly ignores `max_date` if `filter` (and
|
||||
# nothing else) is specified, so we have to rely on doing
|
||||
# a first request to offset from the ID instead.
|
||||
#
|
||||
# Even better, using `filter` and `from_id` seems to always
|
||||
# trigger `RPC_CALL_FAIL` which is "internal issues"...
|
||||
if not isinstance(filter, _tl.InputMessagesFilterEmpty) \
|
||||
and offset_date and not search and not offset_id:
|
||||
async for m in self.client.iter_messages(
|
||||
self.entity, 1, offset_date=offset_date):
|
||||
self.request.offset_id = m.id + 1
|
||||
else:
|
||||
self.request = _tl.fn.messages.GetHistory(
|
||||
peer=self.entity,
|
||||
limit=1,
|
||||
offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
min_id=0,
|
||||
max_id=0,
|
||||
add_offset=add_offset,
|
||||
hash=0
|
||||
)
|
||||
|
||||
if self.limit <= 0:
|
||||
# No messages, but we still need to know the total message count
|
||||
result = await self.client(self.request)
|
||||
if isinstance(result, _tl.messages.MessagesNotModified):
|
||||
self.total = result.count
|
||||
else:
|
||||
self.total = getattr(result, 'count', len(result.messages))
|
||||
raise StopAsyncIteration
|
||||
|
||||
if self.wait_time is None:
|
||||
self.wait_time = 1 if self.limit > 3000 else 0
|
||||
|
||||
# When going in reverse we need an offset of `-limit`, but we
|
||||
# also want to respect what the user passed, so add them together.
|
||||
if self.reverse:
|
||||
self.request.add_offset -= _MAX_CHUNK_SIZE
|
||||
|
||||
self.add_offset = add_offset
|
||||
self.max_id = max_id
|
||||
self.min_id = min_id
|
||||
self.last_id = 0 if self.reverse else float('inf')
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
self.request.limit = min(self.left, _MAX_CHUNK_SIZE)
|
||||
if self.reverse and self.request.limit != _MAX_CHUNK_SIZE:
|
||||
# Remember that we need -limit when going in reverse
|
||||
self.request.add_offset = self.add_offset - self.request.limit
|
||||
|
||||
r = await self.client(self.request)
|
||||
self.total = getattr(r, 'count', len(r.messages))
|
||||
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)}
|
||||
|
||||
messages = reversed(r.messages) if self.reverse else r.messages
|
||||
for message in messages:
|
||||
if (isinstance(message, _tl.MessageEmpty)
|
||||
or self.from_id and message.sender_id != self.from_id):
|
||||
continue
|
||||
|
||||
if not self._message_in_range(message):
|
||||
return True
|
||||
|
||||
# There has been reports that on bad connections this method
|
||||
# was returning duplicated IDs sometimes. Using ``last_id``
|
||||
# is an attempt to avoid these duplicates, since the message
|
||||
# IDs are returned in descending order (or asc if reverse).
|
||||
self.last_id = message.id
|
||||
self.buffer.append(_custom.Message._new(self.client, message, entities, self.entity))
|
||||
|
||||
if len(r.messages) < self.request.limit:
|
||||
return True
|
||||
|
||||
# Get the last message that's not empty (in some rare cases
|
||||
# it can happen that the last message is :tl:`MessageEmpty`)
|
||||
if self.buffer:
|
||||
self._update_offset(self.buffer[-1], r)
|
||||
else:
|
||||
# There are some cases where all the messages we get start
|
||||
# being empty. This can happen on migrated mega-groups if
|
||||
# the history was cleared, and we're using search. Telegram
|
||||
# acts incredibly weird sometimes. Messages are returned but
|
||||
# only "empty", not their contents. If this is the case we
|
||||
# should just give up since there won't be any new Message.
|
||||
return True
|
||||
|
||||
def _message_in_range(self, message):
|
||||
"""
|
||||
Determine whether the given message is in the range or
|
||||
it should be ignored (and avoid loading more chunks).
|
||||
"""
|
||||
# No entity means message IDs between chats may vary
|
||||
if self.entity:
|
||||
if self.reverse:
|
||||
if message.id <= self.last_id or message.id >= self.max_id:
|
||||
return False
|
||||
else:
|
||||
if message.id >= self.last_id or message.id <= self.min_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _update_offset(self, last_message, response):
|
||||
"""
|
||||
After making the request, update its offset with the last message.
|
||||
"""
|
||||
self.request.offset_id = last_message.id
|
||||
if self.reverse:
|
||||
# We want to skip the one we already have
|
||||
self.request.offset_id += 1
|
||||
|
||||
if isinstance(self.request, _tl.fn.messages.Search):
|
||||
# Unlike getHistory and searchGlobal that use *offset* date,
|
||||
# this is *max* date. This means that doing a search in reverse
|
||||
# will break it. Since it's not really needed once we're going
|
||||
# (only for the first request), it's safe to just clear it off.
|
||||
self.request.max_date = None
|
||||
else:
|
||||
# getHistory, searchGlobal and getReplies call it offset_date
|
||||
self.request.offset_date = last_message.date
|
||||
|
||||
if isinstance(self.request, _tl.fn.messages.SearchGlobal):
|
||||
if last_message.input_chat:
|
||||
self.request.offset_peer = last_message.input_chat
|
||||
else:
|
||||
self.request.offset_peer = _tl.InputPeerEmpty()
|
||||
|
||||
self.request.offset_rate = getattr(response, 'next_rate', 0)
|
||||
|
||||
|
||||
class _IDsIter(requestiter.RequestIter):
|
||||
async def _init(self, entity, ids):
|
||||
self.total = len(ids)
|
||||
self._ids = list(reversed(ids)) if self.reverse else ids
|
||||
self._offset = 0
|
||||
self._entity = (await self.client.get_input_entity(entity)) if entity else None
|
||||
self._ty = helpers._entity_type(self._entity) if self._entity else None
|
||||
|
||||
# 30s flood wait every 300 messages (3 requests of 100 each, 30 of 10, etc.)
|
||||
if self.wait_time is None:
|
||||
self.wait_time = 10 if self.limit > 300 else 0
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
ids = self._ids[self._offset:self._offset + _MAX_CHUNK_SIZE]
|
||||
if not ids:
|
||||
raise StopAsyncIteration
|
||||
|
||||
self._offset += _MAX_CHUNK_SIZE
|
||||
|
||||
from_id = None # By default, no need to validate from_id
|
||||
if self._ty == helpers._EntityType.CHANNEL:
|
||||
try:
|
||||
r = await self.client(
|
||||
_tl.fn.channels.GetMessages(self._entity, ids))
|
||||
except errors.MessageIdsEmptyError:
|
||||
# All IDs were invalid, use a dummy result
|
||||
r = _tl.messages.MessagesNotModified(len(ids))
|
||||
else:
|
||||
r = await self.client(_tl.fn.messages.GetMessages(ids))
|
||||
if self._entity:
|
||||
from_id = await _get_peer(self.client, self._entity)
|
||||
|
||||
if isinstance(r, _tl.messages.MessagesNotModified):
|
||||
self.buffer.extend(None for _ in ids)
|
||||
return
|
||||
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)}
|
||||
|
||||
# Telegram seems to return the messages in the order in which
|
||||
# we asked them for, so we don't need to check it ourselves,
|
||||
# unless some messages were invalid in which case Telegram
|
||||
# may decide to not send them at all.
|
||||
#
|
||||
# The passed message IDs may not belong to the desired entity
|
||||
# since the user can enter arbitrary numbers which can belong to
|
||||
# arbitrary chats. Validate these unless ``from_id is None``.
|
||||
for message in r.messages:
|
||||
if isinstance(message, _tl.MessageEmpty) or (
|
||||
from_id and message.peer_id != from_id):
|
||||
self.buffer.append(None)
|
||||
else:
|
||||
self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity))
|
||||
|
||||
|
||||
async def _get_peer(self: 'TelegramClient', input_peer: 'hints.EntityLike'):
|
||||
try:
|
||||
return utils.get_peer(input_peer)
|
||||
except TypeError:
|
||||
# Can only be self by now
|
||||
return _tl.PeerUser(await self.get_peer_id(input_peer))
|
||||
|
||||
|
||||
def get_messages(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
limit: float = (),
|
||||
*,
|
||||
offset_date: 'hints.DateLike' = None,
|
||||
offset_id: int = 0,
|
||||
max_id: int = 0,
|
||||
min_id: int = 0,
|
||||
add_offset: int = 0,
|
||||
search: str = None,
|
||||
filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None,
|
||||
from_user: 'hints.EntityLike' = None,
|
||||
wait_time: float = None,
|
||||
ids: 'typing.Union[int, typing.Sequence[int]]' = None,
|
||||
reverse: bool = False,
|
||||
reply_to: int = None,
|
||||
scheduled: bool = False
|
||||
) -> 'typing.Union[_MessagesIter, _IDsIter]':
|
||||
if ids is not None:
|
||||
if not utils.is_list_like(ids):
|
||||
ids = [ids]
|
||||
|
||||
return _IDsIter(
|
||||
client=self,
|
||||
reverse=reverse,
|
||||
wait_time=wait_time,
|
||||
limit=len(ids),
|
||||
entity=entity,
|
||||
ids=ids
|
||||
)
|
||||
|
||||
return _MessagesIter(
|
||||
client=self,
|
||||
reverse=reverse,
|
||||
wait_time=wait_time,
|
||||
limit=limit,
|
||||
entity=entity,
|
||||
offset_id=offset_id,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
from_user=from_user,
|
||||
offset_date=offset_date,
|
||||
add_offset=add_offset,
|
||||
filter=filter,
|
||||
search=search,
|
||||
reply_to=reply_to,
|
||||
scheduled=scheduled
|
||||
)
|
||||
|
||||
|
||||
async def _get_comment_data(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'typing.Union[int, _tl.Message]'
|
||||
):
|
||||
r = await self(_tl.fn.messages.GetDiscussionMessage(
|
||||
peer=entity,
|
||||
msg_id=utils.get_message_id(message)
|
||||
))
|
||||
m = r.messages[0]
|
||||
chat = next(c for c in r.chats if c.id == m.peer_id.channel_id)
|
||||
return utils.get_input_peer(chat), m.id
|
||||
|
||||
async def send_message(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'hints.MessageLike' = '',
|
||||
*,
|
||||
# - Message contents
|
||||
# Formatting
|
||||
markdown: str = None,
|
||||
html: str = None,
|
||||
formatting_entities: list = None,
|
||||
link_preview: bool = (),
|
||||
# Media
|
||||
file: typing.Optional[hints.FileLike] = None,
|
||||
file_name: str = None,
|
||||
mime_type: str = None,
|
||||
thumb: str = False,
|
||||
force_file: bool = False,
|
||||
file_size: int = None,
|
||||
# Media attributes
|
||||
duration: int = None,
|
||||
width: int = None,
|
||||
height: int = None,
|
||||
title: str = None,
|
||||
performer: str = None,
|
||||
supports_streaming: bool = False,
|
||||
video_note: bool = False,
|
||||
voice_note: bool = False,
|
||||
waveform: bytes = None,
|
||||
# Additional parametrization
|
||||
silent: bool = False,
|
||||
buttons: list = None,
|
||||
ttl: int = None,
|
||||
# - Send options
|
||||
reply_to: 'typing.Union[int, _tl.Message]' = None,
|
||||
send_as: 'hints.EntityLike' = None,
|
||||
clear_draft: bool = False,
|
||||
background: bool = None,
|
||||
noforwards: bool = None,
|
||||
schedule: 'hints.DateLike' = None,
|
||||
comment_to: 'typing.Union[int, _tl.Message]' = None,
|
||||
) -> '_tl.Message':
|
||||
if isinstance(message, str):
|
||||
message = InputMessage(
|
||||
text=message,
|
||||
markdown=markdown,
|
||||
html=html,
|
||||
formatting_entities=formatting_entities,
|
||||
link_preview=link_preview,
|
||||
file=file,
|
||||
file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
thumb=thumb,
|
||||
force_file=force_file,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
title=title,
|
||||
performer=performer,
|
||||
supports_streaming=supports_streaming,
|
||||
video_note=video_note,
|
||||
voice_note=voice_note,
|
||||
waveform=waveform,
|
||||
silent=silent,
|
||||
buttons=buttons,
|
||||
ttl=ttl,
|
||||
)
|
||||
elif isinstance(message, _custom.Message):
|
||||
message = message._as_input()
|
||||
elif not isinstance(message, InputMessage):
|
||||
raise TypeError(f'message must be either str, Message or InputMessage, but got: {message!r}')
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
if comment_to is not None:
|
||||
entity, reply_to = await _get_comment_data(self, entity, comment_to)
|
||||
elif reply_to:
|
||||
reply_to = utils.get_message_id(reply_to)
|
||||
|
||||
if message._file:
|
||||
# TODO Properly implement allow_cache to reuse the sha256 of the file
|
||||
# i.e. `None` was used
|
||||
|
||||
# TODO album
|
||||
if message._file._should_upload_thumb():
|
||||
message._file._set_uploaded_thumb(await self.upload_file(message._file._thumb))
|
||||
|
||||
if message._file._should_upload_file():
|
||||
message._file._set_uploaded_file(await self.upload_file(message._file._file))
|
||||
|
||||
request = _tl.fn.messages.SendMedia(
|
||||
entity, message._file._media, reply_to_msg_id=reply_to, message=message._text,
|
||||
entities=message._fmt_entities, reply_markup=message._reply_markup, silent=message._silent,
|
||||
schedule_date=schedule, clear_draft=clear_draft,
|
||||
background=background, noforwards=noforwards, send_as=send_as
|
||||
)
|
||||
else:
|
||||
request = _tl.fn.messages.SendMessage(
|
||||
peer=entity,
|
||||
message=message._text,
|
||||
entities=formatting_entities,
|
||||
no_webpage=not link_preview,
|
||||
reply_to_msg_id=utils.get_message_id(reply_to),
|
||||
clear_draft=clear_draft,
|
||||
silent=silent,
|
||||
background=background,
|
||||
reply_markup=_custom.button.build_reply_markup(buttons),
|
||||
schedule_date=schedule,
|
||||
noforwards=noforwards,
|
||||
send_as=send_as
|
||||
)
|
||||
|
||||
result = await self(request)
|
||||
if isinstance(result, _tl.UpdateShortSentMessage):
|
||||
return _custom.Message._new(self, _tl.Message(
|
||||
id=result.id,
|
||||
peer_id=await _get_peer(self, entity),
|
||||
message=message._text,
|
||||
date=result.date,
|
||||
out=result.out,
|
||||
media=result.media,
|
||||
entities=result.entities,
|
||||
reply_markup=request.reply_markup,
|
||||
ttl_period=result.ttl_period
|
||||
), {}, entity)
|
||||
|
||||
return self._get_response_message(request, result, entity)
|
||||
|
||||
async def forward_messages(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]',
|
||||
from_peer: 'hints.EntityLike' = None,
|
||||
*,
|
||||
background: bool = None,
|
||||
with_my_score: bool = None,
|
||||
silent: bool = None,
|
||||
as_album: bool = None,
|
||||
schedule: 'hints.DateLike' = None,
|
||||
noforwards: bool = None,
|
||||
send_as: 'hints.EntityLike' = None
|
||||
) -> 'typing.Sequence[_tl.Message]':
|
||||
if as_album is not None:
|
||||
warnings.warn('the as_album argument is deprecated and no longer has any effect')
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
|
||||
if from_peer:
|
||||
from_peer = await self.get_input_entity(from_peer)
|
||||
from_peer_id = await self.get_peer_id(from_peer)
|
||||
else:
|
||||
from_peer_id = None
|
||||
|
||||
def get_key(m):
|
||||
if isinstance(m, int):
|
||||
if from_peer_id is not None:
|
||||
return from_peer_id
|
||||
|
||||
raise ValueError('from_peer must be given if integer IDs are used')
|
||||
elif isinstance(m, _tl.Message):
|
||||
return m.chat_id
|
||||
else:
|
||||
raise TypeError('Cannot forward messages of type {}'.format(type(m)))
|
||||
|
||||
sent = []
|
||||
for _chat_id, chunk in itertools.groupby(messages, key=get_key):
|
||||
chunk = list(chunk)
|
||||
if isinstance(chunk[0], int):
|
||||
chat = from_peer
|
||||
else:
|
||||
chat = await chunk[0].get_input_chat()
|
||||
chunk = [m.id for m in chunk]
|
||||
|
||||
req = _tl.fn.messages.ForwardMessages(
|
||||
from_peer=chat,
|
||||
id=chunk,
|
||||
to_peer=entity,
|
||||
silent=silent,
|
||||
background=background,
|
||||
with_my_score=with_my_score,
|
||||
schedule_date=schedule,
|
||||
noforwards=noforwards,
|
||||
send_as=send_as
|
||||
)
|
||||
result = await self(req)
|
||||
sent.extend(self._get_response_message(req, result, entity))
|
||||
|
||||
return sent[0] if single else sent
|
||||
|
||||
async def edit_message(
|
||||
self: 'TelegramClient',
|
||||
entity: 'typing.Union[hints.EntityLike, _tl.Message]',
|
||||
message: 'hints.MessageLike' = None,
|
||||
text: str = None,
|
||||
*,
|
||||
parse_mode: str = (),
|
||||
attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
|
||||
formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
|
||||
link_preview: bool = True,
|
||||
file: 'hints.FileLike' = None,
|
||||
thumb: 'hints.FileLike' = None,
|
||||
force_document: bool = False,
|
||||
buttons: 'hints.MarkupLike' = None,
|
||||
supports_streaming: bool = False,
|
||||
schedule: 'hints.DateLike' = None
|
||||
) -> '_tl.Message':
|
||||
if formatting_entities is None:
|
||||
text, formatting_entities = await self._parse_message_text(text, parse_mode)
|
||||
file_handle, media, image = await self._file_to_media(file,
|
||||
supports_streaming=supports_streaming,
|
||||
thumb=thumb,
|
||||
attributes=attributes,
|
||||
force_document=force_document)
|
||||
|
||||
if isinstance(message, _tl.InputBotInlineMessageID):
|
||||
request = _tl.fn.messages.EditInlineBotMessage(
|
||||
id=message,
|
||||
message=text,
|
||||
no_webpage=not link_preview,
|
||||
entities=formatting_entities,
|
||||
media=media,
|
||||
reply_markup=_custom.button.build_reply_markup(buttons)
|
||||
)
|
||||
# Invoke `messages.editInlineBotMessage` from the right datacenter.
|
||||
# Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing.
|
||||
exported = self._session_state.dc_id != entity.dc_id
|
||||
if exported:
|
||||
try:
|
||||
sender = await self._borrow_exported_sender(entity.dc_id)
|
||||
return await self._call(sender, request)
|
||||
finally:
|
||||
await self._return_exported_sender(sender)
|
||||
else:
|
||||
return await self(request)
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
request = _tl.fn.messages.EditMessage(
|
||||
peer=entity,
|
||||
id=utils.get_message_id(message),
|
||||
message=text,
|
||||
no_webpage=not link_preview,
|
||||
entities=formatting_entities,
|
||||
media=media,
|
||||
reply_markup=_custom.button.build_reply_markup(buttons),
|
||||
schedule_date=schedule
|
||||
)
|
||||
msg = self._get_response_message(request, await self(request), entity)
|
||||
return msg
|
||||
|
||||
async def delete_messages(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]',
|
||||
*,
|
||||
revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]':
|
||||
messages = (
|
||||
m.id if isinstance(m, (
|
||||
_tl.Message, _tl.MessageService, _tl.MessageEmpty))
|
||||
else int(m) for m in messages
|
||||
)
|
||||
|
||||
if entity:
|
||||
entity = await self.get_input_entity(entity)
|
||||
ty = helpers._entity_type(entity)
|
||||
else:
|
||||
# no entity (None), set a value that's not a channel for private delete
|
||||
ty = helpers._EntityType.USER
|
||||
|
||||
if ty == helpers._EntityType.CHANNEL:
|
||||
res = await self([_tl.fn.channels.DeleteMessages(
|
||||
entity, list(c)) for c in utils.chunks(messages)])
|
||||
else:
|
||||
res = await self([_tl.fn.messages.DeleteMessages(
|
||||
list(c), revoke) for c in utils.chunks(messages)])
|
||||
|
||||
return sum(r.pts_count for r in res)
|
||||
|
||||
async def mark_read(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'hints.MessageIDLike' = None,
|
||||
*,
|
||||
clear_mentions: bool = False) -> bool:
|
||||
if not message:
|
||||
max_id = 0
|
||||
elif isinstance(message, int):
|
||||
max_id = message
|
||||
else:
|
||||
max_id = message.id
|
||||
|
||||
entity = await self.get_input_entity(entity)
|
||||
if clear_mentions:
|
||||
await self(_tl.fn.messages.ReadMentions(entity))
|
||||
|
||||
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
|
||||
return await self(_tl.fn.channels.ReadHistory(
|
||||
utils.get_input_channel(entity), max_id=max_id))
|
||||
else:
|
||||
return await self(_tl.fn.messages.ReadHistory(
|
||||
entity, max_id=max_id))
|
||||
|
||||
return False
|
||||
|
||||
async def pin_message(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'typing.Optional[hints.MessageIDLike]',
|
||||
*,
|
||||
notify: bool = False,
|
||||
pm_oneside: bool = False
|
||||
):
|
||||
return await _pin(self, entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside)
|
||||
|
||||
async def unpin_message(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
message: 'typing.Optional[hints.MessageIDLike]' = None,
|
||||
*,
|
||||
notify: bool = False
|
||||
):
|
||||
return await _pin(self, entity, message, unpin=True, notify=notify)
|
||||
|
||||
async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False):
|
||||
message = utils.get_message_id(message) or 0
|
||||
entity = await self.get_input_entity(entity)
|
||||
if message <= 0: # old behaviour accepted negative IDs to unpin
|
||||
await self(_tl.fn.messages.UnpinAllMessages(entity))
|
||||
return
|
||||
|
||||
request = _tl.fn.messages.UpdatePinnedMessage(
|
||||
peer=entity,
|
||||
id=message,
|
||||
silent=not notify,
|
||||
unpin=unpin,
|
||||
pm_oneside=pm_oneside
|
||||
)
|
||||
result = await self(request)
|
||||
|
||||
# Unpinning does not produce a service message.
|
||||
# Pinning a message that was already pinned also produces no service message.
|
||||
# Pinning a message in your own chat does not produce a service message,
|
||||
# but pinning on a private conversation with someone else does.
|
||||
if unpin or not result.updates:
|
||||
return
|
||||
|
||||
# Pinning a message that doesn't exist would RPC-error earlier
|
||||
return self._get_response_message(request, result, entity)
|
470
telethon/_client/telegrambaseclient.py
Normal file
470
telethon/_client/telegrambaseclient.py
Normal file
|
@ -0,0 +1,470 @@
|
|||
import abc
|
||||
import re
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
import ipaddress
|
||||
import dataclasses
|
||||
|
||||
from .. import version, __name__ as __base_name__, _tl
|
||||
from .._crypto import rsa
|
||||
from .._misc import markdown, enums, helpers
|
||||
from .._network import MTProtoSender, Connection, transports
|
||||
from .._sessions import Session, SQLiteSession, MemorySession
|
||||
from .._sessions.types import DataCenter, SessionState, EntityType, ChannelState
|
||||
from .._updates import EntityCache, MessageBox
|
||||
|
||||
DEFAULT_DC_ID = 2
|
||||
DEFAULT_IPV4_IP = '149.154.167.51'
|
||||
DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a'
|
||||
DEFAULT_PORT = 443
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
_base_log = logging.getLogger(__base_name__)
|
||||
|
||||
|
||||
# In seconds, how long to wait before disconnecting a exported sender.
|
||||
_DISCONNECT_EXPORTED_AFTER = 60
|
||||
|
||||
|
||||
class _ExportState:
|
||||
def __init__(self):
|
||||
# ``n`` is the amount of borrows a given sender has;
|
||||
# once ``n`` reaches ``0``, disconnect the sender after a while.
|
||||
self._n = 0
|
||||
self._zero_ts = 0
|
||||
self._connected = False
|
||||
|
||||
def add_borrow(self):
|
||||
self._n += 1
|
||||
self._connected = True
|
||||
|
||||
def add_return(self):
|
||||
self._n -= 1
|
||||
assert self._n >= 0, 'returned sender more than it was borrowed'
|
||||
if self._n == 0:
|
||||
self._zero_ts = time.time()
|
||||
|
||||
def should_disconnect(self):
|
||||
return (self._n == 0
|
||||
and self._connected
|
||||
and (time.time() - self._zero_ts) > _DISCONNECT_EXPORTED_AFTER)
|
||||
|
||||
def need_connect(self):
|
||||
return not self._connected
|
||||
|
||||
def mark_disconnected(self):
|
||||
assert self.should_disconnect(), 'marked as disconnected when it was borrowed'
|
||||
self._connected = False
|
||||
|
||||
|
||||
# TODO How hard would it be to support both `trio` and `asyncio`?
|
||||
|
||||
|
||||
def init(
|
||||
self: 'TelegramClient',
|
||||
session: 'typing.Union[str, Session]',
|
||||
api_id: int,
|
||||
api_hash: str,
|
||||
*,
|
||||
# Logging.
|
||||
base_logger: typing.Union[str, logging.Logger] = None,
|
||||
# Connection parameters.
|
||||
use_ipv6: bool = False,
|
||||
proxy: typing.Union[tuple, dict] = None,
|
||||
local_addr: typing.Union[str, tuple] = None,
|
||||
device_model: str = None,
|
||||
system_version: str = None,
|
||||
app_version: str = None,
|
||||
lang_code: str = 'en',
|
||||
system_lang_code: str = 'en',
|
||||
# Nice-to-have.
|
||||
auto_reconnect: bool = True,
|
||||
connect_timeout: int = 10,
|
||||
connect_retries: int = 4,
|
||||
connect_retry_delay: int = 1,
|
||||
request_retries: int = 4,
|
||||
flood_sleep_threshold: int = 60,
|
||||
# Update handling.
|
||||
catch_up: bool = False,
|
||||
receive_updates: bool = True,
|
||||
max_queued_updates: int = 100,
|
||||
):
|
||||
# Logging.
|
||||
if isinstance(base_logger, str):
|
||||
base_logger = logging.getLogger(base_logger)
|
||||
elif not isinstance(base_logger, logging.Logger):
|
||||
base_logger = _base_log
|
||||
|
||||
class _Loggers(dict):
|
||||
def __missing__(self, key):
|
||||
if key.startswith("telethon."):
|
||||
key = key.split('.', maxsplit=1)[1]
|
||||
|
||||
return base_logger.getChild(key)
|
||||
|
||||
self._log = _Loggers()
|
||||
|
||||
# Sessions.
|
||||
if isinstance(session, str) or session is None:
|
||||
try:
|
||||
session = SQLiteSession(session)
|
||||
except ImportError:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
'The sqlite3 module is not available under this '
|
||||
'Python installation and no _ session '
|
||||
'instance was given; using MemorySession.\n'
|
||||
'You will need to re-login every time unless '
|
||||
'you use another session storage'
|
||||
)
|
||||
session = MemorySession()
|
||||
elif not isinstance(session, Session):
|
||||
raise TypeError(
|
||||
'The given session must be a str or a Session instance.'
|
||||
)
|
||||
|
||||
self._session = session
|
||||
# In-memory copy of the session's state to avoid a roundtrip as it contains commonly-accessed values.
|
||||
self._session_state = None
|
||||
|
||||
# Nice-to-have.
|
||||
self._request_retries = request_retries
|
||||
self._connect_retries = connect_retries
|
||||
self._connect_retry_delay = connect_retry_delay or 0
|
||||
self._connect_timeout = connect_timeout
|
||||
self.flood_sleep_threshold = flood_sleep_threshold
|
||||
self._flood_waited_requests = {} # prevent calls that would floodwait entirely
|
||||
self._parse_mode = markdown
|
||||
|
||||
# Update handling.
|
||||
self._catch_up = catch_up
|
||||
self._no_updates = not receive_updates
|
||||
self._updates_queue = asyncio.Queue(maxsize=max_queued_updates)
|
||||
self._updates_handle = None
|
||||
self._message_box = MessageBox()
|
||||
self._entity_cache = EntityCache() # required for proper update handling (to know when to getDifference)
|
||||
|
||||
# Connection parameters.
|
||||
if not api_id or not api_hash:
|
||||
raise ValueError(
|
||||
"Your API ID or Hash cannot be empty or None. "
|
||||
"Refer to telethon.rtfd.io for more information.")
|
||||
|
||||
if local_addr is not None:
|
||||
if use_ipv6 is False and ':' in local_addr:
|
||||
raise TypeError('A local IPv6 address must only be used with `use_ipv6=True`.')
|
||||
elif use_ipv6 is True and ':' not in local_addr:
|
||||
raise TypeError('`use_ipv6=True` must only be used with a local IPv6 address.')
|
||||
|
||||
self._transport = transports.Full()
|
||||
self._use_ipv6 = use_ipv6
|
||||
self._local_addr = local_addr
|
||||
self._proxy = proxy
|
||||
self._auto_reconnect = auto_reconnect
|
||||
self._api_id = int(api_id)
|
||||
self._api_hash = api_hash
|
||||
|
||||
# Used on connection. Capture the variables in a lambda since
|
||||
# exporting clients need to create this InvokeWithLayer.
|
||||
system = platform.uname()
|
||||
|
||||
if system.machine in ('x86_64', 'AMD64'):
|
||||
default_device_model = 'PC 64bit'
|
||||
elif system.machine in ('i386','i686','x86'):
|
||||
default_device_model = 'PC 32bit'
|
||||
else:
|
||||
default_device_model = system.machine
|
||||
default_system_version = re.sub(r'-.+','',system.release)
|
||||
|
||||
self._init_request = _tl.fn.InitConnection(
|
||||
api_id=self._api_id,
|
||||
device_model=device_model or default_device_model or 'Unknown',
|
||||
system_version=system_version or default_system_version or '1.0',
|
||||
app_version=app_version or self.__version__,
|
||||
lang_code=lang_code,
|
||||
system_lang_code=system_lang_code,
|
||||
lang_pack='', # "langPacks are for official apps only"
|
||||
query=None,
|
||||
proxy=None
|
||||
)
|
||||
|
||||
self._sender = MTProtoSender(
|
||||
loggers=self._log,
|
||||
retries=self._connect_retries,
|
||||
delay=self._connect_retry_delay,
|
||||
auto_reconnect=self._auto_reconnect,
|
||||
connect_timeout=self._connect_timeout,
|
||||
updates_queue=self._updates_queue,
|
||||
)
|
||||
|
||||
# Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders.
|
||||
self._borrowed_senders = {}
|
||||
self._borrow_sender_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def get_flood_sleep_threshold(self):
|
||||
return self._flood_sleep_threshold
|
||||
|
||||
def set_flood_sleep_threshold(self, value):
|
||||
# None -> 0, negative values don't really matter
|
||||
self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60)
|
||||
|
||||
|
||||
async def connect(self: 'TelegramClient') -> None:
|
||||
all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()}
|
||||
self._session_state = await self._session.get_state()
|
||||
|
||||
if self._session_state is None:
|
||||
try_fetch_user = False
|
||||
self._session_state = SessionState(
|
||||
user_id=0,
|
||||
dc_id=DEFAULT_DC_ID,
|
||||
bot=False,
|
||||
pts=0,
|
||||
qts=0,
|
||||
date=0,
|
||||
seq=0,
|
||||
takeout_id=None,
|
||||
)
|
||||
else:
|
||||
try_fetch_user = self._session_state.user_id == 0
|
||||
if self._catch_up:
|
||||
channel_states = await self._session.get_all_channel_states()
|
||||
self._message_box.load(self._session_state, channel_states)
|
||||
for state in channel_states:
|
||||
entity = await self._session.get_entity(EntityType.CHANNEL, state.channel_id)
|
||||
if entity:
|
||||
self._entity_cache.put(entity)
|
||||
|
||||
dc = all_dcs.get(self._session_state.dc_id)
|
||||
if dc is None:
|
||||
dc = DataCenter(
|
||||
id=DEFAULT_DC_ID,
|
||||
ipv4=None if self._use_ipv6 else int(ipaddress.ip_address(DEFAULT_IPV4_IP)),
|
||||
ipv6=int(ipaddress.ip_address(DEFAULT_IPV6_IP)) if self._use_ipv6 else None,
|
||||
port=DEFAULT_PORT,
|
||||
auth=b'',
|
||||
)
|
||||
all_dcs[dc.id] = dc
|
||||
|
||||
# Use known key, if any
|
||||
self._sender.auth_key.key = dc.auth
|
||||
|
||||
if not await self._sender.connect(Connection(
|
||||
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
|
||||
port=dc.port,
|
||||
transport=self._transport.recreate_fresh(),
|
||||
loggers=self._log,
|
||||
local_addr=self._local_addr,
|
||||
)):
|
||||
# We don't want to init or modify anything if we were already connected
|
||||
return
|
||||
|
||||
if self._sender.auth_key.key != dc.auth:
|
||||
all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key)
|
||||
|
||||
# Need to send invokeWithLayer for things to work out.
|
||||
# Make the most out of this opportunity by also refreshing our state.
|
||||
# During the v1 to v2 migration, this also correctly sets the IPv* columns.
|
||||
self._init_request.query = _tl.fn.help.GetConfig()
|
||||
|
||||
config = await self._sender.send(_tl.fn.InvokeWithLayer(
|
||||
_tl.LAYER, self._init_request
|
||||
))
|
||||
|
||||
for dc in config.dc_options:
|
||||
if dc.media_only or dc.tcpo_only or dc.cdn:
|
||||
continue
|
||||
|
||||
ip = int(ipaddress.ip_address(dc.ip_address))
|
||||
if dc.id in all_dcs:
|
||||
if dc.ipv6:
|
||||
all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv6=ip)
|
||||
else:
|
||||
all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv4=ip)
|
||||
elif dc.ipv6:
|
||||
all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'')
|
||||
else:
|
||||
all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'')
|
||||
|
||||
for dc in all_dcs.values():
|
||||
await self._session.insert_dc(dc)
|
||||
|
||||
if try_fetch_user:
|
||||
# If there was a previous session state, but the current user ID is 0, it means we've
|
||||
# migrated and not yet populated the current user (or the client connected but never
|
||||
# logged in). Attempt to fetch the user now. If it works, also get the update state.
|
||||
me = await self.get_me()
|
||||
if me:
|
||||
await self._update_session_state(me, save=False)
|
||||
|
||||
await self._session.save()
|
||||
|
||||
self._updates_handle = asyncio.create_task(self._update_loop())
|
||||
|
||||
def is_connected(self: 'TelegramClient') -> bool:
|
||||
sender = getattr(self, '_sender', None)
|
||||
return sender and sender.is_connected()
|
||||
|
||||
async def disconnect(self: 'TelegramClient'):
|
||||
return await _disconnect_coro(self)
|
||||
|
||||
def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]):
|
||||
init_proxy = None
|
||||
|
||||
self._init_request.proxy = init_proxy
|
||||
self._proxy = proxy
|
||||
|
||||
# While `await client.connect()` passes new proxy on each new call,
|
||||
# auto-reconnect attempts use already set up `_connection` inside
|
||||
# the `_sender`, so the only way to change proxy between those
|
||||
# is to directly inject parameters.
|
||||
|
||||
connection = getattr(self._sender, "_connection", None)
|
||||
if connection:
|
||||
if isinstance(connection, conns.TcpMTProxy):
|
||||
connection._ip = proxy[0]
|
||||
connection._port = proxy[1]
|
||||
else:
|
||||
connection._proxy = proxy
|
||||
|
||||
async def _disconnect_coro(self: 'TelegramClient'):
|
||||
await _disconnect(self)
|
||||
|
||||
# Also clean-up all exported senders because we're done with them
|
||||
async with self._borrow_sender_lock:
|
||||
for state, sender in self._borrowed_senders.values():
|
||||
# Note that we're not checking for `state.should_disconnect()`.
|
||||
# If the user wants to disconnect the client, ALL connections
|
||||
# to Telegram (including exported senders) should be closed.
|
||||
#
|
||||
# Disconnect should never raise, so there's no try/except.
|
||||
await sender.disconnect()
|
||||
# Can't use `mark_disconnected` because it may be borrowed.
|
||||
state._connected = False
|
||||
|
||||
# If any was borrowed
|
||||
self._borrowed_senders.clear()
|
||||
|
||||
|
||||
async def _disconnect(self: 'TelegramClient'):
|
||||
"""
|
||||
Disconnect only, without closing the session. Used in reconnections
|
||||
to different data centers, where we don't want to close the session
|
||||
file; user disconnects however should close it since it means that
|
||||
their job with the client is complete and we should clean it up all.
|
||||
"""
|
||||
await self._sender.disconnect()
|
||||
await helpers._cancel(self._log[__name__], updates_handle=self._updates_handle)
|
||||
try:
|
||||
await self._updates_handle
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._session.insert_entities(self._entity_cache.get_all_entities())
|
||||
|
||||
session_state, channel_states = self._message_box.session_state()
|
||||
for channel_id, pts in channel_states.items():
|
||||
await self._session.insert_channel_state(ChannelState(channel_id=channel_id, pts=pts))
|
||||
|
||||
await self._replace_session_state(**session_state)
|
||||
|
||||
|
||||
async def _switch_dc(self: 'TelegramClient', new_dc):
|
||||
"""
|
||||
Permanently switches the current connection to the new data center.
|
||||
"""
|
||||
self._log[__name__].info('Reconnecting to new data center %s', new_dc)
|
||||
|
||||
await self._replace_session_state(dc_id=new_dc)
|
||||
await _disconnect(self)
|
||||
return await self.connect()
|
||||
|
||||
async def _create_exported_sender(self: 'TelegramClient', dc_id):
|
||||
"""
|
||||
Creates a new exported `MTProtoSender` for the given `dc_id` and
|
||||
returns it. This method should be used by `_borrow_exported_sender`.
|
||||
"""
|
||||
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
|
||||
# for clearly showing how to export the authorization
|
||||
dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id)
|
||||
# Can't reuse self._sender._connection as it has its own seqno.
|
||||
#
|
||||
# If one were to do that, Telegram would reset the connection
|
||||
# with no further clues.
|
||||
sender = MTProtoSender(loggers=self._log)
|
||||
await self._sender.connect(Connection(
|
||||
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
|
||||
port=dc.port,
|
||||
transport=self._transport.recreate_fresh(),
|
||||
loggers=self._log,
|
||||
local_addr=self._local_addr,
|
||||
))
|
||||
self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc)
|
||||
auth = await self(_tl.fn.auth.ExportAuthorization(dc_id))
|
||||
self._init_request.query = _tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes)
|
||||
req = _tl.fn.InvokeWithLayer(_tl.LAYER, self._init_request)
|
||||
await sender.send(req)
|
||||
return sender
|
||||
|
||||
async def _borrow_exported_sender(self: 'TelegramClient', dc_id):
|
||||
"""
|
||||
Borrows a connected `MTProtoSender` for the given `dc_id`.
|
||||
If it's not cached, creates a new one if it doesn't exist yet,
|
||||
and imports a freshly exported authorization key for it to be usable.
|
||||
|
||||
Once its job is over it should be `_return_exported_sender`.
|
||||
"""
|
||||
async with self._borrow_sender_lock:
|
||||
self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id)
|
||||
state, sender = self._borrowed_senders.get(dc_id, (None, None))
|
||||
|
||||
if state is None:
|
||||
state = _ExportState()
|
||||
sender = await _create_exported_sender(self, dc_id)
|
||||
sender.dc_id = dc_id
|
||||
self._borrowed_senders[dc_id] = (state, sender)
|
||||
|
||||
elif state.need_connect():
|
||||
dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id)
|
||||
|
||||
await self._sender.connect(Connection(
|
||||
ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)),
|
||||
port=dc.port,
|
||||
transport=self._transport.recreate_fresh(),
|
||||
loggers=self._log,
|
||||
local_addr=self._local_addr,
|
||||
))
|
||||
|
||||
state.add_borrow()
|
||||
return sender
|
||||
|
||||
async def _return_exported_sender(self: 'TelegramClient', sender):
|
||||
"""
|
||||
Returns a borrowed exported sender. If all borrows have
|
||||
been returned, the sender is cleanly disconnected.
|
||||
"""
|
||||
async with self._borrow_sender_lock:
|
||||
self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id)
|
||||
state, _ = self._borrowed_senders[sender.dc_id]
|
||||
state.add_return()
|
||||
|
||||
async def _clean_exported_senders(self: 'TelegramClient'):
|
||||
"""
|
||||
Cleans-up all unused exported senders by disconnecting them.
|
||||
"""
|
||||
async with self._borrow_sender_lock:
|
||||
for dc_id, (state, sender) in self._borrowed_senders.items():
|
||||
if state.should_disconnect():
|
||||
self._log[__name__].info(
|
||||
'Disconnecting borrowed sender for DC %d', dc_id)
|
||||
|
||||
# Disconnect should never raise
|
||||
await sender.disconnect()
|
||||
state.mark_disconnected()
|
3520
telethon/_client/telegramclient.py
Normal file
3520
telethon/_client/telegramclient.py
Normal file
File diff suppressed because it is too large
Load Diff
130
telethon/_client/updates.py
Normal file
130
telethon/_client/updates.py
Normal file
|
@ -0,0 +1,130 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
from ..errors._rpcbase import RpcError
|
||||
from .._events.common import EventBuilder, EventCommon
|
||||
from .._events.raw import Raw
|
||||
from .._events.base import StopPropagation, _get_handlers
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
Callback = typing.Callable[[typing.Any], typing.Any]
|
||||
|
||||
|
||||
async def set_receive_updates(self: 'TelegramClient', receive_updates):
|
||||
self._no_updates = not receive_updates
|
||||
if receive_updates:
|
||||
await self(_tl.fn.updates.GetState())
|
||||
|
||||
async def run_until_disconnected(self: 'TelegramClient'):
|
||||
# Make a high-level request to notify that we want updates
|
||||
await self(_tl.fn.updates.GetState())
|
||||
await self._sender.wait_disconnected()
|
||||
|
||||
def on(self: 'TelegramClient', event: EventBuilder):
|
||||
def decorator(f):
|
||||
self.add_event_handler(f, event)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def add_event_handler(
|
||||
self: 'TelegramClient',
|
||||
callback: Callback,
|
||||
event: EventBuilder = None):
|
||||
builders = _get_handlers(callback)
|
||||
if builders is not None:
|
||||
for event in builders:
|
||||
self._event_builders.append((event, callback))
|
||||
return
|
||||
|
||||
if isinstance(event, type):
|
||||
event = event()
|
||||
elif not event:
|
||||
event = Raw()
|
||||
|
||||
self._event_builders.append((event, callback))
|
||||
|
||||
def remove_event_handler(
|
||||
self: 'TelegramClient',
|
||||
callback: Callback,
|
||||
event: EventBuilder = None) -> int:
|
||||
found = 0
|
||||
if event and not isinstance(event, type):
|
||||
event = type(event)
|
||||
|
||||
i = len(self._event_builders)
|
||||
while i:
|
||||
i -= 1
|
||||
ev, cb = self._event_builders[i]
|
||||
if cb == callback and (not event or isinstance(ev, event)):
|
||||
del self._event_builders[i]
|
||||
found += 1
|
||||
|
||||
return found
|
||||
|
||||
def list_event_handlers(self: 'TelegramClient')\
|
||||
-> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]':
|
||||
return [(callback, event) for event, callback in self._event_builders]
|
||||
|
||||
async def catch_up(self: 'TelegramClient'):
|
||||
# The update loop is probably blocked on either timeout or an update to arrive.
|
||||
# Unblock the loop by pushing a dummy update which will always trigger a gap.
|
||||
# This, in return, causes the update loop to catch up.
|
||||
await self._updates_queue.put(_tl.UpdatesTooLong())
|
||||
|
||||
async def _update_loop(self: 'TelegramClient'):
|
||||
try:
|
||||
updates_to_dispatch = deque()
|
||||
while self.is_connected():
|
||||
if updates_to_dispatch:
|
||||
# TODO dispatch
|
||||
updates_to_dispatch.popleft()
|
||||
continue
|
||||
|
||||
get_diff = self._message_box.get_difference()
|
||||
if get_diff:
|
||||
self._log[__name__].info('Getting difference for account updates')
|
||||
diff = await self(get_diff)
|
||||
updates, users, chats = self._message_box.apply_difference(diff, self._entity_cache)
|
||||
self._entity_cache.extend(users, chats)
|
||||
updates_to_dispatch.extend(updates)
|
||||
continue
|
||||
|
||||
get_diff = self._message_box.get_channel_difference(self._entity_cache)
|
||||
if get_diff:
|
||||
self._log[__name__].info('Getting difference for channel updates')
|
||||
diff = await self(get_diff)
|
||||
updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._entity_cache)
|
||||
self._entity_cache.extend(users, chats)
|
||||
updates_to_dispatch.extend(updates)
|
||||
continue
|
||||
|
||||
deadline = self._message_box.check_deadlines()
|
||||
try:
|
||||
updates = await asyncio.wait_for(
|
||||
self._updates_queue.get(),
|
||||
deadline - asyncio.get_running_loop().time()
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
self._log[__name__].info('Timeout waiting for updates expired')
|
||||
continue
|
||||
|
||||
processed = []
|
||||
users, chats = self._message_box.process_updates(updates, self._entity_cache, processed)
|
||||
self._entity_cache.extend(users, chats)
|
||||
updates_to_dispatch.extend(processed)
|
||||
except Exception:
|
||||
self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)')
|
332
telethon/_client/uploads.py
Normal file
332
telethon/_client/uploads.py
Normal file
|
@ -0,0 +1,332 @@
|
|||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import typing
|
||||
from io import BytesIO
|
||||
|
||||
from .._crypto import AES
|
||||
|
||||
from .._misc import utils, helpers, hints
|
||||
from ..types import _custom
|
||||
from .. import _tl
|
||||
|
||||
try:
|
||||
import PIL
|
||||
import PIL.Image
|
||||
except ImportError:
|
||||
PIL = None
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
def _resize_photo_if_needed(
|
||||
file, is_image, width=1280, height=1280, background=(255, 255, 255)):
|
||||
|
||||
# https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254
|
||||
if (not is_image
|
||||
or PIL is None
|
||||
or (isinstance(file, io.IOBase) and not file.seekable())):
|
||||
return file
|
||||
|
||||
if isinstance(file, bytes):
|
||||
file = io.BytesIO(file)
|
||||
|
||||
before = file.tell() if isinstance(file, io.IOBase) else None
|
||||
|
||||
try:
|
||||
# Don't use a `with` block for `image`, or `file` would be closed.
|
||||
# See https://github.com/LonamiWebs/Telethon/issues/1121 for more.
|
||||
image = PIL.Image.open(file)
|
||||
try:
|
||||
kwargs = {'exif': image.info['exif']}
|
||||
except KeyError:
|
||||
kwargs = {}
|
||||
|
||||
if image.width <= width and image.height <= height:
|
||||
return file
|
||||
|
||||
image.thumbnail((width, height), PIL.Image.ANTIALIAS)
|
||||
|
||||
alpha_index = image.mode.find('A')
|
||||
if alpha_index == -1:
|
||||
# If the image mode doesn't have alpha
|
||||
# channel then don't bother masking it away.
|
||||
result = image
|
||||
else:
|
||||
# We could save the resized image with the original format, but
|
||||
# JPEG often compresses better -> smaller size -> faster upload
|
||||
# We need to mask away the alpha channel ([3]), since otherwise
|
||||
# IOError is raised when trying to save alpha channels in JPEG.
|
||||
result = PIL.Image.new('RGB', image.size, background)
|
||||
result.paste(image, mask=image.split()[alpha_index])
|
||||
|
||||
buffer = io.BytesIO()
|
||||
result.save(buffer, 'JPEG', **kwargs)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
except IOError:
|
||||
return file
|
||||
finally:
|
||||
if before is not None:
|
||||
file.seek(before, io.SEEK_SET)
|
||||
|
||||
|
||||
async def send_file(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntityLike',
|
||||
file: typing.Optional[hints.FileLike] = None,
|
||||
*,
|
||||
# - Message contents
|
||||
# Formatting
|
||||
caption: 'hints.MessageLike' = '',
|
||||
markdown: str = None,
|
||||
html: str = None,
|
||||
formatting_entities: list = None,
|
||||
link_preview: bool = (),
|
||||
# Media
|
||||
file_name: str = None,
|
||||
mime_type: str = None,
|
||||
thumb: str = False,
|
||||
force_file: bool = False,
|
||||
file_size: int = None,
|
||||
# Media attributes
|
||||
duration: int = None,
|
||||
width: int = None,
|
||||
height: int = None,
|
||||
title: str = None,
|
||||
performer: str = None,
|
||||
supports_streaming: bool = False,
|
||||
video_note: bool = False,
|
||||
voice_note: bool = False,
|
||||
waveform: bytes = None,
|
||||
# Additional parametrization
|
||||
silent: bool = False,
|
||||
buttons: list = None,
|
||||
ttl: int = None,
|
||||
# - Send options
|
||||
reply_to: 'typing.Union[int, _tl.Message]' = None,
|
||||
clear_draft: bool = False,
|
||||
background: bool = None,
|
||||
noforwards: bool = None,
|
||||
send_as: 'hints.EntityLike' = None,
|
||||
schedule: 'hints.DateLike' = None,
|
||||
comment_to: 'typing.Union[int, _tl.Message]' = None,
|
||||
) -> '_tl.Message':
|
||||
self.send_message(
|
||||
entity=entity,
|
||||
message=caption,
|
||||
markdown=markdown,
|
||||
html=html,
|
||||
formatting_entities=formatting_entities,
|
||||
link_preview=link_preview,
|
||||
file=file,
|
||||
file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
thumb=thumb,
|
||||
force_file=force_file,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
title=title,
|
||||
performer=performer,
|
||||
supports_streaming=supports_streaming,
|
||||
video_note=video_note,
|
||||
voice_note=voice_note,
|
||||
waveform=waveform,
|
||||
silent=silent,
|
||||
buttons=buttons,
|
||||
ttl=ttl,
|
||||
reply_to=reply_to,
|
||||
clear_draft=clear_draft,
|
||||
background=background,
|
||||
schedule=schedule,
|
||||
comment_to=comment_to,
|
||||
noforwards=noforwards,
|
||||
send_as=send_as
|
||||
)
|
||||
|
||||
async def _send_album(self: 'TelegramClient', entity, files, caption='',
|
||||
progress_callback=None, reply_to=None,
|
||||
parse_mode=(), silent=None, schedule=None,
|
||||
supports_streaming=None, clear_draft=None,
|
||||
force_document=False, background=None, ttl=None,
|
||||
send_as=None, noforwards=None):
|
||||
"""Specialized version of .send_file for albums"""
|
||||
# We don't care if the user wants to avoid cache, we will use it
|
||||
# anyway. Why? The cached version will be exactly the same thing
|
||||
# we need to produce right now to send albums (uploadMedia), and
|
||||
# cache only makes a difference for documents where the user may
|
||||
# want the attributes used on them to change.
|
||||
#
|
||||
# In theory documents can be sent inside the albums but they appear
|
||||
# as different messages (not inside the album), and the logic to set
|
||||
# the attributes/avoid cache is already written in .send_file().
|
||||
entity = await self.get_input_entity(entity)
|
||||
if not utils.is_list_like(caption):
|
||||
caption = (caption,)
|
||||
|
||||
captions = []
|
||||
for c in reversed(caption): # Pop from the end (so reverse)
|
||||
captions.append(await self._parse_message_text(c or '', parse_mode))
|
||||
|
||||
reply_to = utils.get_message_id(reply_to)
|
||||
|
||||
# Need to upload the media first, but only if they're not cached yet
|
||||
media = []
|
||||
for file in files:
|
||||
# Albums want :tl:`InputMedia` which, in theory, includes
|
||||
# :tl:`InputMediaUploadedPhoto`. However using that will
|
||||
# make it `raise MediaInvalidError`, so we need to upload
|
||||
# it as media and then convert that to :tl:`InputMediaPhoto`.
|
||||
fh, fm, _ = await _file_to_media(
|
||||
self, file, supports_streaming=supports_streaming,
|
||||
force_document=force_document, ttl=ttl)
|
||||
if isinstance(fm, (_tl.InputMediaUploadedPhoto, _tl.InputMediaPhotoExternal)):
|
||||
r = await self(_tl.fn.messages.UploadMedia(
|
||||
entity, media=fm
|
||||
))
|
||||
|
||||
fm = utils.get_input_media(r.photo)
|
||||
elif isinstance(fm, _tl.InputMediaUploadedDocument):
|
||||
r = await self(_tl.fn.messages.UploadMedia(
|
||||
entity, media=fm
|
||||
))
|
||||
|
||||
fm = utils.get_input_media(
|
||||
r.document, supports_streaming=supports_streaming)
|
||||
|
||||
if captions:
|
||||
caption, msg_entities = captions.pop()
|
||||
else:
|
||||
caption, msg_entities = '', None
|
||||
media.append(_tl.InputSingleMedia(
|
||||
fm,
|
||||
message=caption,
|
||||
entities=msg_entities
|
||||
# random_id is autogenerated
|
||||
))
|
||||
|
||||
# Now we can construct the multi-media request
|
||||
request = _tl.fn.messages.SendMultiMedia(
|
||||
entity, reply_to_msg_id=reply_to, multi_media=media,
|
||||
silent=silent, schedule_date=schedule, clear_draft=clear_draft,
|
||||
background=background, noforwards=noforwards, send_as=send_as
|
||||
)
|
||||
result = await self(request)
|
||||
|
||||
random_ids = [m.random_id for m in media]
|
||||
return self._get_response_message(random_ids, result, entity)
|
||||
|
||||
async def upload_file(
|
||||
self: 'TelegramClient',
|
||||
file: 'hints.FileLike',
|
||||
*,
|
||||
part_size_kb: float = None,
|
||||
file_size: int = None,
|
||||
file_name: str = None,
|
||||
use_cache: type = None,
|
||||
key: bytes = None,
|
||||
iv: bytes = None,
|
||||
progress_callback: 'hints.ProgressCallback' = None) -> '_tl.TypeInputFile':
|
||||
if isinstance(file, (_tl.InputFile, _tl.InputFileBig)):
|
||||
return file # Already uploaded
|
||||
|
||||
pos = 0
|
||||
async with helpers._FileStream(file, file_size=file_size) as stream:
|
||||
# Opening the stream will determine the correct file size
|
||||
file_size = stream.file_size
|
||||
|
||||
if not part_size_kb:
|
||||
part_size_kb = utils.get_appropriated_part_size(file_size)
|
||||
|
||||
if part_size_kb > 512:
|
||||
raise ValueError('The part size must be less or equal to 512KB')
|
||||
|
||||
part_size = int(part_size_kb * 1024)
|
||||
if part_size % 1024 != 0:
|
||||
raise ValueError(
|
||||
'The part size must be evenly divisible by 1024')
|
||||
|
||||
# Set a default file name if None was specified
|
||||
file_id = helpers.generate_random_long()
|
||||
if not file_name:
|
||||
file_name = stream.name or str(file_id)
|
||||
|
||||
# If the file name lacks extension, add it if possible.
|
||||
# Else Telegram complains with `PHOTO_EXT_INVALID_ERROR`
|
||||
# even if the uploaded image is indeed a photo.
|
||||
if not os.path.splitext(file_name)[-1]:
|
||||
file_name += utils._get_extension(stream)
|
||||
|
||||
# Determine whether the file is too big (over 10MB) or not
|
||||
# Telegram does make a distinction between smaller or larger files
|
||||
is_big = file_size > 10 * 1024 * 1024
|
||||
hash_md5 = hashlib.md5()
|
||||
|
||||
part_count = (file_size + part_size - 1) // part_size
|
||||
self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d',
|
||||
file_size, part_count, part_size)
|
||||
|
||||
pos = 0
|
||||
for part_index in range(part_count):
|
||||
# Read the file by in chunks of size part_size
|
||||
part = await helpers._maybe_await(stream.read(part_size))
|
||||
|
||||
if not isinstance(part, bytes):
|
||||
raise TypeError(
|
||||
'file descriptor returned {}, not bytes (you must '
|
||||
'open the file in bytes mode)'.format(type(part)))
|
||||
|
||||
# `file_size` could be wrong in which case `part` may not be
|
||||
# `part_size` before reaching the end.
|
||||
if len(part) != part_size and part_index < part_count - 1:
|
||||
raise ValueError(
|
||||
'read less than {} before reaching the end; either '
|
||||
'`file_size` or `read` are wrong'.format(part_size))
|
||||
|
||||
pos += len(part)
|
||||
|
||||
# Encryption part if needed
|
||||
if key and iv:
|
||||
part = AES.encrypt_ige(part, key, iv)
|
||||
|
||||
if not is_big:
|
||||
# Bit odd that MD5 is only needed for small files and not
|
||||
# big ones with more chance for corruption, but that's
|
||||
# what Telegram wants.
|
||||
hash_md5.update(part)
|
||||
|
||||
# The SavePart is different depending on whether
|
||||
# the file is too large or not (over or less than 10MB)
|
||||
if is_big:
|
||||
request = _tl.fn.upload.SaveBigFilePart(
|
||||
file_id, part_index, part_count, part)
|
||||
else:
|
||||
request = _tl.fn.upload.SaveFilePart(
|
||||
file_id, part_index, part)
|
||||
|
||||
result = await self(request)
|
||||
if result:
|
||||
self._log[__name__].debug('Uploaded %d/%d',
|
||||
part_index + 1, part_count)
|
||||
if progress_callback:
|
||||
await helpers._maybe_await(progress_callback(pos, file_size))
|
||||
else:
|
||||
raise RuntimeError(
|
||||
'Failed to upload file part {}.'.format(part_index))
|
||||
|
||||
if is_big:
|
||||
return _tl.InputFileBig(file_id, part_count, file_name)
|
||||
else:
|
||||
return _custom.InputSizedFile(
|
||||
file_id, part_count, file_name, md5=hash_md5, size=file_size
|
||||
)
|
||||
|
||||
|
393
telethon/_client/users.py
Normal file
393
telethon/_client/users.py
Normal file
|
@ -0,0 +1,393 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import itertools
|
||||
import time
|
||||
import typing
|
||||
|
||||
from ..errors._custom import MultiError
|
||||
from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, UnauthorizedError
|
||||
from .._misc import helpers, utils, hints
|
||||
from .._sessions.types import Entity
|
||||
from .. import errors, _tl
|
||||
from .account import ignore_takeout
|
||||
|
||||
_NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!')
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta):
|
||||
return (
|
||||
'Sleeping%s for %ds (%s) on %s flood wait',
|
||||
' early' if early else '',
|
||||
delay,
|
||||
td(seconds=delay),
|
||||
request.__class__.__name__
|
||||
)
|
||||
|
||||
|
||||
async def call(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None):
|
||||
return await _call(self, self._sender, request, ordered=ordered)
|
||||
|
||||
|
||||
async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None):
|
||||
if flood_sleep_threshold is None:
|
||||
flood_sleep_threshold = self.flood_sleep_threshold
|
||||
requests = (request if utils.is_list_like(request) else (request,))
|
||||
for r in requests:
|
||||
if not isinstance(r, _tl.TLRequest):
|
||||
raise _NOT_A_REQUEST()
|
||||
await r.resolve(self, utils)
|
||||
|
||||
# Avoid making the request if it's already in a flood wait
|
||||
if r.CONSTRUCTOR_ID in self._flood_waited_requests:
|
||||
due = self._flood_waited_requests[r.CONSTRUCTOR_ID]
|
||||
diff = round(due - time.time())
|
||||
if diff <= 3: # Flood waits below 3 seconds are "ignored"
|
||||
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
|
||||
elif diff <= flood_sleep_threshold:
|
||||
self._log[__name__].info(*_fmt_flood(diff, r, early=True))
|
||||
await asyncio.sleep(diff)
|
||||
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
|
||||
else:
|
||||
raise errors.FLOOD_WAIT(420, f'FLOOD_WAIT_{diff}', request=r)
|
||||
|
||||
if self._session_state.takeout_id and not ignore_takeout.get():
|
||||
r = _tl.fn.InvokeWithTakeout(self._session_state.takeout_id, r)
|
||||
|
||||
if self._no_updates:
|
||||
r = _tl.fn.InvokeWithoutUpdates(r)
|
||||
|
||||
request_index = 0
|
||||
last_error = None
|
||||
self._last_request = time.time()
|
||||
|
||||
for attempt in helpers.retry_range(self._request_retries):
|
||||
try:
|
||||
future = sender.send(request, ordered=ordered)
|
||||
if isinstance(future, list):
|
||||
results = []
|
||||
exceptions = []
|
||||
for f in future:
|
||||
try:
|
||||
result = await f
|
||||
except RpcError as e:
|
||||
exceptions.append(e)
|
||||
results.append(None)
|
||||
continue
|
||||
exceptions.append(None)
|
||||
results.append(result)
|
||||
request_index += 1
|
||||
if any(x is not None for x in exceptions):
|
||||
raise MultiError(exceptions, results, requests)
|
||||
else:
|
||||
return results
|
||||
else:
|
||||
result = await future
|
||||
return result
|
||||
except ServerError as e:
|
||||
last_error = e
|
||||
self._log[__name__].warning(
|
||||
'Telegram is having internal issues %s: %s',
|
||||
e.__class__.__name__, e)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
except FloodError as e:
|
||||
last_error = e
|
||||
if utils.is_list_like(request):
|
||||
request = request[request_index]
|
||||
|
||||
# SLOWMODE_WAIT is chat-specific, not request-specific
|
||||
if not isinstance(e, errors.SLOWMODE_WAIT):
|
||||
self._flood_waited_requests\
|
||||
[request.CONSTRUCTOR_ID] = time.time() + e.seconds
|
||||
|
||||
# In test servers, FLOOD_WAIT_0 has been observed, and sleeping for
|
||||
# such a short amount will cause retries very fast leading to issues.
|
||||
if e.seconds == 0:
|
||||
e.seconds = 1
|
||||
|
||||
if e.seconds <= self.flood_sleep_threshold:
|
||||
self._log[__name__].info(*_fmt_flood(e.seconds, request))
|
||||
await asyncio.sleep(e.seconds)
|
||||
else:
|
||||
raise
|
||||
except InvalidDcError as e:
|
||||
last_error = e
|
||||
self._log[__name__].info('Phone migrated to %d', e.new_dc)
|
||||
should_raise = isinstance(e, (
|
||||
errors.PHONE_MIGRATE, errors.NETWORK_MIGRATE
|
||||
))
|
||||
if should_raise and await self.is_user_authorized():
|
||||
raise
|
||||
await self._switch_dc(e.new_dc)
|
||||
|
||||
raise last_error
|
||||
|
||||
|
||||
async def get_me(self: 'TelegramClient', input_peer: bool = False) \
|
||||
-> 'typing.Union[_tl.User, _tl.InputPeerUser]':
|
||||
try:
|
||||
me = (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0]
|
||||
return utils.get_input_peer(me, allow_self=False) if input_peer else me
|
||||
except UnauthorizedError:
|
||||
return None
|
||||
|
||||
async def is_bot(self: 'TelegramClient') -> bool:
|
||||
return self._session_state.bot if self._session_state else False
|
||||
|
||||
async def is_user_authorized(self: 'TelegramClient') -> bool:
|
||||
try:
|
||||
# Any request that requires authorization will work
|
||||
await self(_tl.fn.updates.GetState())
|
||||
return True
|
||||
except RpcError:
|
||||
return False
|
||||
|
||||
async def get_entity(
|
||||
self: 'TelegramClient',
|
||||
entity: 'hints.EntitiesLike') -> 'hints.Entity':
|
||||
single = not utils.is_list_like(entity)
|
||||
if single:
|
||||
entity = (entity,)
|
||||
|
||||
# Group input entities by string (resolve username),
|
||||
# input users (get users), input chat (get chats) and
|
||||
# input channels (get channels) to get the most entities
|
||||
# in the less amount of calls possible.
|
||||
inputs = []
|
||||
for x in entity:
|
||||
if isinstance(x, str):
|
||||
inputs.append(x)
|
||||
else:
|
||||
inputs.append(await self.get_input_entity(x))
|
||||
|
||||
lists = {
|
||||
helpers._EntityType.USER: [],
|
||||
helpers._EntityType.CHAT: [],
|
||||
helpers._EntityType.CHANNEL: [],
|
||||
}
|
||||
for x in inputs:
|
||||
try:
|
||||
lists[helpers._entity_type(x)].append(x)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
users = lists[helpers._EntityType.USER]
|
||||
chats = lists[helpers._EntityType.CHAT]
|
||||
channels = lists[helpers._EntityType.CHANNEL]
|
||||
if users:
|
||||
# GetUsers has a limit of 200 per call
|
||||
tmp = []
|
||||
while users:
|
||||
curr, users = users[:200], users[200:]
|
||||
tmp.extend(await self(_tl.fn.users.GetUsers(curr)))
|
||||
users = tmp
|
||||
if chats: # TODO Handle chats slice?
|
||||
chats = (await self(
|
||||
_tl.fn.messages.GetChats([x.chat_id for x in chats]))).chats
|
||||
if channels:
|
||||
channels = (await self(
|
||||
_tl.fn.channels.GetChannels(channels))).chats
|
||||
|
||||
# Merge users, chats and channels into a single dictionary
|
||||
id_entity = {
|
||||
utils.get_peer_id(x): x
|
||||
for x in itertools.chain(users, chats, channels)
|
||||
}
|
||||
|
||||
# We could check saved usernames and put them into the users,
|
||||
# chats and channels list from before. While this would reduce
|
||||
# the amount of ResolveUsername calls, it would fail to catch
|
||||
# username changes.
|
||||
result = []
|
||||
for x in inputs:
|
||||
if isinstance(x, str):
|
||||
result.append(await _get_entity_from_string(self, x))
|
||||
elif not isinstance(x, _tl.InputPeerSelf):
|
||||
result.append(id_entity[utils.get_peer_id(x)])
|
||||
else:
|
||||
result.append(next(
|
||||
u for u in id_entity.values()
|
||||
if isinstance(u, _tl.User) and u.is_self
|
||||
))
|
||||
|
||||
return result[0] if single else result
|
||||
|
||||
async def get_input_entity(
|
||||
self: 'TelegramClient',
|
||||
peer: 'hints.EntityLike') -> '_tl.TypeInputPeer':
|
||||
# Short-circuit if the input parameter directly maps to an InputPeer
|
||||
try:
|
||||
return utils.get_input_peer(peer)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Then come known strings that take precedence
|
||||
if peer in ('me', 'self'):
|
||||
return _tl.InputPeerSelf()
|
||||
|
||||
# No InputPeer, cached peer, or known string. Fetch from session cache
|
||||
try:
|
||||
peer_id = utils.get_peer_id(peer)
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
entity = await self._session.get_entity(None, peer_id)
|
||||
if entity:
|
||||
if entity.ty in (Entity.USER, Entity.BOT):
|
||||
return _tl.InputPeerUser(entity.id, entity.access_hash)
|
||||
elif entity.ty in (Entity.GROUP):
|
||||
return _tl.InputPeerChat(peer.chat_id)
|
||||
elif entity.ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP):
|
||||
return _tl.InputPeerChannel(entity.id, entity.access_hash)
|
||||
|
||||
# Only network left to try
|
||||
if isinstance(peer, str):
|
||||
return utils.get_input_peer(
|
||||
await _get_entity_from_string(self, peer))
|
||||
|
||||
# If we're a bot and the user has messaged us privately users.getUsers
|
||||
# will work with access_hash = 0. Similar for channels.getChannels.
|
||||
# If we're not a bot but the user is in our contacts, it seems to work
|
||||
# regardless. These are the only two special-cased requests.
|
||||
peer = utils.get_peer(peer)
|
||||
if isinstance(peer, _tl.PeerUser):
|
||||
users = await self(_tl.fn.users.GetUsers([
|
||||
_tl.InputUser(peer.user_id, access_hash=0)]))
|
||||
if users and not isinstance(users[0], _tl.UserEmpty):
|
||||
# If the user passed a valid ID they expect to work for
|
||||
# channels but would be valid for users, we get UserEmpty.
|
||||
# Avoid returning the invalid empty input peer for that.
|
||||
#
|
||||
# We *could* try to guess if it's a channel first, and if
|
||||
# it's not, work as a chat and try to validate it through
|
||||
# another request, but that becomes too much work.
|
||||
return utils.get_input_peer(users[0])
|
||||
elif isinstance(peer, _tl.PeerChat):
|
||||
return _tl.InputPeerChat(peer.chat_id)
|
||||
elif isinstance(peer, _tl.PeerChannel):
|
||||
try:
|
||||
channels = await self(_tl.fn.channels.GetChannels([
|
||||
_tl.InputChannel(peer.channel_id, access_hash=0)]))
|
||||
return utils.get_input_peer(channels.chats[0])
|
||||
except errors.CHANNEL_INVALID:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
'Could not find the input entity for {} ({}). Please read https://'
|
||||
'docs.telethon.dev/en/latest/concepts/entities.html to'
|
||||
' find out more details.'
|
||||
.format(peer, type(peer).__name__)
|
||||
)
|
||||
|
||||
async def get_peer_id(
|
||||
self: 'TelegramClient',
|
||||
peer: 'hints.EntityLike') -> int:
|
||||
if isinstance(peer, int):
|
||||
return utils.get_peer_id(peer)
|
||||
|
||||
try:
|
||||
if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6):
|
||||
# 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer'
|
||||
peer = await self.get_input_entity(peer)
|
||||
except AttributeError:
|
||||
peer = await self.get_input_entity(peer)
|
||||
|
||||
if isinstance(peer, _tl.InputPeerSelf):
|
||||
peer = await self.get_me(input_peer=True)
|
||||
|
||||
return utils.get_peer_id(peer)
|
||||
|
||||
|
||||
async def _get_entity_from_string(self: 'TelegramClient', string):
|
||||
"""
|
||||
Gets a full entity from the given string, which may be a phone or
|
||||
a username, and processes all the found entities on the session.
|
||||
The string may also be a user link, or a channel/chat invite link.
|
||||
|
||||
This method has the side effect of adding the found users to the
|
||||
session database, so it can be queried later without API calls,
|
||||
if this option is enabled on the session.
|
||||
|
||||
Returns the found entity, or raises TypeError if not found.
|
||||
"""
|
||||
phone = utils.parse_phone(string)
|
||||
if phone:
|
||||
try:
|
||||
for user in (await self(
|
||||
_tl.fn.contacts.GetContacts(0))).users:
|
||||
if user.phone == phone:
|
||||
return user
|
||||
except errors.BOT_METHOD_INVALID:
|
||||
raise ValueError('Cannot get entity by phone number as a '
|
||||
'bot (try using integer IDs, not strings)')
|
||||
elif string.lower() in ('me', 'self'):
|
||||
return await self.get_me()
|
||||
else:
|
||||
username, is_join_chat = utils.parse_username(string)
|
||||
if is_join_chat:
|
||||
invite = await self(
|
||||
_tl.fn.messages.CheckChatInvite(username))
|
||||
|
||||
if isinstance(invite, _tl.ChatInvite):
|
||||
raise ValueError(
|
||||
'Cannot get entity from a channel (or group) '
|
||||
'that you are not part of. Join the group and retry'
|
||||
)
|
||||
elif isinstance(invite, _tl.ChatInviteAlready):
|
||||
return invite.chat
|
||||
elif username:
|
||||
try:
|
||||
result = await self(
|
||||
_tl.fn.contacts.ResolveUsername(username))
|
||||
except errors.USERNAME_NOT_OCCUPIED as e:
|
||||
raise ValueError('No user has "{}" as username'
|
||||
.format(username)) from e
|
||||
|
||||
try:
|
||||
pid = utils.get_peer_id(result.peer)
|
||||
if isinstance(result.peer, _tl.PeerUser):
|
||||
return next(x for x in result.users if x.id == pid)
|
||||
else:
|
||||
return next(x for x in result.chats if x.id == pid)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
'Cannot find any entity corresponding to "{}"'.format(string)
|
||||
)
|
||||
|
||||
async def _get_input_dialog(self: 'TelegramClient', dialog):
|
||||
"""
|
||||
Returns a :tl:`InputDialogPeer`. This is a bit tricky because
|
||||
it may or not need access to the client to convert what's given
|
||||
into an input entity.
|
||||
"""
|
||||
try:
|
||||
if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer')
|
||||
dialog.peer = await self.get_input_entity(dialog.peer)
|
||||
return dialog
|
||||
elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer')
|
||||
return _tl.InputDialogPeer(dialog)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return _tl.InputDialogPeer(await self.get_input_entity(dialog))
|
||||
|
||||
async def _get_input_notify(self: 'TelegramClient', notify):
|
||||
"""
|
||||
Returns a :tl:`InputNotifyPeer`. This is a bit tricky because
|
||||
it may or not need access to the client to convert what's given
|
||||
into an input entity.
|
||||
"""
|
||||
try:
|
||||
if notify.SUBCLASS_OF_ID == 0x58981615:
|
||||
if isinstance(notify, _tl.InputNotifyPeer):
|
||||
notify.peer = await self.get_input_entity(notify.peer)
|
||||
return notify
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return _tl.InputNotifyPeer(await self.get_input_entity(notify))
|
|
@ -7,4 +7,3 @@ from .aes import AES
|
|||
from .aesctr import AESModeCTR
|
||||
from .authkey import AuthKey
|
||||
from .factorization import Factorization
|
||||
from .cdndecrypter import CdnDecrypter
|
|
@ -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:
|
|
@ -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('<q', sha1(n + e).digest()[-8:])[0]
|
||||
|
0
telethon/_events/__init__.py
Normal file
0
telethon/_events/__init__.py
Normal file
|
@ -3,9 +3,9 @@ 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
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
_IGNORE_MAX_SIZE = 100 # len()
|
||||
_IGNORE_MAX_AGE = 5 # seconds
|
||||
|
@ -37,15 +37,15 @@ class AlbumHack:
|
|||
# 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
|
||||
self._due = asyncio.get_running_loop().time() + _HACK_DELAY
|
||||
|
||||
client.loop.create_task(self.deliver_event())
|
||||
asyncio.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
|
||||
self._due = asyncio.get_running_loop().time() + _HACK_DELAY
|
||||
|
||||
async def deliver_event(self):
|
||||
while True:
|
||||
|
@ -53,7 +53,7 @@ class AlbumHack:
|
|||
if client is None:
|
||||
return # weakref is dead, nothing to deliver
|
||||
|
||||
diff = self._due - client.loop.time()
|
||||
diff = self._due - asyncio.get_running_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.
|
||||
|
@ -96,13 +96,13 @@ class Album(EventBuilder):
|
|||
super().__init__(chats, blacklist_chats=blacklist_chats, func=func)
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
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):
|
||||
(_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
|
||||
|
@ -130,8 +130,8 @@ class Album(EventBuilder):
|
|||
# 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)
|
||||
if (isinstance(u, (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage))
|
||||
and isinstance(u.message, _tl.Message)
|
||||
and u.message.grouped_id == group)
|
||||
])
|
||||
|
||||
|
@ -140,17 +140,17 @@ class Album(EventBuilder):
|
|||
if len(event.messages) > 1:
|
||||
return super().filter(event)
|
||||
|
||||
class Event(EventCommon, SenderGetter):
|
||||
class Event(EventCommon, _custom.sendergetter.SenderGetter):
|
||||
"""
|
||||
Represents the event of a new album.
|
||||
|
||||
Members:
|
||||
messages (Sequence[`Message <telethon.tl.custom.message.Message>`]):
|
||||
messages (Sequence[`Message <telethon.tl._custom.message.Message>`]):
|
||||
The list of messages belonging to the same album.
|
||||
"""
|
||||
def __init__(self, messages):
|
||||
message = messages[0]
|
||||
if not message.out and isinstance(message.peer_id, types.PeerUser):
|
||||
if not message.out and isinstance(message.peer_id, _tl.PeerUser):
|
||||
# Incoming message (e.g. from a bot) has peer_id=us, and
|
||||
# from_id=bot (the actual "chat" from a user's perspective).
|
||||
chat_peer = message.from_id
|
||||
|
@ -160,16 +160,17 @@ class Album(EventBuilder):
|
|||
super().__init__(chat_peer=chat_peer,
|
||||
msg_id=message.id, broadcast=bool(message.post))
|
||||
|
||||
SenderGetter.__init__(self, message.sender_id)
|
||||
_custom.sendergetter.SenderGetter.__init__(self, message.sender_id)
|
||||
self.messages = messages
|
||||
|
||||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(
|
||||
self.sender_id, self._entities, client._entity_cache)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities)
|
||||
|
||||
for msg in self.messages:
|
||||
msg._finish_init(client, self._entities, None)
|
||||
self.messages = [
|
||||
_custom.Message._new(client, m, self._entities, None)
|
||||
for m in self.messages
|
||||
]
|
||||
|
||||
if len(self.messages) == 1:
|
||||
# This will require hacks to be a proper album event
|
||||
|
@ -217,7 +218,7 @@ class Album(EventBuilder):
|
|||
@property
|
||||
def forward(self):
|
||||
"""
|
||||
The `Forward <telethon.tl.custom.forward.Forward>`
|
||||
The `Forward <telethon.tl._custom.forward.Forward>`
|
||||
information for the first message in the album if it was forwarded.
|
||||
"""
|
||||
# Each individual message in an album all reply to the same message
|
||||
|
@ -229,7 +230,7 @@ class Album(EventBuilder):
|
|||
|
||||
async def get_reply_message(self):
|
||||
"""
|
||||
The `Message <telethon.tl.custom.message.Message>`
|
||||
The `Message <telethon.tl._custom.message.Message>`
|
||||
that this album is replying to, or `None`.
|
||||
|
||||
The result will be cached after its first use.
|
||||
|
@ -308,12 +309,12 @@ class Album(EventBuilder):
|
|||
async def mark_read(self):
|
||||
"""
|
||||
Marks the entire album as read. Shorthand for
|
||||
`client.send_read_acknowledge()
|
||||
<telethon.client.messages.MessageMethods.send_read_acknowledge>`
|
||||
`client.mark_read()
|
||||
<telethon.client.messages.MessageMethods.mark_read>`
|
||||
with both ``entity`` and ``message`` already set.
|
||||
"""
|
||||
if self._client:
|
||||
await self._client.send_read_acknowledge(
|
||||
await self._client.mark_read(
|
||||
await self.get_input_chat(), max_id=self.messages[-1].id)
|
||||
|
||||
async def pin(self, *, notify=False):
|
|
@ -1,13 +1,4 @@
|
|||
from .raw import Raw
|
||||
from .album import Album
|
||||
from .chataction import ChatAction
|
||||
from .messagedeleted import MessageDeleted
|
||||
from .messageedited import MessageEdited
|
||||
from .messageread import MessageRead
|
||||
from .newmessage import NewMessage
|
||||
from .userupdate import UserUpdate
|
||||
from .callbackquery import CallbackQuery
|
||||
from .inlinequery import InlineQuery
|
||||
|
||||
|
||||
_HANDLERS_ATTRIBUTE = '__tl.handlers'
|
|
@ -1,10 +1,26 @@
|
|||
import re
|
||||
import struct
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from .. import utils
|
||||
from ..tl import types, functions
|
||||
from ..tl.custom.sendergetter import SenderGetter
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
|
||||
def auto_answer(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapped(self, *args, **kwargs):
|
||||
if self._answered:
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return (await asyncio.gather(
|
||||
self._answer(),
|
||||
func(*args, **kwargs),
|
||||
))[1]
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -87,14 +103,14 @@ class CallbackQuery(EventBuilder):
|
|||
))
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, types.UpdateBotCallbackQuery):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, _tl.UpdateBotCallbackQuery):
|
||||
return cls.Event(update, update.peer, update.msg_id)
|
||||
elif isinstance(update, types.UpdateInlineBotCallbackQuery):
|
||||
elif isinstance(update, _tl.UpdateInlineBotCallbackQuery):
|
||||
# See https://github.com/LonamiWebs/Telethon/pull/1005
|
||||
# The long message ID is actually just msg_id + peer_id
|
||||
mid, pid = struct.unpack('<ii', struct.pack('<q', update.msg_id.id))
|
||||
peer = types.PeerChannel(-pid) if pid < 0 else types.PeerUser(pid)
|
||||
peer = _tl.PeerChannel(-pid) if pid < 0 else _tl.PeerUser(pid)
|
||||
return cls.Event(update, peer, mid)
|
||||
|
||||
def filter(self, event):
|
||||
|
@ -123,7 +139,7 @@ class CallbackQuery(EventBuilder):
|
|||
return self.func(event)
|
||||
return True
|
||||
|
||||
class Event(EventCommon, SenderGetter):
|
||||
class Event(EventCommon, _custom.sendergetter.SenderGetter):
|
||||
"""
|
||||
Represents the event of a new callback query.
|
||||
|
||||
|
@ -141,7 +157,7 @@ class CallbackQuery(EventBuilder):
|
|||
"""
|
||||
def __init__(self, query, peer, msg_id):
|
||||
super().__init__(peer, msg_id=msg_id)
|
||||
SenderGetter.__init__(self, query.user_id)
|
||||
_custom.sendergetter.SenderGetter.__init__(self, query.user_id)
|
||||
self.query = query
|
||||
self.data_match = None
|
||||
self.pattern_match = None
|
||||
|
@ -150,8 +166,7 @@ class CallbackQuery(EventBuilder):
|
|||
|
||||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(
|
||||
self.sender_id, self._entities, client._entity_cache)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities)
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
|
@ -207,9 +222,6 @@ class CallbackQuery(EventBuilder):
|
|||
self._input_sender = utils.get_input_peer(self._chat)
|
||||
if not getattr(self._input_sender, 'access_hash', True):
|
||||
# getattr with True to handle the InputPeerSelf() case
|
||||
try:
|
||||
self._input_sender = self._client._entity_cache[self._sender_id]
|
||||
except KeyError:
|
||||
m = await self.get_message()
|
||||
if m:
|
||||
self._sender = m._sender
|
||||
|
@ -240,16 +252,15 @@ class CallbackQuery(EventBuilder):
|
|||
if self._answered:
|
||||
return
|
||||
|
||||
self._answered = True
|
||||
return await self._client(
|
||||
functions.messages.SetBotCallbackAnswerRequest(
|
||||
res = await self._client(_tl.fn.messages.SetBotCallbackAnswer(
|
||||
query_id=self.query.query_id,
|
||||
cache_time=cache_time,
|
||||
alert=alert,
|
||||
message=message,
|
||||
url=url
|
||||
)
|
||||
)
|
||||
url=url,
|
||||
))
|
||||
self._answered = True
|
||||
return res
|
||||
|
||||
@property
|
||||
def via_inline(self):
|
||||
|
@ -264,37 +275,38 @@ class CallbackQuery(EventBuilder):
|
|||
chat, so methods like `respond` or `delete` won't work (but
|
||||
`edit` will always work).
|
||||
"""
|
||||
return isinstance(self.query, types.UpdateInlineBotCallbackQuery)
|
||||
return isinstance(self.query, _tl.UpdateInlineBotCallbackQuery)
|
||||
|
||||
@auto_answer
|
||||
async def respond(self, *args, **kwargs):
|
||||
"""
|
||||
Responds to the message (not as a reply). Shorthand for
|
||||
`telethon.client.messages.MessageMethods.send_message` with
|
||||
``entity`` already set.
|
||||
|
||||
This method also creates a task to `answer` the callback.
|
||||
This method will also `answer` the callback if necessary.
|
||||
|
||||
This method will likely fail if `via_inline` is `True`.
|
||||
"""
|
||||
self._client.loop.create_task(self.answer())
|
||||
return await self._client.send_message(
|
||||
await self.get_input_chat(), *args, **kwargs)
|
||||
|
||||
@auto_answer
|
||||
async def reply(self, *args, **kwargs):
|
||||
"""
|
||||
Replies to the message (as a reply). Shorthand for
|
||||
`telethon.client.messages.MessageMethods.send_message` with
|
||||
both ``entity`` and ``reply_to`` already set.
|
||||
|
||||
This method also creates a task to `answer` the callback.
|
||||
This method will also `answer` the callback if necessary.
|
||||
|
||||
This method will likely fail if `via_inline` is `True`.
|
||||
"""
|
||||
self._client.loop.create_task(self.answer())
|
||||
kwargs['reply_to'] = self.query.msg_id
|
||||
return await self._client.send_message(
|
||||
await self.get_input_chat(), *args, **kwargs)
|
||||
|
||||
@auto_answer
|
||||
async def edit(self, *args, **kwargs):
|
||||
"""
|
||||
Edits the message. Shorthand for
|
||||
|
@ -303,18 +315,17 @@ class CallbackQuery(EventBuilder):
|
|||
|
||||
Returns `True` if the edit was successful.
|
||||
|
||||
This method also creates a task to `answer` the callback.
|
||||
This method will also `answer` the callback if necessary.
|
||||
|
||||
.. note::
|
||||
|
||||
This method won't respect the previous message unlike
|
||||
`Message.edit <telethon.tl.custom.message.Message.edit>`,
|
||||
`Message.edit <telethon.tl._custom.message.Message.edit>`,
|
||||
since the message object is normally not present.
|
||||
"""
|
||||
self._client.loop.create_task(self.answer())
|
||||
if isinstance(self.query.msg_id, types.InputBotInlineMessageID):
|
||||
if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID):
|
||||
return await self._client.edit_message(
|
||||
self.query.msg_id, *args, **kwargs
|
||||
None, self.query.msg_id, *args, **kwargs
|
||||
)
|
||||
else:
|
||||
return await self._client.edit_message(
|
||||
|
@ -322,6 +333,7 @@ class CallbackQuery(EventBuilder):
|
|||
*args, **kwargs
|
||||
)
|
||||
|
||||
@auto_answer
|
||||
async def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Deletes the message. Shorthand for
|
||||
|
@ -332,11 +344,10 @@ class CallbackQuery(EventBuilder):
|
|||
this `delete` method. Use a
|
||||
`telethon.client.telegramclient.TelegramClient` instance directly.
|
||||
|
||||
This method also creates a task to `answer` the callback.
|
||||
This method will also `answer` the callback if necessary.
|
||||
|
||||
This method will likely fail if `via_inline` is `True`.
|
||||
"""
|
||||
self._client.loop.create_task(self.answer())
|
||||
return await self._client.delete_messages(
|
||||
await self.get_input_chat(), [self.query.msg_id],
|
||||
*args, **kwargs
|
|
@ -1,6 +1,7 @@
|
|||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from .. import utils
|
||||
from ..tl import types
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -32,27 +33,27 @@ class ChatAction(EventBuilder):
|
|||
"""
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
# Rely on specific pin updates for unpins, but otherwise ignore them
|
||||
# for new pins (we'd rather handle the new service message with pin,
|
||||
# so that we can act on that message').
|
||||
if isinstance(update, types.UpdatePinnedChannelMessages) and not update.pinned:
|
||||
return cls.Event(types.PeerChannel(update.channel_id),
|
||||
if isinstance(update, _tl.UpdatePinnedChannelMessages) and not update.pinned:
|
||||
return cls.Event(_tl.PeerChannel(update.channel_id),
|
||||
pin_ids=update.messages,
|
||||
pin=update.pinned)
|
||||
|
||||
elif isinstance(update, types.UpdatePinnedMessages) and not update.pinned:
|
||||
elif isinstance(update, _tl.UpdatePinnedMessages) and not update.pinned:
|
||||
return cls.Event(update.peer,
|
||||
pin_ids=update.messages,
|
||||
pin=update.pinned)
|
||||
|
||||
elif isinstance(update, types.UpdateChatParticipantAdd):
|
||||
return cls.Event(types.PeerChat(update.chat_id),
|
||||
elif isinstance(update, _tl.UpdateChatParticipantAdd):
|
||||
return cls.Event(_tl.PeerChat(update.chat_id),
|
||||
added_by=update.inviter_id or True,
|
||||
users=update.user_id)
|
||||
|
||||
elif isinstance(update, types.UpdateChatParticipantDelete):
|
||||
return cls.Event(types.PeerChat(update.chat_id),
|
||||
elif isinstance(update, _tl.UpdateChatParticipantDelete):
|
||||
return cls.Event(_tl.PeerChat(update.chat_id),
|
||||
kicked_by=True,
|
||||
users=update.user_id)
|
||||
|
||||
|
@ -61,50 +62,55 @@ class ChatAction(EventBuilder):
|
|||
# better not to rely on this. Rely only in MessageActionChatDeleteUser.
|
||||
|
||||
elif (isinstance(update, (
|
||||
types.UpdateNewMessage, types.UpdateNewChannelMessage))
|
||||
and isinstance(update.message, types.MessageService)):
|
||||
_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage))
|
||||
and isinstance(update.message, _tl.MessageService)):
|
||||
msg = update.message
|
||||
action = update.message.action
|
||||
if isinstance(action, types.MessageActionChatJoinedByLink):
|
||||
if isinstance(action, _tl.MessageActionChatJoinedByLink):
|
||||
return cls.Event(msg,
|
||||
added_by=True,
|
||||
users=msg.from_id)
|
||||
elif isinstance(action, types.MessageActionChatAddUser):
|
||||
elif isinstance(action, _tl.MessageActionChatAddUser):
|
||||
# If a user adds itself, it means they joined via the public chat username
|
||||
added_by = ([msg.sender_id] == action.users) or msg.from_id
|
||||
return cls.Event(msg,
|
||||
added_by=added_by,
|
||||
users=action.users)
|
||||
elif isinstance(action, types.MessageActionChatDeleteUser):
|
||||
elif isinstance(action, _tl.MessageActionChatJoinedByRequest):
|
||||
# user joined from join request (after getting admin approval)
|
||||
return cls.Event(msg,
|
||||
from_approval=True,
|
||||
users=msg.from_id)
|
||||
elif isinstance(action, _tl.MessageActionChatDeleteUser):
|
||||
return cls.Event(msg,
|
||||
kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True,
|
||||
users=action.user_id)
|
||||
elif isinstance(action, types.MessageActionChatCreate):
|
||||
elif isinstance(action, _tl.MessageActionChatCreate):
|
||||
return cls.Event(msg,
|
||||
users=action.users,
|
||||
created=True,
|
||||
new_title=action.title)
|
||||
elif isinstance(action, types.MessageActionChannelCreate):
|
||||
elif isinstance(action, _tl.MessageActionChannelCreate):
|
||||
return cls.Event(msg,
|
||||
created=True,
|
||||
users=msg.from_id,
|
||||
new_title=action.title)
|
||||
elif isinstance(action, types.MessageActionChatEditTitle):
|
||||
elif isinstance(action, _tl.MessageActionChatEditTitle):
|
||||
return cls.Event(msg,
|
||||
users=msg.from_id,
|
||||
new_title=action.title)
|
||||
elif isinstance(action, types.MessageActionChatEditPhoto):
|
||||
elif isinstance(action, _tl.MessageActionChatEditPhoto):
|
||||
return cls.Event(msg,
|
||||
users=msg.from_id,
|
||||
new_photo=action.photo)
|
||||
elif isinstance(action, types.MessageActionChatDeletePhoto):
|
||||
elif isinstance(action, _tl.MessageActionChatDeletePhoto):
|
||||
return cls.Event(msg,
|
||||
users=msg.from_id,
|
||||
new_photo=True)
|
||||
elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to:
|
||||
elif isinstance(action, _tl.MessageActionPinMessage) and msg.reply_to:
|
||||
return cls.Event(msg,
|
||||
pin_ids=[msg.reply_to_msg_id])
|
||||
elif isinstance(action, types.MessageActionGameScore):
|
||||
elif isinstance(action, _tl.MessageActionGameScore):
|
||||
return cls.Event(msg,
|
||||
new_score=action.score)
|
||||
|
||||
|
@ -137,6 +143,10 @@ class ChatAction(EventBuilder):
|
|||
user_kicked (`bool`):
|
||||
`True` if the user was kicked by some other.
|
||||
|
||||
user_approved (`bool`):
|
||||
`True` if the user's join request was approved.
|
||||
along with `user_joined` will be also True.
|
||||
|
||||
created (`bool`, optional):
|
||||
`True` if this chat was just created.
|
||||
|
||||
|
@ -151,9 +161,9 @@ class ChatAction(EventBuilder):
|
|||
"""
|
||||
|
||||
def __init__(self, where, new_photo=None,
|
||||
added_by=None, kicked_by=None, created=None,
|
||||
added_by=None, kicked_by=None, created=None, from_approval=None,
|
||||
users=None, new_title=None, pin_ids=None, pin=None, new_score=None):
|
||||
if isinstance(where, types.MessageService):
|
||||
if isinstance(where, _tl.MessageService):
|
||||
self.action_message = where
|
||||
where = where.peer_id
|
||||
else:
|
||||
|
@ -169,18 +179,19 @@ class ChatAction(EventBuilder):
|
|||
|
||||
self.new_photo = new_photo is not None
|
||||
self.photo = \
|
||||
new_photo if isinstance(new_photo, types.Photo) else None
|
||||
new_photo if isinstance(new_photo, _tl.Photo) else None
|
||||
|
||||
self._added_by = None
|
||||
self._kicked_by = None
|
||||
self.user_added = self.user_joined = self.user_left = \
|
||||
self.user_kicked = self.unpin = False
|
||||
|
||||
if added_by is True:
|
||||
if added_by is True or from_approval is True:
|
||||
self.user_joined = True
|
||||
elif added_by:
|
||||
self.user_added = True
|
||||
self._added_by = added_by
|
||||
self.user_approved = from_approval
|
||||
|
||||
# If `from_id` was not present (it's `True`) or the affected
|
||||
# user was "kicked by itself", then it left. Else it was kicked.
|
||||
|
@ -205,11 +216,6 @@ class ChatAction(EventBuilder):
|
|||
self.new_score = new_score
|
||||
self.unpin = not pin
|
||||
|
||||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
if self.action_message:
|
||||
self.action_message._finish_init(client, self._entities, None)
|
||||
|
||||
async def respond(self, *args, **kwargs):
|
||||
"""
|
||||
Responds to the chat action message (not as a reply). Shorthand for
|
||||
|
@ -283,7 +289,7 @@ class ChatAction(EventBuilder):
|
|||
"""
|
||||
The user who added ``users``, if applicable (`None` otherwise).
|
||||
"""
|
||||
if self._added_by and not isinstance(self._added_by, types.User):
|
||||
if self._added_by and not isinstance(self._added_by, _tl.User):
|
||||
aby = self._entities.get(utils.get_peer_id(self._added_by))
|
||||
if aby:
|
||||
self._added_by = aby
|
||||
|
@ -304,7 +310,7 @@ class ChatAction(EventBuilder):
|
|||
"""
|
||||
The user who kicked ``users``, if applicable (`None` otherwise).
|
||||
"""
|
||||
if self._kicked_by and not isinstance(self._kicked_by, types.User):
|
||||
if self._kicked_by and not isinstance(self._kicked_by, _tl.User):
|
||||
kby = self._entities.get(utils.get_peer_id(self._kicked_by))
|
||||
if kby:
|
||||
self._kicked_by = kby
|
||||
|
@ -393,7 +399,7 @@ class ChatAction(EventBuilder):
|
|||
await self.action_message._reload_message()
|
||||
self._users = [
|
||||
u for u in self.action_message.action_entities
|
||||
if isinstance(u, (types.User, types.UserEmpty))]
|
||||
if isinstance(u, (_tl.User, _tl.UserEmpty))]
|
||||
|
||||
return self._users
|
||||
|
||||
|
@ -405,20 +411,13 @@ class ChatAction(EventBuilder):
|
|||
if self._input_users is None and self._user_ids:
|
||||
self._input_users = []
|
||||
for user_id in self._user_ids:
|
||||
# First try to get it from our entities
|
||||
# Try to get it from our entities
|
||||
try:
|
||||
self._input_users.append(utils.get_input_peer(self._entities[user_id]))
|
||||
continue
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
# If missing, try from the entity cache
|
||||
try:
|
||||
self._input_users.append(self._client._entity_cache[user_id])
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return self._input_users or []
|
||||
|
||||
async def get_input_users(self):
|
||||
|
@ -433,7 +432,7 @@ class ChatAction(EventBuilder):
|
|||
self._input_users = [
|
||||
utils.get_input_peer(u)
|
||||
for u in self.action_message.action_entities
|
||||
if isinstance(u, (types.User, types.UserEmpty))]
|
||||
if isinstance(u, (_tl.User, _tl.UserEmpty))]
|
||||
|
||||
return self._input_users or []
|
||||
|
|
@ -2,9 +2,9 @@ import abc
|
|||
import asyncio
|
||||
import warnings
|
||||
|
||||
from .. import utils
|
||||
from ..tl import TLObject, types
|
||||
from ..tl.custom.chatgetter import ChatGetter
|
||||
from .. import _tl
|
||||
from .._misc import utils, tlobject
|
||||
from ..types._custom.chatgetter import ChatGetter
|
||||
|
||||
|
||||
async def _into_id_set(client, chats):
|
||||
|
@ -18,20 +18,13 @@ async def _into_id_set(client, chats):
|
|||
result = set()
|
||||
for chat in chats:
|
||||
if isinstance(chat, int):
|
||||
if chat < 0:
|
||||
result.add(chat) # Explicitly marked IDs are negative
|
||||
else:
|
||||
result.update({ # Support all valid types of peers
|
||||
utils.get_peer_id(types.PeerUser(chat)),
|
||||
utils.get_peer_id(types.PeerChat(chat)),
|
||||
utils.get_peer_id(types.PeerChannel(chat)),
|
||||
})
|
||||
elif isinstance(chat, TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687:
|
||||
result.add(chat)
|
||||
elif isinstance(chat, tlobject.TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687:
|
||||
# 0x2d45687 == crc32(b'Peer')
|
||||
result.add(utils.get_peer_id(chat))
|
||||
else:
|
||||
chat = await client.get_input_entity(chat)
|
||||
if isinstance(chat, types.InputPeerSelf):
|
||||
if isinstance(chat, _tl.InputPeerSelf):
|
||||
chat = await client.get_me(input_peer=True)
|
||||
result.add(utils.get_peer_id(chat))
|
||||
|
||||
|
@ -74,7 +67,7 @@ class EventBuilder(abc.ABC):
|
|||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
def build(cls, update, others, self_id, entities, client):
|
||||
"""
|
||||
Builds an event for the given update if possible, or returns None.
|
||||
|
||||
|
@ -151,10 +144,10 @@ class EventCommon(ChatGetter, abc.ABC):
|
|||
"""
|
||||
Setter so subclasses can act accordingly when the client is set.
|
||||
"""
|
||||
# TODO Nuke
|
||||
self._client = client
|
||||
if self._chat_peer:
|
||||
self._chat, self._input_chat = utils._get_entity_pair(
|
||||
self.chat_id, self._entities, client._entity_cache)
|
||||
self._chat, self._input_chat = utils._get_entity_pair(self.chat_id, self._entities)
|
||||
else:
|
||||
self._chat = self._input_chat = None
|
||||
|
||||
|
@ -166,10 +159,10 @@ class EventCommon(ChatGetter, abc.ABC):
|
|||
return self._client
|
||||
|
||||
def __str__(self):
|
||||
return TLObject.pretty_format(self.to_dict())
|
||||
return _tl.TLObject.pretty_format(self.to_dict())
|
||||
|
||||
def stringify(self):
|
||||
return TLObject.pretty_format(self.to_dict(), indent=0)
|
||||
return _tl.TLObject.pretty_format(self.to_dict(), indent=0)
|
||||
|
||||
def to_dict(self):
|
||||
d = {k: v for k, v in self.__dict__.items() if k[0] != '_'}
|
|
@ -4,9 +4,9 @@ import re
|
|||
import asyncio
|
||||
|
||||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from .. import utils
|
||||
from ..tl import types, functions, custom
|
||||
from ..tl.custom.sendergetter import SenderGetter
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -61,8 +61,8 @@ class InlineQuery(EventBuilder):
|
|||
raise TypeError('Invalid pattern type given')
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, types.UpdateBotInlineQuery):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, _tl.UpdateBotInlineQuery):
|
||||
return cls.Event(update)
|
||||
|
||||
def filter(self, event):
|
||||
|
@ -74,7 +74,7 @@ class InlineQuery(EventBuilder):
|
|||
|
||||
return super().filter(event)
|
||||
|
||||
class Event(EventCommon, SenderGetter):
|
||||
class Event(EventCommon, _custom.sendergetter.SenderGetter):
|
||||
"""
|
||||
Represents the event of a new callback query.
|
||||
|
||||
|
@ -90,16 +90,15 @@ class InlineQuery(EventBuilder):
|
|||
function, which is ``re.compile(...).match`` by default.
|
||||
"""
|
||||
def __init__(self, query):
|
||||
super().__init__(chat_peer=types.PeerUser(query.user_id))
|
||||
SenderGetter.__init__(self, query.user_id)
|
||||
super().__init__(chat_peer=_tl.PeerUser(query.user_id))
|
||||
_custom.sendergetter.SenderGetter.__init__(self, query.user_id)
|
||||
self.query = query
|
||||
self.pattern_match = None
|
||||
self._answered = False
|
||||
|
||||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(
|
||||
self.sender_id, self._entities, client._entity_cache)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities)
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
|
@ -223,10 +222,10 @@ class InlineQuery(EventBuilder):
|
|||
results = []
|
||||
|
||||
if switch_pm:
|
||||
switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param)
|
||||
switch_pm = _tl.InlineBotSwitchPM(switch_pm, switch_pm_param)
|
||||
|
||||
return await self._client(
|
||||
functions.messages.SetInlineBotResultsRequest(
|
||||
_tl.fn.messages.SetInlineBotResults(
|
||||
query_id=self.query.query_id,
|
||||
results=results,
|
||||
cache_time=cache_time,
|
||||
|
@ -242,6 +241,6 @@ class InlineQuery(EventBuilder):
|
|||
if inspect.isawaitable(obj):
|
||||
return asyncio.ensure_future(obj)
|
||||
|
||||
f = asyncio.get_event_loop().create_future()
|
||||
f = asyncio.get_running_loop().create_future()
|
||||
f.set_result(obj)
|
||||
return f
|
|
@ -1,5 +1,5 @@
|
|||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from ..tl import types
|
||||
from .. import _tl
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -36,16 +36,16 @@ class MessageDeleted(EventBuilder):
|
|||
print('Message', msg_id, 'was deleted in', event.chat_id)
|
||||
"""
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, types.UpdateDeleteMessages):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, _tl.UpdateDeleteMessages):
|
||||
return cls.Event(
|
||||
deleted_ids=update.messages,
|
||||
peer=None
|
||||
)
|
||||
elif isinstance(update, types.UpdateDeleteChannelMessages):
|
||||
elif isinstance(update, _tl.UpdateDeleteChannelMessages):
|
||||
return cls.Event(
|
||||
deleted_ids=update.messages,
|
||||
peer=types.PeerChannel(update.channel_id)
|
||||
peer=_tl.PeerChannel(update.channel_id)
|
||||
)
|
||||
|
||||
class Event(EventCommon):
|
|
@ -1,6 +1,6 @@
|
|||
from .common import name_inner_event
|
||||
from .newmessage import NewMessage
|
||||
from ..tl import types
|
||||
from .. import _tl
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -43,9 +43,9 @@ class MessageEdited(NewMessage):
|
|||
print('Message', event.id, 'changed at', event.date)
|
||||
"""
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, (types.UpdateEditMessage,
|
||||
types.UpdateEditChannelMessage)):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, (_tl.UpdateEditMessage,
|
||||
_tl.UpdateEditChannelMessage)):
|
||||
return cls.Event(update.message)
|
||||
|
||||
class Event(NewMessage.Event):
|
|
@ -1,6 +1,6 @@
|
|||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from .. import utils
|
||||
from ..tl import types
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -35,22 +35,22 @@ class MessageRead(EventBuilder):
|
|||
self.inbox = inbox
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, types.UpdateReadHistoryInbox):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, _tl.UpdateReadHistoryInbox):
|
||||
return cls.Event(update.peer, update.max_id, False)
|
||||
elif isinstance(update, types.UpdateReadHistoryOutbox):
|
||||
elif isinstance(update, _tl.UpdateReadHistoryOutbox):
|
||||
return cls.Event(update.peer, update.max_id, True)
|
||||
elif isinstance(update, types.UpdateReadChannelInbox):
|
||||
return cls.Event(types.PeerChannel(update.channel_id),
|
||||
elif isinstance(update, _tl.UpdateReadChannelInbox):
|
||||
return cls.Event(_tl.PeerChannel(update.channel_id),
|
||||
update.max_id, False)
|
||||
elif isinstance(update, types.UpdateReadChannelOutbox):
|
||||
return cls.Event(types.PeerChannel(update.channel_id),
|
||||
elif isinstance(update, _tl.UpdateReadChannelOutbox):
|
||||
return cls.Event(_tl.PeerChannel(update.channel_id),
|
||||
update.max_id, True)
|
||||
elif isinstance(update, types.UpdateReadMessagesContents):
|
||||
elif isinstance(update, _tl.UpdateReadMessagesContents):
|
||||
return cls.Event(message_ids=update.messages,
|
||||
contents=True)
|
||||
elif isinstance(update, types.UpdateChannelReadMessagesContents):
|
||||
return cls.Event(types.PeerChannel(update.channel_id),
|
||||
elif isinstance(update, _tl.UpdateChannelReadMessagesContents):
|
||||
return cls.Event(_tl.PeerChannel(update.channel_id),
|
||||
message_ids=update.messages,
|
||||
contents=True)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import re
|
||||
|
||||
from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set
|
||||
from .. import utils
|
||||
from ..tl import types
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
|
||||
@name_inner_event
|
||||
|
@ -94,21 +95,21 @@ class NewMessage(EventBuilder):
|
|||
self.from_users = await _into_id_set(client, self.from_users)
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
def build(cls, update, others, self_id, entities, client):
|
||||
if isinstance(update,
|
||||
(types.UpdateNewMessage, types.UpdateNewChannelMessage)):
|
||||
if not isinstance(update.message, types.Message):
|
||||
(_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)):
|
||||
if not isinstance(update.message, _tl.Message):
|
||||
return # We don't care about MessageService's here
|
||||
event = cls.Event(update.message)
|
||||
elif isinstance(update, types.UpdateShortMessage):
|
||||
event = cls.Event(types.Message(
|
||||
msg = update.message
|
||||
elif isinstance(update, _tl.UpdateShortMessage):
|
||||
msg = _tl.Message(
|
||||
out=update.out,
|
||||
mentioned=update.mentioned,
|
||||
media_unread=update.media_unread,
|
||||
silent=update.silent,
|
||||
id=update.id,
|
||||
peer_id=types.PeerUser(update.user_id),
|
||||
from_id=types.PeerUser(self_id if update.out else update.user_id),
|
||||
peer_id=_tl.PeerUser(update.user_id),
|
||||
from_id=_tl.PeerUser(self_id if update.out else update.user_id),
|
||||
message=update.message,
|
||||
date=update.date,
|
||||
fwd_from=update.fwd_from,
|
||||
|
@ -116,16 +117,16 @@ class NewMessage(EventBuilder):
|
|||
reply_to=update.reply_to,
|
||||
entities=update.entities,
|
||||
ttl_period=update.ttl_period
|
||||
))
|
||||
elif isinstance(update, types.UpdateShortChatMessage):
|
||||
event = cls.Event(types.Message(
|
||||
)
|
||||
elif isinstance(update, _tl.UpdateShortChatMessage):
|
||||
msg = _tl.Message(
|
||||
out=update.out,
|
||||
mentioned=update.mentioned,
|
||||
media_unread=update.media_unread,
|
||||
silent=update.silent,
|
||||
id=update.id,
|
||||
from_id=types.PeerUser(self_id if update.out else update.from_id),
|
||||
peer_id=types.PeerChat(update.chat_id),
|
||||
from_id=_tl.PeerUser(self_id if update.out else update.from_id),
|
||||
peer_id=_tl.PeerChat(update.chat_id),
|
||||
message=update.message,
|
||||
date=update.date,
|
||||
fwd_from=update.fwd_from,
|
||||
|
@ -133,11 +134,11 @@ class NewMessage(EventBuilder):
|
|||
reply_to=update.reply_to,
|
||||
entities=update.entities,
|
||||
ttl_period=update.ttl_period
|
||||
))
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
return event
|
||||
return cls.Event(_custom.Message._new(client, msg, entities, None))
|
||||
|
||||
def filter(self, event):
|
||||
if self._no_check:
|
||||
|
@ -207,7 +208,6 @@ class NewMessage(EventBuilder):
|
|||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
m = self.message
|
||||
m._finish_init(client, self._entities, None)
|
||||
self.__dict__['_init'] = True # No new attributes can be set
|
||||
|
||||
def __getattr__(self, item):
|
|
@ -1,5 +1,5 @@
|
|||
from .common import EventBuilder
|
||||
from .. import utils
|
||||
from .._misc import utils
|
||||
|
||||
|
||||
class Raw(EventBuilder):
|
||||
|
@ -42,7 +42,7 @@ class Raw(EventBuilder):
|
|||
self.resolved = True
|
||||
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
return update
|
||||
|
||||
def filter(self, event):
|
|
@ -2,9 +2,9 @@ import datetime
|
|||
import functools
|
||||
|
||||
from .common import EventBuilder, EventCommon, name_inner_event
|
||||
from .. import utils
|
||||
from ..tl import types
|
||||
from ..tl.custom.sendergetter import SenderGetter
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
|
||||
# TODO Either the properties are poorly named or they should be
|
||||
|
@ -49,23 +49,23 @@ class UserUpdate(EventBuilder):
|
|||
await client.send_message(event.user_id, 'What are you sending?')
|
||||
"""
|
||||
@classmethod
|
||||
def build(cls, update, others=None, self_id=None):
|
||||
if isinstance(update, types.UpdateUserStatus):
|
||||
return cls.Event(types.PeerUser(update.user_id),
|
||||
def build(cls, update, others=None, self_id=None, *todo, **todo2):
|
||||
if isinstance(update, _tl.UpdateUserStatus):
|
||||
return cls.Event(_tl.PeerUser(update.user_id),
|
||||
status=update.status)
|
||||
elif isinstance(update, types.UpdateChannelUserTyping):
|
||||
elif isinstance(update, _tl.UpdateChannelUserTyping):
|
||||
return cls.Event(update.from_id,
|
||||
chat_peer=types.PeerChannel(update.channel_id),
|
||||
chat_peer=_tl.PeerChannel(update.channel_id),
|
||||
typing=update.action)
|
||||
elif isinstance(update, types.UpdateChatUserTyping):
|
||||
elif isinstance(update, _tl.UpdateChatUserTyping):
|
||||
return cls.Event(update.from_id,
|
||||
chat_peer=types.PeerChat(update.chat_id),
|
||||
chat_peer=_tl.PeerChat(update.chat_id),
|
||||
typing=update.action)
|
||||
elif isinstance(update, types.UpdateUserTyping):
|
||||
elif isinstance(update, _tl.UpdateUserTyping):
|
||||
return cls.Event(update.user_id,
|
||||
typing=update.action)
|
||||
|
||||
class Event(EventCommon, SenderGetter):
|
||||
class Event(EventCommon, _custom.sendergetter.SenderGetter):
|
||||
"""
|
||||
Represents the event of a user update
|
||||
such as gone online, started typing, etc.
|
||||
|
@ -87,15 +87,14 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
def __init__(self, peer, *, status=None, chat_peer=None, typing=None):
|
||||
super().__init__(chat_peer or peer)
|
||||
SenderGetter.__init__(self, utils.get_peer_id(peer))
|
||||
_custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer))
|
||||
|
||||
self.status = status
|
||||
self.action = typing
|
||||
|
||||
def _set_client(self, client):
|
||||
super()._set_client(client)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(
|
||||
self.sender_id, self._entities, client._entity_cache)
|
||||
self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities)
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
|
@ -126,7 +125,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if the action is typing a message.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageTypingAction)
|
||||
return isinstance(self.action, _tl.SendMessageTypingAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -135,13 +134,13 @@ class UserUpdate(EventBuilder):
|
|||
`True` if the action is uploading something.
|
||||
"""
|
||||
return isinstance(self.action, (
|
||||
types.SendMessageChooseContactAction,
|
||||
types.SendMessageChooseStickerAction,
|
||||
types.SendMessageUploadAudioAction,
|
||||
types.SendMessageUploadDocumentAction,
|
||||
types.SendMessageUploadPhotoAction,
|
||||
types.SendMessageUploadRoundAction,
|
||||
types.SendMessageUploadVideoAction
|
||||
_tl.SendMessageChooseContactAction,
|
||||
_tl.SendMessageChooseStickerAction,
|
||||
_tl.SendMessageUploadAudioAction,
|
||||
_tl.SendMessageUploadDocumentAction,
|
||||
_tl.SendMessageUploadPhotoAction,
|
||||
_tl.SendMessageUploadRoundAction,
|
||||
_tl.SendMessageUploadVideoAction
|
||||
))
|
||||
|
||||
@property
|
||||
|
@ -151,9 +150,9 @@ class UserUpdate(EventBuilder):
|
|||
`True` if the action is recording something.
|
||||
"""
|
||||
return isinstance(self.action, (
|
||||
types.SendMessageRecordAudioAction,
|
||||
types.SendMessageRecordRoundAction,
|
||||
types.SendMessageRecordVideoAction
|
||||
_tl.SendMessageRecordAudioAction,
|
||||
_tl.SendMessageRecordRoundAction,
|
||||
_tl.SendMessageRecordVideoAction
|
||||
))
|
||||
|
||||
@property
|
||||
|
@ -162,7 +161,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if the action is playing a game.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageGamePlayAction)
|
||||
return isinstance(self.action, _tl.SendMessageGamePlayAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -170,7 +169,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if the action was cancelling other actions.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageCancelAction)
|
||||
return isinstance(self.action, _tl.SendMessageCancelAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -178,7 +177,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if what's being uploaded is a geo.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageGeoLocationAction)
|
||||
return isinstance(self.action, _tl.SendMessageGeoLocationAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -187,8 +186,8 @@ class UserUpdate(EventBuilder):
|
|||
`True` if what's being recorded/uploaded is an audio.
|
||||
"""
|
||||
return isinstance(self.action, (
|
||||
types.SendMessageRecordAudioAction,
|
||||
types.SendMessageUploadAudioAction
|
||||
_tl.SendMessageRecordAudioAction,
|
||||
_tl.SendMessageUploadAudioAction
|
||||
))
|
||||
|
||||
@property
|
||||
|
@ -198,8 +197,8 @@ class UserUpdate(EventBuilder):
|
|||
`True` if what's being recorded/uploaded is a round video.
|
||||
"""
|
||||
return isinstance(self.action, (
|
||||
types.SendMessageRecordRoundAction,
|
||||
types.SendMessageUploadRoundAction
|
||||
_tl.SendMessageRecordRoundAction,
|
||||
_tl.SendMessageUploadRoundAction
|
||||
))
|
||||
|
||||
@property
|
||||
|
@ -209,8 +208,8 @@ class UserUpdate(EventBuilder):
|
|||
`True` if what's being recorded/uploaded is an video.
|
||||
"""
|
||||
return isinstance(self.action, (
|
||||
types.SendMessageRecordVideoAction,
|
||||
types.SendMessageUploadVideoAction
|
||||
_tl.SendMessageRecordVideoAction,
|
||||
_tl.SendMessageUploadVideoAction
|
||||
))
|
||||
|
||||
@property
|
||||
|
@ -219,7 +218,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if what's being uploaded (selected) is a contact.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageChooseContactAction)
|
||||
return isinstance(self.action, _tl.SendMessageChooseContactAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -227,7 +226,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if what's being uploaded is document.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageUploadDocumentAction)
|
||||
return isinstance(self.action, _tl.SendMessageUploadDocumentAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -235,7 +234,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if what's being uploaded is a sticker.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageChooseStickerAction)
|
||||
return isinstance(self.action, _tl.SendMessageChooseStickerAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -243,7 +242,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
`True` if what's being uploaded is a photo.
|
||||
"""
|
||||
return isinstance(self.action, types.SendMessageUploadPhotoAction)
|
||||
return isinstance(self.action, _tl.SendMessageUploadPhotoAction)
|
||||
|
||||
@property
|
||||
@_requires_action
|
||||
|
@ -251,7 +250,7 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
Exact `datetime.datetime` when the user was last seen if known.
|
||||
"""
|
||||
if isinstance(self.status, types.UserStatusOffline):
|
||||
if isinstance(self.status, _tl.UserStatusOffline):
|
||||
return self.status.was_online
|
||||
|
||||
@property
|
||||
|
@ -260,19 +259,19 @@ class UserUpdate(EventBuilder):
|
|||
"""
|
||||
The `datetime.datetime` until when the user should appear online.
|
||||
"""
|
||||
if isinstance(self.status, types.UserStatusOnline):
|
||||
if isinstance(self.status, _tl.UserStatusOnline):
|
||||
return self.status.expires
|
||||
|
||||
def _last_seen_delta(self):
|
||||
if isinstance(self.status, types.UserStatusOffline):
|
||||
if isinstance(self.status, _tl.UserStatusOffline):
|
||||
return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online
|
||||
elif isinstance(self.status, types.UserStatusOnline):
|
||||
elif isinstance(self.status, _tl.UserStatusOnline):
|
||||
return datetime.timedelta(days=0)
|
||||
elif isinstance(self.status, types.UserStatusRecently):
|
||||
elif isinstance(self.status, _tl.UserStatusRecently):
|
||||
return datetime.timedelta(days=1)
|
||||
elif isinstance(self.status, types.UserStatusLastWeek):
|
||||
elif isinstance(self.status, _tl.UserStatusLastWeek):
|
||||
return datetime.timedelta(days=7)
|
||||
elif isinstance(self.status, types.UserStatusLastMonth):
|
||||
elif isinstance(self.status, _tl.UserStatusLastMonth):
|
||||
return datetime.timedelta(days=30)
|
||||
else:
|
||||
return datetime.timedelta(days=365)
|
|
@ -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
|
|
@ -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
|
131
telethon/_misc/enums.py
Normal file
131
telethon/_misc/enums.py
Normal file
|
@ -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]
|
|
@ -102,23 +102,11 @@ def strip_text(text, entities):
|
|||
return text
|
||||
|
||||
|
||||
def retry_range(retries, force_retry=True):
|
||||
def retry_range(retries):
|
||||
"""
|
||||
Generates an integer sequence starting from 1. If `retries` is
|
||||
not a zero or a positive integer value, the sequence will be
|
||||
infinite, otherwise it will end at `retries + 1`.
|
||||
Generates an integer sequence starting from 1, always returning once, and adding the given retries.
|
||||
"""
|
||||
|
||||
# We need at least one iteration even if the retries are 0
|
||||
# when force_retry is True.
|
||||
if force_retry and not (retries is None or retries < 0):
|
||||
retries += 1
|
||||
|
||||
attempt = 0
|
||||
while attempt != retries:
|
||||
attempt += 1
|
||||
yield attempt
|
||||
|
||||
return range(1, max(retries, 0) + 2)
|
||||
|
||||
|
||||
async def _maybe_await(value):
|
||||
|
@ -165,34 +153,6 @@ async def _cancel(log, **tasks):
|
|||
'%s (%s)', name, type(task), task)
|
||||
|
||||
|
||||
def _sync_enter(self):
|
||||
"""
|
||||
Helps to cut boilerplate on async context
|
||||
managers that offer synchronous variants.
|
||||
"""
|
||||
if hasattr(self, 'loop'):
|
||||
loop = self.loop
|
||||
else:
|
||||
loop = self._client.loop
|
||||
|
||||
if loop.is_running():
|
||||
raise RuntimeError(
|
||||
'You must use "async with" if the event loop '
|
||||
'is running (i.e. you are inside an "async def")'
|
||||
)
|
||||
|
||||
return loop.run_until_complete(self.__aenter__())
|
||||
|
||||
|
||||
def _sync_exit(self, *args):
|
||||
if hasattr(self, 'loop'):
|
||||
loop = self.loop
|
||||
else:
|
||||
loop = self._client.loop
|
||||
|
||||
return loop.run_until_complete(self.__aexit__(*args))
|
||||
|
||||
|
||||
def _entity_type(entity):
|
||||
# This could be a `utils` method that just ran a few `isinstance` on
|
||||
# `utils.get_peer(...)`'s result. However, there are *a lot* of auto
|
||||
|
@ -228,6 +188,73 @@ def _entity_type(entity):
|
|||
# 'Empty' in name or not found, we don't care, not a valid entity.
|
||||
raise TypeError('{} does not have any entity type'.format(entity))
|
||||
|
||||
|
||||
def pretty_print(obj, indent=None, max_depth=float('inf')):
|
||||
max_depth -= 1
|
||||
if max_depth < 0:
|
||||
return '...'
|
||||
|
||||
to_d = getattr(obj, '_to_dict', None) or getattr(obj, 'to_dict', None)
|
||||
if callable(to_d):
|
||||
obj = to_d()
|
||||
|
||||
if indent is None:
|
||||
if isinstance(obj, dict):
|
||||
return '{}({})'.format(obj.get('_', 'dict'), ', '.join(
|
||||
'{}={}'.format(k, pretty_print(v, indent, max_depth))
|
||||
for k, v in obj.items() if k != '_'
|
||||
))
|
||||
elif isinstance(obj, str) or isinstance(obj, bytes):
|
||||
return repr(obj)
|
||||
elif hasattr(obj, '__iter__'):
|
||||
return '[{}]'.format(
|
||||
', '.join(pretty_print(x, indent, max_depth) for x in obj)
|
||||
)
|
||||
else:
|
||||
return repr(obj)
|
||||
else:
|
||||
result = []
|
||||
|
||||
if isinstance(obj, dict):
|
||||
result.append(obj.get('_', 'dict'))
|
||||
result.append('(')
|
||||
if obj:
|
||||
result.append('\n')
|
||||
indent += 1
|
||||
for k, v in obj.items():
|
||||
if k == '_':
|
||||
continue
|
||||
result.append('\t' * indent)
|
||||
result.append(k)
|
||||
result.append('=')
|
||||
result.append(pretty_print(v, indent, max_depth))
|
||||
result.append(',\n')
|
||||
result.pop() # last ',\n'
|
||||
indent -= 1
|
||||
result.append('\n')
|
||||
result.append('\t' * indent)
|
||||
result.append(')')
|
||||
|
||||
elif isinstance(obj, str) or isinstance(obj, bytes):
|
||||
result.append(repr(obj))
|
||||
|
||||
elif hasattr(obj, '__iter__'):
|
||||
result.append('[\n')
|
||||
indent += 1
|
||||
for x in obj:
|
||||
result.append('\t' * indent)
|
||||
result.append(pretty_print(x, indent, max_depth))
|
||||
result.append(',\n')
|
||||
indent -= 1
|
||||
result.append('\t' * indent)
|
||||
result.append(']')
|
||||
|
||||
else:
|
||||
result.append(repr(obj))
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Cryptographic related utils
|
60
telethon/_misc/hints.py
Normal file
60
telethon/_misc/hints.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
import datetime
|
||||
import typing
|
||||
|
||||
from . import helpers
|
||||
from .. import _tl
|
||||
from ..types import _custom
|
||||
|
||||
Phone = str
|
||||
Username = str
|
||||
PeerID = int
|
||||
Entity = typing.Union[_tl.User, _tl.Chat, _tl.Channel]
|
||||
FullEntity = typing.Union[_tl.UserFull, _tl.messages.ChatFull, _tl.ChatFull, _tl.ChannelFull]
|
||||
|
||||
EntityLike = typing.Union[
|
||||
Phone,
|
||||
Username,
|
||||
PeerID,
|
||||
_tl.TypePeer,
|
||||
_tl.TypeInputPeer,
|
||||
Entity,
|
||||
FullEntity
|
||||
]
|
||||
EntitiesLike = typing.Union[EntityLike, typing.Sequence[EntityLike]]
|
||||
|
||||
ButtonLike = typing.Union[_tl.TypeKeyboardButton, _custom.Button]
|
||||
MarkupLike = typing.Union[
|
||||
_tl.TypeReplyMarkup,
|
||||
ButtonLike,
|
||||
typing.Sequence[ButtonLike],
|
||||
typing.Sequence[typing.Sequence[ButtonLike]]
|
||||
]
|
||||
|
||||
TotalList = helpers.TotalList
|
||||
|
||||
DateLike = typing.Optional[typing.Union[float, datetime.datetime, datetime.date, datetime.timedelta]]
|
||||
|
||||
LocalPath = str
|
||||
ExternalUrl = str
|
||||
BotFileID = str
|
||||
FileLike = typing.Union[
|
||||
LocalPath,
|
||||
ExternalUrl,
|
||||
BotFileID,
|
||||
bytes,
|
||||
typing.BinaryIO,
|
||||
_tl.TypeMessageMedia,
|
||||
_tl.TypeInputFile,
|
||||
_tl.TypeInputFileLocation
|
||||
]
|
||||
|
||||
OutFileLike = typing.Union[
|
||||
str,
|
||||
typing.Type[bytes],
|
||||
typing.BinaryIO
|
||||
]
|
||||
|
||||
MessageLike = typing.Union[str, _tl.Message]
|
||||
MessageIDLike = typing.Union[int, _tl.Message, _tl.TypeInputMessage]
|
||||
|
||||
ProgressCallback = typing.Callable[[int, int], None]
|
|
@ -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 <pre> tag, this <code> 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('<strong>{}</strong>'.format(entity_text))
|
||||
elif entity_type == MessageEntityItalic:
|
||||
elif entity_type == _tl.MessageEntityItalic:
|
||||
html.append('<em>{}</em>'.format(entity_text))
|
||||
elif entity_type == MessageEntityCode:
|
||||
elif entity_type == _tl.MessageEntityCode:
|
||||
html.append('<code>{}</code>'.format(entity_text))
|
||||
elif entity_type == MessageEntityUnderline:
|
||||
elif entity_type == _tl.MessageEntityUnderline:
|
||||
html.append('<u>{}</u>'.format(entity_text))
|
||||
elif entity_type == MessageEntityStrike:
|
||||
elif entity_type == _tl.MessageEntityStrike:
|
||||
html.append('<del>{}</del>'.format(entity_text))
|
||||
elif entity_type == MessageEntityBlockquote:
|
||||
elif entity_type == _tl.MessageEntityBlockquote:
|
||||
html.append('<blockquote>{}</blockquote>'.format(entity_text))
|
||||
elif entity_type == MessageEntityPre:
|
||||
elif entity_type == _tl.MessageEntityPre:
|
||||
if entity.language:
|
||||
html.append(
|
||||
"<pre>\n"
|
||||
|
@ -208,14 +204,14 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
|
|||
else:
|
||||
html.append('<pre><code>{}</code></pre>'
|
||||
.format(entity_text))
|
||||
elif entity_type == MessageEntityEmail:
|
||||
elif entity_type == _tl.MessageEntityEmail:
|
||||
html.append('<a href="mailto:{0}">{0}</a>'.format(entity_text))
|
||||
elif entity_type == MessageEntityUrl:
|
||||
elif entity_type == _tl.MessageEntityUrl:
|
||||
html.append('<a href="{0}">{0}</a>'.format(entity_text))
|
||||
elif entity_type == MessageEntityTextUrl:
|
||||
elif entity_type == _tl.MessageEntityTextUrl:
|
||||
html.append('<a href="{}">{}</a>'
|
||||
.format(escape(entity.url), entity_text))
|
||||
elif entity_type == MessageEntityMentionName:
|
||||
elif entity_type == _tl.MessageEntityMentionName:
|
||||
html.append('<a href="tg://user?id={}">{}</a>'
|
||||
.format(entity.user_id, entity_text))
|
||||
else:
|
169
telethon/_misc/markdown.py
Normal file
169
telethon/_misc/markdown.py
Normal file
|
@ -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)
|
|
@ -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:
|
|
@ -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))
|
|
@ -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__()
|
|
@ -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])
|
||||
|
@ -32,76 +33,10 @@ def _json_default(value):
|
|||
|
||||
|
||||
class TLObject:
|
||||
__slots__ = ()
|
||||
CONSTRUCTOR_ID = None
|
||||
SUBCLASS_OF_ID = None
|
||||
|
||||
@staticmethod
|
||||
def pretty_format(obj, indent=None):
|
||||
"""
|
||||
Pretty formats the given object as a string which is returned.
|
||||
If indent is None, a single line will be returned.
|
||||
"""
|
||||
if indent is None:
|
||||
if isinstance(obj, TLObject):
|
||||
obj = obj.to_dict()
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return '{}({})'.format(obj.get('_', 'dict'), ', '.join(
|
||||
'{}={}'.format(k, TLObject.pretty_format(v))
|
||||
for k, v in obj.items() if k != '_'
|
||||
))
|
||||
elif isinstance(obj, str) or isinstance(obj, bytes):
|
||||
return repr(obj)
|
||||
elif hasattr(obj, '__iter__'):
|
||||
return '[{}]'.format(
|
||||
', '.join(TLObject.pretty_format(x) for x in obj)
|
||||
)
|
||||
else:
|
||||
return repr(obj)
|
||||
else:
|
||||
result = []
|
||||
if isinstance(obj, TLObject):
|
||||
obj = obj.to_dict()
|
||||
|
||||
if isinstance(obj, dict):
|
||||
result.append(obj.get('_', 'dict'))
|
||||
result.append('(')
|
||||
if obj:
|
||||
result.append('\n')
|
||||
indent += 1
|
||||
for k, v in obj.items():
|
||||
if k == '_':
|
||||
continue
|
||||
result.append('\t' * indent)
|
||||
result.append(k)
|
||||
result.append('=')
|
||||
result.append(TLObject.pretty_format(v, indent))
|
||||
result.append(',\n')
|
||||
result.pop() # last ',\n'
|
||||
indent -= 1
|
||||
result.append('\n')
|
||||
result.append('\t' * indent)
|
||||
result.append(')')
|
||||
|
||||
elif isinstance(obj, str) or isinstance(obj, bytes):
|
||||
result.append(repr(obj))
|
||||
|
||||
elif hasattr(obj, '__iter__'):
|
||||
result.append('[\n')
|
||||
indent += 1
|
||||
for x in obj:
|
||||
result.append('\t' * indent)
|
||||
result.append(TLObject.pretty_format(x, indent))
|
||||
result.append(',\n')
|
||||
indent -= 1
|
||||
result.append('\t' * indent)
|
||||
result.append(']')
|
||||
|
||||
else:
|
||||
result.append(repr(obj))
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
@staticmethod
|
||||
def serialize_bytes(data):
|
||||
"""Write bytes by using Telegram guidelines"""
|
||||
|
@ -163,31 +98,32 @@ class TLObject:
|
|||
def __ne__(self, o):
|
||||
return not isinstance(o, type(self)) or self.to_dict() != o.to_dict()
|
||||
|
||||
def __repr__(self):
|
||||
return pretty_print(self)
|
||||
|
||||
def __str__(self):
|
||||
return TLObject.pretty_format(self)
|
||||
return pretty_print(self, max_depth=2)
|
||||
|
||||
def stringify(self):
|
||||
return TLObject.pretty_format(self, indent=0)
|
||||
return pretty_print(self, indent=0)
|
||||
|
||||
def to_dict(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def to_json(self, fp=None, default=_json_default, **kwargs):
|
||||
"""
|
||||
Represent the current `TLObject` as JSON.
|
||||
|
||||
If ``fp`` is given, the JSON will be dumped to said
|
||||
file pointer, otherwise a JSON string will be returned.
|
||||
|
||||
Note that bytes and datetimes cannot be represented
|
||||
in JSON, so if those are found, they will be base64
|
||||
encoded and ISO-formatted, respectively, by default.
|
||||
"""
|
||||
d = self.to_dict()
|
||||
if fp:
|
||||
return json.dump(d, fp, default=default, **kwargs)
|
||||
res = {}
|
||||
pre = ('', 'fn.')[isinstance(self, TLRequest)]
|
||||
mod = self.__class__.__module__[self.__class__.__module__.rfind('.') + 1:]
|
||||
if mod in ('_tl', 'fn'):
|
||||
res['_'] = f'{pre}{self.__class__.__name__}'
|
||||
else:
|
||||
return json.dumps(d, default=default, **kwargs)
|
||||
res['_'] = f'{pre}{mod}.{self.__class__.__name__}'
|
||||
|
||||
for slot in self.__slots__:
|
||||
attr = getattr(self, slot)
|
||||
if isinstance(attr, list):
|
||||
res[slot] = [val.to_dict() if hasattr(val, 'to_dict') else val for val in attr]
|
||||
else:
|
||||
res[slot] = attr.to_dict() if hasattr(attr, 'to_dict') else attr
|
||||
|
||||
return res
|
||||
|
||||
def __bytes__(self):
|
||||
try:
|
|
@ -19,9 +19,9 @@ from collections import namedtuple
|
|||
from mimetypes import guess_extension
|
||||
from types import GeneratorType
|
||||
|
||||
from .extensions import markdown, html
|
||||
from .helpers import add_surrogate, del_surrogate, strip_text
|
||||
from .tl import types
|
||||
from . import markdown, html
|
||||
from .. import _tl
|
||||
|
||||
try:
|
||||
import hachoir
|
||||
|
@ -92,7 +92,7 @@ def get_display_name(entity):
|
|||
Gets the display name for the given :tl:`User`,
|
||||
:tl:`Chat` or :tl:`Channel`. Returns an empty string otherwise.
|
||||
"""
|
||||
if isinstance(entity, types.User):
|
||||
if isinstance(entity, _tl.User):
|
||||
if entity.last_name and entity.first_name:
|
||||
return '{} {}'.format(entity.first_name, entity.last_name)
|
||||
elif entity.first_name:
|
||||
|
@ -102,7 +102,7 @@ def get_display_name(entity):
|
|||
else:
|
||||
return ''
|
||||
|
||||
elif isinstance(entity, (types.Chat, types.ChatForbidden, types.Channel)):
|
||||
elif isinstance(entity, (_tl.Chat, _tl.ChatForbidden, _tl.Channel)):
|
||||
return entity.title
|
||||
|
||||
return ''
|
||||
|
@ -117,14 +117,14 @@ def get_extension(media):
|
|||
return '.jpg'
|
||||
except TypeError:
|
||||
# These cases are not handled by input photo because it can't
|
||||
if isinstance(media, (types.UserProfilePhoto, types.ChatPhoto)):
|
||||
if isinstance(media, (_tl.UserProfilePhoto, _tl.ChatPhoto)):
|
||||
return '.jpg'
|
||||
|
||||
# Documents will come with a mime type
|
||||
if isinstance(media, types.MessageMediaDocument):
|
||||
if isinstance(media, _tl.MessageMediaDocument):
|
||||
media = media.document
|
||||
if isinstance(media, (
|
||||
types.Document, types.WebDocument, types.WebDocumentNoProxy)):
|
||||
_tl.Document, _tl.WebDocument, _tl.WebDocumentNoProxy)):
|
||||
if media.mime_type == 'application/octet-stream':
|
||||
# Octet stream are just bytes, which have no default extension
|
||||
return ''
|
||||
|
@ -184,53 +184,53 @@ def get_input_peer(entity, allow_self=True, check_hash=True):
|
|||
else:
|
||||
_raise_cast_fail(entity, 'InputPeer')
|
||||
|
||||
if isinstance(entity, types.User):
|
||||
if isinstance(entity, _tl.User):
|
||||
if entity.is_self and allow_self:
|
||||
return types.InputPeerSelf()
|
||||
return _tl.InputPeerSelf()
|
||||
elif (entity.access_hash is not None and not entity.min) or not check_hash:
|
||||
return types.InputPeerUser(entity.id, entity.access_hash)
|
||||
return _tl.InputPeerUser(entity.id, entity.access_hash)
|
||||
else:
|
||||
raise TypeError('User without access_hash or min info cannot be input')
|
||||
|
||||
if isinstance(entity, (types.Chat, types.ChatEmpty, types.ChatForbidden)):
|
||||
return types.InputPeerChat(entity.id)
|
||||
if isinstance(entity, (_tl.Chat, _tl.ChatEmpty, _tl.ChatForbidden)):
|
||||
return _tl.InputPeerChat(entity.id)
|
||||
|
||||
if isinstance(entity, types.Channel):
|
||||
if isinstance(entity, _tl.Channel):
|
||||
if (entity.access_hash is not None and not entity.min) or not check_hash:
|
||||
return types.InputPeerChannel(entity.id, entity.access_hash)
|
||||
return _tl.InputPeerChannel(entity.id, entity.access_hash)
|
||||
else:
|
||||
raise TypeError('Channel without access_hash or min info cannot be input')
|
||||
if isinstance(entity, types.ChannelForbidden):
|
||||
if isinstance(entity, _tl.ChannelForbidden):
|
||||
# "channelForbidden are never min", and since their hash is
|
||||
# also not optional, we assume that this truly is the case.
|
||||
return types.InputPeerChannel(entity.id, entity.access_hash)
|
||||
return _tl.InputPeerChannel(entity.id, entity.access_hash)
|
||||
|
||||
if isinstance(entity, types.InputUser):
|
||||
return types.InputPeerUser(entity.user_id, entity.access_hash)
|
||||
if isinstance(entity, _tl.InputUser):
|
||||
return _tl.InputPeerUser(entity.user_id, entity.access_hash)
|
||||
|
||||
if isinstance(entity, types.InputChannel):
|
||||
return types.InputPeerChannel(entity.channel_id, entity.access_hash)
|
||||
if isinstance(entity, _tl.InputChannel):
|
||||
return _tl.InputPeerChannel(entity.channel_id, entity.access_hash)
|
||||
|
||||
if isinstance(entity, types.InputUserSelf):
|
||||
return types.InputPeerSelf()
|
||||
if isinstance(entity, _tl.InputUserSelf):
|
||||
return _tl.InputPeerSelf()
|
||||
|
||||
if isinstance(entity, types.InputUserFromMessage):
|
||||
return types.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id)
|
||||
if isinstance(entity, _tl.InputUserFromMessage):
|
||||
return _tl.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id)
|
||||
|
||||
if isinstance(entity, types.InputChannelFromMessage):
|
||||
return types.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id)
|
||||
if isinstance(entity, _tl.InputChannelFromMessage):
|
||||
return _tl.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id)
|
||||
|
||||
if isinstance(entity, types.UserEmpty):
|
||||
return types.InputPeerEmpty()
|
||||
if isinstance(entity, _tl.UserEmpty):
|
||||
return _tl.InputPeerEmpty()
|
||||
|
||||
if isinstance(entity, types.UserFull):
|
||||
if isinstance(entity, _tl.UserFull):
|
||||
return get_input_peer(entity.user)
|
||||
|
||||
if isinstance(entity, types.ChatFull):
|
||||
return types.InputPeerChat(entity.id)
|
||||
if isinstance(entity, _tl.ChatFull):
|
||||
return _tl.InputPeerChat(entity.id)
|
||||
|
||||
if isinstance(entity, types.PeerChat):
|
||||
return types.InputPeerChat(entity.chat_id)
|
||||
if isinstance(entity, _tl.PeerChat):
|
||||
return _tl.InputPeerChat(entity.chat_id)
|
||||
|
||||
_raise_cast_fail(entity, 'InputPeer')
|
||||
|
||||
|
@ -251,14 +251,14 @@ def get_input_channel(entity):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(entity, 'InputChannel')
|
||||
|
||||
if isinstance(entity, (types.Channel, types.ChannelForbidden)):
|
||||
return types.InputChannel(entity.id, entity.access_hash or 0)
|
||||
if isinstance(entity, (_tl.Channel, _tl.ChannelForbidden)):
|
||||
return _tl.InputChannel(entity.id, entity.access_hash or 0)
|
||||
|
||||
if isinstance(entity, types.InputPeerChannel):
|
||||
return types.InputChannel(entity.channel_id, entity.access_hash)
|
||||
if isinstance(entity, _tl.InputPeerChannel):
|
||||
return _tl.InputChannel(entity.channel_id, entity.access_hash)
|
||||
|
||||
if isinstance(entity, types.InputPeerChannelFromMessage):
|
||||
return types.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id)
|
||||
if isinstance(entity, _tl.InputPeerChannelFromMessage):
|
||||
return _tl.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id)
|
||||
|
||||
_raise_cast_fail(entity, 'InputChannel')
|
||||
|
||||
|
@ -279,26 +279,26 @@ def get_input_user(entity):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(entity, 'InputUser')
|
||||
|
||||
if isinstance(entity, types.User):
|
||||
if isinstance(entity, _tl.User):
|
||||
if entity.is_self:
|
||||
return types.InputUserSelf()
|
||||
return _tl.InputUserSelf()
|
||||
else:
|
||||
return types.InputUser(entity.id, entity.access_hash or 0)
|
||||
return _tl.InputUser(entity.id, entity.access_hash or 0)
|
||||
|
||||
if isinstance(entity, types.InputPeerSelf):
|
||||
return types.InputUserSelf()
|
||||
if isinstance(entity, _tl.InputPeerSelf):
|
||||
return _tl.InputUserSelf()
|
||||
|
||||
if isinstance(entity, (types.UserEmpty, types.InputPeerEmpty)):
|
||||
return types.InputUserEmpty()
|
||||
if isinstance(entity, (_tl.UserEmpty, _tl.InputPeerEmpty)):
|
||||
return _tl.InputUserEmpty()
|
||||
|
||||
if isinstance(entity, types.UserFull):
|
||||
if isinstance(entity, _tl.UserFull):
|
||||
return get_input_user(entity.user)
|
||||
|
||||
if isinstance(entity, types.InputPeerUser):
|
||||
return types.InputUser(entity.user_id, entity.access_hash)
|
||||
if isinstance(entity, _tl.InputPeerUser):
|
||||
return _tl.InputUser(entity.user_id, entity.access_hash)
|
||||
|
||||
if isinstance(entity, types.InputPeerUserFromMessage):
|
||||
return types.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id)
|
||||
if isinstance(entity, _tl.InputPeerUserFromMessage):
|
||||
return _tl.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id)
|
||||
|
||||
_raise_cast_fail(entity, 'InputUser')
|
||||
|
||||
|
@ -309,12 +309,12 @@ def get_input_dialog(dialog):
|
|||
if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer')
|
||||
return dialog
|
||||
if dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer')
|
||||
return types.InputDialogPeer(dialog)
|
||||
return _tl.InputDialogPeer(dialog)
|
||||
except AttributeError:
|
||||
_raise_cast_fail(dialog, 'InputDialogPeer')
|
||||
|
||||
try:
|
||||
return types.InputDialogPeer(get_input_peer(dialog))
|
||||
return _tl.InputDialogPeer(get_input_peer(dialog))
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
|
@ -329,18 +329,18 @@ def get_input_document(document):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(document, 'InputDocument')
|
||||
|
||||
if isinstance(document, types.Document):
|
||||
return types.InputDocument(
|
||||
if isinstance(document, _tl.Document):
|
||||
return _tl.InputDocument(
|
||||
id=document.id, access_hash=document.access_hash,
|
||||
file_reference=document.file_reference)
|
||||
|
||||
if isinstance(document, types.DocumentEmpty):
|
||||
return types.InputDocumentEmpty()
|
||||
if isinstance(document, _tl.DocumentEmpty):
|
||||
return _tl.InputDocumentEmpty()
|
||||
|
||||
if isinstance(document, types.MessageMediaDocument):
|
||||
if isinstance(document, _tl.MessageMediaDocument):
|
||||
return get_input_document(document.document)
|
||||
|
||||
if isinstance(document, types.Message):
|
||||
if isinstance(document, _tl.Message):
|
||||
return get_input_document(document.media)
|
||||
|
||||
_raise_cast_fail(document, 'InputDocument')
|
||||
|
@ -354,32 +354,32 @@ def get_input_photo(photo):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(photo, 'InputPhoto')
|
||||
|
||||
if isinstance(photo, types.Message):
|
||||
if isinstance(photo, _tl.Message):
|
||||
photo = photo.media
|
||||
|
||||
if isinstance(photo, (types.photos.Photo, types.MessageMediaPhoto)):
|
||||
if isinstance(photo, (_tl.photos.Photo, _tl.MessageMediaPhoto)):
|
||||
photo = photo.photo
|
||||
|
||||
if isinstance(photo, types.Photo):
|
||||
return types.InputPhoto(id=photo.id, access_hash=photo.access_hash,
|
||||
if isinstance(photo, _tl.Photo):
|
||||
return _tl.InputPhoto(id=photo.id, access_hash=photo.access_hash,
|
||||
file_reference=photo.file_reference)
|
||||
|
||||
if isinstance(photo, types.PhotoEmpty):
|
||||
return types.InputPhotoEmpty()
|
||||
if isinstance(photo, _tl.PhotoEmpty):
|
||||
return _tl.InputPhotoEmpty()
|
||||
|
||||
if isinstance(photo, types.messages.ChatFull):
|
||||
if isinstance(photo, _tl.messages.ChatFull):
|
||||
photo = photo.full_chat
|
||||
|
||||
if isinstance(photo, types.ChannelFull):
|
||||
if isinstance(photo, _tl.ChannelFull):
|
||||
return get_input_photo(photo.chat_photo)
|
||||
elif isinstance(photo, types.UserFull):
|
||||
elif isinstance(photo, _tl.UserFull):
|
||||
return get_input_photo(photo.profile_photo)
|
||||
elif isinstance(photo, (types.Channel, types.Chat, types.User)):
|
||||
elif isinstance(photo, (_tl.Channel, _tl.Chat, _tl.User)):
|
||||
return get_input_photo(photo.photo)
|
||||
|
||||
if isinstance(photo, (types.UserEmpty, types.ChatEmpty,
|
||||
types.ChatForbidden, types.ChannelForbidden)):
|
||||
return types.InputPhotoEmpty()
|
||||
if isinstance(photo, (_tl.UserEmpty, _tl.ChatEmpty,
|
||||
_tl.ChatForbidden, _tl.ChannelForbidden)):
|
||||
return _tl.InputPhotoEmpty()
|
||||
|
||||
_raise_cast_fail(photo, 'InputPhoto')
|
||||
|
||||
|
@ -390,15 +390,15 @@ def get_input_chat_photo(photo):
|
|||
if photo.SUBCLASS_OF_ID == 0xd4eb2d74: # crc32(b'InputChatPhoto')
|
||||
return photo
|
||||
elif photo.SUBCLASS_OF_ID == 0xe7655f1f: # crc32(b'InputFile'):
|
||||
return types.InputChatUploadedPhoto(photo)
|
||||
return _tl.InputChatUploadedPhoto(photo)
|
||||
except AttributeError:
|
||||
_raise_cast_fail(photo, 'InputChatPhoto')
|
||||
|
||||
photo = get_input_photo(photo)
|
||||
if isinstance(photo, types.InputPhoto):
|
||||
return types.InputChatPhoto(photo)
|
||||
elif isinstance(photo, types.InputPhotoEmpty):
|
||||
return types.InputChatPhotoEmpty()
|
||||
if isinstance(photo, _tl.InputPhoto):
|
||||
return _tl.InputChatPhoto(photo)
|
||||
elif isinstance(photo, _tl.InputPhotoEmpty):
|
||||
return _tl.InputChatPhotoEmpty()
|
||||
|
||||
_raise_cast_fail(photo, 'InputChatPhoto')
|
||||
|
||||
|
@ -411,16 +411,16 @@ def get_input_geo(geo):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(geo, 'InputGeoPoint')
|
||||
|
||||
if isinstance(geo, types.GeoPoint):
|
||||
return types.InputGeoPoint(lat=geo.lat, long=geo.long)
|
||||
if isinstance(geo, _tl.GeoPoint):
|
||||
return _tl.InputGeoPoint(lat=geo.lat, long=geo.long)
|
||||
|
||||
if isinstance(geo, types.GeoPointEmpty):
|
||||
return types.InputGeoPointEmpty()
|
||||
if isinstance(geo, _tl.GeoPointEmpty):
|
||||
return _tl.InputGeoPointEmpty()
|
||||
|
||||
if isinstance(geo, types.MessageMediaGeo):
|
||||
if isinstance(geo, _tl.MessageMediaGeo):
|
||||
return get_input_geo(geo.geo)
|
||||
|
||||
if isinstance(geo, types.Message):
|
||||
if isinstance(geo, _tl.Message):
|
||||
return get_input_geo(geo.media)
|
||||
|
||||
_raise_cast_fail(geo, 'InputGeoPoint')
|
||||
|
@ -443,39 +443,39 @@ def get_input_media(
|
|||
if media.SUBCLASS_OF_ID == 0xfaf846f4: # crc32(b'InputMedia')
|
||||
return media
|
||||
elif media.SUBCLASS_OF_ID == 0x846363e0: # crc32(b'InputPhoto')
|
||||
return types.InputMediaPhoto(media, ttl_seconds=ttl)
|
||||
return _tl.InputMediaPhoto(media, ttl_seconds=ttl)
|
||||
elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument')
|
||||
return types.InputMediaDocument(media, ttl_seconds=ttl)
|
||||
return _tl.InputMediaDocument(media, ttl_seconds=ttl)
|
||||
except AttributeError:
|
||||
_raise_cast_fail(media, 'InputMedia')
|
||||
|
||||
if isinstance(media, types.MessageMediaPhoto):
|
||||
return types.InputMediaPhoto(
|
||||
if isinstance(media, _tl.MessageMediaPhoto):
|
||||
return _tl.InputMediaPhoto(
|
||||
id=get_input_photo(media.photo),
|
||||
ttl_seconds=ttl or media.ttl_seconds
|
||||
)
|
||||
|
||||
if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)):
|
||||
return types.InputMediaPhoto(
|
||||
if isinstance(media, (_tl.Photo, _tl.photos.Photo, _tl.PhotoEmpty)):
|
||||
return _tl.InputMediaPhoto(
|
||||
id=get_input_photo(media),
|
||||
ttl_seconds=ttl
|
||||
)
|
||||
|
||||
if isinstance(media, types.MessageMediaDocument):
|
||||
return types.InputMediaDocument(
|
||||
if isinstance(media, _tl.MessageMediaDocument):
|
||||
return _tl.InputMediaDocument(
|
||||
id=get_input_document(media.document),
|
||||
ttl_seconds=ttl or media.ttl_seconds
|
||||
)
|
||||
|
||||
if isinstance(media, (types.Document, types.DocumentEmpty)):
|
||||
return types.InputMediaDocument(
|
||||
if isinstance(media, (_tl.Document, _tl.DocumentEmpty)):
|
||||
return _tl.InputMediaDocument(
|
||||
id=get_input_document(media),
|
||||
ttl_seconds=ttl
|
||||
)
|
||||
|
||||
if isinstance(media, (types.InputFile, types.InputFileBig)):
|
||||
if isinstance(media, (_tl.InputFile, _tl.InputFileBig)):
|
||||
if is_photo:
|
||||
return types.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl)
|
||||
return _tl.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl)
|
||||
else:
|
||||
attrs, mime = get_attributes(
|
||||
media,
|
||||
|
@ -485,29 +485,29 @@ def get_input_media(
|
|||
video_note=video_note,
|
||||
supports_streaming=supports_streaming
|
||||
)
|
||||
return types.InputMediaUploadedDocument(
|
||||
return _tl.InputMediaUploadedDocument(
|
||||
file=media, mime_type=mime, attributes=attrs, force_file=force_document,
|
||||
ttl_seconds=ttl)
|
||||
|
||||
if isinstance(media, types.MessageMediaGame):
|
||||
return types.InputMediaGame(id=types.InputGameID(
|
||||
if isinstance(media, _tl.MessageMediaGame):
|
||||
return _tl.InputMediaGame(id=_tl.InputGameID(
|
||||
id=media.game.id,
|
||||
access_hash=media.game.access_hash
|
||||
))
|
||||
|
||||
if isinstance(media, types.MessageMediaContact):
|
||||
return types.InputMediaContact(
|
||||
if isinstance(media, _tl.MessageMediaContact):
|
||||
return _tl.InputMediaContact(
|
||||
phone_number=media.phone_number,
|
||||
first_name=media.first_name,
|
||||
last_name=media.last_name,
|
||||
vcard=''
|
||||
)
|
||||
|
||||
if isinstance(media, types.MessageMediaGeo):
|
||||
return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo))
|
||||
if isinstance(media, _tl.MessageMediaGeo):
|
||||
return _tl.InputMediaGeoPoint(geo_point=get_input_geo(media.geo))
|
||||
|
||||
if isinstance(media, types.MessageMediaVenue):
|
||||
return types.InputMediaVenue(
|
||||
if isinstance(media, _tl.MessageMediaVenue):
|
||||
return _tl.InputMediaVenue(
|
||||
geo_point=get_input_geo(media.geo),
|
||||
title=media.title,
|
||||
address=media.address,
|
||||
|
@ -516,19 +516,19 @@ def get_input_media(
|
|||
venue_type=''
|
||||
)
|
||||
|
||||
if isinstance(media, types.MessageMediaDice):
|
||||
return types.InputMediaDice(media.emoticon)
|
||||
if isinstance(media, _tl.MessageMediaDice):
|
||||
return _tl.InputMediaDice(media.emoticon)
|
||||
|
||||
if isinstance(media, (
|
||||
types.MessageMediaEmpty, types.MessageMediaUnsupported,
|
||||
types.ChatPhotoEmpty, types.UserProfilePhotoEmpty,
|
||||
types.ChatPhoto, types.UserProfilePhoto)):
|
||||
return types.InputMediaEmpty()
|
||||
_tl.MessageMediaEmpty, _tl.MessageMediaUnsupported,
|
||||
_tl.ChatPhotoEmpty, _tl.UserProfilePhotoEmpty,
|
||||
_tl.ChatPhoto, _tl.UserProfilePhoto)):
|
||||
return _tl.InputMediaEmpty()
|
||||
|
||||
if isinstance(media, types.Message):
|
||||
if isinstance(media, _tl.Message):
|
||||
return get_input_media(media.media, is_photo=is_photo, ttl=ttl)
|
||||
|
||||
if isinstance(media, types.MessageMediaPoll):
|
||||
if isinstance(media, _tl.MessageMediaPoll):
|
||||
if media.poll.quiz:
|
||||
if not media.results.results:
|
||||
# A quiz has correct answers, which we don't know until answered.
|
||||
|
@ -539,15 +539,15 @@ def get_input_media(
|
|||
else:
|
||||
correct_answers = None
|
||||
|
||||
return types.InputMediaPoll(
|
||||
return _tl.InputMediaPoll(
|
||||
poll=media.poll,
|
||||
correct_answers=correct_answers,
|
||||
solution=media.results.solution,
|
||||
solution_entities=media.results.solution_entities,
|
||||
)
|
||||
|
||||
if isinstance(media, types.Poll):
|
||||
return types.InputMediaPoll(media)
|
||||
if isinstance(media, _tl.Poll):
|
||||
return _tl.InputMediaPoll(media)
|
||||
|
||||
_raise_cast_fail(media, 'InputMedia')
|
||||
|
||||
|
@ -556,11 +556,11 @@ def get_input_message(message):
|
|||
"""Similar to :meth:`get_input_peer`, but for input messages."""
|
||||
try:
|
||||
if isinstance(message, int): # This case is really common too
|
||||
return types.InputMessageID(message)
|
||||
return _tl.InputMessageID(message)
|
||||
elif message.SUBCLASS_OF_ID == 0x54b6bcc5: # crc32(b'InputMessage'):
|
||||
return message
|
||||
elif message.SUBCLASS_OF_ID == 0x790009e3: # crc32(b'Message'):
|
||||
return types.InputMessageID(message.id)
|
||||
return _tl.InputMessageID(message.id)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
@ -573,21 +573,17 @@ def get_input_group_call(call):
|
|||
if call.SUBCLASS_OF_ID == 0x58611ab1: # crc32(b'InputGroupCall')
|
||||
return call
|
||||
elif call.SUBCLASS_OF_ID == 0x20b4f320: # crc32(b'GroupCall')
|
||||
return types.InputGroupCall(id=call.id, access_hash=call.access_hash)
|
||||
return _tl.InputGroupCall(id=call.id, access_hash=call.access_hash)
|
||||
except AttributeError:
|
||||
_raise_cast_fail(call, 'InputGroupCall')
|
||||
|
||||
|
||||
def _get_entity_pair(entity_id, entities, cache,
|
||||
def _get_entity_pair(entity_id, entities,
|
||||
get_input_peer=get_input_peer):
|
||||
"""
|
||||
Returns ``(entity, input_entity)`` for the given entity ID.
|
||||
"""
|
||||
entity = entities.get(entity_id)
|
||||
try:
|
||||
input_entity = cache[entity_id]
|
||||
except KeyError:
|
||||
# KeyError is unlikely, so another TypeError won't hurt
|
||||
try:
|
||||
input_entity = get_input_peer(entity)
|
||||
except TypeError:
|
||||
|
@ -677,8 +673,8 @@ def get_attributes(file, *, attributes=None, mime_type=None,
|
|||
if mime_type is None:
|
||||
mime_type = mimetypes.guess_type(name)[0]
|
||||
|
||||
attr_dict = {types.DocumentAttributeFilename:
|
||||
types.DocumentAttributeFilename(os.path.basename(name))}
|
||||
attr_dict = {_tl.DocumentAttributeFilename:
|
||||
_tl.DocumentAttributeFilename(os.path.basename(name))}
|
||||
|
||||
if is_audio(file):
|
||||
m = _get_metadata(file)
|
||||
|
@ -690,8 +686,8 @@ def get_attributes(file, *, attributes=None, mime_type=None,
|
|||
else:
|
||||
performer = None
|
||||
|
||||
attr_dict[types.DocumentAttributeAudio] = \
|
||||
types.DocumentAttributeAudio(
|
||||
attr_dict[_tl.DocumentAttributeAudio] = \
|
||||
_tl.DocumentAttributeAudio(
|
||||
voice=voice_note,
|
||||
title=m.get('title') if m.has('title') else None,
|
||||
performer=performer,
|
||||
|
@ -702,7 +698,7 @@ def get_attributes(file, *, attributes=None, mime_type=None,
|
|||
if not force_document and is_video(file):
|
||||
m = _get_metadata(file)
|
||||
if m:
|
||||
doc = types.DocumentAttributeVideo(
|
||||
doc = _tl.DocumentAttributeVideo(
|
||||
round_message=video_note,
|
||||
w=m.get('width') if m.has('width') else 1,
|
||||
h=m.get('height') if m.has('height') else 1,
|
||||
|
@ -719,22 +715,22 @@ def get_attributes(file, *, attributes=None, mime_type=None,
|
|||
if t_m and t_m.has("height"):
|
||||
height = t_m.get("height")
|
||||
|
||||
doc = types.DocumentAttributeVideo(
|
||||
doc = _tl.DocumentAttributeVideo(
|
||||
0, width, height, round_message=video_note,
|
||||
supports_streaming=supports_streaming)
|
||||
else:
|
||||
doc = types.DocumentAttributeVideo(
|
||||
doc = _tl.DocumentAttributeVideo(
|
||||
0, 1, 1, round_message=video_note,
|
||||
supports_streaming=supports_streaming)
|
||||
|
||||
attr_dict[types.DocumentAttributeVideo] = doc
|
||||
attr_dict[_tl.DocumentAttributeVideo] = doc
|
||||
|
||||
if voice_note:
|
||||
if types.DocumentAttributeAudio in attr_dict:
|
||||
attr_dict[types.DocumentAttributeAudio].voice = True
|
||||
if _tl.DocumentAttributeAudio in attr_dict:
|
||||
attr_dict[_tl.DocumentAttributeAudio].voice = True
|
||||
else:
|
||||
attr_dict[types.DocumentAttributeAudio] = \
|
||||
types.DocumentAttributeAudio(0, voice=True)
|
||||
attr_dict[_tl.DocumentAttributeAudio] = \
|
||||
_tl.DocumentAttributeAudio(0, voice=True)
|
||||
|
||||
# Now override the attributes if any. As we have a dict of
|
||||
# {cls: instance}, we can override any class with the list
|
||||
|
@ -803,23 +799,23 @@ def _get_file_info(location):
|
|||
except AttributeError:
|
||||
_raise_cast_fail(location, 'InputFileLocation')
|
||||
|
||||
if isinstance(location, types.Message):
|
||||
if isinstance(location, _tl.Message):
|
||||
location = location.media
|
||||
|
||||
if isinstance(location, types.MessageMediaDocument):
|
||||
if isinstance(location, _tl.MessageMediaDocument):
|
||||
location = location.document
|
||||
elif isinstance(location, types.MessageMediaPhoto):
|
||||
elif isinstance(location, _tl.MessageMediaPhoto):
|
||||
location = location.photo
|
||||
|
||||
if isinstance(location, types.Document):
|
||||
return _FileInfo(location.dc_id, types.InputDocumentFileLocation(
|
||||
if isinstance(location, _tl.Document):
|
||||
return _FileInfo(location.dc_id, _tl.InputDocumentFileLocation(
|
||||
id=location.id,
|
||||
access_hash=location.access_hash,
|
||||
file_reference=location.file_reference,
|
||||
thumb_size='' # Presumably to download one of its thumbnails
|
||||
), location.size)
|
||||
elif isinstance(location, types.Photo):
|
||||
return _FileInfo(location.dc_id, types.InputPhotoFileLocation(
|
||||
elif isinstance(location, _tl.Photo):
|
||||
return _FileInfo(location.dc_id, _tl.InputPhotoFileLocation(
|
||||
id=location.id,
|
||||
access_hash=location.access_hash,
|
||||
file_reference=location.file_reference,
|
||||
|
@ -856,11 +852,7 @@ def is_image(file):
|
|||
"""
|
||||
Returns `True` if the file extension looks like an image file to Telegram.
|
||||
"""
|
||||
match = re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE)
|
||||
if match:
|
||||
return True
|
||||
else:
|
||||
return isinstance(resolve_bot_file_id(file), types.Photo)
|
||||
return bool(re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE))
|
||||
|
||||
|
||||
def is_gif(file):
|
||||
|
@ -965,59 +957,45 @@ def get_inner_text(text, entities):
|
|||
|
||||
def get_peer(peer):
|
||||
try:
|
||||
if isinstance(peer, int):
|
||||
pid, cls = resolve_id(peer)
|
||||
return cls(pid)
|
||||
elif peer.SUBCLASS_OF_ID == 0x2d45687:
|
||||
if peer.SUBCLASS_OF_ID == 0x2d45687:
|
||||
return peer
|
||||
elif isinstance(peer, (
|
||||
types.contacts.ResolvedPeer, types.InputNotifyPeer,
|
||||
types.TopPeer, types.Dialog, types.DialogPeer)):
|
||||
_tl.contacts.ResolvedPeer, _tl.InputNotifyPeer,
|
||||
_tl.TopPeer, _tl.Dialog, _tl.DialogPeer)):
|
||||
return peer.peer
|
||||
elif isinstance(peer, types.ChannelFull):
|
||||
return types.PeerChannel(peer.id)
|
||||
elif isinstance(peer, types.UserEmpty):
|
||||
return types.PeerUser(peer.id)
|
||||
elif isinstance(peer, types.ChatEmpty):
|
||||
return types.PeerChat(peer.id)
|
||||
elif isinstance(peer, _tl.ChannelFull):
|
||||
return _tl.PeerChannel(peer.id)
|
||||
elif isinstance(peer, _tl.UserEmpty):
|
||||
return _tl.PeerUser(peer.id)
|
||||
elif isinstance(peer, _tl.ChatEmpty):
|
||||
return _tl.PeerChat(peer.id)
|
||||
|
||||
if peer.SUBCLASS_OF_ID in (0x7d7c6f86, 0xd9c7fc18):
|
||||
# ChatParticipant, ChannelParticipant
|
||||
return types.PeerUser(peer.user_id)
|
||||
return _tl.PeerUser(peer.user_id)
|
||||
|
||||
peer = get_input_peer(peer, allow_self=False, check_hash=False)
|
||||
if isinstance(peer, (types.InputPeerUser, types.InputPeerUserFromMessage)):
|
||||
return types.PeerUser(peer.user_id)
|
||||
elif isinstance(peer, types.InputPeerChat):
|
||||
return types.PeerChat(peer.chat_id)
|
||||
elif isinstance(peer, (types.InputPeerChannel, types.InputPeerChannelFromMessage)):
|
||||
return types.PeerChannel(peer.channel_id)
|
||||
if isinstance(peer, (_tl.InputPeerUser, _tl.InputPeerUserFromMessage)):
|
||||
return _tl.PeerUser(peer.user_id)
|
||||
elif isinstance(peer, _tl.InputPeerChat):
|
||||
return _tl.PeerChat(peer.chat_id)
|
||||
elif isinstance(peer, (_tl.InputPeerChannel, _tl.InputPeerChannelFromMessage)):
|
||||
return _tl.PeerChannel(peer.channel_id)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
_raise_cast_fail(peer, 'Peer')
|
||||
|
||||
|
||||
def get_peer_id(peer, add_mark=True):
|
||||
def get_peer_id(peer):
|
||||
"""
|
||||
Convert the given peer into its marked ID by default.
|
||||
|
||||
This "mark" comes from the "bot api" format, and with it the peer type
|
||||
can be identified back. User ID is left unmodified, chat ID is negated,
|
||||
and channel ID is "prefixed" with -100:
|
||||
|
||||
* ``user_id``
|
||||
* ``-chat_id``
|
||||
* ``-100channel_id``
|
||||
|
||||
The original ID and the peer type class can be returned with
|
||||
a call to :meth:`resolve_id(marked_id)`.
|
||||
Extract the integer ID from the given peer.
|
||||
"""
|
||||
# First we assert it's a Peer TLObject, or early return for integers
|
||||
if isinstance(peer, int):
|
||||
return peer if add_mark else resolve_id(peer)[0]
|
||||
return peer
|
||||
|
||||
# Tell the user to use their client to resolve InputPeerSelf if we got one
|
||||
if isinstance(peer, types.InputPeerSelf):
|
||||
if isinstance(peer, _tl.InputPeerSelf):
|
||||
_raise_cast_fail(peer, 'int (you might want to use client.get_peer_id)')
|
||||
|
||||
try:
|
||||
|
@ -1025,38 +1003,13 @@ def get_peer_id(peer, add_mark=True):
|
|||
except TypeError:
|
||||
_raise_cast_fail(peer, 'int')
|
||||
|
||||
if isinstance(peer, types.PeerUser):
|
||||
if isinstance(peer, _tl.PeerUser):
|
||||
return peer.user_id
|
||||
elif isinstance(peer, types.PeerChat):
|
||||
# Check in case the user mixed things up to avoid blowing up
|
||||
if not (0 < peer.chat_id <= 9999999999):
|
||||
peer.chat_id = resolve_id(peer.chat_id)[0]
|
||||
|
||||
return -peer.chat_id if add_mark else peer.chat_id
|
||||
else: # if isinstance(peer, types.PeerChannel):
|
||||
# Check in case the user mixed things up to avoid blowing up
|
||||
if not (0 < peer.channel_id <= 9999999999):
|
||||
peer.channel_id = resolve_id(peer.channel_id)[0]
|
||||
|
||||
if not add_mark:
|
||||
elif isinstance(peer, _tl.PeerChat):
|
||||
return peer.chat_id
|
||||
else: # if isinstance(peer, _tl.PeerChannel):
|
||||
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
|
||||
|
||||
|
||||
def _rle_decode(data):
|
||||
"""
|
||||
|
@ -1119,198 +1072,6 @@ def _encode_telegram_base64(string):
|
|||
return None # not valid base64, not valid ascii, not a string
|
||||
|
||||
|
||||
def resolve_bot_file_id(file_id):
|
||||
"""
|
||||
Given a Bot API-style `file_id <telethon.tl.custom.file.File.id>`,
|
||||
returns the media it represents. If the `file_id <telethon.tl.custom.file.File.id>`
|
||||
is not valid, `None` is returned instead.
|
||||
|
||||
Note that the `file_id <telethon.tl.custom.file.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('<iiqq', data)
|
||||
# elif version == 4:
|
||||
else:
|
||||
# TODO Figure out what the extra byte means
|
||||
file_type, dc_id, media_id, access_hash, _ = struct.unpack('<iiqqb', data)
|
||||
|
||||
if not (1 <= dc_id <= 5):
|
||||
# Valid `file_id`'s must have valid DC IDs. Since this method is
|
||||
# called when sending a file and the user may have entered a path
|
||||
# they believe is correct but the file doesn't exist, this method
|
||||
# may detect a path as "valid" bot `file_id` even when it's not.
|
||||
# By checking the `dc_id`, we greatly reduce the chances of this
|
||||
# happening.
|
||||
return None
|
||||
|
||||
attributes = []
|
||||
if file_type == 3 or file_type == 9:
|
||||
attributes.append(types.DocumentAttributeAudio(
|
||||
duration=0,
|
||||
voice=file_type == 3
|
||||
))
|
||||
elif file_type == 4 or file_type == 13:
|
||||
attributes.append(types.DocumentAttributeVideo(
|
||||
duration=0,
|
||||
w=0,
|
||||
h=0,
|
||||
round_message=file_type == 13
|
||||
))
|
||||
# elif file_type == 5: # other, cannot know which
|
||||
elif file_type == 8:
|
||||
attributes.append(types.DocumentAttributeSticker(
|
||||
alt='',
|
||||
stickerset=types.InputStickerSetEmpty()
|
||||
))
|
||||
elif file_type == 10:
|
||||
attributes.append(types.DocumentAttributeAnimated())
|
||||
|
||||
return types.Document(
|
||||
id=media_id,
|
||||
access_hash=access_hash,
|
||||
date=None,
|
||||
mime_type='',
|
||||
size=0,
|
||||
thumbs=None,
|
||||
dc_id=dc_id,
|
||||
attributes=attributes,
|
||||
file_reference=b''
|
||||
)
|
||||
elif (version == 2 and len(data) == 44) or (version == 4 and len(data) in (49, 77)):
|
||||
if version == 2:
|
||||
(file_type, dc_id, media_id, access_hash,
|
||||
volume_id, secret, local_id) = struct.unpack('<iiqqqqi', data)
|
||||
# else version == 4:
|
||||
elif len(data) == 49:
|
||||
# TODO Figure out what the extra five bytes mean
|
||||
(file_type, dc_id, media_id, access_hash,
|
||||
volume_id, secret, local_id, _) = struct.unpack('<iiqqqqi5s', data)
|
||||
elif len(data) == 77:
|
||||
# See #1613.
|
||||
(file_type, dc_id, _, media_id, access_hash, volume_id, _, local_id, _) = struct.unpack('<ii28sqqq12sib', data)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not (1 <= dc_id <= 5):
|
||||
return None
|
||||
|
||||
# Thumbnails (small) always have ID 0; otherwise size 'x'
|
||||
photo_size = 's' if media_id or access_hash else 'x'
|
||||
return types.Photo(
|
||||
id=media_id,
|
||||
access_hash=access_hash,
|
||||
file_reference=b'',
|
||||
date=None,
|
||||
sizes=[types.PhotoSize(
|
||||
type=photo_size,
|
||||
w=0,
|
||||
h=0,
|
||||
size=0
|
||||
)],
|
||||
dc_id=dc_id,
|
||||
has_stickers=None
|
||||
)
|
||||
|
||||
|
||||
def pack_bot_file_id(file):
|
||||
"""
|
||||
Inverse operation for `resolve_bot_file_id`.
|
||||
|
||||
The only parameters this method will accept are :tl:`Document` and
|
||||
:tl:`Photo`, and it will return a variable-length ``file_id`` string.
|
||||
|
||||
If an invalid parameter is given, it will ``return None``.
|
||||
"""
|
||||
if isinstance(file, types.MessageMediaDocument):
|
||||
file = file.document
|
||||
elif isinstance(file, types.MessageMediaPhoto):
|
||||
file = file.photo
|
||||
|
||||
if isinstance(file, types.Document):
|
||||
file_type = 5
|
||||
for attribute in file.attributes:
|
||||
if isinstance(attribute, types.DocumentAttributeAudio):
|
||||
file_type = 3 if attribute.voice else 9
|
||||
elif isinstance(attribute, types.DocumentAttributeVideo):
|
||||
file_type = 13 if attribute.round_message else 4
|
||||
elif isinstance(attribute, types.DocumentAttributeSticker):
|
||||
file_type = 8
|
||||
elif isinstance(attribute, types.DocumentAttributeAnimated):
|
||||
file_type = 10
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
return _encode_telegram_base64(_rle_encode(struct.pack(
|
||||
'<iiqqb', file_type, file.dc_id, file.id, file.access_hash, 2)))
|
||||
|
||||
elif isinstance(file, types.Photo):
|
||||
size = next((x for x in reversed(file.sizes) if isinstance(
|
||||
x, (types.PhotoSize, types.PhotoCachedSize))), None)
|
||||
|
||||
if not size:
|
||||
return None
|
||||
|
||||
size = size.location
|
||||
return _encode_telegram_base64(_rle_encode(struct.pack(
|
||||
'<iiqqqqib', 2, file.dc_id, file.id, file.access_hash,
|
||||
size.volume_id, 0, size.local_id, 2 # 0 = old `secret`
|
||||
)))
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_invite_link(link):
|
||||
"""
|
||||
Resolves the given invite link. Returns a tuple of
|
||||
``(link creator user id, global chat id, random int)``.
|
||||
|
||||
Note that for broadcast channels or with the newest link format, the link
|
||||
creator user ID will be zero to protect their identity. Normal chats and
|
||||
megagroup channels will have such ID.
|
||||
|
||||
Note that the chat ID may not be accurate for chats with a link that were
|
||||
upgraded to megagroup, since the link can remain the same, but the chat
|
||||
ID will be correct once a new link is generated.
|
||||
"""
|
||||
link_hash, is_link = parse_username(link)
|
||||
if not is_link:
|
||||
# Perhaps the user passed the link hash directly
|
||||
link_hash = link
|
||||
|
||||
# Little known fact, but invite links with a
|
||||
# hex-string of bytes instead of base64 also works.
|
||||
if re.match(r'[a-fA-F\d]+', link_hash) and len(link_hash) in (24, 32):
|
||||
payload = bytes.fromhex(link_hash)
|
||||
else:
|
||||
payload = _decode_telegram_base64(link_hash)
|
||||
|
||||
try:
|
||||
if len(payload) == 12:
|
||||
return (0, *struct.unpack('>LQ', payload))
|
||||
elif len(payload) == 16:
|
||||
return struct.unpack('>LLQ', payload)
|
||||
else:
|
||||
pass
|
||||
except (struct.error, TypeError):
|
||||
pass
|
||||
return None, None, None
|
||||
|
||||
|
||||
def resolve_inline_message_id(inline_msg_id):
|
||||
"""
|
||||
Resolves an inline message ID. Returns a tuple of
|
||||
|
@ -1326,7 +1087,7 @@ def resolve_inline_message_id(inline_msg_id):
|
|||
try:
|
||||
dc_id, message_id, pid, access_hash = \
|
||||
struct.unpack('<iiiq', _decode_telegram_base64(inline_msg_id))
|
||||
peer = types.PeerChannel(-pid) if pid < 0 else types.PeerUser(pid)
|
||||
peer = _tl.PeerChannel(-pid) if pid < 0 else _tl.PeerUser(pid)
|
||||
return message_id, peer, dc_id, access_hash
|
||||
except (struct.error, TypeError):
|
||||
return None, None, None, None
|
||||
|
@ -1360,14 +1121,14 @@ def encode_waveform(waveform):
|
|||
file = 'my.ogg'
|
||||
|
||||
# Send 'my.ogg' with a ascending-triangle waveform
|
||||
await client.send_file(chat, file, attributes=[types.DocumentAttributeAudio(
|
||||
await client.send_file(chat, file, attributes=[_tl.DocumentAttributeAudio(
|
||||
duration=7,
|
||||
voice=True,
|
||||
waveform=utils.encode_waveform(bytes(range(2 ** 5)) # 2**5 because 5-bit
|
||||
)]
|
||||
|
||||
# Send 'my.ogg' with a square waveform
|
||||
await client.send_file(chat, file, attributes=[types.DocumentAttributeAudio(
|
||||
await client.send_file(chat, file, attributes=[_tl.DocumentAttributeAudio(
|
||||
duration=7,
|
||||
voice=True,
|
||||
waveform=utils.encode_waveform(bytes((31, 31, 15, 15, 15, 15, 31, 31)) * 4)
|
||||
|
@ -1542,18 +1303,18 @@ def stripped_photo_to_jpg(stripped):
|
|||
|
||||
|
||||
def _photo_size_byte_count(size):
|
||||
if isinstance(size, types.PhotoSize):
|
||||
if isinstance(size, _tl.PhotoSize):
|
||||
return size.size
|
||||
elif isinstance(size, types.PhotoStrippedSize):
|
||||
elif isinstance(size, _tl.PhotoStrippedSize):
|
||||
if len(size.bytes) < 3 or size.bytes[0] != 1:
|
||||
return len(size.bytes)
|
||||
|
||||
return len(size.bytes) + 622
|
||||
elif isinstance(size, types.PhotoCachedSize):
|
||||
elif isinstance(size, _tl.PhotoCachedSize):
|
||||
return len(size.bytes)
|
||||
elif isinstance(size, types.PhotoSizeEmpty):
|
||||
elif isinstance(size, _tl.PhotoSizeEmpty):
|
||||
return 0
|
||||
elif isinstance(size, types.PhotoSizeProgressive):
|
||||
elif isinstance(size, _tl.PhotoSizeProgressive):
|
||||
return max(size.sizes)
|
||||
else:
|
||||
return None
|
|
@ -5,10 +5,5 @@ with Telegram's servers and the protocol used (TCP full, abridged, etc.).
|
|||
from .mtprotoplainsender import MTProtoPlainSender
|
||||
from .authenticator import do_authentication
|
||||
from .mtprotosender import MTProtoSender
|
||||
from .connection import (
|
||||
Connection,
|
||||
ConnectionTcpFull, ConnectionTcpIntermediate, ConnectionTcpAbridged,
|
||||
ConnectionTcpObfuscated, ConnectionTcpMTProxyAbridged,
|
||||
ConnectionTcpMTProxyIntermediate,
|
||||
ConnectionTcpMTProxyRandomizedIntermediate, ConnectionHttp, TcpMTProxy
|
||||
)
|
||||
from .connection import Connection
|
||||
from . import transports
|
|
@ -6,17 +6,11 @@ import os
|
|||
import time
|
||||
from hashlib import sha1
|
||||
|
||||
from ..tl.types import (
|
||||
ResPQ, PQInnerData, ServerDHParamsFail, ServerDHParamsOk,
|
||||
ServerDHInnerData, ClientDHInnerData, DhGenOk, DhGenRetry, DhGenFail
|
||||
)
|
||||
from .. import helpers
|
||||
from ..crypto import AES, AuthKey, Factorization, rsa
|
||||
from ..errors import SecurityError
|
||||
from ..extensions import BinaryReader
|
||||
from ..tl.functions import (
|
||||
ReqPqMultiRequest, ReqDHParamsRequest, SetClientDHParamsRequest
|
||||
)
|
||||
from .. import _tl
|
||||
from .._misc import helpers
|
||||
from .._crypto import AES, AuthKey, Factorization, rsa
|
||||
from ..errors._custom import SecurityError
|
||||
from .._misc.binaryreader import BinaryReader
|
||||
|
||||
|
||||
async def do_authentication(sender):
|
||||
|
@ -28,8 +22,8 @@ async def do_authentication(sender):
|
|||
"""
|
||||
# Step 1 sending: PQ Request, endianness doesn't matter since it's random
|
||||
nonce = int.from_bytes(os.urandom(16), 'big', signed=True)
|
||||
res_pq = await sender.send(ReqPqMultiRequest(nonce))
|
||||
assert isinstance(res_pq, ResPQ), 'Step 1 answer was %s' % res_pq
|
||||
res_pq = await sender.send(_tl.fn.ReqPqMulti(nonce))
|
||||
assert isinstance(res_pq, _tl.ResPQ), 'Step 1 answer was %s' % res_pq
|
||||
|
||||
if res_pq.nonce != nonce:
|
||||
raise SecurityError('Step 1 invalid nonce from server')
|
||||
|
@ -41,7 +35,7 @@ async def do_authentication(sender):
|
|||
p, q = rsa.get_byte_array(p), rsa.get_byte_array(q)
|
||||
new_nonce = int.from_bytes(os.urandom(32), 'little', signed=True)
|
||||
|
||||
pq_inner_data = bytes(PQInnerData(
|
||||
pq_inner_data = bytes(_tl.PQInnerData(
|
||||
pq=rsa.get_byte_array(pq), p=p, q=q,
|
||||
nonce=res_pq.nonce,
|
||||
server_nonce=res_pq.server_nonce,
|
||||
|
@ -72,7 +66,7 @@ async def do_authentication(sender):
|
|||
)
|
||||
)
|
||||
|
||||
server_dh_params = await sender.send(ReqDHParamsRequest(
|
||||
server_dh_params = await sender.send(_tl.fn.ReqDHParams(
|
||||
nonce=res_pq.nonce,
|
||||
server_nonce=res_pq.server_nonce,
|
||||
p=p, q=q,
|
||||
|
@ -81,7 +75,7 @@ async def do_authentication(sender):
|
|||
))
|
||||
|
||||
assert isinstance(
|
||||
server_dh_params, (ServerDHParamsOk, ServerDHParamsFail)),\
|
||||
server_dh_params, (_tl.ServerDHParamsOk, _tl.ServerDHParamsFail)),\
|
||||
'Step 2.1 answer was %s' % server_dh_params
|
||||
|
||||
if server_dh_params.nonce != res_pq.nonce:
|
||||
|
@ -90,7 +84,7 @@ async def do_authentication(sender):
|
|||
if server_dh_params.server_nonce != res_pq.server_nonce:
|
||||
raise SecurityError('Step 2 invalid server nonce from server')
|
||||
|
||||
if isinstance(server_dh_params, ServerDHParamsFail):
|
||||
if isinstance(server_dh_params, _tl.ServerDHParamsFail):
|
||||
nnh = int.from_bytes(
|
||||
sha1(new_nonce.to_bytes(32, 'little', signed=True)).digest()[4:20],
|
||||
'little', signed=True
|
||||
|
@ -98,7 +92,7 @@ async def do_authentication(sender):
|
|||
if server_dh_params.new_nonce_hash != nnh:
|
||||
raise SecurityError('Step 2 invalid DH fail nonce from server')
|
||||
|
||||
assert isinstance(server_dh_params, ServerDHParamsOk),\
|
||||
assert isinstance(server_dh_params, _tl.ServerDHParamsOk),\
|
||||
'Step 2.2 answer was %s' % server_dh_params
|
||||
|
||||
# Step 3 sending: Complete DH Exchange
|
||||
|
@ -116,7 +110,7 @@ async def do_authentication(sender):
|
|||
with BinaryReader(plain_text_answer) as reader:
|
||||
reader.read(20) # hash sum
|
||||
server_dh_inner = reader.tgread_object()
|
||||
assert isinstance(server_dh_inner, ServerDHInnerData),\
|
||||
assert isinstance(server_dh_inner, _tl.ServerDHInnerData),\
|
||||
'Step 3 answer was %s' % server_dh_inner
|
||||
|
||||
if server_dh_inner.nonce != res_pq.nonce:
|
||||
|
@ -157,7 +151,7 @@ async def do_authentication(sender):
|
|||
raise SecurityError('g_b is not within (2^{2048-64}, dh_prime - 2^{2048-64})')
|
||||
|
||||
# Prepare client DH Inner Data
|
||||
client_dh_inner = bytes(ClientDHInnerData(
|
||||
client_dh_inner = bytes(_tl.ClientDHInnerData(
|
||||
nonce=res_pq.nonce,
|
||||
server_nonce=res_pq.server_nonce,
|
||||
retry_id=0, # TODO Actual retry ID
|
||||
|
@ -170,13 +164,13 @@ async def do_authentication(sender):
|
|||
client_dh_encrypted = AES.encrypt_ige(client_dh_inner_hashed, key, iv)
|
||||
|
||||
# Prepare Set client DH params
|
||||
dh_gen = await sender.send(SetClientDHParamsRequest(
|
||||
dh_gen = await sender.send(_tl.fn.SetClientDHParams(
|
||||
nonce=res_pq.nonce,
|
||||
server_nonce=res_pq.server_nonce,
|
||||
encrypted_data=client_dh_encrypted,
|
||||
))
|
||||
|
||||
nonce_types = (DhGenOk, DhGenRetry, DhGenFail)
|
||||
nonce_types = (_tl.DhGenOk, _tl.DhGenRetry, _tl.DhGenFail)
|
||||
assert isinstance(dh_gen, nonce_types), 'Step 3.1 answer was %s' % dh_gen
|
||||
name = dh_gen.__class__.__name__
|
||||
if dh_gen.nonce != res_pq.nonce:
|
||||
|
@ -194,7 +188,7 @@ async def do_authentication(sender):
|
|||
if dh_hash != new_nonce_hash:
|
||||
raise SecurityError('Step 3 invalid new nonce hash')
|
||||
|
||||
if not isinstance(dh_gen, DhGenOk):
|
||||
if not isinstance(dh_gen, _tl.DhGenOk):
|
||||
raise AssertionError('Step 3.2 answer was %s' % dh_gen)
|
||||
|
||||
return auth_key, time_offset
|
63
telethon/_network/connection.py
Normal file
63
telethon/_network/connection.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
import asyncio
|
||||
import socket
|
||||
|
||||
from .transports.transport import Transport
|
||||
|
||||
|
||||
CHUNK_SIZE = 32 * 1024
|
||||
|
||||
|
||||
# TODO ideally the mtproto impl would also be sans-io, but that's less pressing
|
||||
class Connection:
|
||||
def __init__(self, ip, port, *, transport: Transport, loggers, local_addr=None):
|
||||
self._ip = ip
|
||||
self._port = port
|
||||
self._log = loggers[__name__]
|
||||
self._local_addr = local_addr
|
||||
|
||||
self._sock = None
|
||||
self._in_buffer = bytearray()
|
||||
self._transport = transport
|
||||
|
||||
async def connect(self, timeout=None, ssl=None):
|
||||
"""
|
||||
Establishes a connection with the server.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setblocking(False)
|
||||
if self._local_addr:
|
||||
sock.bind(self._local_addr)
|
||||
|
||||
# TODO https://github.com/LonamiWebs/Telethon/issues/1337 may be an issue again
|
||||
# perhaps we just need to ignore async connect on windows and block?
|
||||
await asyncio.wait_for(loop.sock_connect(sock, (self._ip, self._port)), timeout)
|
||||
self._sock = sock
|
||||
|
||||
async def disconnect(self):
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
async def send(self, data):
|
||||
if not self._sock:
|
||||
raise ConnectionError('not connected')
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_sendall(self._sock, self._transport.pack(data))
|
||||
|
||||
async def recv(self):
|
||||
if not self._sock:
|
||||
raise ConnectionError('not connected')
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
length, body = self._transport.unpack(self._in_buffer)
|
||||
del self._in_buffer[:length]
|
||||
return body
|
||||
except EOFError:
|
||||
self._in_buffer += await loop.sock_recv(self._sock, CHUNK_SIZE)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self._ip}:{self._port}/{self._transport.__class__.__name__}'
|
|
@ -5,8 +5,8 @@ in plain text, when no authorization key has been created yet.
|
|||
import struct
|
||||
|
||||
from .mtprotostate import MTProtoState
|
||||
from ..errors import InvalidBufferError
|
||||
from ..extensions import BinaryReader
|
||||
from ..errors._custom import InvalidBufferError
|
||||
from .._misc.binaryreader import BinaryReader
|
||||
|
||||
|
||||
class MTProtoPlainSender:
|
|
@ -1,29 +1,29 @@
|
|||
import asyncio
|
||||
import collections
|
||||
import struct
|
||||
import logging
|
||||
import random
|
||||
|
||||
from . import authenticator
|
||||
from ..extensions.messagepacker import MessagePacker
|
||||
from .._misc.messagepacker import MessagePacker
|
||||
from ..errors._rpcbase import _mk_error_type
|
||||
from .mtprotoplainsender import MTProtoPlainSender
|
||||
from .requeststate import RequestState
|
||||
from .mtprotostate import MTProtoState
|
||||
from ..tl.tlobject import TLRequest
|
||||
from .. import helpers, utils
|
||||
from ..errors import (
|
||||
BadMessageError, InvalidBufferError, SecurityError,
|
||||
TypeNotFoundError, rpc_message_to_error
|
||||
)
|
||||
from ..extensions import BinaryReader
|
||||
from ..tl.core import RpcResult, MessageContainer, GzipPacked
|
||||
from ..tl.functions.auth import LogOutRequest
|
||||
from ..tl.functions import PingRequest, DestroySessionRequest
|
||||
from ..tl.types import (
|
||||
MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts,
|
||||
MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo, MsgsStateReq,
|
||||
MsgsStateInfo, MsgsAllInfo, MsgResendReq, upload, DestroySessionOk, DestroySessionNone,
|
||||
)
|
||||
from ..crypto import AuthKey
|
||||
from ..helpers import retry_range
|
||||
from .._misc.binaryreader import BinaryReader
|
||||
from .._misc.tlobject import TLRequest
|
||||
from ..types._core import RpcResult, MessageContainer, GzipPacked
|
||||
from .._crypto import AuthKey
|
||||
from .._misc import helpers, utils
|
||||
from .. import _tl
|
||||
|
||||
|
||||
UPDATE_BUFFER_FULL_WARN_DELAY = 15 * 60
|
||||
PING_DELAY = 60
|
||||
|
||||
|
||||
class MTProtoSender:
|
||||
|
@ -41,10 +41,8 @@ class MTProtoSender:
|
|||
A new authorization key will be generated on connection if no other
|
||||
key exists yet.
|
||||
"""
|
||||
def __init__(self, auth_key, *, loggers,
|
||||
retries=5, delay=1, auto_reconnect=True, connect_timeout=None,
|
||||
auth_key_callback=None,
|
||||
update_callback=None, auto_reconnect_callback=None):
|
||||
def __init__(self, *, loggers, updates_queue,
|
||||
retries=5, delay=1, auto_reconnect=True, connect_timeout=None,):
|
||||
self._connection = None
|
||||
self._loggers = loggers
|
||||
self._log = loggers[__name__]
|
||||
|
@ -52,11 +50,10 @@ class MTProtoSender:
|
|||
self._delay = delay
|
||||
self._auto_reconnect = auto_reconnect
|
||||
self._connect_timeout = connect_timeout
|
||||
self._auth_key_callback = auth_key_callback
|
||||
self._update_callback = update_callback
|
||||
self._auto_reconnect_callback = auto_reconnect_callback
|
||||
self._updates_queue = updates_queue
|
||||
self._connect_lock = asyncio.Lock()
|
||||
self._ping = None
|
||||
self._next_ping = None
|
||||
|
||||
# Whether the user has explicitly connected or disconnected.
|
||||
#
|
||||
|
@ -66,15 +63,15 @@ class MTProtoSender:
|
|||
# pending futures should be cancelled.
|
||||
self._user_connected = False
|
||||
self._reconnecting = False
|
||||
self._disconnected = asyncio.get_event_loop().create_future()
|
||||
self._disconnected.set_result(None)
|
||||
self._disconnected = asyncio.Queue(1)
|
||||
self._disconnected.put_nowait(None)
|
||||
|
||||
# We need to join the loops upon disconnection
|
||||
self._send_loop_handle = None
|
||||
self._recv_loop_handle = None
|
||||
|
||||
# Preserving the references of the AuthKey and state is important
|
||||
self.auth_key = auth_key or AuthKey(None)
|
||||
self.auth_key = AuthKey(None)
|
||||
self._state = MTProtoState(self.auth_key, loggers=self._loggers)
|
||||
|
||||
# Outgoing messages are put in a queue and sent in a batch.
|
||||
|
@ -92,24 +89,27 @@ class MTProtoSender:
|
|||
# is received, but we may still need to resend their state on bad salts.
|
||||
self._last_acks = collections.deque(maxlen=10)
|
||||
|
||||
# Last time we warned about the update buffer being full
|
||||
self._last_update_warn = -UPDATE_BUFFER_FULL_WARN_DELAY
|
||||
|
||||
# Jump table from response ID to method that handles it
|
||||
self._handlers = {
|
||||
RpcResult.CONSTRUCTOR_ID: self._handle_rpc_result,
|
||||
MessageContainer.CONSTRUCTOR_ID: self._handle_container,
|
||||
GzipPacked.CONSTRUCTOR_ID: self._handle_gzip_packed,
|
||||
Pong.CONSTRUCTOR_ID: self._handle_pong,
|
||||
BadServerSalt.CONSTRUCTOR_ID: self._handle_bad_server_salt,
|
||||
BadMsgNotification.CONSTRUCTOR_ID: self._handle_bad_notification,
|
||||
MsgDetailedInfo.CONSTRUCTOR_ID: self._handle_detailed_info,
|
||||
MsgNewDetailedInfo.CONSTRUCTOR_ID: self._handle_new_detailed_info,
|
||||
NewSessionCreated.CONSTRUCTOR_ID: self._handle_new_session_created,
|
||||
MsgsAck.CONSTRUCTOR_ID: self._handle_ack,
|
||||
FutureSalts.CONSTRUCTOR_ID: self._handle_future_salts,
|
||||
MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
|
||||
MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
|
||||
MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all,
|
||||
DestroySessionOk: self._handle_destroy_session,
|
||||
DestroySessionNone: self._handle_destroy_session,
|
||||
_tl.Pong.CONSTRUCTOR_ID: self._handle_pong,
|
||||
_tl.BadServerSalt.CONSTRUCTOR_ID: self._handle_bad_server_salt,
|
||||
_tl.BadMsgNotification.CONSTRUCTOR_ID: self._handle_bad_notification,
|
||||
_tl.MsgDetailedInfo.CONSTRUCTOR_ID: self._handle_detailed_info,
|
||||
_tl.MsgNewDetailedInfo.CONSTRUCTOR_ID: self._handle_new_detailed_info,
|
||||
_tl.NewSessionCreated.CONSTRUCTOR_ID: self._handle_new_session_created,
|
||||
_tl.MsgsAck.CONSTRUCTOR_ID: self._handle_ack,
|
||||
_tl.FutureSalts.CONSTRUCTOR_ID: self._handle_future_salts,
|
||||
_tl.MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
|
||||
_tl.MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
|
||||
_tl.MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all,
|
||||
_tl.DestroySessionOk: self._handle_destroy_session,
|
||||
_tl.DestroySessionNone: self._handle_destroy_session,
|
||||
}
|
||||
|
||||
# Public API
|
||||
|
@ -126,6 +126,7 @@ class MTProtoSender:
|
|||
self._connection = connection
|
||||
await self._connect()
|
||||
self._user_connected = True
|
||||
self._next_ping = asyncio.get_running_loop().time() + PING_DELAY
|
||||
return True
|
||||
|
||||
def is_connected(self):
|
||||
|
@ -199,16 +200,14 @@ class MTProtoSender:
|
|||
self._send_queue.extend(states)
|
||||
return futures
|
||||
|
||||
@property
|
||||
def disconnected(self):
|
||||
async def wait_disconnected(self):
|
||||
"""
|
||||
Future that resolves when the connection to Telegram
|
||||
ends, either by user action or in the background.
|
||||
|
||||
Note that it may resolve in either a ``ConnectionError``
|
||||
or any other unexpected error that could not be handled.
|
||||
Wait until the client is disconnected.
|
||||
Raise if the disconnection finished with error.
|
||||
"""
|
||||
return asyncio.shield(self._disconnected)
|
||||
res = await self._disconnected.get()
|
||||
if isinstance(res, BaseException):
|
||||
raise res
|
||||
|
||||
# Private methods
|
||||
|
||||
|
@ -222,7 +221,7 @@ class MTProtoSender:
|
|||
|
||||
connected = False
|
||||
|
||||
for attempt in retry_range(self._retries):
|
||||
for attempt in helpers.retry_range(self._retries):
|
||||
if not connected:
|
||||
connected = await self._try_connect(attempt)
|
||||
if not connected:
|
||||
|
@ -250,24 +249,23 @@ class MTProtoSender:
|
|||
break # all steps done, break retry loop
|
||||
else:
|
||||
if not connected:
|
||||
raise ConnectionError('Connection to Telegram failed {} time(s)'.format(self._retries))
|
||||
raise ConnectionError('Connection to Telegram failed {} time(s)'.format(1 + self._retries))
|
||||
|
||||
e = ConnectionError('auth_key generation failed {} time(s)'.format(self._retries))
|
||||
e = ConnectionError('auth_key generation failed {} time(s)'.format(1 + self._retries))
|
||||
await self._disconnect(error=e)
|
||||
raise e
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
self._log.debug('Starting send loop')
|
||||
self._send_loop_handle = loop.create_task(self._send_loop())
|
||||
self._send_loop_handle = asyncio.create_task(self._send_loop())
|
||||
|
||||
self._log.debug('Starting receive loop')
|
||||
self._recv_loop_handle = loop.create_task(self._recv_loop())
|
||||
self._recv_loop_handle = asyncio.create_task(self._recv_loop())
|
||||
|
||||
# _disconnected only completes after manual disconnection
|
||||
# or errors after which the sender cannot continue such
|
||||
# as failing to reconnect or any unexpected error.
|
||||
if self._disconnected.done():
|
||||
self._disconnected = loop.create_future()
|
||||
while not self._disconnected.empty():
|
||||
self._disconnected.get_nowait()
|
||||
|
||||
self._log.info('Connection to %s complete!', self._connection)
|
||||
|
||||
|
@ -290,13 +288,6 @@ class MTProtoSender:
|
|||
self.auth_key.key, self._state.time_offset = \
|
||||
await authenticator.do_authentication(plain)
|
||||
|
||||
# This is *EXTREMELY* important since we don't control
|
||||
# external references to the authorization key, we must
|
||||
# notify whenever we change it. This is crucial when we
|
||||
# switch to different data centers.
|
||||
if self._auth_key_callback:
|
||||
self._auth_key_callback(self.auth_key)
|
||||
|
||||
self._log.debug('auth_key generation success!')
|
||||
return True
|
||||
except (SecurityError, AssertionError) as e:
|
||||
|
@ -332,11 +323,8 @@ class MTProtoSender:
|
|||
self._log.info('Disconnection from %s complete!', self._connection)
|
||||
self._connection = None
|
||||
|
||||
if self._disconnected and not self._disconnected.done():
|
||||
if error:
|
||||
self._disconnected.set_exception(error)
|
||||
else:
|
||||
self._disconnected.set_result(None)
|
||||
if not self._disconnected.full():
|
||||
self._disconnected.put_nowait(error)
|
||||
|
||||
async def _reconnect(self, last_error):
|
||||
"""
|
||||
|
@ -361,12 +349,11 @@ class MTProtoSender:
|
|||
# Start with a clean state (and thus session ID) to avoid old msgs
|
||||
self._state.reset()
|
||||
|
||||
retries = self._retries if self._auto_reconnect else 0
|
||||
retry_range = helpers.retry_range(self._retries) if self._auto_reconnect else range(0)
|
||||
|
||||
attempt = 0
|
||||
ok = True
|
||||
# We're already "retrying" to connect, so we don't want to force retries
|
||||
for attempt in retry_range(retries, force_retry=False):
|
||||
for attempt in retry_range:
|
||||
try:
|
||||
await self._connect()
|
||||
except (IOError, asyncio.TimeoutError) as e:
|
||||
|
@ -379,8 +366,6 @@ class MTProtoSender:
|
|||
if isinstance(e, InvalidBufferError) and e.code == 404:
|
||||
self._log.info('Broken authorization key; resetting')
|
||||
self.auth_key.key = None
|
||||
if self._auth_key_callback:
|
||||
self._auth_key_callback(None)
|
||||
|
||||
ok = False
|
||||
break
|
||||
|
@ -396,10 +381,6 @@ class MTProtoSender:
|
|||
else:
|
||||
self._send_queue.extend(self._pending_state.values())
|
||||
self._pending_state.clear()
|
||||
|
||||
if self._auto_reconnect_callback:
|
||||
asyncio.get_event_loop().create_task(self._auto_reconnect_callback())
|
||||
|
||||
break
|
||||
else:
|
||||
ok = False
|
||||
|
@ -423,17 +404,17 @@ class MTProtoSender:
|
|||
# gets stuck.
|
||||
# TODO It still gets stuck? Investigate where and why.
|
||||
self._reconnecting = True
|
||||
asyncio.get_event_loop().create_task(self._reconnect(error))
|
||||
asyncio.create_task(self._reconnect(error))
|
||||
|
||||
def _keepalive_ping(self, rnd_id):
|
||||
def _trigger_keepalive_ping(self):
|
||||
"""
|
||||
Send a keep-alive ping. If a pong for the last ping was not received
|
||||
yet, this means we're probably not connected.
|
||||
"""
|
||||
# TODO this is ugly, update loop shouldn't worry about this, sender should
|
||||
if self._ping is None:
|
||||
self._ping = rnd_id
|
||||
self.send(PingRequest(rnd_id))
|
||||
self._ping = random.randrange(-2**63, 2**63)
|
||||
self.send(_tl.fn.Ping(self._ping))
|
||||
self._next_ping = asyncio.get_running_loop().time() + PING_DELAY
|
||||
else:
|
||||
self._start_reconnect(None)
|
||||
|
||||
|
@ -448,7 +429,7 @@ class MTProtoSender:
|
|||
"""
|
||||
while self._user_connected and not self._reconnecting:
|
||||
if self._pending_ack:
|
||||
ack = RequestState(MsgsAck(list(self._pending_ack)))
|
||||
ack = RequestState(_tl.MsgsAck(list(self._pending_ack)))
|
||||
self._send_queue.append(ack)
|
||||
self._last_acks.append(ack)
|
||||
self._pending_ack.clear()
|
||||
|
@ -457,7 +438,11 @@ class MTProtoSender:
|
|||
# TODO Wait for the connection send queue to be empty?
|
||||
# This means that while it's not empty we can wait for
|
||||
# more messages to be added to the send queue.
|
||||
batch, data = await self._send_queue.get()
|
||||
try:
|
||||
batch, data = await asyncio.wait_for(self._send_queue.get(), self._next_ping - asyncio.get_running_loop().time())
|
||||
except asyncio.TimeoutError:
|
||||
self._trigger_keepalive_ping()
|
||||
continue
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
@ -523,8 +508,6 @@ class MTProtoSender:
|
|||
if isinstance(e, InvalidBufferError) and e.code == 404:
|
||||
self._log.info('Broken authorization key; resetting')
|
||||
self.auth_key.key = None
|
||||
if self._auth_key_callback:
|
||||
self._auth_key_callback(None)
|
||||
|
||||
await self._disconnect(error=e)
|
||||
else:
|
||||
|
@ -598,19 +581,26 @@ class MTProtoSender:
|
|||
# which contain the real response right after.
|
||||
try:
|
||||
with BinaryReader(rpc_result.body) as reader:
|
||||
if not isinstance(reader.tgread_object(), upload.File):
|
||||
if not isinstance(reader.tgread_object(), _tl.upload.File):
|
||||
raise ValueError('Not an upload.File')
|
||||
except (TypeNotFoundError, ValueError):
|
||||
self._log.info('Received response without parent request: %s', rpc_result.body)
|
||||
return
|
||||
|
||||
if rpc_result.error:
|
||||
error = rpc_message_to_error(rpc_result.error, state.request)
|
||||
self._send_queue.append(
|
||||
RequestState(MsgsAck([state.msg_id])))
|
||||
RequestState(_tl.MsgsAck([state.msg_id])))
|
||||
|
||||
if not state.future.cancelled():
|
||||
state.future.set_exception(error)
|
||||
err_ty = _mk_error_type(
|
||||
name=rpc_result.error.error_message,
|
||||
code=rpc_result.error.error_code,
|
||||
)
|
||||
state.future.set_exception(err_ty(
|
||||
rpc_result.error.error_code,
|
||||
rpc_result.error.error_message,
|
||||
state.request
|
||||
))
|
||||
else:
|
||||
try:
|
||||
with BinaryReader(rpc_result.body) as reader:
|
||||
|
@ -620,6 +610,7 @@ class MTProtoSender:
|
|||
if not state.future.cancelled():
|
||||
state.future.set_exception(e)
|
||||
else:
|
||||
self._store_own_updates(result)
|
||||
if not state.future.cancelled():
|
||||
state.future.set_result(result)
|
||||
|
||||
|
@ -641,7 +632,15 @@ class MTProtoSender:
|
|||
"""
|
||||
self._log.debug('Handling gzipped data')
|
||||
with BinaryReader(message.obj.data) as reader:
|
||||
try:
|
||||
message.obj = reader.tgread_object()
|
||||
except TypeNotFoundError as e:
|
||||
# Received object which we don't know how to deserialize.
|
||||
# This is somewhat expected while receiving updates, which
|
||||
# will eventually trigger a gap error to recover from.
|
||||
self._log.info('Type %08x not found, remaining data %r',
|
||||
e.invalid_constructor_id, e.remaining)
|
||||
else:
|
||||
await self._process_message(message)
|
||||
|
||||
async def _handle_update(self, message):
|
||||
|
@ -652,8 +651,30 @@ class MTProtoSender:
|
|||
return
|
||||
|
||||
self._log.debug('Handling update %s', message.obj.__class__.__name__)
|
||||
if self._update_callback:
|
||||
self._update_callback(message.obj)
|
||||
try:
|
||||
self._updates_queue.put_nowait(message.obj)
|
||||
except asyncio.QueueFull:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now - self._last_update_warn >= 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:
|
|
@ -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('<qii', msg_id, seq_no, len(body)))
|
||||
buffer.write(body)
|
4
telethon/_network/transports/__init__.py
Normal file
4
telethon/_network/transports/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .transport import Transport
|
||||
from .abridged import Abridged
|
||||
from .full import Full
|
||||
from .intermediate import Intermediate
|
43
telethon/_network/transports/abridged.py
Normal file
43
telethon/_network/transports/abridged.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
from .transport import Transport
|
||||
import struct
|
||||
|
||||
|
||||
class Abridged(Transport):
|
||||
def __init__(self):
|
||||
self._init = False
|
||||
|
||||
def recreate_fresh(self):
|
||||
return type(self)()
|
||||
|
||||
def pack(self, input: bytes) -> 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('<i', input[1:4] + b'\0')[0]
|
||||
|
||||
length = (length << 2) + offset
|
||||
|
||||
if len(input) < length:
|
||||
raise EOFError()
|
||||
|
||||
return length, input[offset:length]
|
41
telethon/_network/transports/full.py
Normal file
41
telethon/_network/transports/full.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from .transport import Transport
|
||||
import struct
|
||||
from zlib import crc32
|
||||
|
||||
|
||||
class Full(Transport):
|
||||
def __init__(self):
|
||||
self._send_counter = 0
|
||||
self._recv_counter = 0
|
||||
|
||||
def recreate_fresh(self):
|
||||
return type(self)()
|
||||
|
||||
def pack(self, input: bytes) -> bytes:
|
||||
# https://core.telegram.org/mtproto#tcp-transport
|
||||
length = len(input) + 12
|
||||
data = struct.pack('<ii', length, self._send_counter) + input
|
||||
crc = struct.pack('<I', crc32(data))
|
||||
self._send_counter += 1
|
||||
return data + crc
|
||||
|
||||
def unpack(self, input: bytes) -> (int, bytes):
|
||||
if len(input) < 12:
|
||||
raise EOFError()
|
||||
|
||||
length, seq = struct.unpack('<ii', input[:8])
|
||||
if len(input) < length:
|
||||
raise EOFError()
|
||||
|
||||
if seq != self._recv_counter:
|
||||
raise ValueError(f'expected sequence value {self._recv_counter!r}, got {seq!r}')
|
||||
|
||||
body = input[8:length - 4]
|
||||
checksum = struct.unpack('<I', input[length - 4:length])[0]
|
||||
|
||||
valid_checksum = crc32(input[:length - 4])
|
||||
if checksum != valid_checksum:
|
||||
raise InvalidChecksumError(checksum, valid_checksum)
|
||||
|
||||
self._recv_counter += 1
|
||||
return length, body
|
29
telethon/_network/transports/intermediate.py
Normal file
29
telethon/_network/transports/intermediate.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from .transport import Transport
|
||||
import struct
|
||||
|
||||
|
||||
class Intermediate(Transport):
|
||||
def __init__(self):
|
||||
self._init = False
|
||||
|
||||
def recreate_fresh(self):
|
||||
return type(self)()
|
||||
|
||||
def pack(self, input: bytes) -> bytes:
|
||||
if self._init:
|
||||
header = b''
|
||||
else:
|
||||
header = b'\xee\xee\xee\xee'
|
||||
self._init = True
|
||||
|
||||
return header + struct.pack('<i', len(data)) + data
|
||||
|
||||
def unpack(self, input: bytes) -> (int, bytes):
|
||||
if len(input) < 4:
|
||||
raise EOFError()
|
||||
|
||||
length = struct.unpack('<i', input[:4])[0] + 4
|
||||
if len(input) < length:
|
||||
raise EOFError()
|
||||
|
||||
return length, input[4:length]
|
17
telethon/_network/transports/transport.py
Normal file
17
telethon/_network/transports/transport.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
import abc
|
||||
|
||||
|
||||
class Transport(abc.ABC):
|
||||
# Should return a newly-created instance of itself
|
||||
@abc.abstractmethod
|
||||
def recreate_fresh(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def pack(self, input: bytes) -> bytes:
|
||||
pass
|
||||
|
||||
# Should raise EOFError if it does not have enough bytes
|
||||
@abc.abstractmethod
|
||||
def unpack(self, input: bytes) -> (int, bytes):
|
||||
pass
|
92
telethon/_sessions/abstract.py
Normal file
92
telethon/_sessions/abstract.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
from .types import DataCenter, ChannelState, SessionState, EntityType, Entity
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Session(ABC):
|
||||
@abstractmethod
|
||||
async def insert_dc(self, dc: DataCenter):
|
||||
"""
|
||||
Store a new or update an existing `DataCenter` with matching ``id``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_dc(self) -> List[DataCenter]:
|
||||
"""
|
||||
Get a list of all currently-stored `DataCenter`. Should not contain duplicate ``id``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_state(self, state: SessionState):
|
||||
"""
|
||||
Set the state about the current session.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_state(self) -> Optional[SessionState]:
|
||||
"""
|
||||
Get the state about the current session.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def insert_channel_state(self, state: ChannelState):
|
||||
"""
|
||||
Store a new or update an existing `ChannelState` with matching ``id``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_channel_states(self) -> List[ChannelState]:
|
||||
"""
|
||||
Get a list of all currently-stored `ChannelState`. Should not contain duplicate ``id``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def insert_entities(self, entities: List[Entity]):
|
||||
"""
|
||||
Store new or update existing `Entity` with matching ``id``.
|
||||
|
||||
Entities should be saved on a best-effort. It is okay to not save them, although the
|
||||
library may need to do extra work if a previously-saved entity is missing, or even be
|
||||
unable to continue without the entity.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_entity(self, ty: Optional[EntityType], id: int) -> Optional[Entity]:
|
||||
"""
|
||||
Get the `Entity` with matching ``ty`` and ``id``.
|
||||
|
||||
The following groups of ``ty`` should be treated to be equivalent, that is, for a given
|
||||
``ty`` and ``id``, if the ``ty`` is in a given group, a matching ``access_hash`` with
|
||||
that ``id`` from within any ``ty`` in that group should be returned.
|
||||
|
||||
* `EntityType.USER` and `EntityType.BOT`.
|
||||
* `EntityType.GROUP`.
|
||||
* `EntityType.CHANNEL`, `EntityType.MEGAGROUP` and `EntityType.GIGAGROUP`.
|
||||
|
||||
For example, if a ``ty`` representing a bot is stored but the asking ``ty`` is a user,
|
||||
the corresponding ``access_hash`` should still be returned.
|
||||
|
||||
You may use ``EntityType.canonical`` to find out the canonical type.
|
||||
|
||||
A ``ty`` with the value of ``None`` should be treated as "any entity with matching ID".
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def save(self):
|
||||
"""
|
||||
Save the session.
|
||||
|
||||
May do nothing if the other methods already saved when they were called.
|
||||
|
||||
May return custom data when manual saving is intended.
|
||||
"""
|
||||
raise NotImplementedError
|
47
telethon/_sessions/memory.py
Normal file
47
telethon/_sessions/memory.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from .types import DataCenter, ChannelState, SessionState, Entity
|
||||
from .abstract import Session
|
||||
from .._misc import utils, tlobject
|
||||
from .. import _tl
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class MemorySession(Session):
|
||||
__slots__ = ('dcs', 'state', 'channel_states', 'entities')
|
||||
|
||||
def __init__(self):
|
||||
self.dcs = {}
|
||||
self.state = None
|
||||
self.channel_states = {}
|
||||
self.entities = {}
|
||||
|
||||
async def insert_dc(self, dc: DataCenter):
|
||||
self.dcs[dc.id] = dc
|
||||
|
||||
async def get_all_dc(self) -> List[DataCenter]:
|
||||
return list(self.dcs.values())
|
||||
|
||||
async def set_state(self, state: SessionState):
|
||||
self.state = state
|
||||
|
||||
async def get_state(self) -> Optional[SessionState]:
|
||||
return self.state
|
||||
|
||||
async def insert_channel_state(self, state: ChannelState):
|
||||
self.channel_states[state.channel_id] = state
|
||||
|
||||
async def get_all_channel_states(self) -> List[ChannelState]:
|
||||
return list(self.channel_states.values())
|
||||
|
||||
async def insert_entities(self, entities: List[Entity]):
|
||||
self.entities.update((e.id, (e.ty, e.access_hash)) for e in entities)
|
||||
|
||||
async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]:
|
||||
try:
|
||||
ty, access_hash = self.entities[id]
|
||||
return Entity(ty, id, access_hash)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
async def save(self):
|
||||
pass
|
284
telethon/_sessions/sqlite.py
Normal file
284
telethon/_sessions/sqlite.py
Normal file
|
@ -0,0 +1,284 @@
|
|||
import datetime
|
||||
import os
|
||||
import time
|
||||
import ipaddress
|
||||
from typing import Optional, List
|
||||
|
||||
from .abstract import Session
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from .types import DataCenter, ChannelState, SessionState, Entity
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
sqlite3_err = None
|
||||
except ImportError as e:
|
||||
sqlite3 = None
|
||||
sqlite3_err = type(e)
|
||||
|
||||
EXTENSION = '.session'
|
||||
CURRENT_VERSION = 8 # database version
|
||||
|
||||
|
||||
class SQLiteSession(Session):
|
||||
"""
|
||||
This session contains the required information to login into your
|
||||
Telegram account. NEVER give the saved session file to anyone, since
|
||||
they would gain instant access to all your messages and contacts.
|
||||
|
||||
If you think the session has been compromised, close all the sessions
|
||||
through an official Telegram client to revoke the authorization.
|
||||
"""
|
||||
|
||||
def __init__(self, session_id=None):
|
||||
if sqlite3 is None:
|
||||
raise sqlite3_err
|
||||
|
||||
super().__init__()
|
||||
self.filename = ':memory:'
|
||||
self.save_entities = True
|
||||
|
||||
if session_id:
|
||||
self.filename = session_id
|
||||
if not self.filename.endswith(EXTENSION):
|
||||
self.filename += EXTENSION
|
||||
|
||||
self._conn = None
|
||||
c = self._cursor()
|
||||
c.execute("select name from sqlite_master "
|
||||
"where type='table' and name='version'")
|
||||
if c.fetchone():
|
||||
# Tables already exist, check for the version
|
||||
c.execute("select version from version")
|
||||
version = c.fetchone()[0]
|
||||
if version < CURRENT_VERSION:
|
||||
self._upgrade_database(old=version)
|
||||
c.execute("delete from version")
|
||||
c.execute("insert into version values (?)", (CURRENT_VERSION,))
|
||||
self._conn.commit()
|
||||
else:
|
||||
# Tables don't exist, create new ones
|
||||
self._create_table(c, 'version (version integer primary key)')
|
||||
self._mk_tables(c)
|
||||
c.execute("insert into version values (?)", (CURRENT_VERSION,))
|
||||
self._conn.commit()
|
||||
|
||||
# Must have committed or else the version will not have been updated while new tables
|
||||
# exist, leading to a half-upgraded state.
|
||||
c.close()
|
||||
|
||||
def _upgrade_database(self, old):
|
||||
c = self._cursor()
|
||||
if old == 1:
|
||||
old += 1
|
||||
# old == 1 doesn't have the old sent_files so no need to drop
|
||||
if old == 2:
|
||||
old += 1
|
||||
# Old cache from old sent_files lasts then a day anyway, drop
|
||||
c.execute('drop table sent_files')
|
||||
self._create_table(c, """sent_files (
|
||||
md5_digest blob,
|
||||
file_size integer,
|
||||
type integer,
|
||||
id integer,
|
||||
hash integer,
|
||||
primary key(md5_digest, file_size, type)
|
||||
)""")
|
||||
if old == 3:
|
||||
old += 1
|
||||
self._create_table(c, """update_state (
|
||||
id integer primary key,
|
||||
pts integer,
|
||||
qts integer,
|
||||
date integer,
|
||||
seq integer
|
||||
)""")
|
||||
if old == 4:
|
||||
old += 1
|
||||
c.execute("alter table sessions add column takeout_id integer")
|
||||
if old == 5:
|
||||
# Not really any schema upgrade, but potentially all access
|
||||
# hashes for User and Channel are wrong, so drop them off.
|
||||
old += 1
|
||||
c.execute('delete from entities')
|
||||
if old == 6:
|
||||
old += 1
|
||||
c.execute("alter table entities add column date integer")
|
||||
if old == 7:
|
||||
self._mk_tables(c)
|
||||
c.execute('''
|
||||
insert into datacenter (id, ipv4, ipv6, port, auth)
|
||||
select dc_id, server_address, server_address, port, auth_key
|
||||
from sessions
|
||||
''')
|
||||
c.execute('''
|
||||
insert into session (user_id, dc_id, bot, pts, qts, date, seq, takeout_id)
|
||||
select
|
||||
0,
|
||||
s.dc_id,
|
||||
0,
|
||||
coalesce(u.pts, 0),
|
||||
coalesce(u.qts, 0),
|
||||
coalesce(u.date, 0),
|
||||
coalesce(u.seq, 0),
|
||||
s.takeout_id
|
||||
from sessions s
|
||||
left join update_state u on u.id = 0
|
||||
limit 1
|
||||
''')
|
||||
c.execute('''
|
||||
insert into entity (id, access_hash, ty)
|
||||
select
|
||||
case
|
||||
when id < -1000000000000 then -(id + 1000000000000)
|
||||
when id < 0 then -id
|
||||
else id
|
||||
end,
|
||||
hash,
|
||||
case
|
||||
when id < -1000000000000 then 67
|
||||
when id < 0 then 71
|
||||
else 85
|
||||
end
|
||||
from entities
|
||||
''')
|
||||
c.execute('drop table sessions')
|
||||
c.execute('drop table entities')
|
||||
c.execute('drop table sent_files')
|
||||
c.execute('drop table update_state')
|
||||
|
||||
def _mk_tables(self, c):
|
||||
self._create_table(
|
||||
c,
|
||||
'''datacenter (
|
||||
id integer primary key,
|
||||
ipv4 text not null,
|
||||
ipv6 text,
|
||||
port integer not null,
|
||||
auth blob not null
|
||||
)''',
|
||||
'''session (
|
||||
user_id integer primary key,
|
||||
dc_id integer not null,
|
||||
bot integer not null,
|
||||
pts integer not null,
|
||||
qts integer not null,
|
||||
date integer not null,
|
||||
seq integer not null,
|
||||
takeout_id integer
|
||||
)''',
|
||||
'''channel (
|
||||
channel_id integer primary key,
|
||||
pts integer not null
|
||||
)''',
|
||||
'''entity (
|
||||
id integer primary key,
|
||||
access_hash integer not null,
|
||||
ty integer not null
|
||||
)''',
|
||||
)
|
||||
|
||||
async def insert_dc(self, dc: DataCenter):
|
||||
self._execute(
|
||||
'insert or replace into datacenter values (?,?,?,?,?)',
|
||||
dc.id,
|
||||
str(ipaddress.ip_address(dc.ipv4)),
|
||||
str(ipaddress.ip_address(dc.ipv6)) if dc.ipv6 else None,
|
||||
dc.port,
|
||||
dc.auth
|
||||
)
|
||||
|
||||
async def get_all_dc(self) -> List[DataCenter]:
|
||||
c = self._cursor()
|
||||
res = []
|
||||
for (id, ipv4, ipv6, port, auth) in c.execute('select * from datacenter'):
|
||||
res.append(DataCenter(
|
||||
id=id,
|
||||
ipv4=int(ipaddress.ip_address(ipv4)),
|
||||
ipv6=int(ipaddress.ip_address(ipv6)) if ipv6 else None,
|
||||
port=port,
|
||||
auth=auth,
|
||||
))
|
||||
return res
|
||||
|
||||
async def set_state(self, state: SessionState):
|
||||
c = self._cursor()
|
||||
try:
|
||||
self._execute('delete from session')
|
||||
self._execute(
|
||||
'insert into session values (?,?,?,?,?,?,?,?)',
|
||||
state.user_id,
|
||||
state.dc_id,
|
||||
int(state.bot),
|
||||
state.pts,
|
||||
state.qts,
|
||||
state.date,
|
||||
state.seq,
|
||||
state.takeout_id,
|
||||
)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
async def get_state(self) -> Optional[SessionState]:
|
||||
row = self._execute('select * from session')
|
||||
return SessionState(*row) if row else None
|
||||
|
||||
async def insert_channel_state(self, state: ChannelState):
|
||||
self._execute(
|
||||
'insert or replace into channel values (?,?)',
|
||||
state.channel_id,
|
||||
state.pts,
|
||||
)
|
||||
|
||||
async def get_all_channel_states(self) -> List[ChannelState]:
|
||||
c = self._cursor()
|
||||
try:
|
||||
return [
|
||||
ChannelState(*row)
|
||||
for row in c.execute('select * from channel')
|
||||
]
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
async def insert_entities(self, entities: List[Entity]):
|
||||
c = self._cursor()
|
||||
try:
|
||||
c.executemany(
|
||||
'insert or replace into entity values (?,?,?)',
|
||||
[(e.id, e.access_hash, e.ty.value) for e in entities]
|
||||
)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]:
|
||||
row = self._execute('select ty, id, access_hash from entity where id = ?', id)
|
||||
return Entity(*row) if row else None
|
||||
|
||||
async def save(self):
|
||||
# This is a no-op if there are no changes to commit, so there's
|
||||
# no need for us to keep track of an "unsaved changes" variable.
|
||||
if self._conn is not None:
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _create_table(c, *definitions):
|
||||
for definition in definitions:
|
||||
c.execute('create table {}'.format(definition))
|
||||
|
||||
def _cursor(self):
|
||||
"""Asserts that the connection is open and returns a cursor"""
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(self.filename,
|
||||
check_same_thread=False)
|
||||
return self._conn.cursor()
|
||||
|
||||
def _execute(self, stmt, *values):
|
||||
"""
|
||||
Gets a cursor, executes `stmt` and closes the cursor,
|
||||
fetching one row afterwards and returning its result.
|
||||
"""
|
||||
c = self._cursor()
|
||||
try:
|
||||
return c.execute(stmt, values).fetchone()
|
||||
finally:
|
||||
c.close()
|
|
@ -4,7 +4,7 @@ import struct
|
|||
|
||||
from .abstract import Session
|
||||
from .memory import MemorySession
|
||||
from ..crypto import AuthKey
|
||||
from .types import DataCenter, ChannelState, SessionState, Entity
|
||||
|
||||
_STRUCT_PREFORMAT = '>B{}sH256s'
|
||||
|
||||
|
@ -34,12 +34,33 @@ class StringSession(MemorySession):
|
|||
|
||||
string = string[1:]
|
||||
ip_len = 4 if len(string) == 352 else 16
|
||||
self._dc_id, ip, self._port, key = struct.unpack(
|
||||
dc_id, ip, port, key = struct.unpack(
|
||||
_STRUCT_PREFORMAT.format(ip_len), StringSession.decode(string))
|
||||
|
||||
self._server_address = ipaddress.ip_address(ip).compressed
|
||||
if any(key):
|
||||
self._auth_key = AuthKey(key)
|
||||
self.state = SessionState(
|
||||
dc_id=dc_id,
|
||||
user_id=0,
|
||||
bot=False,
|
||||
pts=0,
|
||||
qts=0,
|
||||
date=0,
|
||||
seq=0,
|
||||
takeout_id=0
|
||||
)
|
||||
if ip_len == 4:
|
||||
ipv4 = int.from_bytes(ip, 'big', False)
|
||||
ipv6 = None
|
||||
else:
|
||||
ipv4 = None
|
||||
ipv6 = int.from_bytes(ip, 'big', signed=False)
|
||||
|
||||
self.dcs[dc_id] = DataCenter(
|
||||
id=dc_id,
|
||||
ipv4=ipv4,
|
||||
ipv6=ipv6,
|
||||
port=port,
|
||||
auth=key
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode(x: bytes) -> str:
|
||||
|
@ -50,14 +71,18 @@ class StringSession(MemorySession):
|
|||
return base64.urlsafe_b64decode(x)
|
||||
|
||||
def save(self: Session):
|
||||
if not self.auth_key:
|
||||
if not self.state:
|
||||
return ''
|
||||
|
||||
ip = ipaddress.ip_address(self.server_address).packed
|
||||
if self.state.ipv6 is not None:
|
||||
ip = self.state.ipv6.to_bytes(16, 'big', signed=False)
|
||||
else:
|
||||
ip = self.state.ipv6.to_bytes(4, 'big', signed=False)
|
||||
|
||||
return CURRENT_VERSION + StringSession.encode(struct.pack(
|
||||
_STRUCT_PREFORMAT.format(len(ip)),
|
||||
self.dc_id,
|
||||
self.state.dc_id,
|
||||
ip,
|
||||
self.port,
|
||||
self.auth_key.key
|
||||
self.state.port,
|
||||
self.dcs[self.state.dc_id].auth
|
||||
))
|
116
telethon/_sessions/types.py
Normal file
116
telethon/_sessions/types.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
from typing import Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataCenter:
|
||||
"""
|
||||
Stores the information needed to connect to a datacenter.
|
||||
|
||||
* id: 32-bit number representing the datacenter identifier as given by Telegram.
|
||||
* ipv4 and ipv6: 32-bit or 128-bit number storing the IP address of the datacenter.
|
||||
* port: 16-bit number storing the port number needed to connect to the datacenter.
|
||||
* bytes: arbitrary binary payload needed to authenticate to the datacenter.
|
||||
"""
|
||||
__slots__ = ('id', 'ipv4', 'ipv6', 'port', 'auth')
|
||||
|
||||
id: int
|
||||
ipv4: int
|
||||
ipv6: Optional[int]
|
||||
port: int
|
||||
auth: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionState:
|
||||
"""
|
||||
Stores the information needed to fetch updates and about the current user.
|
||||
|
||||
* user_id: 64-bit number representing the user identifier.
|
||||
* dc_id: 32-bit number relating to the datacenter identifier where the user is.
|
||||
* bot: is the logged-in user a bot?
|
||||
* pts: 64-bit number holding the state needed to fetch updates.
|
||||
* qts: alternative 64-bit number holding the state needed to fetch updates.
|
||||
* date: 64-bit number holding the date needed to fetch updates.
|
||||
* seq: 64-bit-number holding the sequence number needed to fetch updates.
|
||||
* takeout_id: 64-bit-number holding the identifier of the current takeout session.
|
||||
|
||||
Note that some of the numbers will only use 32 out of the 64 available bits.
|
||||
However, for future-proofing reasons, we recommend you pretend they are 64-bit long.
|
||||
"""
|
||||
__slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id')
|
||||
|
||||
user_id: int
|
||||
dc_id: int
|
||||
bot: bool
|
||||
pts: int
|
||||
qts: int
|
||||
date: int
|
||||
seq: int
|
||||
takeout_id: Optional[int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelState:
|
||||
"""
|
||||
Stores the information needed to fetch updates from a channel.
|
||||
|
||||
* channel_id: 64-bit number representing the channel identifier.
|
||||
* pts: 64-bit number holding the state needed to fetch updates.
|
||||
"""
|
||||
__slots__ = ('channel_id', 'pts')
|
||||
|
||||
channel_id: int
|
||||
pts: int
|
||||
|
||||
|
||||
class EntityType(IntEnum):
|
||||
"""
|
||||
You can rely on the type value to be equal to the ASCII character one of:
|
||||
|
||||
* 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``.
|
||||
* 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``.
|
||||
* 'G' (71): this entity belongs to a small group :tl:`Chat`.
|
||||
* 'C' (67): this entity belongs to a standard broadcast :tl:`Channel`.
|
||||
* 'M' (77): this entity belongs to a megagroup :tl:`Channel`.
|
||||
* 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`.
|
||||
"""
|
||||
USER = ord('U')
|
||||
BOT = ord('B')
|
||||
GROUP = ord('G')
|
||||
CHANNEL = ord('C')
|
||||
MEGAGROUP = ord('M')
|
||||
GIGAGROUP = ord('E')
|
||||
|
||||
def canonical(self):
|
||||
"""
|
||||
Return the canonical version of this type.
|
||||
"""
|
||||
return _canon_entity_types[self]
|
||||
|
||||
|
||||
_canon_entity_types = {
|
||||
EntityType.USER: EntityType.USER,
|
||||
EntityType.BOT: EntityType.USER,
|
||||
EntityType.GROUP: EntityType.GROUP,
|
||||
EntityType.CHANNEL: EntityType.CHANNEL,
|
||||
EntityType.MEGAGROUP: EntityType.CHANNEL,
|
||||
EntityType.GIGAGROUP: EntityType.CHANNEL,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entity:
|
||||
"""
|
||||
Stores the information needed to use a certain user, chat or channel with the API.
|
||||
|
||||
* ty: 8-bit number indicating the type of the entity.
|
||||
* id: 64-bit number uniquely identifying the entity among those of the same type.
|
||||
* access_hash: 64-bit number needed to use this entity with the API.
|
||||
"""
|
||||
__slots__ = ('ty', 'id', 'access_hash')
|
||||
|
||||
ty: EntityType
|
||||
id: int
|
||||
access_hash: int
|
2
telethon/_updates/__init__.py
Normal file
2
telethon/_updates/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .entitycache import EntityCache, PackedChat
|
||||
from .messagebox import MessageBox
|
103
telethon/_updates/entitycache.py
Normal file
103
telethon/_updates/entitycache.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
import inspect
|
||||
import itertools
|
||||
from dataclasses import dataclass, field
|
||||
from collections import namedtuple
|
||||
|
||||
from .._misc import utils
|
||||
from .. import _tl
|
||||
from .._sessions.types import EntityType, Entity
|
||||
|
||||
|
||||
class PackedChat(namedtuple('PackedChat', 'ty id hash')):
|
||||
__slots__ = ()
|
||||
|
||||
@property
|
||||
def is_user(self):
|
||||
return self.ty in (EntityType.USER, EntityType.BOT)
|
||||
|
||||
@property
|
||||
def is_chat(self):
|
||||
return self.ty in (EntityType.GROUP,)
|
||||
|
||||
@property
|
||||
def is_channel(self):
|
||||
return self.ty in (EntityType.CHANNEL, EntityType.MEGAGROUP, EntityType.GIGAGROUP)
|
||||
|
||||
def to_peer(self):
|
||||
if self.is_user:
|
||||
return _tl.PeerUser(user_id=self.id)
|
||||
elif self.is_chat:
|
||||
return _tl.PeerChat(chat_id=self.id)
|
||||
elif self.is_channel:
|
||||
return _tl.PeerChannel(channel_id=self.id)
|
||||
|
||||
def to_input_peer(self):
|
||||
if self.is_user:
|
||||
return _tl.InputPeerUser(user_id=self.id, access_hash=self.hash)
|
||||
elif self.is_chat:
|
||||
return _tl.InputPeerChat(chat_id=self.id)
|
||||
elif self.is_channel:
|
||||
return _tl.InputPeerChannel(channel_id=self.id, access_hash=self.hash)
|
||||
|
||||
def try_to_input_user(self):
|
||||
if self.is_user:
|
||||
return _tl.InputUser(user_id=self.id, access_hash=self.hash)
|
||||
else:
|
||||
return None
|
||||
|
||||
def try_to_chat_id(self):
|
||||
if self.is_chat:
|
||||
return self.id
|
||||
else:
|
||||
return None
|
||||
|
||||
def try_to_input_channel(self):
|
||||
if self.is_channel:
|
||||
return _tl.InputChannel(channel_id=self.id, access_hash=self.hash)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
return f'{chr(self.ty.value)}.{self.id}.{self.hash}'
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityCache:
|
||||
hash_map: dict = field(default_factory=dict) # id -> (hash, ty)
|
||||
self_id: int = None
|
||||
self_bot: bool = False
|
||||
|
||||
def set_self_user(self, id, bot):
|
||||
self.self_id = id
|
||||
self.self_bot = bot
|
||||
|
||||
def get(self, id):
|
||||
value = self.hash_map.get(id)
|
||||
return PackedChat(ty=value[1], id=id, hash=value[0]) if value else None
|
||||
|
||||
def extend(self, users, chats):
|
||||
# See https://core.telegram.org/api/min for "issues" with "min constructors".
|
||||
self.hash_map.update(
|
||||
(u.id, (
|
||||
u.access_hash,
|
||||
EntityType.BOT if u.bot else EntityType.USER,
|
||||
))
|
||||
for u in users
|
||||
if getattr(u, 'access_hash', None) and not u.min
|
||||
)
|
||||
self.hash_map.update(
|
||||
(c.id, (
|
||||
c.access_hash,
|
||||
EntityType.MEGAGROUP if c.megagroup else (
|
||||
EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL
|
||||
),
|
||||
))
|
||||
for c in chats
|
||||
if getattr(c, 'access_hash', None) and not getattr(c, 'min', None)
|
||||
)
|
||||
|
||||
def get_all_entities(self):
|
||||
return [Entity(ty, id, hash) for id, (hash, ty) in self.hash_map.items()]
|
||||
|
||||
def put(self, entity):
|
||||
self.hash_map[entity.id] = (entity.access_hash, entity.ty)
|
576
telethon/_updates/messagebox.py
Normal file
576
telethon/_updates/messagebox.py
Normal file
|
@ -0,0 +1,576 @@
|
|||
"""
|
||||
This module deals with correct handling of updates, including gaps, and knowing when the code
|
||||
should "get difference" (the set of updates that the client should know by now minus the set
|
||||
of updates that it actually knows).
|
||||
|
||||
Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point").
|
||||
At any given time, the message box may be either getting difference for them (entry is in
|
||||
[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be
|
||||
found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is
|
||||
on its happy path.
|
||||
|
||||
Gaps are cleared when they are either resolved on their own (by waiting for a short time)
|
||||
or because we got the difference for the corresponding entry.
|
||||
|
||||
While there are entries for which their difference must be fetched,
|
||||
[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time
|
||||
to get the difference.
|
||||
"""
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from .._sessions.types import SessionState, ChannelState
|
||||
from .. import _tl
|
||||
|
||||
|
||||
# Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too.
|
||||
NO_SEQ = 0
|
||||
|
||||
# See https://core.telegram.org/method/updates.getChannelDifference.
|
||||
BOT_CHANNEL_DIFF_LIMIT = 100000
|
||||
USER_CHANNEL_DIFF_LIMIT = 100
|
||||
|
||||
# > It may be useful to wait up to 0.5 seconds
|
||||
POSSIBLE_GAP_TIMEOUT = 0.5
|
||||
|
||||
# After how long without updates the client will "timeout".
|
||||
#
|
||||
# When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the
|
||||
# updates that arrive in the meantime. After all updates are fetched when this happens, the
|
||||
# client will resume normal operation, and the timeout will reset.
|
||||
#
|
||||
# Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates).
|
||||
NO_UPDATES_TIMEOUT = 15 * 60
|
||||
|
||||
# Entry "enum".
|
||||
# Account-wide `pts` includes private conversations (one-to-one) and small group chats.
|
||||
ENTRY_ACCOUNT = object()
|
||||
# Account-wide `qts` includes only "secret" one-to-one chats.
|
||||
ENTRY_SECRET = object()
|
||||
# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels.
|
||||
|
||||
|
||||
def next_updates_deadline():
|
||||
return asyncio.get_running_loop().time() + NO_UPDATES_TIMEOUT
|
||||
|
||||
|
||||
class GapError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# Represents the information needed to correctly handle a specific `tl::enums::Update`.
|
||||
@dataclass
|
||||
class PtsInfo:
|
||||
pts: int
|
||||
pts_count: int
|
||||
entry: object
|
||||
|
||||
@classmethod
|
||||
def from_update(cls, update):
|
||||
pts = getattr(update, 'pts', None)
|
||||
if pts:
|
||||
pts_count = getattr(update, 'pts_count', None) or 0
|
||||
try:
|
||||
entry = update.message.peer_id.channel_id
|
||||
except AttributeError:
|
||||
entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT
|
||||
return cls(pts=pts, pts_count=pts_count, entry=entry)
|
||||
|
||||
qts = getattr(update, 'qts', None)
|
||||
if qts:
|
||||
pts_count = 1 if isinstance(update, _tl.UpdateNewEncryptedMessage) else 0
|
||||
return cls(pts=qts, pts_count=pts_count, entry=ENTRY_SECRET)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# The state of a particular entry in the message box.
|
||||
@dataclass
|
||||
class State:
|
||||
# Current local persistent timestamp.
|
||||
pts: int
|
||||
|
||||
# Next instant when we would get the update difference if no updates arrived before then.
|
||||
deadline: float
|
||||
|
||||
|
||||
# > ### Recovering gaps
|
||||
# > […] Manually obtaining updates is also required in the following situations:
|
||||
# > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above).
|
||||
# > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update
|
||||
# > arrives, that fills the gap.
|
||||
#
|
||||
# This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because
|
||||
# the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone).
|
||||
@dataclass
|
||||
class PossibleGap:
|
||||
deadline: float
|
||||
# Pending updates (those with a larger PTS, producing the gap which may later be filled).
|
||||
updates: list # of updates
|
||||
|
||||
|
||||
# Represents a "message box" (event `pts` for a specific entry).
|
||||
#
|
||||
# See https://core.telegram.org/api/updates#message-related-event-sequences.
|
||||
@dataclass
|
||||
class MessageBox:
|
||||
# Map each entry to their current state.
|
||||
map: dict = field(default_factory=dict) # entry -> state
|
||||
|
||||
# Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
|
||||
date: int = 1
|
||||
seq: int = NO_SEQ
|
||||
|
||||
# Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline).
|
||||
next_deadline: object = None # entry
|
||||
|
||||
# Which entries have a gap and may soon trigger a need to get difference.
|
||||
#
|
||||
# If a gap is found, stores the required information to resolve it (when should it timeout and what updates
|
||||
# should be held in case the gap is resolved on its own).
|
||||
#
|
||||
# Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have
|
||||
# a gap in them).
|
||||
possible_gaps: dict = field(default_factory=dict) # entry -> possiblegap
|
||||
|
||||
# For which entries are we currently getting difference.
|
||||
getting_diff_for: set = field(default_factory=set) # entry
|
||||
|
||||
# Temporarily stores which entries should have their update deadline reset.
|
||||
# Stored in the message box in order to reuse the allocation.
|
||||
reset_deadlines_for: set = field(default_factory=set) # entry
|
||||
|
||||
# region Creation, querying, and setting base state.
|
||||
|
||||
def load(self, session_state, channel_states):
|
||||
"""
|
||||
Create a [`MessageBox`] from a previously known update state.
|
||||
"""
|
||||
deadline = next_updates_deadline()
|
||||
|
||||
self.map.clear()
|
||||
if session_state.pts != NO_SEQ:
|
||||
self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline)
|
||||
if session_state.qts != NO_SEQ:
|
||||
self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline)
|
||||
self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states)
|
||||
|
||||
self.date = session_state.date
|
||||
self.seq = session_state.seq
|
||||
self.next_deadline = ENTRY_ACCOUNT
|
||||
|
||||
def session_state(self):
|
||||
"""
|
||||
Return the current state.
|
||||
|
||||
This should be used for persisting the state.
|
||||
"""
|
||||
return dict(
|
||||
pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ,
|
||||
qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
|
||||
date=self.date,
|
||||
seq=self.seq,
|
||||
), {id: state.pts for id, state in self.map.items() if isinstance(id, int)}
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""
|
||||
Return true if the message box is empty and has no state yet.
|
||||
"""
|
||||
return ENTRY_ACCOUNT not in self.map
|
||||
|
||||
def check_deadlines(self):
|
||||
"""
|
||||
Return the next deadline when receiving updates should timeout.
|
||||
|
||||
If a deadline expired, the corresponding entries will be marked as needing to get its difference.
|
||||
While there are entries pending of getting their difference, this method returns the current instant.
|
||||
"""
|
||||
now = asyncio.get_running_loop().time()
|
||||
|
||||
if self.getting_diff_for:
|
||||
return now
|
||||
|
||||
deadline = next_updates_deadline()
|
||||
|
||||
# Most of the time there will be zero or one gap in flight so finding the minimum is cheap.
|
||||
if self.possible_gaps:
|
||||
deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values()))
|
||||
elif self.next_deadline in self.map:
|
||||
deadline = min(deadline, self.map[self.next_deadline].deadline)
|
||||
|
||||
if now > deadline:
|
||||
# Check all expired entries and add them to the list that needs getting difference.
|
||||
self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now > gap.deadline)
|
||||
self.getting_diff_for.update(entry for entry, state in self.map.items() if now > state.deadline)
|
||||
|
||||
# When extending `getting_diff_for`, it's important to have the moral equivalent of
|
||||
# `begin_get_diff` (that is, clear possible gaps if we're now getting difference).
|
||||
for entry in self.getting_diff_for:
|
||||
self.possible_gaps.pop(entry, None)
|
||||
|
||||
return deadline
|
||||
|
||||
# Reset the deadline for the periods without updates for a given entry.
|
||||
#
|
||||
# It also updates the next deadline time to reflect the new closest deadline.
|
||||
def reset_deadline(self, entry, deadline):
|
||||
if entry in self.map:
|
||||
self.map[entry].deadline = deadline
|
||||
# TODO figure out why not in map may happen
|
||||
|
||||
if self.next_deadline == entry:
|
||||
# If the updated deadline was the closest one, recalculate the new minimum.
|
||||
self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0]
|
||||
elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline:
|
||||
# If the updated deadline is smaller than the next deadline, change the next deadline to be the new one.
|
||||
self.next_deadline = entry
|
||||
# else an unrelated deadline was updated, so the closest one remains unchanged.
|
||||
|
||||
# Convenience to reset a channel's deadline, with optional timeout.
|
||||
def reset_channel_deadline(self, channel_id, timeout):
|
||||
self.reset_deadline(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT))
|
||||
|
||||
# Reset all the deadlines in `reset_deadlines_for` and then empty the set.
|
||||
def apply_deadlines_reset(self):
|
||||
next_deadline = next_updates_deadline()
|
||||
|
||||
reset_deadlines_for = self.reset_deadlines_for
|
||||
self.reset_deadlines_for = set() # "move" the set to avoid self.reset_deadline() from touching it during iter
|
||||
|
||||
for entry in reset_deadlines_for:
|
||||
self.reset_deadline(entry, next_deadline)
|
||||
|
||||
reset_deadlines_for.clear() # reuse allocation, the other empty set was a temporary dummy value
|
||||
self.reset_deadlines_for = reset_deadlines_for
|
||||
|
||||
# Sets the update state.
|
||||
#
|
||||
# Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable
|
||||
# updates will be fetched.
|
||||
def set_state(self, state):
|
||||
deadline = next_updates_deadline()
|
||||
|
||||
if state.pts != NO_SEQ:
|
||||
self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline)
|
||||
else:
|
||||
self.map.pop(ENTRY_ACCOUNT, None)
|
||||
|
||||
if state.qts != NO_SEQ:
|
||||
self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline)
|
||||
else:
|
||||
self.map.pop(ENTRY_SECRET, None)
|
||||
|
||||
self.date = state.date
|
||||
self.seq = state.seq
|
||||
|
||||
# Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs.
|
||||
#
|
||||
# The update state will only be updated if no entry was known previously.
|
||||
def try_set_channel_state(self, id, pts):
|
||||
if id not in self.map:
|
||||
self.map[id] = State(pts=pts, deadline=next_updates_deadline())
|
||||
|
||||
# Begin getting difference for the given entry.
|
||||
#
|
||||
# Clears any previous gaps.
|
||||
def begin_get_diff(self, entry):
|
||||
self.getting_diff_for.add(entry)
|
||||
self.possible_gaps.pop(entry, None)
|
||||
|
||||
# Finish getting difference for the given entry.
|
||||
#
|
||||
# It also resets the deadline.
|
||||
def end_get_diff(self, entry):
|
||||
try:
|
||||
self.getting_diff_for.remove(entry)
|
||||
except KeyError:
|
||||
pass
|
||||
self.reset_deadline(entry, next_updates_deadline())
|
||||
assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference"
|
||||
|
||||
# endregion Creation, querying, and setting base state.
|
||||
|
||||
# region "Normal" updates flow (processing and detection of gaps).
|
||||
|
||||
# Process an update and return what should be done with it.
|
||||
#
|
||||
# Updates corresponding to entries for which their difference is currently being fetched
|
||||
# will be ignored. While according to the [updates' documentation]:
|
||||
#
|
||||
# > Implementations [have] to postpone updates received via the socket while
|
||||
# > filling gaps in the event and `Update` sequences, as well as avoid filling
|
||||
# > gaps in the same sequence.
|
||||
#
|
||||
# In practice, these updates should have also been retrieved through getting difference.
|
||||
#
|
||||
# [updates documentation] https://core.telegram.org/api/updates
|
||||
def process_updates(
|
||||
self,
|
||||
updates,
|
||||
chat_hashes,
|
||||
result, # out list of updates; returns list of user, chat, or raise if gap
|
||||
):
|
||||
date = getattr(updates, 'date', None)
|
||||
if date is None:
|
||||
# updatesTooLong is the only one with no date (we treat it as a gap)
|
||||
raise GapError
|
||||
|
||||
seq = getattr(updates, 'seq', None) or NO_SEQ
|
||||
seq_start = getattr(updates, 'seq_start', None) or seq
|
||||
users = getattr(updates, 'users') or []
|
||||
chats = getattr(updates, 'chats') or []
|
||||
updates = getattr(updates, 'updates', None) or [updates]
|
||||
|
||||
# > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors
|
||||
# > there is no need to check `seq` or change a local state.
|
||||
if seq_start != NO_SEQ:
|
||||
if self.seq + 1 > seq_start:
|
||||
# Skipping updates that were already handled
|
||||
return (users, chats)
|
||||
elif self.seq + 1 < seq_start:
|
||||
# Gap detected
|
||||
self.begin_get_diff(ENTRY_ACCOUNT)
|
||||
raise GapError
|
||||
# else apply
|
||||
|
||||
self.date = date
|
||||
if seq != NO_SEQ:
|
||||
self.seq = seq
|
||||
|
||||
result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates)))
|
||||
|
||||
self.apply_deadlines_reset()
|
||||
|
||||
def _sort_gaps(update):
|
||||
pts = PtsInfo.from_update(update)
|
||||
return pts.pts - pts.pts_count if pts else 0
|
||||
|
||||
if self.possible_gaps:
|
||||
# For each update in possible gaps, see if the gap has been resolved already.
|
||||
for key in list(self.possible_gaps.keys()):
|
||||
self.possible_gaps[key].updates.sort(key=_sort_gaps)
|
||||
|
||||
for _ in range(len(self.possible_gaps[key].updates)):
|
||||
update = self.possible_gaps[key].updates.pop(0)
|
||||
|
||||
# If this fails to apply, it will get re-inserted at the end.
|
||||
# All should fail, so the order will be preserved (it would've cycled once).
|
||||
update = self.apply_pts_info(update, reset_deadline=False)
|
||||
if update:
|
||||
result.append(update)
|
||||
|
||||
# Clear now-empty gaps.
|
||||
self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates}
|
||||
|
||||
return (users, chats)
|
||||
|
||||
# Tries to apply the input update if its `PtsInfo` follows the correct order.
|
||||
#
|
||||
# If the update can be applied, it is returned; otherwise, the update is stored in a
|
||||
# possible gap (unless it was already handled or would be handled through getting
|
||||
# difference) and `None` is returned.
|
||||
def apply_pts_info(
|
||||
self,
|
||||
update,
|
||||
*,
|
||||
reset_deadline,
|
||||
):
|
||||
pts = PtsInfo.from_update(update)
|
||||
if not pts:
|
||||
# No pts means that the update can be applied in any order.
|
||||
return update
|
||||
|
||||
# As soon as we receive an update of any form related to messages (has `PtsInfo`),
|
||||
# the "no updates" period for that entry is reset.
|
||||
#
|
||||
# Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry.
|
||||
if reset_deadline:
|
||||
self.reset_deadlines_for.add(pts.entry)
|
||||
|
||||
if pts.entry in self.getting_diff_for:
|
||||
# Note: early returning here also prevents gap from being inserted (which they should
|
||||
# not be while getting difference).
|
||||
return None
|
||||
|
||||
if pts.entry in self.map:
|
||||
local_pts = self.map[pts.entry].pts
|
||||
if local_pts + pts.pts_count > pts.pts:
|
||||
# Ignore
|
||||
return None
|
||||
elif local_pts + pts.pts_count < pts.pts:
|
||||
# Possible gap
|
||||
# TODO store chats too?
|
||||
if pts.entry not in self.possible_gaps:
|
||||
self.possible_gaps[pts.entry] = PossibleGap(
|
||||
deadline=asyncio.get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
|
||||
updates=[]
|
||||
)
|
||||
|
||||
self.possible_gaps[pts.entry].updates.append(update)
|
||||
return None
|
||||
else:
|
||||
# Apply
|
||||
pass
|
||||
else:
|
||||
# No previous `pts` known, and because this update has to be "right" (it's the first one) our
|
||||
# `local_pts` must be one less.
|
||||
local_pts = pts.pts - 1
|
||||
|
||||
# For example, when we're in a channel, we immediately receive:
|
||||
# * ReadChannelInbox (pts = X)
|
||||
# * NewChannelMessage (pts = X, pts_count = 1)
|
||||
#
|
||||
# Notice how both `pts` are the same. If we stored the one from the first, then the second one would
|
||||
# be considered "already handled" and ignored, which is not desirable. Instead, advance local `pts`
|
||||
# by `pts_count` (which is 0 for updates not directly related to messages, like reading inbox).
|
||||
if pts.entry in self.map:
|
||||
self.map[pts.entry].pts = local_pts + pts.pts_count
|
||||
else:
|
||||
self.map[pts.entry] = State(pts=local_pts + pts.pts_count, deadline=next_updates_deadline())
|
||||
|
||||
return update
|
||||
|
||||
# endregion "Normal" updates flow (processing and detection of gaps).
|
||||
|
||||
# region Getting and applying account difference.
|
||||
|
||||
# Return the request that needs to be made to get the difference, if any.
|
||||
def get_difference(self):
|
||||
entry = ENTRY_ACCOUNT
|
||||
if entry in self.getting_diff_for:
|
||||
if entry in self.map:
|
||||
return _tl.fn.updates.GetDifference(
|
||||
pts=self.map[ENTRY_ACCOUNT].pts,
|
||||
pts_total_limit=None,
|
||||
date=self.date,
|
||||
qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
|
||||
)
|
||||
else:
|
||||
# TODO investigate when/why/if this can happen
|
||||
self.end_get_diff(entry)
|
||||
|
||||
return None
|
||||
|
||||
# Similar to [`MessageBox::process_updates`], but using the result from getting difference.
|
||||
def apply_difference(
|
||||
self,
|
||||
diff,
|
||||
chat_hashes,
|
||||
):
|
||||
if isinstance(diff, _tl.updates.DifferenceEmpty):
|
||||
self.date = diff.date
|
||||
self.seq = diff.seq
|
||||
self.end_get_diff(ENTRY_ACCOUNT)
|
||||
return [], [], []
|
||||
elif isinstance(diff, _tl.updates.Difference):
|
||||
self.end_get_diff(ENTRY_ACCOUNT)
|
||||
chat_hashes.extend(diff.users, diff.chats)
|
||||
return self.apply_difference_type(diff)
|
||||
elif isinstance(diff, _tl.updates.DifferenceSlice):
|
||||
chat_hashes.extend(diff.users, diff.chats)
|
||||
return self.apply_difference_type(diff)
|
||||
elif isinstance(diff, _tl.updates.DifferenceTooLong):
|
||||
# TODO when are deadlines reset if we update the map??
|
||||
self.map[ENTRY_ACCOUNT].pts = diff.pts
|
||||
self.end_get_diff(ENTRY_ACCOUNT)
|
||||
return [], [], []
|
||||
|
||||
def apply_difference_type(
|
||||
self,
|
||||
diff,
|
||||
):
|
||||
state = getattr(diff, 'intermediate_state', None) or diff.state
|
||||
self.set_state(state)
|
||||
|
||||
for u in diff.other_updates:
|
||||
if isinstance(u, _tl.UpdateChannelTooLong):
|
||||
self.begin_get_diff(u.channel_id)
|
||||
|
||||
diff.other_updates.extend(_tl.UpdateNewMessage(
|
||||
message=m,
|
||||
pts=NO_SEQ,
|
||||
pts_count=NO_SEQ,
|
||||
) for m in diff.new_messages)
|
||||
diff.other_updates.extend(_tl.UpdateNewEncryptedMessage(
|
||||
message=m,
|
||||
qts=NO_SEQ,
|
||||
) for m in diff.new_encrypted_messages)
|
||||
|
||||
return diff.other_updates, diff.users, diff.chats
|
||||
|
||||
# endregion Getting and applying account difference.
|
||||
|
||||
# region Getting and applying channel difference.
|
||||
|
||||
# Return the request that needs to be made to get a channel's difference, if any.
|
||||
def get_channel_difference(
|
||||
self,
|
||||
chat_hashes,
|
||||
):
|
||||
entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
packed = chat_hashes.get(entry)
|
||||
if not packed:
|
||||
# Cannot get channel difference as we're missing its hash
|
||||
self.end_get_diff(entry)
|
||||
# Remove the outdated `pts` entry from the map so that the next update can correct
|
||||
# it. Otherwise, it will spam that the access hash is missing.
|
||||
self.map.pop(entry, None)
|
||||
return None
|
||||
|
||||
state = self.map.get(entry)
|
||||
if not state:
|
||||
# TODO investigate when/why/if this can happen
|
||||
# Cannot get channel difference as we're missing its pts
|
||||
self.end_get_diff(entry)
|
||||
return None
|
||||
|
||||
return _tl.fn.updates.GetChannelDifference(
|
||||
force=False,
|
||||
channel=packed.try_to_input_channel(),
|
||||
filter=_tl.ChannelMessagesFilterEmpty(),
|
||||
pts=state.pts,
|
||||
limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT
|
||||
)
|
||||
|
||||
# Similar to [`MessageBox::process_updates`], but using the result from getting difference.
|
||||
def apply_channel_difference(
|
||||
self,
|
||||
request,
|
||||
diff,
|
||||
chat_hashes,
|
||||
):
|
||||
entry = request.channel.channel_id
|
||||
self.possible_gaps.pop(entry, None)
|
||||
|
||||
if isinstance(diff, _tl.updates.ChannelDifferenceEmpty):
|
||||
assert diff.final
|
||||
self.end_get_diff(entry)
|
||||
self.map[entry].pts = diff.pts
|
||||
return [], [], []
|
||||
elif isinstance(diff, _tl.updates.ChannelDifferenceTooLong):
|
||||
assert diff.final
|
||||
self.map[entry].pts = diff.dialog.pts
|
||||
chat_hashes.extend(diff.users, diff.chats)
|
||||
self.reset_channel_deadline(entry, diff.timeout)
|
||||
# This `diff` has the "latest messages and corresponding chats", but it would
|
||||
# be strange to give the user only partial changes of these when they would
|
||||
# expect all updates to be fetched. Instead, nothing is returned.
|
||||
return [], [], []
|
||||
elif isinstance(diff, _tl.updates.ChannelDifference):
|
||||
if diff.final:
|
||||
self.end_get_diff(entry)
|
||||
|
||||
self.map[entry].pts = diff.pts
|
||||
diff.other_updates.extend(_tl.UpdateNewMessage(
|
||||
message=m,
|
||||
pts=NO_SEQ,
|
||||
pts_count=NO_SEQ,
|
||||
) for m in diff.new_messages)
|
||||
chat_hashes.extend(diff.users, diff.chats)
|
||||
self.reset_channel_deadline(entry, None)
|
||||
|
||||
return diff.other_updates, diff.users, diff.chats
|
||||
|
||||
# endregion Getting and applying channel difference.
|
|
@ -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
|
|
@ -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
|
||||
<telethon.client.messages.MessageMethods.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
|
||||
<telethon.sessions.abstract.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
|
|
@ -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 <https://t.me/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 <https://t.me/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
|
|
@ -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
|
||||
<telethon.tl.custom.inlineresult.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)
|
|
@ -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)
|
File diff suppressed because it is too large
Load Diff
|
@ -1,606 +0,0 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import typing
|
||||
|
||||
from .. import helpers, utils, hints, errors
|
||||
from ..requestiter import RequestIter
|
||||
from ..tl import types, functions, custom
|
||||
|
||||
_MAX_CHUNK_SIZE = 100
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .telegramclient import TelegramClient
|
||||
|
||||
|
||||
def _dialog_message_key(peer, message_id):
|
||||
"""
|
||||
Get the key to get messages from a dialog.
|
||||
|
||||
We cannot just use the message ID because channels share message IDs,
|
||||
and the peer ID is required to distinguish between them. But it is not
|
||||
necessary in small group chats and private chats.
|
||||
"""
|
||||
return (peer.channel_id if isinstance(peer, types.PeerChannel) else None), message_id
|
||||
|
||||
|
||||
class _DialogsIter(RequestIter):
|
||||
async def _init(
|
||||
self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder
|
||||
):
|
||||
self.request = functions.messages.GetDialogsRequest(
|
||||
offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
offset_peer=offset_peer,
|
||||
limit=1,
|
||||
hash=0,
|
||||
exclude_pinned=ignore_pinned,
|
||||
folder_id=folder
|
||||
)
|
||||
|
||||
if self.limit <= 0:
|
||||
# Special case, get a single dialog and determine count
|
||||
dialogs = await self.client(self.request)
|
||||
self.total = getattr(dialogs, 'count', len(dialogs.dialogs))
|
||||
raise StopAsyncIteration
|
||||
|
||||
self.seen = set()
|
||||
self.offset_date = offset_date
|
||||
self.ignore_migrated = ignore_migrated
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
self.request.limit = min(self.left, _MAX_CHUNK_SIZE)
|
||||
r = await self.client(self.request)
|
||||
|
||||
self.total = getattr(r, 'count', len(r.dialogs))
|
||||
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)
|
||||
if not isinstance(x, (types.UserEmpty, types.ChatEmpty))}
|
||||
|
||||
messages = {}
|
||||
for m in r.messages:
|
||||
m._finish_init(self.client, entities, None)
|
||||
messages[_dialog_message_key(m.peer_id, m.id)] = m
|
||||
|
||||
for d in r.dialogs:
|
||||
# We check the offset date here because Telegram may ignore it
|
||||
message = messages.get(_dialog_message_key(d.peer, d.top_message))
|
||||
if self.offset_date:
|
||||
date = getattr(message, 'date', None)
|
||||
if not date or date.timestamp() > self.offset_date.timestamp():
|
||||
continue
|
||||
|
||||
peer_id = utils.get_peer_id(d.peer)
|
||||
if peer_id not in self.seen:
|
||||
self.seen.add(peer_id)
|
||||
if peer_id not in entities:
|
||||
# > In which case can a UserEmpty appear in the list of banned members?
|
||||
# > In a very rare cases. This is possible but isn't an expected behavior.
|
||||
# Real world example: https://t.me/TelethonChat/271471
|
||||
continue
|
||||
|
||||
cd = custom.Dialog(self.client, d, entities, message)
|
||||
if cd.dialog.pts:
|
||||
self.client._channel_pts[cd.id] = cd.dialog.pts
|
||||
|
||||
if not self.ignore_migrated or getattr(
|
||||
cd.entity, 'migrated_to', None) is None:
|
||||
self.buffer.append(cd)
|
||||
|
||||
if len(r.dialogs) < self.request.limit\
|
||||
or not isinstance(r, types.messages.DialogsSlice):
|
||||
# Less than we requested means we reached the end, or
|
||||
# we didn't get a DialogsSlice which means we got all.
|
||||
return True
|
||||
|
||||
# We can't use `messages[-1]` as the offset ID / date.
|
||||
# Why? Because pinned dialogs will mess with the order
|
||||
# in this list. Instead, we find the last dialog which
|
||||
# has a message, and use it as an offset.
|
||||
last_message = next(filter(None, (
|
||||
messages.get(_dialog_message_key(d.peer, d.top_message))
|
||||
for d in reversed(r.dialogs)
|
||||
)), None)
|
||||
|
||||
self.request.exclude_pinned = True
|
||||
self.request.offset_id = last_message.id if last_message else 0
|
||||
self.request.offset_date = last_message.date if last_message else None
|
||||
self.request.offset_peer = self.buffer[-1].input_entity
|
||||
|
||||
|
||||
class _DraftsIter(RequestIter):
|
||||
async def _init(self, entities, **kwargs):
|
||||
if not entities:
|
||||
r = await self.client(functions.messages.GetAllDraftsRequest())
|
||||
items = r.updates
|
||||
else:
|
||||
peers = []
|
||||
for entity in entities:
|
||||
peers.append(types.InputDialogPeer(
|
||||
await self.client.get_input_entity(entity)))
|
||||
|
||||
r = await self.client(functions.messages.GetPeerDialogsRequest(peers))
|
||||
items = r.dialogs
|
||||
|
||||
# TODO Maybe there should be a helper method for this?
|
||||
entities = {utils.get_peer_id(x): x
|
||||
for x in itertools.chain(r.users, r.chats)}
|
||||
|
||||
self.buffer.extend(
|
||||
custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
|
||||
for d in items
|
||||
)
|
||||
|
||||
async def _load_next_chunk(self):
|
||||
return []
|
||||
|
||||
|
||||
class DialogMethods:
|
||||
|
||||
# region Public methods
|
||||
|
||||
def iter_dialogs(
|
||||
self: 'TelegramClient',
|
||||
limit: float = None,
|
||||
*,
|
||||
offset_date: 'hints.DateLike' = None,
|
||||
offset_id: int = 0,
|
||||
offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(),
|
||||
ignore_pinned: bool = False,
|
||||
ignore_migrated: bool = False,
|
||||
folder: int = None,
|
||||
archived: bool = None
|
||||
) -> _DialogsIter:
|
||||
"""
|
||||
Iterator over the dialogs (open conversations/subscribed channels).
|
||||
|
||||
The order is the same as the one seen in official applications
|
||||
(first pinned, them from those with the most recent message to
|
||||
those with the oldest message).
|
||||
|
||||
Arguments
|
||||
limit (`int` | `None`):
|
||||
How many dialogs to be retrieved as maximum. Can be set to
|
||||
`None` to retrieve all dialogs. Note that this may take
|
||||
whole minutes if you have hundreds of dialogs, as Telegram
|
||||
will tell the library to slow down through a
|
||||
``FloodWaitError``.
|
||||
|
||||
offset_date (`datetime`, optional):
|
||||
The offset date to be used.
|
||||
|
||||
offset_id (`int`, optional):
|
||||
The message ID to be used as an offset.
|
||||
|
||||
offset_peer (:tl:`InputPeer`, optional):
|
||||
The peer to be used as an offset.
|
||||
|
||||
ignore_pinned (`bool`, optional):
|
||||
Whether pinned dialogs should be ignored or not.
|
||||
When set to `True`, these won't be yielded at all.
|
||||
|
||||
ignore_migrated (`bool`, optional):
|
||||
Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel`
|
||||
should be included or not. By default all the chats in your
|
||||
dialogs are returned, but setting this to `True` will ignore
|
||||
(i.e. skip) them in the same way official applications do.
|
||||
|
||||
folder (`int`, optional):
|
||||
The folder from which the dialogs should be retrieved.
|
||||
|
||||
If left unspecified, all dialogs (including those from
|
||||
folders) will be returned.
|
||||
|
||||
If set to ``0``, all dialogs that don't belong to any
|
||||
folder will be returned.
|
||||
|
||||
If set to a folder number like ``1``, only those from
|
||||
said folder will be returned.
|
||||
|
||||
By default Telegram assigns the folder ID ``1`` to
|
||||
archived chats, so you should use that if you need
|
||||
to fetch the archived dialogs.
|
||||
|
||||
archived (`bool`, optional):
|
||||
Alias for `folder`. If unspecified, all will be returned,
|
||||
`False` implies ``folder=0`` and `True` implies ``folder=1``.
|
||||
Yields
|
||||
Instances of `Dialog <telethon.tl.custom.dialog.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 <telethon.helpers.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 <telethon.tl.custom.draft.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() <telethon.tl.custom.dialog.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 <telethon.tl.custom.conversation.Conversation>`
|
||||
with the given entity.
|
||||
|
||||
.. note::
|
||||
|
||||
This Conversation API has certain shortcomings, such as lacking
|
||||
persistence, poor interaction with other event handlers, and
|
||||
overcomplicated usage for anything beyond the simplest case.
|
||||
|
||||
If you plan to interact with a bot without handlers, this works
|
||||
fine, but when running a bot yourself, you may instead prefer
|
||||
to follow the advice from https://stackoverflow.com/a/62246569/.
|
||||
|
||||
This is not the same as just sending a message to create a "dialog"
|
||||
with them, but rather a way to easily send messages and await for
|
||||
responses or other reactions. Refer to its documentation for more.
|
||||
|
||||
Arguments
|
||||
entity (`entity`):
|
||||
The entity with which a new conversation should be opened.
|
||||
|
||||
timeout (`int` | `float`, optional):
|
||||
The default timeout (in seconds) *per action* to be used. You
|
||||
may also override this timeout on a per-method basis. By
|
||||
default each action can take up to 60 seconds (the value of
|
||||
this timeout).
|
||||
|
||||
total_timeout (`int` | `float`, optional):
|
||||
The total timeout (in seconds) to use for the whole
|
||||
conversation. This takes priority over per-action
|
||||
timeouts. After these many seconds pass, subsequent
|
||||
actions will result in ``asyncio.TimeoutError``.
|
||||
|
||||
max_messages (`int`, optional):
|
||||
The maximum amount of messages this conversation will
|
||||
remember. After these many messages arrive in the
|
||||
specified chat, subsequent actions will result in
|
||||
``ValueError``.
|
||||
|
||||
exclusive (`bool`, optional):
|
||||
By default, conversations are exclusive within a single
|
||||
chat. That means that while a conversation is open in a
|
||||
chat, you can't open another one in the same chat, unless
|
||||
you disable this flag.
|
||||
|
||||
If you try opening an exclusive conversation for
|
||||
a chat where it's already open, it will raise
|
||||
``AlreadyInConversationError``.
|
||||
|
||||
replies_are_responses (`bool`, optional):
|
||||
Whether replies should be treated as responses or not.
|
||||
|
||||
If the setting is enabled, calls to `conv.get_response
|
||||
<telethon.tl.custom.conversation.Conversation.get_response>`
|
||||
and a subsequent call to `conv.get_reply
|
||||
<telethon.tl.custom.conversation.Conversation.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 <telethon.tl.custom.conversation.Conversation>`.
|
||||
|
||||
Example
|
||||
.. code-block:: python
|
||||
|
||||
# <you> denotes outgoing messages you sent
|
||||
# <usr> denotes incoming response messages
|
||||
with bot.conversation(chat) as conv:
|
||||
# <you> Hi!
|
||||
conv.send_message('Hi!')
|
||||
|
||||
# <usr> Hello!
|
||||
hello = conv.get_response()
|
||||
|
||||
# <you> Please tell me your name
|
||||
conv.send_message('Please tell me your name')
|
||||
|
||||
# <usr> ?
|
||||
name = conv.get_response().raw_text
|
||||
|
||||
while not any(x.isalpha() for x in name):
|
||||
# <you> Your name didn't have any letters! Try again
|
||||
conv.send_message("Your name didn't have any letters! Try again")
|
||||
|
||||
# <usr> Human
|
||||
name = conv.get_response().raw_text
|
||||
|
||||
# <you> 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
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user