Compare commits

..

No commits in common. "v1" and "v1.18.2" have entirely different histories.
v1 ... v1.18.2

126 changed files with 2704 additions and 7942 deletions

27
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@ -0,0 +1,27 @@
---
name: Bug Report
about: Create a report about a bug inside the library or issues with the documentation
title: ''
labels: ''
assignees: ''
---
**Checklist**
* [ ] The error is in the library's code, and not in my own.
* [ ] I have searched for this issue before posting it and there isn't a duplicate.
* [ ] I ran `pip install -U https://github.com/LonamiWebs/Telethon/archive/master.zip` and triggered the bug in the latest version.
**Code that causes the issue**
```python
from telethon.sync import TelegramClient
...
```
**Traceback**
```
Traceback (most recent call last):
File "code.py", line 1, in <code>
```

View File

@ -1,96 +0,0 @@
name: Bug Report
description: Create a report about a bug inside the library.
body:
- type: textarea
id: reproducing-example
attributes:
label: Code that causes the issue
description: Provide a code example that reproduces the problem. Try to keep it short without other dependencies.
placeholder: |
```python
from telethon.sync import TelegramClient
...
```
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: Explain what you should expect to happen. Include reproduction steps.
placeholder: |
"I was doing... I was expecting the following to happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: Explain what actually happens.
placeholder: |
"This happened instead..."
validations:
required: true
- type: textarea
id: traceback
attributes:
label: Traceback
description: |
The traceback, if the problem is a crash.
placeholder: |
```
Traceback (most recent call last):
File "code.py", line 1, in <code>
```
- type: input
id: telethon-version
attributes:
label: Telethon version
description: The output of `python -c "import telethon; print(telethon.__version__)"`.
placeholder: "1.x"
validations:
required: true
- type: input
id: python-version
attributes:
label: Python version
description: The output of `python --version`.
placeholder: "3.x"
validations:
required: true
- type: input
id: os
attributes:
label: Operating system (including distribution name and version)
placeholder: Windows 11, macOS 13.4, Ubuntu 23.04...
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments. Is it a server? Network condition?
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: The error is in the library's code, and not in my own.
required: true
- label: I have searched for this issue before posting it and there isn't an open duplicate.
required: true
- label: I ran `pip install -U https://github.com/LonamiWebs/Telethon/archive/v1.zip` and triggered the bug in the latest version.
required: true

View File

@ -1,4 +1,3 @@
blank_issues_enabled: false
contact_links:
- name: Ask questions in StackOverflow
url: https://stackoverflow.com/questions/ask?tags=telethon

View File

@ -1,22 +0,0 @@
name: Documentation Issue
description: Report a problem with the documentation.
labels: [documentation]
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the problem in detail.
placeholder: This part is unclear...
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: This is a documentation problem, not a question or a bug report.
required: true
- label: I have searched for this issue before posting it and there isn't a duplicate.
required: true

View File

@ -0,0 +1,10 @@
---
name: Feature Request
about: Suggest ideas, changes or other enhancements for the library
title: ''
labels: enhancement
assignees: ''
---
Please describe your idea. Would you like another friendly method? Renaming them to something more appropriated? Changing the way something works?

View File

@ -1,22 +0,0 @@
name: Feature Request
description: Suggest ideas, changes or other enhancements for the library.
labels: [enhancement]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggested feature
description: Please describe your idea. Would you like another friendly method? Renaming them to something more appropriate? Changing the way something works?
placeholder: "It should work like this..."
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched for this issue before posting it and there isn't a duplicate.
required: true

View File

@ -1,5 +0,0 @@
<!--
Thanks for the PR! Please keep in mind that v1 is *feature frozen*.
New features very likely won't be merged, although fixes can be sent.
All new development should happen in v2. Thanks!
-->

View File

@ -1,6 +1,6 @@
name: Python Library
on: [push, pull_request]
on: [push]
jobs:
build:

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Generated code
/telethon/tl/functions/
/telethon/tl/types/
/telethon/tl/patched/
/telethon/tl/alltlobjects.py
/telethon/errors/rpcerrorlist.py

View File

@ -1,18 +0,0 @@
# https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.11"
sphinx:
configuration: readthedocs/conf.py
formats:
- pdf
- epub
python:
install:
- requirements: readthedocs/requirements.txt

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2016-Present LonamiWebs
Copyright (c) 2016-2020 LonamiWebs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -12,8 +12,6 @@ as a user or through a bot account (bot API alternative).
If you have code using Telethon before its 1.0 version, you must
read `Compatibility and Convenience`_ to learn how to migrate.
As with any third-party library for Telegram, be careful not to
break `Telegram's ToS`_ or `Telegram can ban the account`_.
What is this?
-------------
@ -77,9 +75,7 @@ useful information.
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _MTProto: https://core.telegram.org/mtproto
.. _Telegram: https://telegram.org
.. _Compatibility and Convenience: https://docs.telethon.dev/en/stable/misc/compatibility-and-convenience.html
.. _Telegram's ToS: https://core.telegram.org/api/terms
.. _Telegram can ban the account: https://docs.telethon.dev/en/stable/quick-references/faq.html#my-account-was-deleted-limited-when-using-the-library
.. _Compatibility and Convenience: https://docs.telethon.dev/en/latest/misc/compatibility-and-convenience.html
.. _Read The Docs: https://docs.telethon.dev
.. |logo| image:: logo.svg

View File

@ -3,4 +3,3 @@ pysocks
python-socks[asyncio]
hachoir
pillow
isal

View File

@ -6,16 +6,14 @@ Installation
Telethon is a Python library, which means you need to download and install
Python from https://www.python.org/downloads/ if you haven't already. Once
you have Python installed, `upgrade pip`__ and run:
you have Python installed, run:
.. code-block:: sh
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade telethon
pip3 install -U telethon --user
…to install or upgrade the library to the latest version.
To install or upgrade the library to the latest version.
.. __: https://pythonspeed.com/articles/upgrade-pip/
Installing Development Versions
===============================
@ -25,7 +23,7 @@ you can run the following command instead:
.. code-block:: sh
python3 -m pip install --upgrade https://github.com/LonamiWebs/Telethon/archive/v1.zip
pip3 install -U https://github.com/LonamiWebs/Telethon/archive/master.zip --user
.. note::
@ -76,7 +74,7 @@ manually.
Some of the modules may require additional dependencies before being
installed through ``pip``. If you have an ``apt``-based system, consider
installing the most commonly missing dependencies (with the right ``pip``):
installing the most commonly missing dependencies:
.. code-block:: sh
@ -87,7 +85,6 @@ manually.
Thanks to `@bb010g`_ for writing down this nice list.
.. _cryptg: https://github.com/cher-nov/cryptg
.. _pyaes: https://github.com/ricmoo/pyaes
.. _pillow: https://python-pillow.org

View File

@ -20,27 +20,3 @@ that are worth learning and understanding.
From now on, you can keep pressing the "Next" button if you want,
or use the menu on the left, since some pages are quite lengthy.
A note on developing applications
=================================
If you're using the library to make an actual application (and not just
automate things), you should make sure to `comply with the ToS`__:
[…] when logging in as an existing user, apps are supposed to call
[:tl:`GetTermsOfServiceUpdate`] to check for any updates to the Terms of
Service; this call should be repeated after ``expires`` seconds have
elapsed. If an update to the Terms Of Service is available, clients are
supposed to show a consent popup; if accepted, clients should call
[:tl:`AcceptTermsOfService`], providing the ``termsOfService id`` JSON
object; in case of denial, clients are to delete the account using
[:tl:`DeleteAccount`], providing Decline ToS update as deletion reason.
.. __: https://core.telegram.org/api/config#terms-of-service
However, if you use the library to automate or enhance your Telegram
experience, it's very likely that you are using other applications doing this
check for you (so you wouldn't run the risk of violating the ToS).
The library itself will not automatically perform this check or accept the ToS
because it should require user action (the only exception is during sign-up).

View File

@ -148,9 +148,9 @@ The allowed values for the argument ``proxy_type`` are:
* For Python >= 3.6:
* All of the above
* ``python_socks.ProxyType.SOCKS5``
* ``python_socks.ProxyType.SOCKS4``
* ``python_socks.ProxyType.HTTP``
* ``python_socks.SOCKS5``
* ``python_socks.SOCKS4``
* ``python_socks.HTTP``
Example:
@ -170,7 +170,6 @@ For backwards compatibility with ``PySocks`` the following format
is possible (but discouraged):
.. code-block:: python
proxy = (socks.SOCKS5, '1.1.1.1', 5555, True, 'foo', 'bar')
.. __: https://github.com/romis2012/python-socks#installation

View File

@ -16,7 +16,7 @@ For that, you can use **events**.
.. code-block:: python
import logging
logging.basicConfig(format='[%(levelname) %(asctime)s] %(name)s: %(message)s',
logging.basicConfig(format='[%(levelname) 5s/%(asctime)s] %(name)s: %(message)s',
level=logging.WARNING)

View File

@ -40,22 +40,22 @@ because tasks are smaller than threads, which are smaller than processes.
What are asyncio basics?
========================
The code samples below assume that you have Python 3.7 or greater installed.
.. code-block:: python
# First we need the asyncio library
import asyncio
# Then we need a loop to work with
loop = asyncio.get_event_loop()
# We also need something to run
async def main():
for char in 'Hello, world!\n':
print(char, end='', flush=True)
await asyncio.sleep(0.2)
# Then, we can create a new asyncio loop and use it to run our coroutine.
# The creation and tear-down of the loop is hidden away from us.
asyncio.run(main())
# Then, we need to run the loop with a task
loop.run_until_complete(main())
What does telethon.sync do?
@ -101,7 +101,7 @@ Instead of this:
# or, using asyncio's default loop (it's the same)
import asyncio
loop = asyncio.get_running_loop() # == client.loop
loop = asyncio.get_event_loop() # == client.loop
me = loop.run_until_complete(client.get_me())
print(me.username)
@ -158,10 +158,13 @@ loops or use ``async with``:
print(message.sender.username)
asyncio.run(main())
# ^ this will create a new asyncio loop behind the scenes and tear it down
# once the function returns. It will run the loop untiil main finishes.
# You should only use this function if there is no other loop running.
loop = asyncio.get_event_loop()
# ^ this assigns the default event loop from the main thread to a variable
loop.run_until_complete(main())
# ^ this runs the *entire* loop until the main() function finishes.
# While the main() function does not finish, the loop will be running.
# While the loop is running, you can't run it again.
The ``await`` keyword blocks the *current* task, and the loop can run
@ -181,14 +184,14 @@ concurrently:
await asyncio.sleep(delay) # await tells the loop this task is "busy"
print('world') # eventually the loop finishes all tasks
async def main():
asyncio.create_task(world(2)) # create the world task, passing 2 as delay
asyncio.create_task(hello(delay=1)) # another task, but with delay 1
await asyncio.sleep(3) # wait for three seconds before exiting
loop = asyncio.get_event_loop() # get the default loop for the main thread
loop.create_task(world(2)) # create the world task, passing 2 as delay
loop.create_task(hello(delay=1)) # another task, but with delay 1
try:
# create a new temporary asyncio loop and use it to run main
asyncio.run(main())
# run the event loop forever; ctrl+c to stop it
# we could also run the loop for three seconds:
# loop.run_until_complete(asyncio.sleep(3))
loop.run_forever()
except KeyboardInterrupt:
pass
@ -206,15 +209,10 @@ The same example, but without the comment noise:
await asyncio.sleep(delay)
print('world')
async def main():
asyncio.create_task(world(2))
asyncio.create_task(hello(delay=1))
await asyncio.sleep(3)
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
loop = asyncio.get_event_loop()
loop.create_task(world(2))
loop.create_task(hello(1))
loop.run_until_complete(asyncio.sleep(3))
Can I use threads?
@ -252,9 +250,9 @@ You may have seen this error:
RuntimeError: There is no current event loop in thread 'Thread-1'.
It just means you didn't create a loop for that thread. Please refer to
the ``asyncio`` documentation to correctly learn how to set the event loop
for non-main threads.
It just means you didn't create a loop for that thread, and if you don't
pass a loop when creating the client, it uses ``asyncio.get_event_loop()``,
which only works in the main thread.
client.run_until_disconnected() blocks!

View File

@ -28,9 +28,6 @@ their own Telegram bots. Quoting their main page:
Bot API is simply an HTTP endpoint which translates your requests to it into
MTProto calls through tdlib_, their bot backend.
Configuration of your bot, such as its available commands and auto-completion,
is configured through `@BotFather <https://t.me/BotFather>`_.
What is MTProto?
================
@ -91,7 +88,7 @@ Next, we will see some examples from the most popular libraries.
Migrating from python-telegram-bot
----------------------------------
Let's take their `echobot.py`_ example and shorten it a bit:
Let's take their `echobot2.py`_ example and shorten it a bit:
.. code-block:: python
@ -110,7 +107,7 @@ Let's take their `echobot.py`_ example and shorten it a bit:
updater = Updater("TOKEN")
dp = updater.dispatcher
dp.add_handler(CommandHandler("start", start))
dp.add_handler(MessageHandler(Filters.text & ~Filters.command, echo))
dp.add_handler(MessageHandler(Filters.text, echo))
updater.start_polling()
@ -148,7 +145,7 @@ After using Telethon:
Key differences:
* The recommended way to do it imports fewer things.
* The recommended way to do it imports less things.
* All handlers trigger by default, so we need ``events.StopPropagation``.
* Adding handlers, responding and running is a lot less verbose.
* Telethon needs ``async def`` and ``await``.
@ -299,7 +296,7 @@ After rewriting:
class Subbot(TelegramClient):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
await super().__init__(*a, **kw)
self.add_event_handler(self.on_update, events.NewMessage)
async def connect():
@ -333,4 +330,4 @@ Key differences:
.. _aiohttp: https://docs.aiohttp.org/en/stable
.. _aiogram: https://aiogram.readthedocs.io
.. _dumbot: https://github.com/Lonami/dumbot
.. _echobot.py: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot.py
.. _echobot2.py: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot2.py

View File

@ -1,169 +0,0 @@
.. _chats-channels:
=================
Chats vs Channels
=================
Telegram's raw API can get very confusing sometimes, in particular when it
comes to talking about "chats", "channels", "groups", "megagroups", and all
those concepts.
This section will try to explain what each of these concepts are.
Chats
=====
A ``Chat`` can be used to talk about either the common "subclass" that both
chats and channels share, or the concrete :tl:`Chat` type.
Technically, both :tl:`Chat` and :tl:`Channel` are a form of the `Chat type`_.
**Most of the time**, the term :tl:`Chat` is used to talk about *small group
chats*. When you create a group through an official application, this is the
type that you get. Official applications refer to these as "Group".
Both the bot API and Telethon will add a minus sign (negate) the real chat ID
so that you can tell at a glance, with just a number, the entity type.
For example, if you create a chat with :tl:`CreateChatRequest`, the real chat
ID might be something like `123`. If you try printing it from a
`message.chat_id` you will see `-123`. This ID helps Telethon know you're
talking about a :tl:`Chat`.
Channels
========
Official applications create a *broadcast* channel when you create a new
channel (used to broadcast messages, only administrators can post messages).
Official applications implicitly *migrate* an *existing* :tl:`Chat` to a
*megagroup* :tl:`Channel` when you perform certain actions (exceed user limit,
add a public username, set certain permissions, etc.).
A ``Channel`` can be created directly with :tl:`CreateChannelRequest`, as
either a ``megagroup`` or ``broadcast``.
Official applications use the term "channel" **only** for broadcast channels.
The API refers to the different types of :tl:`Channel` with certain attributes:
* A **broadcast channel** is a :tl:`Channel` with the ``channel.broadcast``
attribute set to `True`.
* A **megagroup channel** is a :tl:`Channel` with the ``channel.megagroup``
attribute set to `True`. Official applications refer to this as "supergroup".
* A **gigagroup channel** is a :tl:`Channel` with the ``channel.gigagroup``
attribute set to `True`. Official applications refer to this as "broadcast
groups", and is used when a megagroup becomes very large and administrators
want to transform it into something where only they can post messages.
Both the bot API and Telethon will "concatenate" ``-100`` to the real chat ID
so that you can tell at a glance, with just a number, the entity type.
For example, if you create a new broadcast channel, the real channel ID might
be something like `456`. If you try printing it from a `message.chat_id` you
will see `-1000000000456`. This ID helps Telethon know you're talking about a
:tl:`Channel`.
Converting IDs
==============
You can convert between the "marked" identifiers (prefixed with a minus sign)
and the real ones with ``utils.resolve_id``. It will return a tuple with the
real ID, and the peer type (the class):
.. code-block:: python
from telethon import utils
real_id, peer_type = utils.resolve_id(-1000000000456)
print(real_id) # 456
print(peer_type) # <class 'telethon.tl.types.PeerChannel'>
peer = peer_type(real_id)
print(peer) # PeerChannel(channel_id=456)
The reverse operation can be done with ``utils.get_peer_id``:
.. code-block:: python
print(utils.get_peer_id(types.PeerChannel(456))) # -1000000000456
Note that this function can also work with other types, like :tl:`Chat` or
:tl:`Channel` instances.
If you need to convert other types like usernames which might need to perform
API calls to find out the identifier, you can use ``client.get_peer_id``:
.. code-block:: python
print(await client.get_peer_id('me')) # your id
If there is no "mark" (no minus sign), Telethon will assume your identifier
refers to a :tl:`User`. If this is **not** the case, you can manually fix it:
.. code-block:: python
from telethon import types
await client.send_message(types.PeerChannel(456), 'hello')
# ^^^^^^^^^^^^^^^^^ explicit peer type
A note on raw API
=================
Certain methods only work on a :tl:`Chat`, and some others only work on a
:tl:`Channel` (and these may only work in broadcast, or megagroup). Your code
likely knows what it's working with, so it shouldn't be too much of an issue.
If you need to find the :tl:`Channel` from a :tl:`Chat` that migrated to it,
access the `migrated_to` property:
.. code-block:: python
# chat is a Chat
channel = await client.get_entity(chat.migrated_to)
# channel is now a Channel
Channels do not have a "migrated_from", but a :tl:`ChannelFull` does. You can
use :tl:`GetFullChannelRequest` to obtain this:
.. code-block:: python
from telethon import functions
full = await client(functions.channels.GetFullChannelRequest(your_channel))
full_channel = full.full_chat
# full_channel is a ChannelFull
print(full_channel.migrated_from_chat_id)
This way, you can also access the linked discussion megagroup of a broadcast channel:
.. code-block:: python
print(full_channel.linked_chat_id) # prints ID of linked discussion group or None
You do not need to use ``client.get_entity`` to access the
``migrated_from_chat_id`` :tl:`Chat` or the ``linked_chat_id`` :tl:`Channel`.
They are in the ``full.chats`` attribute:
.. code-block:: python
if full_channel.migrated_from_chat_id:
migrated_from_chat = next(c for c in full.chats if c.id == full_channel.migrated_from_chat_id)
print(migrated_from_chat.title)
if full_channel.linked_chat_id:
linked_group = next(c for c in full.chats if c.id == full_channel.linked_chat_id)
print(linked_group.username)
.. _Chat type: https://tl.telethon.dev/types/chat.html

View File

@ -268,7 +268,7 @@ That means you can do this:
.. code-block:: python
message.user_id
await message.get_input_sender()
await message.get_input_user()
message.user
# ...etc

View File

@ -150,6 +150,6 @@ You can also except it and act as you prefer:
VoIP numbers are very limited, and some countries are more limited too.
.. _list of known errors: https://github.com/LonamiWebs/Telethon/blob/v1/telethon_generator/data/errors.csv
.. _list of known errors: https://github.com/LonamiWebs/Telethon/blob/master/telethon_generator/data/errors.csv
.. _raw API page: https://tl.telethon.dev/
.. _messages.sendMessage: https://tl.telethon.dev/methods/messages/send_message.html

View File

@ -10,20 +10,13 @@ The Full API
methods listed on :ref:`client-ref` unless you have a better reason
not to, like a method not existing or you wanting more control.
.. contents::
Introduction
============
The :ref:`telethon-client` doesn't offer a method for every single request
the Telegram API supports. However, it's very simple to *call* or *invoke*
any request defined in Telegram's API.
any request. Whenever you need something, don't forget to `check the documentation`_
and look for the `method you need`_. There you can go through a sorted list
of everything you can do.
This section will teach you how to use what Telethon calls the `TL reference`_.
The linked page contains a list and a way to search through *all* types
generated from the definition of Telegram's API (in ``.tl`` file format,
hence the name). These types include requests and constructors.
.. note::
@ -32,193 +25,19 @@ hence the name). These types include requests and constructors.
as you type, and a "Copy import" button. If you like namespaces, you
can also do ``from telethon.tl import types, functions``. Both work.
Telegram makes these ``.tl`` files public, which other implementations, such
as Telethon, can also use to generate code. These files are versioned under
what's called "layers". ``.tl`` files consist of thousands of definitions,
and newer layers often add, change, or remove them. Each definition refers
to either a Remote Procedure Call (RPC) function, or a type (which the
`TL reference`_ calls "constructors", as they construct particular type
instances).
As such, the `TL reference`_ is a good place to go to learn about all possible
requests, types, and what they look like. If you're curious about what's been
changed between layers, you can refer to the `TL diff`_ site.
.. important::
All the examples in this documentation assume that you have
``from telethon import sync`` or ``import telethon.sync`` for the
sake of simplicity and that you understand what it does (see
:ref:`compatibility-and-convenience` for more). Simply add
either line at the beginning of your project and it will work.
Navigating the TL reference
===========================
Functions
---------
"Functions" is the term used for the Remote Procedure Calls (RPC) that can be
sent to Telegram to ask it to perform something (e.g. "send message"). These
requests have an associated return type. These can be invoked ("called"):
.. code-block:: python
client = TelegramClient(...)
function_instance = SomeRequest(...)
# Invoke the request
returned_type = await client(function_instance)
Whenever you find the type for a function in the `TL reference`_, the page
will contain the following information:
* What type of account can use the method. This information is regenerated
from time to time (by attempting to invoke the function under both account
types and finding out where it fails). Some requests can only be used by
bot accounts, others by user accounts, and others by both.
* The TL definition. This helps you get a feel for the what the function
looks like. This is not Python code. It just contains the definition in
a concise manner.
* "Copy import" button. Does what it says: it will copy the necessary Python
code to import the function to your system's clipboard for easy access.
* Returns. The returned type. When you invoke the function, this is what the
result will be. It also includes which of the constructors can be returned
inline, to save you a click.
* Parameters. The parameters accepted by the function, including their type,
whether they expect a list, and whether they're optional.
* Known RPC errors. A best-effort list of known errors the request may cause.
This list is not complete and may be out of date, but should provide an
overview of what could go wrong.
* Example. Autogenerated example, showcasing how you may want to call it.
Bear in mind that this is *autogenerated*. It may be spitting out non-sense.
The goal of this example is not to show you everything you can do with the
request, only to give you a feel for what it looks like to use it.
It is very important to click through the links and navigate to get the full
picture. A specific page will show you what the specific function returns and
needs as input parameters. But it may reference other types, so you need to
navigate to those to learn what those contain or need.
Types
-----
"Types" as understood by TL are not actually generated in Telethon.
They would be the "abstract base class" of the constructors, but since Python
is duck-typed, there is hardly any need to generate mostly unnecessary code.
The page for a type contains:
* Constructors. Every type will have one or more constructors. These
constructors *are* generated and can be immported and used.
* Requests returning this type. A helpful way to find out "what requests can
return this?". This is how you may learn what request you need to use to
obtain a particular instance of a type.
* Requests accepting this type as input. A helpful way to find out "what
requests can use this type as one of their input parameters?". This is how
you may learn where a type is used.
* Other types containing this type. A helpful way to find out "where else
does this type appear?". This is how you can walk back through nested
objects.
Constructors
------------
Constructors are used to create instances of a particular type, and are also
returned when invoking requests. You will have to create instances yourself
when invoking requests that need a particular type as input.
The page for a constructor contains:
* Belongs to. The parent type. This is a link back to the types page for the
specific constructor. It also contains the sibling constructors inline, to
save you a click.
* Members. Both the input parameters *and* fields the constructor contains.
Using the TL reference
======================
After you've found a request you want to send, a good start would be to simply
copy and paste the autogenerated example into your script. Then you can simply
tweak it to your needs.
If you want to do it from scratch, first, make sure to import the request into
your code (either using the "Copy import" button near the top, or by manually
spelling out the package under ``telethon.tl.functions.*``).
Then, start reading the parameters one by one. If the parameter cannot be
omitted, you **will** need to specify it, so make sure to spell it out as
an input parameter when constructing the request instance. Let's look at
`PingRequest`_ for example. First, we copy the import:
.. code-block:: python
from telethon.tl.functions import PingRequest
Then, we look at the parameters:
ping_id - long
A single parameter, and it's a long (a integer number with a large range of
values). It doesn't say it can be omitted, so we must provide it, like so:
.. code-block:: python
PingRequest(
ping_id=48641868471
)
(In this case, the ping ID is a random number. You often have to guess what
the parameter needs just by looking at the name.)
Now that we have our request, we can invoke it:
.. code-block:: python
response = await client(PingRequest(
ping_id=48641868471
))
To find out what ``response`` looks like, we can do as the autogenerated
example suggests and "stringify" the result as a pretty-printed string:
.. code-block:: python
print(result.stringify())
This will print out the following:
.. code-block:: python
Pong(
msg_id=781875678118,
ping_id=48641868471
)
Which is a very easy way to get a feel for a response. You should nearly
always print the stringified result, at least once, when trying out requests,
to get a feel for what the response may look like.
But of course, you don't need to do that. Without writing any code, you could
have navigated through the "Returns" link to learn ``PingRequest`` returns a
``Pong``, which only has one constructor, and the constructor has two members,
``msg_id`` and ``ping_id``.
If you wanted to create your own ``Pong``, you would use both members as input
parameters:
.. code-block:: python
my_pong = Pong(
msg_id=781875678118,
ping_id=48641868471
)
(Yes, constructing object instances can use the same code that ``.stringify``
would return!)
And if you wanted to access the ``msg_id`` member, you would simply access it
like any other attribute access in Python:
.. code-block:: python
print(response.msg_id)
Example walkthrough
===================
You should also refer to the documentation to see what the objects
(constructors) Telegram returns look like. Every constructor inherits
from a common type, and that's the reason for this distinction.
Say `client.send_message()
<telethon.client.messages.MessageMethods.send_message>` didn't exist,
@ -414,7 +233,6 @@ and still access the successful results:
# The second request failed.
second = e.exceptions[1]
.. _TL reference: https://tl.telethon.dev
.. _TL diff: https://diff.telethon.dev
.. _PingRequest: https://tl.telethon.dev/methods/ping.html
.. _check the documentation: https://tl.telethon.dev
.. _method you need: https://tl.telethon.dev/methods/index.html
.. _use the search: https://tl.telethon.dev/?q=message&redirect=no

View File

@ -143,7 +143,7 @@ output (likely your terminal).
.. warning::
**Keep this string safe!** Anyone with this string can use it
to login into your account and do anything they want to.
to login into your account and do anything they want to to do.
This is similar to leaking your ``*.session`` files online,
but it is easier to leak a string than it is to leak a file.

View File

@ -191,7 +191,8 @@ so the code above and the following are equivalent:
async def main():
await client.disconnected
asyncio.run(main())
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
You could also run `client.disconnected
@ -206,7 +207,7 @@ Notice that unlike `client.disconnected
<telethon.client.telegrambaseclient.TelegramBaseClient.disconnected>`,
`client.run_until_disconnected
<telethon.client.updates.UpdateMethods.run_until_disconnected>` will
handle ``KeyboardInterrupt`` for you. This method is special and can
handle ``KeyboardInterrupt`` with you. This method is special and can
also be ran while the loop is running, so you can do this:
.. code-block:: python

View File

@ -85,7 +85,7 @@ release = version
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'en'
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.

View File

@ -35,7 +35,3 @@ times, in this case, ``22222`` so we can hardcode that:
client.start(
phone='9996621234', code_callback=lambda: '22222'
)
Note that Telegram has changed the length of login codes multiple times in the
past, so if ``dc_id`` repeated five times does not work, try repeating it six
times.

View File

@ -84,10 +84,6 @@ use is very straightforward, or :tl:`InviteToChannelRequest` for channels:
[users_to_add]
))
Note that this method will only really work for friends or bot accounts.
Trying to mass-add users with this approach will not work, and can put both
your account and group to risk, possibly being flagged as spam and limited.
Checking a link without joining
===============================

View File

@ -25,7 +25,7 @@ you should use :tl:`GetFullUser`:
# or even
full = await client(GetFullUserRequest('username'))
bio = full.full_user.about
bio = full.about
See :tl:`UserFull` to know what other fields you can access.
@ -71,4 +71,4 @@ through :tl:`UploadProfilePhoto`:
await client(UploadProfilePhotoRequest(
await client.upload_file('/path/to/some/file')
))
)))

View File

@ -2,12 +2,44 @@
Working with messages
=====================
.. note::
These examples assume you have read :ref:`full-api`.
This section has been `moved to the wiki`_, where it can be easily edited as new
features arrive and the API changes. Please refer to the linked page to learn how
to send spoilers, custom emoji, stickers, react to messages, and more things.
.. contents::
.. _moved to the wiki: https://github.com/LonamiWebs/Telethon/wiki/Sending-more-than-just-messages
Sending stickers
================
Stickers are nothing else than ``files``, and when you successfully retrieve
the stickers for a certain sticker set, all you will have are ``handles`` to
these files. Remember, the files Telegram holds on their servers can be
referenced through this pair of ID/hash (unique per user), and you need to
use this handle when sending a "document" message. This working example will
send yourself the very first sticker you have:
.. code-block:: python
# Get all the sticker sets this user has
from telethon.tl.functions.messages import GetAllStickersRequest
sticker_sets = await client(GetAllStickersRequest(0))
# Choose a sticker set
from telethon.tl.functions.messages import GetStickerSetRequest
from telethon.tl.types import InputStickerSetID
sticker_set = sticker_sets.sets[0]
# Get the stickers for this sticker set
stickers = await client(GetStickerSetRequest(
stickerset=InputStickerSetID(
id=sticker_set.id, access_hash=sticker_set.access_hash
)
))
# Stickers are nothing more than files, so send that
await client.send_file('me', stickers.documents[0])
.. _issues: https://github.com/LonamiWebs/Telethon/issues/215

View File

@ -68,7 +68,6 @@ You can also use the menu on the left to quickly skip over sections.
concepts/strings
concepts/entities
concepts/chats-vs-channels
concepts/updates
concepts/sessions
concepts/full-api
@ -103,6 +102,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/compatibility-and-convenience
.. toctree::

View File

@ -13,772 +13,6 @@ it can take advantage of new goodies!
.. contents:: List of All Versions
New layer (v1.41)
=================
+------------------------+
| Scheme layer used: 214 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=201&to=214>`__.
Additions
~~~~~~~~~
* ``send_as`` and ``effect`` added to ``send_file``.
* ``mime_type`` added to ``send_file``.
* ``tg-emoji`` now works with HTML parse mode.
* Clicking a button now lets you choose whether to open the browser.
* Persistent and placeholder buttons.
* More separate RPC error classes.
Enhancements
~~~~~~~~~~~~
* Update entities should now be cached to session more reliably.
* ``utils.get_display_name`` now handles more types.
* Improved some type hints.
* Reply properties for stories now behave as expected.
* ``isal`` can now be used as an optional dependency for faster compression.
* Potential slight speed improvements to deserialization.
Bug fixes
~~~~~~~~~
* Library was not saving update sequence from certain updates.
* Input peer cache should no longer overwrite valid data with min peers.
* Spoiler for input photos and documents was not being respected.
New layer (v1.40)
=================
+------------------------+
| Scheme layer used: 201 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=199&to=201>`__.
Additions
~~~~~~~~~
* ``send_as`` and ``effect`` added to ``send_message`` and related methods.
* :tl:`MessageMediaGeoLive` is now recognized for auto-input conversion.
Enhancements
~~~~~~~~~~~~
* Improved wording when using a likely unintended session file.
* Improved behaviour for matching Markdown links.
* A truly clean update-state is now fetched upon login. This was most notably important for bots.
* Time offset is now updated more reliably after connecting. This should fix legitimate "message too old/new" issues.
Bug fixes
~~~~~~~~~
* :tl:`ChannelParticipantLeft` is now skipped in ``iter_participants``.
* ``spoiler`` flag was lost on :tl:`MessageMediaPhoto` auto-input conversion.
* :tl:`KeyboardButtonCopy` is now recognized as an inline button.
* Downloading web-documents should now work again. Note that this still fetches the file from the original server.
New layer (v1.39)
=================
+------------------------+
| Scheme layer used: 199 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=193&to=199>`__.
Additions
~~~~~~~~~
* ``drop_media_captions`` added to ``forward_messages``, and documented together with ``drop_author``.
* :tl:`InputMediaDocumentExternal` is now recognized when sending albums.
Enhancements
~~~~~~~~~~~~
* ``receive_updates=False`` now covers more cases, however, Telegram is still free to ignore it.
* Better type-hints in several methods.
* Markdown parsing of inline links should cover more cases.
* ``range`` is now considered "list-like" and can be used on e.g. ``ids`` parameters.
Bug fixes
~~~~~~~~~
* Session is now saved after setting the DC.
* Fixed rare crash in entity cache handling when iterating through dialogs.
* Fixed IOError that could occur during automatic resizing of some photos.
New layer (v1.38)
=================
+------------------------+
| Scheme layer used: 193 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=188&to=193>`__.
Bug fixes
~~~~~~~~~
* Formatting entities misbehaved with albums.
* Sending a Message object with a file did not use the new file.
New layer (v1.37)
=================
+------------------------+
| Scheme layer used: 188 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=181&to=188>`__.
Additions
~~~~~~~~~
* Support for CDN downloads should be back. Telethon still prefers no CDN by default.
Enhancements
~~~~~~~~~~~~
* ``FloodWaitPremium`` should now be handled like any other floodwaits.
Bug fixes
~~~~~~~~~
* Fixed edge-case when using ``get_messages(..., reverse=True)``.
* ``ConnectionError`` when using proxies should be raised properly.
New layer (v1.36)
=================
+------------------------+
| Scheme layer used: 181 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=178&to=181>`__.
Bug fixes
~~~~~~~~~
* Certain updates, such as :tl:`UpdateBotStopped`, should now be processed reliably.
New layer (v1.35)
=================
+------------------------+
| Scheme layer used: 178 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=173&to=178>`__.
Additions
~~~~~~~~~
* ``drop_author`` parameter now exposed in ``forward_messages``.
Enhancements
~~~~~~~~~~~~
* "Custom secret support" should work with ``TcpMTProxy``.
* Some type hints should now be more accurate.
Bug fixes
~~~~~~~~~
* Session path couldn't be a ``pathlib.Path`` or ``None``.
* Python versions older than 3.9 should now be supported again.
* Readthedocs should hopefully build the v1 documentation again.
New layer (v1.34)
=================
+------------------------+
| Scheme layer used: 173 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=167&to=173>`__.
Additions
~~~~~~~~~
* ``reply_to_chat`` and ``reply_to_sender`` are now in ``Message``.
This is useful when you lack access to the chat, but Telegram still included some basic information.
Bug fixes
~~~~~~~~~
* ``parse_mode`` with a custom instance containing both ``parse`` and ``unparse`` should now work.
* Parsing and unparsing message entities should now behave better in certain corner-cases.
New layer (v1.33)
=================
+------------------------+
| Scheme layer used: 167 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=166&to=167>`__.
Enhancements
~~~~~~~~~~~~
* ``webbrowser`` is now imported conditionally, to support niche environments.
* Library should now retry on the suddenly-common ``TimedOutError``.
Bug fixes
~~~~~~~~~
* Sending photos which were automatically resized should work again (included in the v1.32 series).
New layer (v1.32)
=================
+------------------------+
| Scheme layer used: 166 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=165&to=166>`__.
This enables you to use custom languages in preformatted blocks using HTML:
.. code-block:: html
<pre>
<code class='language-python'>from telethon import TelegramClient</code>
</pre>
Note that Telethon v1's markdown is a custom format and won't support language tags.
If you want to set a custom language, you have to use HTML or a custom formatter.
Dropped imghdr support (v1.31)
==============================
+------------------------+
| Scheme layer used: 165 |
+------------------------+
This release contains a breaking change in preparation for Python 3.12.
If you were sending photos from in-memory ``bytes`` or ``BytesIO`` containing images,
you should now use ``BytesIO`` and set the ``.name`` property to a dummy name.
This will allow Telethon to detect the correct extension (and file type).
.. code-block:: python
# before
image_data = b'...'
client.send_file(chat, image_data)
# after
from io import BytesIO
image_data = BytesIO(b'...')
image_data.name = 'a.jpg' # any name, only the extension matters
client.send_file(chat, image_data)
Bug fixes
~~~~~~~~~
* Code generation wasn't working under PyPy.
* Obtaining markdown or HTML from message text could produce unexpected results sometimes.
* Other fixes for bugs from the previous version, which were already fixed in patch versions.
Breaking Changes
~~~~~~~~~~~~~~~~
* ``imghdr`` is deprecated in newer Python versions, so Telethon no longer uses it.
This means there might be some cases where Telethon fails to infer the file extension for buffers containing images.
If you were relying on this, add ``.name = 'a.jpg'`` (or other extension) to the ``BytesIO`` buffers you upload.
Layer bump and small changes (v1.30)
====================================
+------------------------+
| Scheme layer used: 162 |
+------------------------+
Some of the bug fixes were already present in patch versions of ``v1.29``, but
the new layer necessitated a minor bump.
Enhancements
~~~~~~~~~~~~
* Removed client-side checks for editing messages.
This only affects ``Message.edit``, as ``client.edit_message`` already had
no checks.
* Library should not understand more server-side errors during update handling
which should reduce crashes.
* Client-side image compression should behave better now.
Bug fixes
~~~~~~~~~
* Some updates such as ``UpdateChatParticipant`` were being missed due to the
order in which Telegram sent them. The library now more carefully checks for
the sequence and pts contained in them to avoid dropping them.
* Fixed ``is_inline`` check for :tl:`KeyboardButtonWebView`.
* Fixed some issues getting entity from cache by ID.
* ``reply_to`` should now work when sending albums.
More bug fixing (v1.29)
=======================
+------------------------+
| Scheme layer used: 160 |
+------------------------+
This layer introduces the necessary raw API methods to work with stories.
The library is aiming to be "feature-frozen" for as long as v1 is active,
so friendly client methods are not implemented, but example code to use
stories can be found in the GitHub wiki of the project.
Enhancements
~~~~~~~~~~~~
* Removed client-side checks for methods dealing with chat permissions.
In particular, this means you can now ban channels.
* Improved some error messages and added new classes for more RPC errors.
* The client-side check for valid usernames has been loosened, so that
very short premium usernames are no longer considered invalid.
Bug fixes
~~~~~~~~~
* Attempting to download a thumbnail from documnets without one would fail,
rather than do nothing (since nothing can be downloaded if there is no thumb).
* More errors are caught in the update handling loop.
* HTML ``.text`` should now "unparse" any message contents correctly.
* Fixed some problems related to logging.
* ``comment_to`` should now work as expected with albums.
* ``asyncio.CancelledError`` should now correctly propagate from the update loop.
* Removed some absolute imports in favour of relative imports.
* ``UserUpdate.last_seen`` should now behave correctly.
* Fixed a rare ``ValueError`` during ``connect`` if the session cache was bad.
New Layer and housekeeping (v1.28)
==================================
+------------------------+
| Scheme layer used: 155 |
+------------------------+
Plenty of stale issues closed, as well as improvements for some others.
Additions
~~~~~~~~~
* New ``entity_cache_limit`` parameter in the ``TelegramClient`` constructor.
This should help a bit in keeping memory usage in check.
Enhancements
~~~~~~~~~~~~
* ``progress_callback`` is now called when dealing with albums. See the
documentation on `client.send_file() <telethon.client.uploads.UploadMethods.send_file>`
for details.
* Update state and entities are now periodically saved, so that the information
isn't lost in the case of crash or unexpected script terminations. You should
still be calling ``disconnect`` or using the context-manager, though.
* The client should no longer unnecessarily call ``get_me`` every time it's started.
Bug fixes
~~~~~~~~~
* Messages obtained via raw API could not be used in ``forward_messages``.
* ``force_sms`` and ``sign_up`` have been deprecated. See `issue 4050`_ for details.
It is no longer possible for third-party applications, such as those made with
Telethon, to use those features.
* ``events.ChatAction`` should now work in more cases in groups with hidden members.
* Errors that occur at the connection level should now be properly propagated, so that
you can actually have a chance to handle them.
* Update handling should be more resilient.
* ``PhoneCodeExpiredError`` will correctly clear the stored hash if it occurs in ``sign_in``.
* In patch ``v1.28.2``, :tl:`InputBotInlineMessageID64` can now be used
to edit inline messages.
.. _issue 4050: https://github.com/LonamiWebs/Telethon/issues/4050
New Layer and some Bug fixes (v1.27)
====================================
+------------------------+
| Scheme layer used: 152 |
+------------------------+
Bug fixes
~~~~~~~~~
* When the account is logged-out, the library should now correctly propagate
an error through ``run_until_disconnected`` to let you handle it.
* The library no longer uses ``asyncio.get_event_loop()`` in newer Python
versions, which should get rid of some deprecation warnings.
* It could happen that bots would receive messages sent by themselves,
very often right after they deleted a message. This should happen far
less often now (but might still happen with unlucky timings).
* Maximum photo size for automatic image resizing is now larger.
* The initial request is now correctly wrapped in ``invokeWithoutUpdates``
when updates are disabled after constructing the client instance.
* Using a ``pathlib.Path`` to download contacts and web documents should
now work correctly.
New Layer and some Bug fixes (v1.26)
====================================
+------------------------+
| Scheme layer used: 149 |
+------------------------+
This new layer includes things such as emoji status, more admin log events,
forum topics and message reactions, among other things. You can access these
using raw API. It also contains a few bug fixes.
These were fixed in the v1.25 series:
* ``client.edit_admin`` did not work on small group chats.
* ``client.get_messages`` could stop early in some channels.
* ``client.download_profile_photo`` now should work even if ``User.min``.
* ``client.disconnect`` should no longer hang when being called from within
an event handlers.
* ``client.get_dialogs`` now initializes the update state for channels.
* The message sender should not need to be fetched in more cases.
* Lowered the severity of some log messages to be less spammy.
These are new to v1.26.0:
* Layer update.
* New documented RPC errors.
* Sometimes the first message update to a channel could be missed if said
message was read immediately.
* ``client.get_dialogs`` would fail when the total count evenly divided
the chunk size of 100.
* ``client.get_messages`` could get stuck during a global search.
* Potentially fixed some issues when sending certain videos.
* Update handling should be more resilient.
* The client should handle having its auth key destroyed more gracefully.
* Fixed some issues when logging certain messages.
Bug fixes (v1.25.1)
===================
This version should fix some of the problems that came with the revamped
update handling.
* Some inline URLs were not parsing correctly with markdown.
* ``events.Raw`` was handling :tl:`UpdateShort` which it shouldn't do.
* ``events.Album`` should now work again.
* ``CancelledError`` was being incorrectly logged as a fatal error.
* Some fixes to update handling primarly aimed for bot accounts.
* Update handling now can deal with more errors without crashing.
* Unhandled errors from update handling will now be propagated through
``client.run_until_disconnected``.
* Invite links with ``+`` are now recognized.
* Added new known RPC errors.
* ``telethon.types`` could not be used as a module.
* 0-length message entities are now stripped to avoid errors.
* ``client.send_message`` was not returning a message with ``reply_to``
in some cases.
* ``aggressive`` in ``client.iter_participants`` now does nothing (it did
not really work anymore anyway, and this should prevent other errors).
* ``client.iter_participants`` was failing in some groups.
* Text with HTML URLs could sometimes fail to parse.
* Added a hard timeout during disconnect in order to prevent the program
from freezing.
Please be sure to report issues with update handling if you still encounter
some errors!
Update handling overhaul (v1.25)
================================
+------------------------+
| Scheme layer used: 144 |
+------------------------+
I had plans to release v2 way earlier, but my motivation drained off, so that
didn't happen. The reason for another v1 release is that there was a clear
need to fix some things regarding update handling (which were present in v2).
I did not want to make this release. But with the release date for v2 still
being unclear, I find it necessary to release another v1 version. I apologize
for the delay (I should've done this a lot sooner but didn't because in my
head I would've pushed through and finished v2, but I underestimated how much
work that was and I probably experienced burn-out).
I still don't intend to make new additions to the v1 series (beyond updating
the Telegram layer being used). I still have plans to finish v2 some day.
But in the meantime, new features, such as reactions, will have to be used
through raw API.
This update also backports the update overhaul from v2. If you experience
issues with updates, please report them on the GitHub page for the project.
However, this new update handling should be more reliable, and ``catch_up``
should actually work properly.
Breaking Changes
~~~~~~~~~~~~~~~~
* In order for ``catch_up`` to work (new flag in the ``TelegramClient``
constructor), sessions need to impleemnt the new ``get_update_states``.
Third-party session storages won't have this implemented by the time
this version released, so ``catch_up`` may not work with those.
Rushed release to fix login (v1.24)
===================================
+------------------------+
| Scheme layer used: 133 |
+------------------------+
This is a rushed release. It contains a layer recent enough to not fail with
``UPDATE_APP_TO_LOGIN``, but still not the latest, to avoid breaking more
than necessary.
Breaking Changes
~~~~~~~~~~~~~~~~
* The biggest change is user identifiers (and chat identifiers, and others)
**now use up to 64 bits**, rather than 32. If you were storing them in some
storage with fixed size, you may need to update (such as database tables
storing only integers).
There have been other changes which I currently don't have the time to document.
You can refer to the following link to see them early:
https://github.com/LonamiWebs/Telethon/compare/v1.23.0...v1.24.0
New schema and bug fixes (v1.23)
================================
+------------------------+
| Scheme layer used: 130 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=129&to=130>`__.
Enhancements
~~~~~~~~~~~~
* `client.pin_message() <telethon.client.messages.MessageMethods.pin_message>`
can now pin on a single side in PMs.
* Iterating participants should now be less expensive floodwait-wise.
Bug fixes
~~~~~~~~~
* The QR login URL was being encoded incorrectly.
* ``force_document`` was being ignored in inline queries for document.
* ``manage_call`` permission was accidentally set to ``True`` by default.
New schema and bug fixes (v1.22)
================================
+------------------------+
| Scheme layer used: 129 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=125&to=129>`__.
Enhancements
~~~~~~~~~~~~
* You can now specify a message in `client.get_stats()
<telethon.client.chats.ChatMethods.get_stats>`.
* Metadata extraction from audio files with ``hachoir`` now recognises "artist".
* Get default chat permissions by not supplying a user to `client.get_permissions()
<telethon.client.chats.ChatMethods.get_permissions>`.
* You may now use ``thumb`` when editing messages.
Bug fixes
~~~~~~~~~
* Fixes regarding bot markup in messages.
* Gracefully handle :tl:`ChannelForbidden` in ``get_sender``.
And from v1.21.1:
* ``file.width`` and ``.height`` was not working correctly in photos.
* Raw API was mis-interpreting ``False`` values on boolean flag parameters.
New schema and QoL improvements (v1.21)
=======================================
+------------------------+
| Scheme layer used: 125 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=124&to=125>`__.
Not many changes in this release, mostly the layer change. Lately quite a few
people have been reporting `TypeNotFoundError`, which occurs when the server
**sends types that it shouldn't**. This can happen when Telegram decides to
add a new, incomplete layer, and then they change the layer without bumping
the layer number (so some constructor IDs no longer match and the error
occurs). This layer change
`should fix it <https://github.com/LonamiWebs/Telethon/issues/1724>`__.
Additions
~~~~~~~~~
* `Message.click() <telethon.tl.custom.message.Message.click>` now supports
a ``password`` parameter, needed when doing things like changing the owner
of a bot via `@BotFather <https://t.me/BotFather>`__.
Enhancements
~~~~~~~~~~~~
* ``tgcrypto`` will now be used for encryption when installed.
Bug fixes
~~~~~~~~~
* `Message.edit <telethon.tl.custom.message.Message.edit>` wasn't working in
your own chat on events other than ``NewMessage``.
* `client.delete_dialog() <telethon.client.dialogs.DialogMethods.delete_dialog>`
was not working on chats.
* ``events.UserUpdate`` should now handle channels' typing status.
* :tl:`InputNotifyPeer` auto-cast should now work on other ``TLObject``.
* For some objects, ``False`` was not correctly serialized.
New schema and QoL improvements (v1.20)
=======================================
+------------------------+
| Scheme layer used: 124 |
+------------------------+
`View new and changed raw API methods <https://diff.telethon.dev/?from=122&to=124>`__.
A bit late to the party, but Telethon now offers a convenient way to comment
on channel posts. It works very similar to ``reply_to``:
.. code-block:: python
client.send_message(channel, 'Great update!', comment_to=1134)
This code will leave a comment to the channel post with ID ``1134`` in
``channel``.
In addition, the library now logs warning or error messages to ``stderr`` by
default! You no longer should be left wondering "why isn't my event handler
working" if you forgot to configure logging. It took so long for this change
to arrive because nobody noticed that Telethon was using a
``logging.NullHandler`` when it really shouldn't have.
If you want the old behaviour of no messages being logged, you can configure
`logging` to ``CRITICAL`` severity:
.. code-block:: python
import logging
logging.basicConfig(level=logging.CRITICAL)
This is not considered a breaking change because ``stderr`` should only be
used for logging purposes, not to emit information others may consume (use
``stdout`` for that).
Additions
~~~~~~~~~
* New ``comment_to`` parameter in `client.send_message()
<telethon.client.messages.MessageMethods.send_message>`, and
`client.send_file() <telethon.client.uploads.UploadMethods.send_file>`
to comment on channel posts.
Enhancements
~~~~~~~~~~~~
* ``utils.resolve_invite_link`` handles the newer link format.
* Downloading files now retries once on `TimeoutError`, which has been
happening recently. It is not guaranteed to work, but it should help.
* Sending albums of photo URLs is now supported.
* EXIF metadata is respected when automatically resizing photos, so the
orientation information should no longer be lost.
* Downloading a thumbnail by index should now use the correct size ordering.
Bug fixes
~~~~~~~~~
* Fixed a `KeyError` on certain cases with ``Conversation``.
* Thumbnails should properly render on more clients. Installing ``hachoir``
may help.
* Message search was broken when using a certain combination of parameters.
* ``utils.resolve_id`` was misbehaving with some identifiers.
* Fix ``TypeNotFoundError`` was not being propagated, causing deadlocks.
* Invoking multiple requests at once with ``ordered=True`` was deadlocking.
New raw API call methods (v1.19)
================================
+------------------------+
| Scheme layer used: 122 |
+------------------------+
Telegram has had group calls for some weeks now. This new version contains the
raw API methods needed to initiate and manage these group calls, however, the
library will likely **not offer ways to stream audio directly**.
Telethon's focus is being an asyncio-based, pure-Python implementation to
interact with Telegram's API. Streaming audio is beyond the current scope of
the project and would be a big undertaking.
However, that doesn't mean calls are not possible with Telethon. If you want
to help design a Python library to perform audio calls, which can then be used
with Telethon (so you can use Telethon + that new library to perform calls
with Telethon), please refer to `@pytgcallschat <https://t.me/pytgcallschat/>`__
and join the relevant chat to discuss and help with the implementation!
The above message was also `posted in the official Telegram group
<https://t.me/TelethonChat/284717>`__, if you wish to discuss it further.
With that out of the way, let's list the additions and bug fixes in this
release:
Additions
~~~~~~~~~
* New ``has_left`` property for user permissions on `client.get_permissions()
<telethon.client.chats.ChatMethods.get_permissions>`.
Enhancements
~~~~~~~~~~~~
* Updated documentation and list of known RPC errors.
* The library now treats a lack of ping responses as a network error.
* `client.kick_participant() <telethon.client.chats.ChatMethods.kick_participant>`
now returns the service message about the user being kicked, so you can
delete it.
Bug fixes
~~~~~~~~~
* When editing inline messages, the text parameter is preferred if provided.
* Additional senders are unconditionally disconnected when disconnecting the
main client, which should reduce the amount of asyncio warnings.
* Automatic reconnection with no retries was failing.
* :tl:`PhotoPathSize` is now ignored when determining a download size, since
this "size" is not a JPEG thumbnail unlike the rest.
* `events.ChatAction <telethon.events.chataction.ChatAction>` should misbehave
less.
New layer and QoL improvements (v1.18)
======================================
@ -2515,7 +1749,7 @@ the scenes! This means you're now able to do both of the following:
async def main():
await client.send_message('me', 'Hello!')
asyncio.run(main())
asyncio.get_event_loop().run_until_complete(main())
# ...can be rewritten as:

View File

@ -161,17 +161,19 @@ just get rid of ``telethon.sync`` and work inside an ``async def``:
await client.run_until_disconnected()
asyncio.run(main())
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
The ``telethon.sync`` magic module essentially wraps every method behind:
The ``telethon.sync`` magic module simply wraps every method behind:
.. code-block:: python
asyncio.run(main())
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
With some other tricks, so that you don't have to write it yourself every time.
That's the overhead you pay if you import it, and what you save if you don't.
So that you don't have to write it yourself every time. That's the
overhead you pay if you import it, and what you save if you don't.
Learning
========

View File

@ -0,0 +1,65 @@
=============
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

View File

@ -32,6 +32,7 @@ Auth
send_code_request
sign_in
qr_login
sign_up
log_out
edit_2fa
@ -168,7 +169,6 @@ Updates
remove_event_handler
list_event_handlers
catch_up
set_receive_updates
Bots
----

View File

@ -20,7 +20,7 @@ To enable logging, at the following code to the top of your main file:
.. code-block:: python
import logging
logging.basicConfig(format='[%(levelname) %(asctime)s] %(name)s: %(message)s',
logging.basicConfig(format='[%(levelname) 5s/%(asctime)s] %(name)s: %(message)s',
level=logging.WARNING)
You can change the logging level to be something different, from less to more information:
@ -60,16 +60,6 @@ And except them as such:
My account was deleted/limited when using the library
=====================================================
First and foremost, **this is not a problem exclusive to Telethon.
Any third-party library is prone to cause the accounts to appear banned.**
Even official applications can make Telegram ban an account under certain
circumstances. Third-party libraries such as Telethon are a lot easier to
use, and as such, they are misused to spam, which causes Telegram to learn
certain patterns and ban suspicious activity.
There is no point in Telethon trying to circumvent this. Even if it succeeded,
spammers would then abuse the library again, and the cycle would repeat.
The library will only do things that you tell it to do. If you use
the library with bad intentions, Telegram will hopefully ban you.
@ -77,7 +67,8 @@ However, you may also be part of a limited country, such as Iran or Russia.
In that case, we have bad news for you. Telegram is much more likely to ban
these numbers, as they are often used to spam other accounts, likely through
the use of libraries like this one. The best advice we can give you is to not
abuse the API, like calling many requests really quickly.
abuse the API, like calling many requests really quickly, and to sign up with
these phones through an official application.
We have also had reports from Kazakhstan and China, where connecting
would fail. To solve these connection problems, you should use a proxy.
@ -85,16 +76,6 @@ would fail. To solve these connection problems, you should use a proxy.
Telegram may also ban virtual (VoIP) phone numbers,
as again, they're likely to be used for spam.
More recently (year 2023 onwards), Telegram has started putting a lot more
measures to prevent spam (with even additions such as anonymous participants
in groups or the inability to fetch group members at all). This means some
of the anti-spam measures have gotten more aggressive.
The recommendation has usually been to use the library only on well-established
accounts (and not an account you just created), and to not perform actions that
could be seen as abuse. Telegram decides what those actions are, and they're
free to change how they operate at any time.
If you want to check if your account has been limited,
simply send a private message to `@SpamBot`_ through Telegram itself.
You should notice this by getting errors like ``PeerFloodError``,
@ -198,137 +179,6 @@ won't do unnecessary work unless you need to:
sender = await event.get_sender()
File download is slow or sending files takes too long
=====================================================
The communication with Telegram is encrypted. Encryption requires a lot of
math, and doing it in pure Python is very slow. ``cryptg`` is a library which
containns the encryption functions used by Telethon. If it is installed (via
``pip install cryptg``), it will automatically be used and should provide
a considerable speed boost. You can know whether it's used by configuring
``logging`` (at ``INFO`` level or lower) *before* importing ``telethon``.
Note that the library does *not* download or upload files in parallel, which
can also help with the speed of downloading or uploading a single file. There
are snippets online implementing that. The reason why this is not built-in
is because the limiting factor in the long run are ``FloodWaitError``, and
using parallel download or uploads only makes them occur sooner.
What does "Server sent a very new message with ID" mean?
========================================================
You may also see this error as "Server sent a very old message with ID".
This is a security feature from Telethon that cannot be disabled and is
meant to protect you against replay attacks.
When this message is incorrectly reported as a "bug",
the most common patterns seem to be:
* Your system time is incorrect.
* The proxy you're using may be interfering somehow.
* The Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
What does "Server replied with a wrong session ID" mean?
========================================================
This is a security feature from Telethon that cannot be disabled and is
meant to protect you against unwanted session reuse.
When this message is reported as a "bug", the most common patterns seem to be:
* The proxy you're using may be interfering somehow.
* The Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
* You may be using multiple connections to the Telegram server, which seems
to confuse Telegram.
Most of the time it should be safe to ignore this warning. If the library
still doesn't behave correctly, make sure to check if any of the above bullet
points applies in your case and try to work around it.
If the issue persists and there is a way to reliably reproduce this error,
please add a comment with any additional details you can provide to
`issue 3759`_, and perhaps some additional investigation can be done
(but it's unlikely, as Telegram *is* sending unexpected data).
What does "Could not find a matching Constructor ID for the TLObject" mean?
===========================================================================
Telegram uses "layers", which you can think of as "versions" of the API they
offer. When Telethon reads responses that the Telegram servers send, these
need to be deserialized (into what Telethon calls "TLObjects").
Every Telethon version understands a single Telegram layer. When Telethon
connects to Telegram, both agree on the layer to use. If the layers don't
match, Telegram may send certain objects which Telethon no longer understands.
When this message is reported as a "bug", the most common patterns seem to be
that the Telethon session is being used or has been used from somewhere else.
Make sure that you created the session from Telethon, and are not using the
same session anywhere else. If you need to use the same account from
multiple places, login and use a different session for each place you need.
What does "Task was destroyed but it is pending" mean?
======================================================
Your script likely finished abruptly, the ``asyncio`` event loop got
destroyed, and the library did not get a chance to properly close the
connection and close the session.
Make sure you're either using the context manager for the client or always
call ``await client.disconnect()`` (by e.g. using a ``try/finally``).
What does "The asyncio event loop must not change after connection" mean?
=========================================================================
Telethon uses ``asyncio``, and makes use of things like tasks and queues
internally to manage the connection to the server and match responses to the
requests you make. Most of them are initialized after the client is connected.
For example, if the library expects a result to a request made in loop A, but
you attempt to get that result in loop B, you will very likely find a deadlock.
To avoid a deadlock, the library checks to make sure the loop in use is the
same as the one used to initialize everything, and if not, it throws an error.
The most common cause is ``asyncio.run``, since it creates a new event loop.
If you ``asyncio.run`` a function to create the client and set it up, and then
you ``asyncio.run`` another function to do work, things won't work, so the
library throws an error early to let you know something is wrong.
Instead, it's often a good idea to have a single ``async def main`` and simply
``asyncio.run()`` it and do all the work there. From it, you're also able to
call other ``async def`` without having to touch ``asyncio.run`` again:
.. code-block:: python
# It's fine to create the client outside as long as you don't connect
client = TelegramClient(...)
async def main():
# Now the client will connect, so the loop must not change from now on.
# But as long as you do all the work inside main, including calling
# other async functions, things will work.
async with client:
....
if __name__ == '__main__':
asyncio.run(main())
Be sure to read the ``asyncio`` documentation if you want a better
understanding of event loop, tasks, and what functions you can use.
What does "bases ChatGetter" mean?
==================================
@ -354,36 +204,6 @@ Telegram has a lot to offer, and inheritance helps the library reduce
boilerplate, so it's important to know this concept. For newcomers,
this may be a problem, so we explain what it means here in the FAQ.
Can I send files by ID?
=======================
When people talk about IDs, they often refer to one of two things:
the integer ID inside media, and a random-looking long string.
You cannot use the integer ID to send media. Generally speaking, sending media
requires a combination of ID, ``access_hash`` and ``file_reference``.
The first two are integers, while the last one is a random ``bytes`` sequence.
* The integer ``id`` will always be the same for every account, so every user
or bot looking at a particular media file, will see a consistent ID.
* The ``access_hash`` will always be the same for a given account, but
different accounts will each see their own, different ``access_hash``.
This makes it impossible to get media object from one account and use it in
another. The other account must fetch the media object itself.
* The ``file_reference`` is random for everyone and will only work for a few
hours before it expires. It must be refetched before the media can be used
(to either resend the media or download it).
The second type of "`file ID <https://core.telegram.org/bots/api#inputfile>`_"
people refer to is a concept from the HTTP Bot API. It's a custom format which
encodes enough information to use the media.
Telethon provides an old version of these HTTP Bot API-style file IDs via
``message.file.id``, however, this feature is no longer maintained, so it may
not work. It will be removed in future versions. Nonetheless, it is possible
to find a different Python package (or write your own) to parse these file IDs
and construct the necessary input file objects to send or download the media.
Can I use Flask with the library?
=================================
@ -419,5 +239,4 @@ file and run that, or use the normal ``python`` interpreter.
.. _logging: https://docs.python.org/3/library/logging.html
.. _@SpamBot: https://t.me/SpamBot
.. _issue 297: https://github.com/LonamiWebs/Telethon/issues/297
.. _issue 3759: https://github.com/LonamiWebs/Telethon/issues/3759
.. _quart_login.py: https://github.com/LonamiWebs/Telethon/tree/v1/telethon_examples#quart_loginpy
.. _quart_login.py: https://github.com/LonamiWebs/Telethon/tree/master/telethon_examples#quart_loginpy

View File

@ -1,2 +1 @@
./
sphinx-rtd-theme~=1.3.0
telethon

View File

@ -16,7 +16,6 @@ import os
import re
import shutil
import sys
import urllib.request
from pathlib import Path
from subprocess import run
@ -44,8 +43,6 @@ class TempWorkDir:
os.chdir(self.original)
API_REF_URL = 'https://tl.telethon.dev/'
GENERATOR_DIR = Path('telethon_generator')
LIBRARY_DIR = Path('telethon')
@ -158,31 +155,14 @@ def main(argv):
generate(argv[2:], argv[1])
elif len(argv) >= 2 and argv[1] == 'pypi':
# Make sure tl.telethon.dev is up-to-date first
with urllib.request.urlopen(API_REF_URL) as resp:
html = resp.read()
m = re.search(br'layer\s+(\d+)', html)
if not m:
print('Failed to check that the API reference is up to date:', API_REF_URL)
return
from telethon_generator.parsers import find_layer
layer = next(filter(None, map(find_layer, TLOBJECT_IN_TLS)))
published_layer = int(m[1])
if published_layer != layer:
print('Published layer', published_layer, 'does not match current layer', layer, '.')
print('Make sure to update the API reference site first:', API_REF_URL)
return
# (Re)generate the code to make sure we don't push without it
generate(['tl', 'errors'])
# Try importing the telethon module to assert it has no errors
try:
import telethon
except Exception as e:
except:
print('Packaging for PyPi aborted, importing the module failed.')
print(e)
return
remove_dirs = ['__pycache__', 'build', 'dist', 'Telethon.egg-info']

View File

@ -1,8 +1,8 @@
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, types, functions, custom
from . import version, events, utils, errors
__version__ = version.__version__

View File

@ -1,3 +0,0 @@
from .entitycache import EntityCache
from .messagebox import MessageBox, GapError, PrematureEndReason
from .session import SessionState, ChannelState, Entity, EntityType

View File

@ -1,59 +0,0 @@
from .session import EntityType, Entity
_sentinel = object()
class EntityCache:
def __init__(
self,
hash_map: dict = _sentinel,
self_id: int = None,
self_bot: bool = None
):
self.hash_map = {} if hash_map is _sentinel else hash_map
self.self_id = self_id
self.self_bot = self_bot
def set_self_user(self, id, bot, hash):
self.self_id = id
self.self_bot = bot
if hash:
self.hash_map[id] = (hash, EntityType.BOT if bot else EntityType.USER)
def get(self, id):
try:
hash, ty = self.hash_map[id]
return Entity(ty, id, hash)
except KeyError:
return None
def extend(self, users, chats):
# See https://core.telegram.org/api/min for "issues" with "min constructors".
self.hash_map.update(
(u.id, (
u.access_hash,
EntityType.BOT if u.bot else EntityType.USER,
))
for u in users
if getattr(u, 'access_hash', None) and not u.min
)
self.hash_map.update(
(c.id, (
c.access_hash,
EntityType.MEGAGROUP if c.megagroup else (
EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL
),
))
for c in chats
if getattr(c, 'access_hash', None) and not getattr(c, 'min', None)
)
def put(self, entity):
self.hash_map[entity.id] = (entity.hash, entity.ty)
def retain(self, filter):
self.hash_map = {k: v for k, v in self.hash_map.items() if filter(k)}
def __len__(self):
return len(self.hash_map)

View File

@ -1,825 +0,0 @@
"""
This module deals with correct handling of updates, including gaps, and knowing when the code
should "get difference" (the set of updates that the client should know by now minus the set
of updates that it actually knows).
Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point").
At any given time, the message box may be either getting difference for them (entry is in
[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be
found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is
on its happy path.
Gaps are cleared when they are either resolved on their own (by waiting for a short time)
or because we got the difference for the corresponding entry.
While there are entries for which their difference must be fetched,
[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time
to get the difference.
"""
import asyncio
import datetime
import time
import logging
from enum import Enum
from .session import SessionState, ChannelState
from ..tl import types as tl, functions as fn
from ..helpers import get_running_loop
# 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
# object() but with a tag to make it easier to debug
class Sentinel:
__slots__ = ('tag',)
def __init__(self, tag=None):
self.tag = tag or '_'
def __repr__(self):
return self.tag
# Entry "enum".
# Account-wide `pts` includes private conversations (one-to-one) and small group chats.
ENTRY_ACCOUNT = Sentinel('ACCOUNT')
# Account-wide `qts` includes only "secret" one-to-one chats.
ENTRY_SECRET = Sentinel('SECRET')
# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels.
# Python's logging doesn't define a TRACE level. Pick halfway between DEBUG and NOTSET.
# We don't define a name for this as libraries shouldn't do that though.
LOG_LEVEL_TRACE = (logging.DEBUG - logging.NOTSET) // 2
_sentinel = Sentinel()
def next_updates_deadline():
return get_running_loop().time() + NO_UPDATES_TIMEOUT
def epoch():
return datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
class GapError(ValueError):
def __repr__(self):
return 'GapError()'
class PrematureEndReason(Enum):
TEMPORARY_SERVER_ISSUES = 'tmp'
BANNED = 'ban'
# Represents the information needed to correctly handle a specific `tl::enums::Update`.
class PtsInfo:
__slots__ = ('pts', 'pts_count', 'entry')
def __init__(
self,
pts: int,
pts_count: int,
entry: object
):
self.pts = pts
self.pts_count = pts_count
self.entry = entry
@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:
return cls(pts=qts, pts_count=1, entry=ENTRY_SECRET)
return None
def __repr__(self):
return f'PtsInfo(pts={self.pts}, pts_count={self.pts_count}, entry={self.entry})'
# The state of a particular entry in the message box.
class State:
__slots__ = ('pts', 'deadline')
def __init__(
self,
# Current local persistent timestamp.
pts: int,
# Next instant when we would get the update difference if no updates arrived before then.
deadline: float
):
self.pts = pts
self.deadline = deadline
def __repr__(self):
return f'State(pts={self.pts}, deadline={self.deadline})'
# > ### 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).
class PossibleGap:
__slots__ = ('deadline', 'updates')
def __init__(
self,
deadline: float,
# Pending updates (those with a larger PTS, producing the gap which may later be filled).
updates: list # of updates
):
self.deadline = deadline
self.updates = updates
def __repr__(self):
return f'PossibleGap(deadline={self.deadline}, update_count={len(self.updates)})'
# Represents a "message box" (event `pts` for a specific entry).
#
# See https://core.telegram.org/api/updates#message-related-event-sequences.
class MessageBox:
__slots__ = ('_log', 'map', 'date', 'seq', 'next_deadline', 'possible_gaps', 'getting_diff_for')
def __init__(
self,
log,
# Map each entry to their current state.
map: dict = _sentinel, # entry -> state
# Additional fields beyond PTS needed by `ENTRY_ACCOUNT`.
date: datetime.datetime = epoch() + datetime.timedelta(seconds=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 = _sentinel, # entry -> possiblegap
# For which entries are we currently getting difference.
getting_diff_for: set = _sentinel, # entry
):
self._log = log
self.map = {} if map is _sentinel else map
self.date = date
self.seq = seq
self.next_deadline = next_deadline
self.possible_gaps = {} if possible_gaps is _sentinel else possible_gaps
self.getting_diff_for = set() if getting_diff_for is _sentinel else getting_diff_for
if __debug__:
self._trace('MessageBox initialized')
def _trace(self, msg, *args, **kwargs):
# Calls to trace can't really be removed beforehand without some dark magic.
# So every call to trace is prefixed with `if __debug__`` instead, to remove
# it when using `python -O`. Probably unnecessary, but it's nice to avoid
# paying the cost for something that is not used.
self._log.log(LOG_LEVEL_TRACE, 'Current MessageBox state: seq = %r, date = %s, map = %r',
self.seq, self.date.isoformat(), self.map)
self._log.log(LOG_LEVEL_TRACE, msg, *args, **kwargs)
# region Creation, querying, and setting base state.
def load(self, session_state, channel_states):
"""
Create a [`MessageBox`] from a previously known update state.
"""
if __debug__:
self._trace('Loading MessageBox with session_state = %r, channel_states = %r', session_state, channel_states)
deadline = next_updates_deadline()
self.map.clear()
if session_state.pts != NO_SEQ:
self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline)
if session_state.qts != NO_SEQ:
self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline)
self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states)
self.date = datetime.datetime.fromtimestamp(session_state.date, tz=datetime.timezone.utc)
self.seq = session_state.seq
self.next_deadline = ENTRY_ACCOUNT
def session_state(self):
"""
Return the current state.
This should be used for persisting the state.
"""
return dict(
pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ,
qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ,
date=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 = 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)
# asyncio's loop time precision only seems to be about 3 decimal places, so it's possible that
# we find the same number again on repeated calls. Without the "or equal" part we would log the
# timeout for updates several times (it also makes sense to get difference if now is the 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)
if __debug__:
self._trace('Deadlines met, now getting diff for %r', self.getting_diff_for)
# 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 the given entries.
#
# It also updates the next deadline time to reflect the new closest deadline.
def reset_deadlines(self, entries, deadline):
if not entries:
return
for entry in entries:
if entry not in self.map:
raise RuntimeError('Called reset_deadline on an entry for which we do not have state')
self.map[entry].deadline = deadline
if self.next_deadline in entries:
# 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.
# Any entry will do, so the one from the last iteration is fine.
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_deadlines({channel_id}, get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT))
# 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, reset=True):
if __debug__:
self._trace('Setting state %s', state)
deadline = next_updates_deadline()
if state.pts != NO_SEQ or not reset:
self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline)
else:
self.map.pop(ENTRY_ACCOUNT, None)
# Telegram seems to use the `qts` for bot accounts, but while applying difference,
# it might be reset back to 0. See issue #3873 for more details.
#
# During login, a value of zero would mean the `pts` is unknown,
# so the map shouldn't contain that entry.
# But while applying difference, if the value is zero, it (probably)
# truly means that's what should be used (hence the `reset` flag).
if state.qts != NO_SEQ or not reset:
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 __debug__:
self._trace('Trying to set channel state for %r: %r', id, pts)
if id not in self.map:
self.map[id] = State(pts=pts, deadline=next_updates_deadline())
# Try to begin getting difference for the given entry.
# Fails if the entry does not have a previously-known state that can be used to get its difference.
#
# Clears any previous gaps.
def try_begin_get_diff(self, entry, reason):
if entry not in self.map:
# Won't actually be able to get difference for this entry if we don't have a pts to start off from.
if entry in self.possible_gaps:
raise RuntimeError('Should not have a possible_gap for an entry not in the state map')
if __debug__:
self._trace('Should get difference for %r because %s but cannot due to missing hash', entry, reason)
return
if __debug__:
self._trace('Marking %r as needing difference because %s', entry, reason)
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:
raise RuntimeError('Called end_get_diff on an entry which was not getting diff for')
self.reset_deadlines({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
):
# v1 has never sent updates produced by the client itself to the handlers.
# However proper update handling requires those to be processed.
# This is an ugly workaround for that.
self_outgoing = getattr(updates, '_self_outgoing', False)
real_result = result
result = []
date = getattr(updates, 'date', None)
seq = getattr(updates, 'seq', None)
seq_start = getattr(updates, 'seq_start', None)
users = getattr(updates, 'users', None) or []
chats = getattr(updates, 'chats', None) or []
if __debug__:
self._trace('Processing updates with seq = %r, seq_start = %r, date = %s: %s',
seq, seq_start, date.isoformat() if date else None, updates)
if date is None:
# updatesTooLong is the only one with no date (we treat it as a gap)
self.try_begin_get_diff(ENTRY_ACCOUNT, 'received updatesTooLong')
raise GapError
if seq is None:
seq = NO_SEQ
if seq_start is None:
seq_start = seq
# updateShort is the only update which cannot be dispatched directly but doesn't have 'updates' field
updates = getattr(updates, 'updates', None) or [updates.update if isinstance(updates, tl.UpdateShort) else updates]
for u in updates:
u._self_outgoing = self_outgoing
# > 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
if __debug__:
self._trace('Skipping updates as they should have already been handled')
return (users, chats)
elif self.seq + 1 < seq_start:
# Gap detected
self.try_begin_get_diff(ENTRY_ACCOUNT, 'detected gap')
raise GapError
# else apply
def _sort_gaps(update):
pts = PtsInfo.from_update(update)
return pts.pts - pts.pts_count if pts else 0
reset_deadlines = set() # temporary buffer
result.extend(filter(None, (
self.apply_pts_info(u, reset_deadlines=reset_deadlines)
# Telegram can send updates out of order (e.g. ReadChannelInbox first
# and then NewChannelMessage, both with the same pts, but the count is
# 0 and 1 respectively), so we sort them first.
for u in sorted(updates, key=_sort_gaps))))
self.reset_deadlines(reset_deadlines, next_updates_deadline())
if self.possible_gaps:
if __debug__:
self._trace('Trying to re-apply %r possible gaps', len(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_deadlines=None)
if update:
result.append(update)
if __debug__:
self._trace('Resolved gap with %r: %s', PtsInfo.from_update(update), update)
# Clear now-empty gaps.
self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates}
real_result.extend(u for u in result if not u._self_outgoing)
if result and not self.possible_gaps:
# > If the updates were applied, local *Updates* state must be updated
# > with `seq` (unless it's 0) and `date` from the constructor.
if __debug__:
self._trace('Updating seq as all updates were applied')
if date != epoch():
self.date = date
if seq != NO_SEQ:
self.seq = seq
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_deadlines,
):
# This update means we need to call getChannelDifference to get the updates from the channel
if isinstance(update, tl.UpdateChannelTooLong):
self.try_begin_get_diff(update.channel_id, 'received updateChannelTooLong')
return None
pts = PtsInfo.from_update(update)
if not pts:
# No pts means that the update can be applied in any order.
if __debug__:
self._trace('No pts in update, so it can be applied in any order: %s', update)
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.
#
# By the time this method returns, self.map will have an entry for which we can reset its deadline.
if reset_deadlines:
reset_deadlines.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).
if __debug__:
self._trace('Skipping update with %r as its difference is being fetched', pts)
return None
if pts.entry in self.map:
local_pts = self.map[pts.entry].pts
if local_pts + pts.pts_count > pts.pts:
# Ignore
if __debug__:
self._trace('Skipping update since local pts %r > %r: %s', local_pts, pts, update)
return None
elif local_pts + pts.pts_count < pts.pts:
# Possible gap
# TODO store chats too?
if __debug__:
self._trace('Possible gap since local pts %r < %r: %s', local_pts, pts, update)
if pts.entry not in self.possible_gaps:
self.possible_gaps[pts.entry] = PossibleGap(
deadline=get_running_loop().time() + POSSIBLE_GAP_TIMEOUT,
updates=[]
)
self.possible_gaps[pts.entry].updates.append(update)
return None
else:
# Apply
if __debug__:
self._trace('Applying update pts since local pts %r = %r: %s', local_pts, pts, update)
# In a channel, we may immediately receive:
# * ReadChannelInbox (pts = X, pts_count = 0)
# * NewChannelMessage (pts = X, pts_count = 1)
#
# Notice how both `pts` are the same. If they were to be applied out of order, the first
# one however would've triggered a gap because `local_pts` + `pts_count` of 0 would be
# less than `remote_pts`. So there is no risk by setting the `local_pts` to match the
# `remote_pts` here of missing the new message.
#
# The message would however be lost if we initialized the pts with the first one, since
# the second one would appear "already handled". To prevent this we set the pts to be
# one less when the count is 0 (which might be wrong and trigger a gap later on, but is
# unlikely). This will prevent us from losing updates in the unlikely scenario where these
# two updates arrive in different packets (and therefore couldn't be sorted beforehand).
if pts.entry in self.map:
self.map[pts.entry].pts = pts.pts
else:
# When a chat is migrated to a megagroup, the first update can be a `ReadChannelInbox`
# with `pts = 1, pts_count = 0` followed by a `NewChannelMessage` with `pts = 2, pts_count=1`.
# Note how the `pts` for the message is 2 and not 1 unlike the case described before!
# This is likely because the `pts` cannot be 0 (or it would fail with PERSISTENT_TIMESTAMP_EMPTY),
# which forces the first update to be 1. But if we got difference with 1 and the second update
# also used 1, we would miss it, so Telegram probably uses 2 to work around that.
self.map[pts.entry] = State(
pts=(pts.pts - (0 if pts.pts_count else 1)) or 1,
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):
for entry in (ENTRY_ACCOUNT, ENTRY_SECRET):
if entry in self.getting_diff_for:
if entry not in self.map:
raise RuntimeError('Should not try to get difference for an entry without known state')
gd = fn.updates.GetDifferenceRequest(
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,
)
if __debug__:
self._trace('Requesting account difference %s', gd)
return gd
return None
# Similar to [`MessageBox::process_updates`], but using the result from getting difference.
def apply_difference(
self,
diff,
chat_hashes,
):
if __debug__:
self._trace('Applying account difference %s', diff)
finish = None
result = None
if isinstance(diff, tl.updates.DifferenceEmpty):
finish = True
self.date = diff.date
self.seq = diff.seq
result = [], [], []
elif isinstance(diff, tl.updates.Difference):
finish = True
chat_hashes.extend(diff.users, diff.chats)
result = self.apply_difference_type(diff, chat_hashes)
elif isinstance(diff, tl.updates.DifferenceSlice):
finish = False
chat_hashes.extend(diff.users, diff.chats)
result = self.apply_difference_type(diff, chat_hashes)
elif isinstance(diff, tl.updates.DifferenceTooLong):
finish = True
self.map[ENTRY_ACCOUNT].pts = diff.pts # the deadline will be reset once the diff ends
result = [], [], []
if finish:
account = ENTRY_ACCOUNT in self.getting_diff_for
secret = ENTRY_SECRET in self.getting_diff_for
if not account and not secret:
raise RuntimeError('Should not be applying the difference when neither account or secret was diff was active')
# Both may be active if both expired at the same time.
if account:
self.end_get_diff(ENTRY_ACCOUNT)
if secret:
self.end_get_diff(ENTRY_SECRET)
return result
def apply_difference_type(
self,
diff,
chat_hashes,
):
state = getattr(diff, 'intermediate_state', None) or diff.state
self.set_state(state, reset=False)
# diff.other_updates can contain things like UpdateChannelTooLong and UpdateNewChannelMessage.
# We need to process those as if they were socket updates to discard any we have already handled.
updates = []
self.process_updates(tl.Updates(
updates=diff.other_updates,
users=diff.users,
chats=diff.chats,
date=epoch(),
seq=NO_SEQ, # this way date is not used
), chat_hashes, updates)
updates.extend(tl.UpdateNewMessage(
message=m,
pts=NO_SEQ,
pts_count=NO_SEQ,
) for m in diff.new_messages)
updates.extend(tl.UpdateNewEncryptedMessage(
message=m,
qts=NO_SEQ,
) for m in diff.new_encrypted_messages)
return updates, diff.users, diff.chats
def end_difference(self):
if __debug__:
self._trace('Ending account difference')
account = ENTRY_ACCOUNT in self.getting_diff_for
secret = ENTRY_SECRET in self.getting_diff_for
if not account and not secret:
raise RuntimeError('Should not be ending get difference when neither account or secret was diff was active')
# Both may be active if both expired at the same time.
if account:
self.end_get_diff(ENTRY_ACCOUNT)
if secret:
self.end_get_diff(ENTRY_SECRET)
# 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
# TODO we should probably log this
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:
raise RuntimeError('Should not try to get difference for an entry without known state')
gd = fn.updates.GetChannelDifferenceRequest(
force=False,
channel=tl.InputChannel(packed.id, packed.hash),
filter=tl.ChannelMessagesFilterEmpty(),
pts=state.pts,
limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT
)
if __debug__:
self._trace('Requesting channel difference %s', gd)
return gd
# 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
if __debug__:
self._trace('Applying channel difference for %r: %s', entry, diff)
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
chat_hashes.extend(diff.users, diff.chats)
updates = []
self.process_updates(tl.Updates(
updates=diff.other_updates,
users=diff.users,
chats=diff.chats,
date=epoch(),
seq=NO_SEQ, # this way date is not used
), chat_hashes, updates)
updates.extend(tl.UpdateNewChannelMessage(
message=m,
pts=NO_SEQ,
pts_count=NO_SEQ,
) for m in diff.new_messages)
self.reset_channel_deadline(entry, None)
return updates, diff.users, diff.chats
def end_channel_difference(self, request, reason: PrematureEndReason, chat_hashes):
entry = request.channel.channel_id
if __debug__:
self._trace('Ending channel difference for %r because %s', entry, reason)
if reason == PrematureEndReason.TEMPORARY_SERVER_ISSUES:
# Temporary issues. End getting difference without updating the pts so we can retry later.
self.possible_gaps.pop(entry, None)
self.end_get_diff(entry)
elif reason == PrematureEndReason.BANNED:
# Banned in the channel. Forget its state since we can no longer fetch updates from it.
self.possible_gaps.pop(entry, None)
self.end_get_diff(entry)
del self.map[entry]
else:
raise RuntimeError('Unknown reason to end channel difference')
# endregion Getting and applying channel difference.

View File

@ -1,195 +0,0 @@
from typing import Optional, Tuple
from enum import IntEnum
from ..tl.types import InputPeerUser, InputPeerChat, InputPeerChannel
import struct
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')
def __init__(
self,
user_id: int,
dc_id: int,
bot: bool,
pts: int,
qts: int,
date: int,
seq: int,
takeout_id: Optional[int]
):
self.user_id = user_id
self.dc_id = dc_id
self.bot = bot
self.pts = pts
self.qts = qts
self.date = date
self.seq = seq
self.takeout_id = takeout_id
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})
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')
def __init__(
self,
channel_id: int,
pts: int,
):
self.channel_id = channel_id
self.pts = pts
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})
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,
}
class Entity:
"""
Stores the information needed to use a certain user, chat or channel with the API.
* ty: 8-bit number indicating the type of the entity (of type `EntityType`).
* id: 64-bit number uniquely identifying the entity among those of the same type.
* hash: 64-bit signed number needed to use this entity with the API.
The string representation of this class is considered to be stable, for as long as
Telegram doesn't need to add more fields to the entities. It can also be converted
to bytes with ``bytes(entity)``, for a more compact representation.
"""
__slots__ = ('ty', 'id', 'hash')
def __init__(
self,
ty: EntityType,
id: int,
hash: int
):
self.ty = ty
self.id = id
self.hash = hash
@property
def is_user(self):
"""
``True`` if the entity is either a user or a bot.
"""
return self.ty in (EntityType.USER, EntityType.BOT)
@property
def is_group(self):
"""
``True`` if the entity is a small group chat or `megagroup`_.
.. _megagroup: https://telegram.org/blog/supergroups5k
"""
return self.ty in (EntityType.GROUP, EntityType.MEGAGROUP)
@property
def is_broadcast(self):
"""
``True`` if the entity is a broadcast channel or `broadcast group`_.
.. _broadcast group: https://telegram.org/blog/autodelete-inv2#groups-with-unlimited-members
"""
return self.ty in (EntityType.CHANNEL, EntityType.GIGAGROUP)
@classmethod
def from_str(cls, string: str):
"""
Convert the string into an `Entity`.
"""
try:
ty, id, hash = string.split('.')
ty, id, hash = ord(ty), int(id), int(hash)
except AttributeError:
raise TypeError(f'expected str, got {string!r}') from None
except (TypeError, ValueError):
raise ValueError(f'malformed entity str (must be T.id.hash), got {string!r}') from None
return cls(EntityType(ty), id, hash)
@classmethod
def from_bytes(cls, blob):
"""
Convert the bytes into an `Entity`.
"""
try:
ty, id, hash = struct.unpack('<Bqq', blob)
except struct.error:
raise ValueError(f'malformed entity data, got {blob!r}') from None
return cls(EntityType(ty), id, hash)
def __str__(self):
return f'{chr(self.ty)}.{self.id}.{self.hash}'
def __bytes__(self):
return struct.pack('<Bqq', self.ty, self.id, self.hash)
def _as_input_peer(self):
if self.is_user:
return InputPeerUser(self.id, self.hash)
elif self.ty == EntityType.GROUP:
return InputPeerChat(self.id)
else:
return InputPeerChannel(self.id, self.hash)
def __repr__(self):
return repr({k: getattr(self, k) for k in self.__slots__})

View File

@ -7,7 +7,6 @@ import warnings
from .. import utils, helpers, errors, password as pwd_mod
from ..tl import types, functions, custom
from .._updates import SessionState
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
@ -19,8 +18,8 @@ class AuthMethods:
def start(
self: 'TelegramClient',
phone: typing.Union[typing.Callable[[], str], str] = lambda: input('Please enter your phone (or bot token): '),
password: typing.Union[typing.Callable[[], str], str] = lambda: getpass.getpass('Please enter your password: '),
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,
@ -34,6 +33,12 @@ class AuthMethods:
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.
@ -147,16 +152,14 @@ class AuthMethods:
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; '
'if you were expecting a different user, check whether '
'you are accidentally reusing an existing session'
'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; '
'if you were expecting a different user, check whether '
'you are accidentally reusing an existing session'
'not login to the user account using the provided '
'phone (it may not be using the user you expect)'
)
return self
@ -184,6 +187,7 @@ class AuthMethods:
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()
@ -196,12 +200,19 @@ class AuthMethods:
if not value:
raise errors.PhoneCodeEmptyError(request=None)
# Raises SessionPasswordNeededError if 2FA enabled
me = await self.sign_in(phone, code=value)
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,
@ -240,14 +251,13 @@ class AuthMethods:
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)
tos = '; remember to not break the ToS or you will risk an account ban!'
signed, name = 'Signed in successfully as', utils.get_display_name(me)
try:
print(signed, name, tos, sep='')
print(signed, name)
except UnicodeEncodeError:
# Some terminals don't support certain characters
print(signed, name.encode('utf-8', errors='ignore')
.decode('ascii', errors='ignore'), tos, sep='')
.decode('ascii', errors='ignore'))
return self
@ -355,18 +365,13 @@ class AuthMethods:
'and a password only if an RPCError was raised before.'
)
try:
result = await self(request)
except errors.PhoneCodeExpiredError:
self._phone_code_hash.pop(phone, None)
raise
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 await self._on_login(result.user)
return self._on_login(result.user)
async def sign_up(
self: 'TelegramClient',
@ -377,41 +382,109 @@ class AuthMethods:
phone: str = None,
phone_code_hash: str = None) -> 'types.User':
"""
This method can no longer be used, and will immediately raise a ``ValueError``.
See `issue #4050 <https://github.com/LonamiWebs/Telethon/issues/4050>`_ for context.
"""
raise ValueError('Third-party applications cannot sign up for Telegram. See https://github.com/LonamiWebs/Telethon/issues/4050 for details')
Signs up to Telegram as a new user account.
async def _on_login(self, user):
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._mb_entity_cache.set_self_user(user.id, user.bot, user.access_hash)
self._bot = bool(user.bot)
self._self_input_peer = utils.get_input_peer(user, allow_self=False)
self._authorized = True
state = await self(functions.updates.GetStateRequest())
# the server may send an old qts in getState
difference = await self(functions.updates.GetDifferenceRequest(pts=state.pts, date=state.date, qts=state.qts))
if isinstance(difference, types.updates.Difference):
state = difference.state
elif isinstance(difference, types.updates.DifferenceSlice):
state = difference.intermediate_state
elif isinstance(difference, types.updates.DifferenceTooLong):
state.pts = difference.pts
self._message_box.load(SessionState(0, 0, 0, state.pts, state.qts, int(state.date.timestamp()), state.seq, 0), [])
return user
async def send_code_request(
self: 'TelegramClient',
phone: str,
*,
force_sms: bool = False,
_retry_count: int = 0) -> 'types.auth.SentCode':
force_sms: bool = False) -> 'types.auth.SentCode':
"""
Sends the Telegram code needed to login to the given phone number.
@ -420,8 +493,7 @@ class AuthMethods:
The phone to which the code will be sent.
force_sms (`bool`, optional):
Whether to force sending as SMS. This has been deprecated.
See `issue #4050 <https://github.com/LonamiWebs/Telethon/issues/4050>`_ for context.
Whether to force sending as SMS.
Returns
An instance of :tl:`SentCode`.
@ -433,10 +505,6 @@ class AuthMethods:
sent = await client.send_code_request(phone)
print(sent)
"""
if force_sms:
warnings.warn('force_sms has been deprecated and no longer works')
force_sms = False
result = None
phone = utils.parse_phone(phone) or self._phone
phone_hash = self._phone_code_hash.get(phone)
@ -446,14 +514,7 @@ class AuthMethods:
result = await self(functions.auth.SendCodeRequest(
phone, self.api_id, self.api_hash, types.CodeSettings()))
except errors.AuthRestartError:
if _retry_count > 2:
raise
return await self.send_code_request(
phone, force_sms=force_sms, _retry_count=_retry_count+1)
# TODO figure out when/if/how this can happen
if isinstance(result, types.auth.SentCodeSuccess):
raise RuntimeError('logged in right after sending the code')
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):
@ -468,21 +529,8 @@ class AuthMethods:
self._phone = phone
if force_sms:
try:
result = await self(
functions.auth.ResendCodeRequest(phone, phone_hash))
except errors.PhoneCodeExpiredError:
if _retry_count > 2:
raise
self._phone_code_hash.pop(phone, None)
self._log[__name__].info(
"Phone code expired in ResendCodeRequest, requesting a new code"
)
return await self.send_code_request(
phone, force_sms=False, _retry_count=_retry_count+1)
if isinstance(result, types.auth.SentCodeSuccess):
raise RuntimeError('logged in right after resending the code')
result = await self(
functions.auth.ResendCodeRequest(phone, phone_hash))
self._phone_code_hash[phone] = result.phone_code_hash
@ -520,9 +568,6 @@ class AuthMethods:
# Important! You need to wait for the login to complete!
await qr_login.wait()
# If you have 2FA enabled, `wait` will raise `telethon.errors.SessionPasswordNeededError`.
# You should except that error and call `sign_in` with the password if this happens.
"""
qr_login = custom.QRLogin(self, ignored_ids or [])
await qr_login.recreate()
@ -532,8 +577,6 @@ class AuthMethods:
"""
Logs out Telegram and deletes the current ``*.session`` file.
The client is unusable after logging out and a new instance should be created.
Returns
`True` if the operation was successful.
@ -548,12 +591,13 @@ class AuthMethods:
except errors.RPCError:
return False
self._mb_entity_cache.set_self_user(None, None, None)
self._bot = None
self._self_input_peer = None
self._authorized = False
self._state_cache.reset()
await self.disconnect()
await utils.maybe_async(self.session.delete())
self.session = None
self.session.delete()
return True
async def edit_2fa(

View File

@ -7,8 +7,8 @@ from ..tl import types, custom
class ButtonMethods:
@staticmethod
def build_reply_markup(
buttons: 'typing.Optional[hints.MarkupLike]'
) -> 'typing.Optional[types.TypeReplyMarkup]':
buttons: 'typing.Optional[hints.MarkupLike]',
inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]':
"""
Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for
the given buttons.
@ -26,6 +26,9 @@ class ButtonMethods:
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
@ -39,8 +42,8 @@ class ButtonMethods:
return None
try:
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: # crc32(b'ReplyMarkup'):
return buttons
if buttons.SUBCLASS_OF_ID == 0xe2e10ef2:
return buttons # crc32(b'ReplyMarkup'):
except AttributeError:
pass
@ -54,8 +57,6 @@ class ButtonMethods:
resize = None
single_use = None
selective = None
persistent = None
placeholder = None
rows = []
for row in buttons:
@ -68,10 +69,6 @@ class ButtonMethods:
single_use = button.single_use
if button.selective is not None:
selective = button.selective
if button.persistent is not None:
persistent = button.persistent
if button.placeholder is not None:
placeholder = button.placeholder
button = button.button
elif isinstance(button, custom.MessageButton):
@ -81,21 +78,19 @@ class ButtonMethods:
is_inline |= inline
is_normal |= not inline
if button.SUBCLASS_OF_ID == 0xbad74a3: # crc32(b'KeyboardButton')
if button.SUBCLASS_OF_ID == 0xbad74a3:
# 0xbad74a3 == crc32(b'KeyboardButton')
current.append(button)
if current:
rows.append(types.KeyboardButtonRow(current))
if is_inline and is_normal:
if inline_only and is_normal:
raise ValueError('You cannot use non-inline buttons here')
elif is_inline == is_normal and is_normal:
raise ValueError('You cannot mix inline with normal buttons')
elif is_inline:
return types.ReplyInlineMarkup(rows)
# elif is_normal:
return types.ReplyKeyboardMarkup(
rows=rows,
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)
rows, resize=resize, single_use=single_use, selective=selective)

View File

@ -22,7 +22,6 @@ class _ChatAction:
'contact': types.SendMessageChooseContactAction(),
'game': types.SendMessageGamePlayAction(),
'location': types.SendMessageGeoLocationAction(),
'sticker': types.SendMessageChooseStickerAction(),
'record-audio': types.SendMessageRecordAudioAction(),
'record-voice': types.SendMessageRecordAudioAction(), # alias
@ -97,7 +96,7 @@ class _ChatAction:
class _ParticipantsIter(RequestIter):
async def _init(self, entity, filter, search):
async def _init(self, entity, filter, search, aggressive):
if isinstance(filter, type):
if filter in (types.ChannelParticipantsBanned,
types.ChannelParticipantsKicked,
@ -122,25 +121,33 @@ class _ParticipantsIter(RequestIter):
self.filter_entity = lambda ent: True
# Only used for channels, but we should always set the attribute
# Called `requests` even though it's just one for legacy purposes.
self.requests = None
self.requests = []
if ty == helpers._EntityType.CHANNEL:
self.total = (await self.client(
functions.channels.GetFullChannelRequest(entity)
)).full_chat.participants_count
if self.limit <= 0:
# May not have access to the channel, but getFull can get the .total.
self.total = (await self.client(
functions.channels.GetFullChannelRequest(entity)
)).full_chat.participants_count
raise StopAsyncIteration
self.seen = set()
self.requests = functions.channels.GetParticipantsRequest(
channel=entity,
filter=filter or types.ChannelParticipantsSearch(search),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
)
if aggressive and not filter:
self.requests.extend(functions.channels.GetParticipantsRequest(
channel=entity,
filter=types.ChannelParticipantsSearch(x),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
) for x in (search or string.ascii_lowercase))
else:
self.requests.append(functions.channels.GetParticipantsRequest(
channel=entity,
filter=filter or types.ChannelParticipantsSearch(search),
offset=0,
limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
hash=0
))
elif ty == helpers._EntityType.CHAT:
full = await self.client(
@ -155,18 +162,11 @@ class _ParticipantsIter(RequestIter):
users = {user.id: user for user in full.users}
for participant in full.full_chat.participants.participants:
if isinstance(participant, types.ChannelParticipantLeft):
# See issue #3231 to learn why this is ignored.
continue
elif isinstance(participant, types.ChannelParticipantBanned):
user_id = participant.peer.user_id
else:
user_id = participant.user_id
user = users[user_id]
user = users[participant.user_id]
if not self.filter_entity(user):
continue
user = users[user_id]
user = users[participant.user_id]
user.participant = participant
self.buffer.append(user)
@ -185,74 +185,51 @@ class _ParticipantsIter(RequestIter):
if not self.requests:
return True
self.requests.limit = min(self.limit - self.requests.offset, _MAX_PARTICIPANTS_CHUNK_SIZE)
# Only care about the limit for the first request
# (small amount of people, won't be aggressive).
#
# Most people won't care about getting exactly 12,345
# members so it doesn't really matter not to be 100%
# precise with being out of the offset/limit here.
self.requests[0].limit = min(
self.limit - self.requests[0].offset, _MAX_PARTICIPANTS_CHUNK_SIZE)
if self.requests.offset > self.limit:
if self.requests[0].offset > self.limit:
return True
if self.total is None:
f = self.requests.filter
if (
not isinstance(f, types.ChannelParticipantsRecent)
and (not isinstance(f, types.ChannelParticipantsSearch) or f.q)
):
# Only do an additional getParticipants here to get the total
# if there's a filter which would reduce the real total number.
# getParticipants is cheaper than getFull.
self.total = (await self.client(functions.channels.GetParticipantsRequest(
channel=self.requests.channel,
filter=types.ChannelParticipantsRecent(),
offset=0,
limit=1,
hash=0
))).count
participants = await self.client(self.requests)
if self.total is None:
# Will only get here if there was one request with a filter that matched all users.
self.total = participants.count
if not participants.users:
self.requests = None
return
self.requests.offset += len(participants.participants)
users = {user.id: user for user in participants.users}
for participant in participants.participants:
if isinstance(participant, types.ChannelParticipantLeft):
# See issue #3231 to learn why this is ignored.
results = await self.client(self.requests)
for i in reversed(range(len(self.requests))):
participants = results[i]
if not participants.users:
self.requests.pop(i)
continue
elif isinstance(participant, types.ChannelParticipantBanned):
if not isinstance(participant.peer, types.PeerUser):
# May have the entire channel banned. See #3105.
self.requests[i].offset += len(participants.participants)
users = {user.id: user for user in participants.users}
for participant in participants.participants:
user = users[participant.user_id]
if not self.filter_entity(user) or user.id in self.seen:
continue
user_id = participant.peer.user_id
else:
user_id = participant.user_id
user = users[user_id]
if not self.filter_entity(user) or user.id in self.seen:
continue
self.seen.add(user_id)
user = users[user_id]
user.participant = participant
self.buffer.append(user)
self.seen.add(participant.user_id)
user = users[participant.user_id]
user.participant = participant
self.buffer.append(user)
class _AdminLogIter(RequestIter):
async def _init(
self, entity, admins, search, min_id, max_id,
join, leave, invite, restrict, unrestrict, ban, unban,
promote, demote, info, settings, pinned, edit, delete,
group_call
promote, demote, info, settings, pinned, edit, delete
):
if any((join, leave, invite, restrict, unrestrict, ban, unban,
promote, demote, info, settings, pinned, edit, delete,
group_call)):
promote, demote, info, settings, pinned, edit, delete)):
events_filter = types.ChannelAdminLogEventsFilter(
join=join, leave=leave, invite=invite, ban=restrict,
unban=unrestrict, kick=ban, unkick=unban, promote=promote,
demote=demote, info=info, settings=settings, pinned=pinned,
edit=edit, delete=delete, group_call=group_call
edit=edit, delete=delete
)
else:
events_filter = None
@ -419,6 +396,9 @@ class ChatMethods:
search (`str`, optional):
Look for participants with this string in name/username.
If ``aggressive is True``, the symbols from this string will
be used.
filter (:tl:`ChannelParticipantsFilter`, optional):
The filter to be used, if you want e.g. only admins
Note that you might not have permissions for some filter.
@ -431,11 +411,14 @@ class ChatMethods:
use :tl:`ChannelParticipantsKicked` instead.
aggressive (`bool`, optional):
Does nothing. This is kept for backwards-compatibility.
Aggressively looks for all participants in the chat.
There have been several changes to Telegram's API that limits
the amount of members that can be retrieved, and this was a
hack that no longer works.
This is useful for channels since 20 July 2018,
Telegram added a server-side limit where only the
first 200 members can be retrieved. With this flag
set, more than 200 will be often be retrieved.
This has no effect if a ``filter`` is given.
Yields
The :tl:`User` objects returned by :tl:`GetParticipantsRequest`
@ -464,7 +447,8 @@ class ChatMethods:
limit,
entity=entity,
filter=filter,
search=search
search=search,
aggressive=aggressive
)
async def get_participants(
@ -489,7 +473,6 @@ class ChatMethods:
get_participants.__signature__ = inspect.signature(iter_participants)
def iter_admin_log(
self: 'TelegramClient',
entity: 'hints.EntityLike',
@ -512,8 +495,7 @@ class ChatMethods:
settings: bool = None,
pinned: bool = None,
edit: bool = None,
delete: bool = None,
group_call: bool = None) -> _AdminLogIter:
delete: bool = None) -> _AdminLogIter:
"""
Iterator over the admin log for the specified channel.
@ -600,9 +582,6 @@ class ChatMethods:
delete (`bool`):
If `True`, events of message deletions will be returned.
group_call (`bool`):
If `True`, events related to group calls will be returned.
Yields
Instances of `AdminLogEvent <telethon.tl.custom.adminlogevent.AdminLogEvent>`.
@ -634,8 +613,7 @@ class ChatMethods:
settings=settings,
pinned=pinned,
edit=edit,
delete=delete,
group_call=group_call
delete=delete
)
async def get_admin_log(
@ -755,7 +733,6 @@ class ChatMethods:
* ``'contact'``: choosing a contact.
* ``'game'``: playing a game.
* ``'location'``: choosing a geo location.
* ``'sticker'``: choosing a sticker.
* ``'record-audio'``: recording a voice note.
You may use ``'record-voice'`` as alias.
* ``'record-round'``: recording a round video.
@ -805,8 +782,7 @@ class ChatMethods:
try:
action = _ChatAction._str_mapping[action.lower()]
except KeyError:
raise ValueError(
'No such action "{}"'.format(action)) from None
raise ValueError('No such action "{}"'.format(action)) from None
elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21:
# 0x20b2cc21 = crc32(b'SendMessageAction')
if isinstance(action, type):
@ -835,7 +811,6 @@ class ChatMethods:
invite_users: bool = None,
pin_messages: bool = None,
add_admins: bool = None,
manage_call: bool = None,
anonymous: bool = None,
is_admin: bool = None,
title: str = None) -> types.Updates:
@ -880,9 +855,6 @@ class ChatMethods:
add_admins (`bool`, optional):
Whether the user will be able to add admins.
manage_call (`bool`, optional):
Whether the user will be able to manage group calls.
anonymous (`bool`, optional):
Whether the user will remain anonymous when sending messages.
The sender of the anonymous messages becomes the group itself.
@ -928,11 +900,14 @@ class ChatMethods:
"""
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',
'anonymous',
)
ty = helpers._entity_type(entity)
@ -965,11 +940,10 @@ class ChatMethods:
is_admin = any(locals()[x] for x in perm_names)
return await self(functions.messages.EditChatAdminRequest(
entity.chat_id, user, is_admin=is_admin))
entity, user, is_admin=is_admin))
else:
raise ValueError(
'You can only edit permissions in groups and channels')
raise ValueError('You can only edit permissions in groups and channels')
async def edit_permissions(
self: 'TelegramClient',
@ -1114,10 +1088,16 @@ class ChatMethods:
))
user = await self.get_input_entity(user)
ty = helpers._entity_type(user)
if ty != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
if isinstance(user, types.InputPeerSelf):
raise ValueError('You cannot restrict yourself')
return await self(functions.channels.EditBannedRequest(
channel=entity,
participant=user,
user_id=user,
banned_rights=rights
))
@ -1144,57 +1124,48 @@ class ChatMethods:
user (`entity`, optional):
The user to kick.
Returns
Returns the service `Message <telethon.tl.custom.message.Message>`
produced about a user being kicked, if any.
Example
.. code-block:: python
# Kick some user from some chat, and deleting the service message
msg = await client.kick_participant(chat, user)
await msg.delete()
# Kick some user from some chat
await client.kick_participant(chat, user)
# Leaving chat
await client.kick_participant(chat, 'me')
"""
entity = await self.get_input_entity(entity)
user = await self.get_input_entity(user)
if helpers._entity_type(user) != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
ty = helpers._entity_type(entity)
if ty == helpers._EntityType.CHAT:
resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user))
await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user))
elif ty == helpers._EntityType.CHANNEL:
if isinstance(user, types.InputPeerSelf):
# Despite no longer being in the channel, the account still
# seems to get the service message.
resp = await self(functions.channels.LeaveChannelRequest(entity))
await self(functions.channels.LeaveChannelRequest(entity))
else:
resp = await self(functions.channels.EditBannedRequest(
await self(functions.channels.EditBannedRequest(
channel=entity,
participant=user,
banned_rights=types.ChatBannedRights(
until_date=None, view_messages=True)
user_id=user,
banned_rights=types.ChatBannedRights(until_date=None, view_messages=True)
))
await asyncio.sleep(0.5)
await self(functions.channels.EditBannedRequest(
channel=entity,
participant=user,
user_id=user,
banned_rights=types.ChatBannedRights(until_date=None)
))
else:
raise ValueError('You must pass either a channel or a chat')
return self._get_response_message(None, resp, entity)
async def get_permissions(
self: 'TelegramClient',
entity: 'hints.EntityLike',
user: 'hints.EntityLike' = None
user: 'hints.EntityLike'
) -> 'typing.Optional[custom.ParticipantPermissions]':
"""
Fetches the permissions of a user in a specific chat or channel or
get Default Restricted Rights of Chat or Channel.
Fetches the permissions of a user in a specific chat or channel.
.. note::
@ -1205,7 +1176,7 @@ class ChatMethods:
entity (`entity`):
The channel or chat the user is participant of.
user (`entity`, optional):
user (`entity`):
Target user.
Returns
@ -1219,23 +1190,11 @@ class ChatMethods:
permissions = await client.get_permissions(chat, user)
if permissions.is_admin:
# do something
# Get Banned Permissions of Chat
await client.get_permissions(chat)
"""
entity = await self.get_entity(entity)
if not user:
if isinstance(entity, types.Channel):
FullChat = await self(functions.channels.GetFullChannelRequest(entity))
elif isinstance(entity, types.Chat):
FullChat = await self(functions.messages.GetFullChatRequest(entity.id))
else:
return
return FullChat.chats[0].default_banned_rights
entity = await self.get_input_entity(entity)
user = await self.get_input_entity(user)
if helpers._entity_type(user) != helpers._EntityType.USER:
raise ValueError('You must pass a user entity')
if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
participant = await self(functions.channels.GetParticipantRequest(
entity,
@ -1244,7 +1203,7 @@ class ChatMethods:
return custom.ParticipantPermissions(participant.participant, False)
elif helpers._entity_type(entity) == helpers._EntityType.CHAT:
chat = await self(functions.messages.GetFullChatRequest(
entity.chat_id
entity
))
if isinstance(user, types.InputPeerSelf):
user = await self.get_me(input_peer=True)
@ -1258,7 +1217,6 @@ class ChatMethods:
async def get_stats(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'typing.Union[int, types.Message]' = None,
):
"""
Retrieves statistics from the given megagroup or broadcast channel.
@ -1271,10 +1229,6 @@ class ChatMethods:
entity (`entity`):
The channel from which to get statistics.
message (`int` | ``Message``, optional):
The message ID from which to get statistics, if your goal is
to obtain the statistics of a single message.
Raises
If the given entity is not a channel (broadcast or megagroup),
a `TypeError` is raised.
@ -1283,10 +1237,8 @@ class ChatMethods:
``telethon.errors.ChatAdminRequiredError`` will appear.
Returns
If both ``entity`` and ``message`` were provided, returns
:tl:`MessageStats`. Otherwise, either :tl:`BroadcastStats` or
:tl:`MegagroupStats`, depending on whether the input belonged to a
broadcast channel or megagroup.
Either :tl:`BroadcastStats` or :tl:`MegagroupStats`, depending on
whether the input belonged to a broadcast channel or megagroup.
Example
.. code-block:: python
@ -1301,30 +1253,22 @@ class ChatMethods:
"""
entity = await self.get_input_entity(entity)
if helpers._entity_type(entity) != helpers._EntityType.CHANNEL:
raise TypeError('You must pass a channel entity')
raise TypeError('You must pass a user entity')
message = utils.get_message_id(message)
if message is not None:
# Don't bother fetching the Channel entity (costs a request), instead
# try to guess and if it fails we know it's the other one (best case
# no extra request, worst just one).
try:
req = functions.stats.GetBroadcastStatsRequest(entity)
return await self(req)
except errors.StatsMigrateError as e:
dc = e.dc
except errors.BroadcastRequiredError:
req = functions.stats.GetMegagroupStatsRequest(entity)
try:
req = functions.stats.GetMessageStatsRequest(entity, message)
return await self(req)
except errors.StatsMigrateError as e:
dc = e.dc
else:
# Don't bother fetching the Channel entity (costs a request), instead
# try to guess and if it fails we know it's the other one (best case
# no extra request, worst just one).
try:
req = functions.stats.GetBroadcastStatsRequest(entity)
return await self(req)
except errors.StatsMigrateError as e:
dc = e.dc
except errors.BroadcastRequiredError:
req = functions.stats.GetMegagroupStatsRequest(entity)
try:
return await self(req)
except errors.StatsMigrateError as e:
dc = e.dc
sender = await self._borrow_exported_sender(dc)
try:

View File

@ -58,8 +58,6 @@ class _DialogsIter(RequestIter):
for x in itertools.chain(r.users, r.chats)
if not isinstance(x, (types.UserEmpty, types.ChatEmpty))}
self.client._mb_entity_cache.extend(r.users, r.chats)
messages = {}
for m in r.messages:
m._finish_init(self.client, entities, None)
@ -84,16 +82,14 @@ class _DialogsIter(RequestIter):
cd = custom.Dialog(self.client, d, entities, message)
if cd.dialog.pts:
self.client._message_box.try_set_channel_state(
utils.get_peer_id(d.peer, add_mark=False), 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 not self.buffer or len(r.dialogs) < self.request.limit\
if len(r.dialogs) < self.request.limit\
or not isinstance(r, types.messages.DialogsSlice):
# Buffer being empty means all returned dialogs were skipped (due to offsets).
# Less than we requested means we reached the end, or
# we didn't get a DialogsSlice which means we got all.
return True
@ -374,7 +370,7 @@ class DialogMethods:
await client.edit_folder(dialogs, [0, 1])
# Un-archiving all dialogs
await client.edit_folder(unpack=1)
await client.archive(unpack=1)
"""
if (entity is None) == (unpack is None):
raise ValueError('You can only set either entities or unpack, not both')
@ -460,8 +456,7 @@ class DialogMethods:
if ty == helpers._EntityType.CHAT and not deactivated:
try:
result = await self(functions.messages.DeleteChatUserRequest(
entity.chat_id, types.InputUserSelf(), revoke_history=revoke
))
entity.chat_id, types.InputUserSelf()))
except errors.PeerIdInvalidError:
# Happens if we didn't have the deactivated information
result = None
@ -486,16 +481,6 @@ class DialogMethods:
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.

View File

@ -4,7 +4,6 @@ import os
import pathlib
import typing
import inspect
import asyncio
from ..crypto import AES
@ -24,34 +23,21 @@ if typing.TYPE_CHECKING:
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 _CdnRedirect(Exception):
def __init__(self, cdn_redirect=None):
self.cdn_redirect = cdn_redirect
class _DirectDownloadIter(RequestIter):
async def _init(
self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data, cdn_redirect=None):
self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data
):
self.request = functions.upload.GetFileRequest(
file, offset=offset, limit=request_size)
self._client = self.client
self._cdn_redirect = cdn_redirect
if cdn_redirect is not None:
self.request = functions.upload.GetCdnFileRequest(cdn_redirect.file_token, offset=offset, limit=request_size)
self._client = await self.client._get_cdn_client(cdn_redirect)
file, offset=offset, limit=request_size)
self.total = file_size
self._stride = stride
self._chunk_size = chunk_size
self._last_part = None
self._msg_data = msg_data
self._timed_out = False
self._exported = dc_id and self._client.session.dc_id != dc_id
self._exported = dc_id and self.client.session.dc_id != dc_id
if not self._exported:
# The used sender will also change if ``FileMigrateError`` occurs
self._sender = self.client._sender
@ -63,12 +49,9 @@ class _DirectDownloadIter(RequestIter):
config = await self.client(functions.help.GetConfigRequest())
for option in config.dc_options:
if option.ip_address == self.client.session.server_address:
await utils.maybe_async(
self.client.session.set_dc(
option.id, option.ip_address, option.port
)
)
await utils.maybe_async(self.client.session.save())
self.client.session.set_dc(
option.id, option.ip_address, option.port)
self.client.session.save()
break
# TODO Figure out why the session may have the wrong DC ID
@ -86,36 +69,19 @@ class _DirectDownloadIter(RequestIter):
async def _request(self):
try:
result = await self._client._call(self._sender, self.request)
self._timed_out = False
result = await self.client._call(self._sender, self.request)
if isinstance(result, types.upload.FileCdnRedirect):
if self.client._mb_entity_cache.self_bot:
raise ValueError('FileCdnRedirect but the GetCdnFileRequest API access for bot users is restricted. Try to change api_id to avoid FileCdnRedirect')
raise _CdnRedirect(result)
if isinstance(result, types.upload.CdnFileReuploadNeeded):
await self.client._call(self.client._sender, functions.upload.ReuploadCdnFileRequest(file_token=self._cdn_redirect.file_token, request_token=result.request_token))
result = await self._client._call(self._sender, self.request)
return result.bytes
raise NotImplementedError # TODO Implement
else:
return result.bytes
except errors.TimedOutError 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, errors.FileReferenceExpiredError) as e:
except errors.FilerefUpgradeNeededError as e:
# Only implemented for documents which are the ones that may take that long to download
if not self._msg_data \
or not isinstance(self.request.location, types.InputDocumentFileLocation) \
@ -161,12 +127,12 @@ class _DirectDownloadIter(RequestIter):
class _GenericDownloadIter(_DirectDownloadIter):
async def _load_next_chunk(self):
async def _load_next_chunk(self, mask=MIN_CHUNK_SIZE - 1):
# 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
bad = self.request.offset & mask
before = self.request.offset
# 1.2. We have to fetch from a valid offset, so remove that bad part
@ -242,8 +208,7 @@ class DownloadMethods:
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
If file is the type `bytes`, it will be downloaded in-memory
and returned as a bytestring (i.e. ``file=bytes``, without
parentheses or quotes).
as a bytestring (e.g. ``file=bytes``).
download_big (`bool`, optional):
Whether to use the big version of the available photos.
@ -291,11 +256,11 @@ class DownloadMethods:
if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)):
dc_id = photo.dc_id
which = photo.photo_big if download_big else photo.photo_small
loc = types.InputPeerPhotoFileLocation(
# min users can be used to download profile photos
# self.get_input_entity would otherwise not accept those
peer=utils.get_input_peer(entity, check_hash=False),
photo_id=photo.photo_id,
peer=await self.get_input_entity(entity),
local_id=which.local_id,
volume_id=which.volume_id,
big=download_big
)
else:
@ -353,8 +318,7 @@ class DownloadMethods:
The output file path, directory, or stream-like object.
If the path exists and is a file, it will be overwritten.
If file is the type `bytes`, it will be downloaded in-memory
and returned as a bytestring (i.e. ``file=bytes``, without
parentheses or quotes).
as a bytestring (e.g. ``file=bytes``).
progress_callback (`callable`, optional):
A callback function accepting two parameters:
@ -397,9 +361,6 @@ class DownloadMethods:
path = await message.download_media()
await message.download_media(filename)
# Downloading to memory
blob = await client.download_media(message, bytes)
# Printing download progress
def callback(current, total):
print('Downloaded', current, 'out of', total,
@ -424,11 +385,6 @@ class DownloadMethods:
if isinstance(media, str):
media = utils.resolve_bot_file_id(media)
if isinstance(media, types.MessageService):
if isinstance(message.action,
types.MessageActionChatEditPhoto):
media = media.photo
if isinstance(media, types.MessageMediaWebPage):
if isinstance(media.webpage, types.WebPage):
media = media.webpage.document or media.webpage.photo
@ -535,9 +491,7 @@ class DownloadMethods:
dc_id: int = None,
key: bytes = None,
iv: bytes = None,
msg_data: tuple = None,
cdn_redirect: types.upload.FileCdnRedirect = None
) -> typing.Optional[bytes]:
msg_data: tuple = None) -> typing.Optional[bytes]:
if not part_size_kb:
if not file_size:
part_size_kb = 64 # Reasonable default
@ -564,7 +518,7 @@ class DownloadMethods:
try:
async for chunk in self._iter_download(
input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data, cdn_redirect=cdn_redirect):
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)
@ -582,20 +536,6 @@ class DownloadMethods:
if in_memory:
return f.getvalue()
except _CdnRedirect as e:
self._log[__name__].info('FileCdnRedirect to CDN data center %s', e.cdn_redirect.dc_id)
return await self._download_file(
input_location=input_location,
file=file,
part_size_kb=part_size_kb,
file_size=file_size,
progress_callback=progress_callback,
dc_id=e.cdn_redirect.dc_id,
key=e.cdn_redirect.encryption_key,
iv=e.cdn_redirect.encryption_iv,
msg_data=msg_data,
cdn_redirect=e.cdn_redirect
)
finally:
if isinstance(file, str) or in_memory:
f.close()
@ -717,8 +657,7 @@ class DownloadMethods:
request_size: int = MAX_CHUNK_SIZE,
file_size: int = None,
dc_id: int = None,
msg_data: tuple = None,
cdn_redirect: types.upload.FileCdnRedirect = None
msg_data: tuple = None
):
info = utils._get_file_info(file)
if info.dc_id is not None:
@ -748,8 +687,7 @@ class DownloadMethods:
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):
and stride % MIN_CHUNK_SIZE == 0:
cls = _DirectDownloadIter
self._log[__name__].info('Starting direct file download in chunks of '
'%d at %d, stride %d', request_size, offset, stride)
@ -769,7 +707,6 @@ class DownloadMethods:
request_size=request_size,
file_size=file_size,
msg_data=msg_data,
cdn_redirect=cdn_redirect
)
# endregion
@ -778,9 +715,6 @@ class DownloadMethods:
@staticmethod
def _get_thumb(thumbs, thumb):
if not thumbs:
return None
# Seems Telegram has changed the order and put `PhotoStrippedSize`
# last while this is the smallest (layer 116). Ensure we have the
# sizes sorted correctly with a custom function.
@ -791,8 +725,6 @@ class DownloadMethods:
return 1, len(thumb.bytes)
if isinstance(thumb, types.PhotoSize):
return 1, thumb.size
if isinstance(thumb, types.PhotoSizeProgressive):
return 1, max(thumb.sizes)
if isinstance(thumb, types.VideoSize):
return 2, thumb.size
@ -801,13 +733,6 @@ class DownloadMethods:
thumbs = list(sorted(thumbs, key=sort_thumbs))
for i in reversed(range(len(thumbs))):
# :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually
# a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this
# thumb size doesn't actually exist (#1655).
if isinstance(thumbs[i], types.PhotoPathSize):
thumbs.pop(i)
if thumb is None:
return thumbs[-1]
elif isinstance(thumb, int):
@ -863,11 +788,6 @@ class DownloadMethods:
if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
return self._download_cached_photo_size(size, file)
if isinstance(size, types.PhotoSizeProgressive):
file_size = max(size.sizes)
else:
file_size = size.size
result = await self.download_file(
types.InputPhotoFileLocation(
id=photo.id,
@ -876,7 +796,7 @@ class DownloadMethods:
thumb_size=size.type
),
file,
file_size=file_size,
file_size=size.size,
progress_callback=progress_callback
)
return result if file is bytes else file
@ -923,9 +843,6 @@ class DownloadMethods:
else:
file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
size = self._get_thumb(document.thumbs, thumb)
if not size or isinstance(size, types.PhotoSizeEmpty):
return
if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
return self._download_cached_photo_size(size, file)
@ -966,19 +883,22 @@ class DownloadMethods:
'END:VCARD\n'
).format(f=first_name, l=last_name, p=phone_number).encode('utf-8')
file = cls._get_proper_filename(
file, 'contact', '.vcard',
possible_names=[first_name, phone_number, last_name]
)
if file is bytes:
return result
f = file if hasattr(file, 'write') else open(file, 'wb')
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 f != file:
if isinstance(file, str):
f.close()
return file
@ -995,20 +915,21 @@ class DownloadMethods:
)
# TODO Better way to get opened handles of files and auto-close
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
)
if file is bytes:
in_memory = file is bytes
if in_memory:
f = io.BytesIO()
elif hasattr(file, 'write'):
f = file
else:
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:
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:
@ -1018,10 +939,10 @@ class DownloadMethods:
break
f.write(chunk)
finally:
if f != file:
if isinstance(file, str) or file is bytes:
f.close()
return f.getvalue() if file is bytes else file
return f.getvalue() if in_memory else file
@staticmethod
def _get_proper_filename(file, kind, extension,

View File

@ -67,7 +67,7 @@ class MessageParseMethods:
entities[i].offset, entities[i].length,
await self.get_input_entity(user)
)
return True
return True
except (ValueError, TypeError):
return False
@ -83,19 +83,10 @@ class MessageParseMethods:
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 not e.length:
# 0-length MessageEntity is no longer valid #3884.
# Because the user can provide their own parser (with reasonable 0-length
# entities), strip them here rather than fixing the built-in parsers.
del msg_entities[i]
elif isinstance(e, types.MessageEntityTextUrl):
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
@ -132,6 +123,7 @@ class MessageParseMethods:
random_to_id = {}
id_to_message = {}
sched_to_message = {} # scheduled IDs may collide with normal IDs
for update in updates:
if isinstance(update, types.UpdateMessageID):
random_to_id[update.random_id] = update.id
@ -142,7 +134,7 @@ class MessageParseMethods:
# 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 directly.
#
# It could also be a list (e.g. when sending albums).
#
@ -172,10 +164,7 @@ class MessageParseMethods:
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
sched_to_message[update.message.id] = update.message
elif isinstance(update, types.UpdateMessagePoll):
if request.media.poll.id == update.poll_id:
@ -193,6 +182,17 @@ class MessageParseMethods:
if request is None:
return id_to_message
# Use the scheduled mapping if we got a request with a scheduled message
#
# This breaks if the schedule date is too young, however, since the message
# is sent immediately, so have a fallback.
if getattr(request, 'schedule_date', None) is None:
mapping = id_to_message
opposite = {} # if there's no schedule it can never be scheduled
else:
mapping = sched_to_message
opposite = id_to_message # scheduled may be treated as normal, though
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.
@ -201,7 +201,9 @@ class MessageParseMethods:
return None
if not utils.is_list_like(random_id):
msg = id_to_message.get(random_to_id.get(random_id))
msg = mapping.get(random_to_id.get(random_id))
if not msg:
msg = opposite.get(random_to_id.get(random_id))
if not msg:
self._log[__name__].warning(
@ -210,21 +212,24 @@ class MessageParseMethods:
return msg
try:
return [id_to_message[random_to_id[rnd]] for rnd in random_id]
return [mapping[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)
try:
return [opposite[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])
(mapping.get(random_to_id[rnd]) or opposite.get(random_to_id[rnd]))
if rnd in random_to_id
else None
for rnd in random_id

View File

@ -19,8 +19,7 @@ class _MessagesIter(RequestIter):
"""
async def _init(
self, entity, offset_id, min_id, max_id,
from_user, offset_date, add_offset, filter, search, reply_to,
scheduled
from_user, offset_date, add_offset, filter, search, reply_to
):
# Note that entity being `None` will perform a global search.
if entity:
@ -71,8 +70,6 @@ class _MessagesIter(RequestIter):
if filter is None:
filter = types.InputMessagesFilterEmpty()
else:
filter = filter() if isinstance(filter, type) else filter
if not self.entity:
self.request = functions.messages.SearchGlobalRequest(
@ -80,16 +77,11 @@ class _MessagesIter(RequestIter):
filter=filter,
min_date=None,
max_date=offset_date,
offset_rate=0,
offset_rate=None,
offset_peer=types.InputPeerEmpty(),
offset_id=offset_id,
limit=1
)
elif scheduled:
self.request = functions.messages.GetScheduledHistoryRequest(
peer=entity,
hash=0
)
elif reply_to is not None:
self.request = functions.messages.GetRepliesRequest(
peer=self.entity,
@ -102,7 +94,7 @@ class _MessagesIter(RequestIter):
min_id=0,
hash=0
)
elif search is not None or not isinstance(filter, types.InputMessagesFilterEmpty) or from_user:
elif search is not None or filter or from_user:
# Telegram completely ignores `from_id` in private chats
ty = helpers._entity_type(self.entity)
if ty == helpers._EntityType.USER:
@ -117,7 +109,7 @@ class _MessagesIter(RequestIter):
self.request = functions.messages.SearchRequest(
peer=self.entity,
q=search or '',
filter=filter,
filter=filter() if isinstance(filter, type) else filter,
min_date=None,
max_date=offset_date,
offset_id=offset_id,
@ -204,24 +196,7 @@ class _MessagesIter(RequestIter):
message._finish_init(self.client, entities, self.entity)
self.buffer.append(message)
# Not a slice (using offset would return the same, with e.g. SearchGlobal).
if isinstance(r, types.messages.Messages):
return True
# Some channels are "buggy" and may return less messages than
# requested (apparently, the messages excluded are, for example,
# "not displayable due to local laws").
#
# This means it's not safe to rely on `len(r.messages) < req.limit` as
# the stop condition. Unfortunately more requests must be made.
#
# However we can still check if the highest ID is equal to or lower
# than the limit, in which case there won't be any more messages
# because the lowest message ID is 1.
#
# We also assume the API will always return, at least, one message if
# there is more to fetch.
if not r.messages or (not self.reverse and r.messages[0].id <= self.request.limit):
if len(r.messages) < self.request.limit:
return True
# Get the last message that's not empty (in some rare cases
@ -278,7 +253,7 @@ class _MessagesIter(RequestIter):
else:
self.request.offset_peer = types.InputPeerEmpty()
self.request.offset_rate = getattr(response, 'next_rate', 0)
self.request.offset_rate = getattr(response, 'next_rate', None)
class _IDsIter(RequestIter):
@ -359,8 +334,7 @@ class MessageMethods:
wait_time: float = None,
ids: 'typing.Union[int, typing.Sequence[int]]' = None,
reverse: bool = False,
reply_to: int = None,
scheduled: bool = False
reply_to: int = None
) -> 'typing.Union[_MessagesIter, _IDsIter]':
"""
Iterator over the messages for the given chat.
@ -487,10 +461,6 @@ class MessageMethods:
a message and replies to it itself, that reply will not
be included in the results.
scheduled (`bool`, optional):
If set to `True`, messages which are scheduled will be returned.
All other parameter will be ignored for this, except `entity`.
Yields
Instances of `Message <telethon.tl.custom.message.Message>`.
@ -549,13 +519,10 @@ class MessageMethods:
add_offset=add_offset,
filter=filter,
search=search,
reply_to=reply_to,
scheduled=scheduled
reply_to=reply_to
)
async def get_messages(
self: 'TelegramClient', *args, **kwargs
) -> typing.Union['hints.TotalList', typing.Optional['types.Message']]:
async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList':
"""
Same as `iter_messages()`, but returns a
`TotalList <telethon.helpers.TotalList>` instead.
@ -610,42 +577,21 @@ class MessageMethods:
# region Message sending/editing/deleting
async def _get_comment_data(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'typing.Union[int, types.Message]'
):
r = await self(functions.messages.GetDiscussionMessageRequest(
peer=entity,
msg_id=utils.get_message_id(message)
))
m = min(r.messages, key=lambda msg: msg.id)
chat = next(c for c in r.chats if c.id == m.peer_id.channel_id)
return utils.get_input_peer(chat), m.id
async def send_message(
self: 'TelegramClient',
entity: 'hints.EntityLike',
message: 'hints.MessageLike' = '',
*,
reply_to: 'typing.Union[int, types.Message]' = None,
attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
parse_mode: typing.Optional[str] = (),
formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
link_preview: bool = True,
file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None,
thumb: 'hints.FileLike' = None,
force_document: bool = False,
clear_draft: bool = False,
buttons: typing.Optional['hints.MarkupLike'] = None,
buttons: 'hints.MarkupLike' = None,
silent: bool = None,
background: bool = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None,
comment_to: 'typing.Union[int, types.Message]' = None,
nosound_video: bool = None,
send_as: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = None
schedule: 'hints.DateLike' = None
) -> 'types.Message':
"""
Sends a message to the specified user, chat or channel.
@ -680,10 +626,6 @@ class MessageMethods:
Whether to reply to a message or not. If an integer is provided,
it should be the ID of the message that it should reply to.
attributes (`list`, optional):
Optional attributes that override the inferred ones, like
:tl:`DocumentAttributeFilename` and so on.
parse_mode (`object`, optional):
See the `TelegramClient.parse_mode
<telethon.client.messageparse.MessageParseMethods.parse_mode>`
@ -700,17 +642,6 @@ class MessageMethods:
Sends a message with a file attached (e.g. a photo,
video, audio or document). The ``message`` may be empty.
thumb (`str` | `bytes` | `file`, optional):
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
force_document (`bool`, optional):
Whether to send the given file as a document or not.
@ -736,48 +667,11 @@ class MessageMethods:
channel or not. Defaults to `False`, which means it will
notify them. Set it to `True` to alter this behaviour.
background (`bool`, optional):
Whether the message should be send in background.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
and others like AVI or MKV will not work. You should convert
these to MP4 before sending if you want them to be streamable.
Unsupported formats will result in ``VideoContentTypeError``.
schedule (`hints.DateLike`, optional):
If set, the message won't send immediately, and instead
it will be scheduled to be automatically sent at a later
time.
comment_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
Similar to ``reply_to``, but replies in the linked group of a
broadcast channel instead (effectively leaving a "comment to"
the specified message).
This parameter takes precedence over ``reply_to``. If there is
no linked chat, `telethon.errors.sgIdInvalidError` is raised.
nosound_video (`bool`, optional):
Only applicable when sending a video file without an audio
track. If set to ``True``, the video will be displayed in
Telegram as a video. If set to ``False``, Telegram will attempt
to display the video as an animated gif. (It may still display
as a video due to other factors.) The value is ignored if set
on non-video files. This is set to ``True`` for albums, as gifs
cannot be sent in albums.
send_as (`entity`):
Unique identifier (int) or username (str) of the chat or channel to send the message as.
You can use this to send the message on behalf of a chat or channel where you have appropriate permissions.
Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat,
This setting applies to the current message and will remain effective for future messages unless explicitly changed.
To set this behavior permanently for all messages, use SaveDefaultSendAs.
message_effect_id (`int`, optional):
Unique identifier of the message effect to be added to the message; for private chats only
Returns
The sent `custom.Message <telethon.tl.custom.message.Message>`.
@ -838,27 +732,14 @@ class MessageMethods:
await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5))
"""
if file is not None:
if isinstance(message, types.Message):
formatting_entities = formatting_entities or message.entities
message = message.message
return await self.send_file(
entity, file, caption=message, reply_to=reply_to,
attributes=attributes, parse_mode=parse_mode,
force_document=force_document, thumb=thumb,
parse_mode=parse_mode, force_document=force_document,
buttons=buttons, clear_draft=clear_draft, silent=silent,
schedule=schedule, supports_streaming=supports_streaming,
formatting_entities=formatting_entities,
comment_to=comment_to, background=background,
nosound_video=nosound_video,
send_as=send_as, message_effect_id=message_effect_id
schedule=schedule, formatting_entities=formatting_entities
)
entity = await self.get_input_entity(entity)
if comment_to is not None:
entity, reply_to = await self._get_comment_data(entity, comment_to)
else:
reply_to = utils.get_message_id(reply_to)
if isinstance(message, types.Message):
if buttons is None:
markup = message.reply_markup
@ -875,29 +756,23 @@ class MessageMethods:
message.media,
caption=message.message,
silent=silent,
background=background,
reply_to=reply_to,
buttons=markup,
formatting_entities=message.entities,
parse_mode=None, # explicitly disable parse_mode to force using even empty formatting_entities
schedule=schedule,
send_as=send_as, message_effect_id=message_effect_id
schedule=schedule
)
request = functions.messages.SendMessageRequest(
peer=entity,
message=message.message or '',
silent=silent,
background=background,
reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to),
reply_to_msg_id=utils.get_message_id(reply_to),
reply_markup=markup,
entities=message.entities,
clear_draft=clear_draft,
no_webpage=not isinstance(
message.media, types.MessageMediaWebPage),
schedule_date=schedule,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
schedule_date=schedule
)
message = message.message
else:
@ -913,14 +788,11 @@ class MessageMethods:
message=message,
entities=formatting_entities,
no_webpage=not link_preview,
reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to),
reply_to_msg_id=utils.get_message_id(reply_to),
clear_draft=clear_draft,
silent=silent,
background=background,
reply_markup=self.build_reply_markup(buttons),
schedule_date=schedule,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
schedule_date=schedule
)
result = await self(request)
@ -933,9 +805,7 @@ class MessageMethods:
out=result.out,
media=result.media,
entities=result.entities,
reply_markup=request.reply_markup,
ttl_period=result.ttl_period,
reply_to=request.reply_to
reply_markup=request.reply_markup
)
message._finish_init(self, {}, entity)
return message
@ -948,13 +818,9 @@ class MessageMethods:
messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]',
from_peer: 'hints.EntityLike' = None,
*,
background: bool = None,
with_my_score: bool = None,
silent: bool = None,
as_album: bool = None,
schedule: 'hints.DateLike' = None,
drop_author: bool = None,
drop_media_captions: bool = None,
schedule: 'hints.DateLike' = None
) -> 'typing.Sequence[types.Message]':
"""
Forwards the given messages to the specified entity.
@ -984,12 +850,6 @@ class MessageMethods:
the person has the chat muted). Set it to `True` to alter
this behaviour.
background (`bool`, optional):
Whether the message should be forwarded in background.
with_my_score (`bool`, optional):
Whether forwarded should contain your game score.
as_album (`bool`, optional):
This flag no longer has any effect.
@ -998,12 +858,6 @@ class MessageMethods:
instead they will be scheduled to be automatically sent
at a later time.
drop_author (`bool`, optional):
Whether to forward messages without quoting the original author.
drop_media_captions (`bool`, optional):
Whether to strip captions from media. Setting this to `True` requires that `drop_author` also be set to `True`.
Returns
The list of forwarded `Message <telethon.tl.custom.message.Message>`,
or a single one if a list wasn't provided as input.
@ -1062,7 +916,7 @@ class MessageMethods:
if isinstance(chunk[0], int):
chat = from_peer
else:
chat = from_peer or await self.get_input_entity(chunk[0].peer_id)
chat = await chunk[0].get_input_chat()
chunk = [m.id for m in chunk]
req = functions.messages.ForwardMessagesRequest(
@ -1070,11 +924,7 @@ class MessageMethods:
id=chunk,
to_peer=entity,
silent=silent,
background=background,
with_my_score=with_my_score,
schedule_date=schedule,
drop_author=drop_author,
drop_media_captions=drop_media_captions
schedule_date=schedule
)
result = await self(req)
sent.extend(self._get_response_message(req, result, entity))
@ -1084,18 +934,15 @@ class MessageMethods:
async def edit_message(
self: 'TelegramClient',
entity: 'typing.Union[hints.EntityLike, types.Message]',
message: 'typing.Union[int, types.Message, types.InputMessageID, str]' = None,
message: 'hints.MessageLike' = None,
text: str = None,
*,
parse_mode: str = (),
attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
link_preview: bool = True,
file: 'hints.FileLike' = None,
thumb: 'hints.FileLike' = None,
force_document: bool = False,
buttons: typing.Optional['hints.MarkupLike'] = None,
supports_streaming: bool = False,
buttons: 'hints.MarkupLike' = None,
schedule: 'hints.DateLike' = None
) -> 'types.Message':
"""
@ -1110,11 +957,11 @@ class MessageMethods:
from it, so the next parameter will be assumed to be the
message text.
You may also pass a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64`,
You may also pass a :tl:`InputBotInlineMessageID`,
which is the only way to edit messages that were sent
after the user selects an inline query result.
message (`int` | `Message <telethon.tl.custom.message.Message>` | :tl:`InputMessageID` | `str`):
message (`int` | `Message <telethon.tl.custom.message.Message>` | `str`):
The ID of the message (or `Message
<telethon.tl.custom.message.Message>` itself) to be edited.
If the `entity` was a `Message
@ -1131,10 +978,6 @@ class MessageMethods:
property for allowed values. Markdown parsing will be used by
default.
attributes (`list`, optional):
Optional attributes that override the inferred ones, like
:tl:`DocumentAttributeFilename` and so on.
formatting_entities (`list`, optional):
A list of message formatting entities. When provided, the ``parse_mode`` is ignored.
@ -1145,17 +988,6 @@ class MessageMethods:
The file object that should replace the existing media
in the message.
thumb (`str` | `bytes` | `file`, optional):
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
force_document (`bool`, optional):
Whether to send the given file as a document or not.
@ -1165,13 +997,6 @@ class MessageMethods:
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
and others like AVI or MKV will not work. You should convert
these to MP4 before sending if you want them to be streamable.
Unsupported formats will result in ``VideoContentTypeError``.
schedule (`hints.DateLike`, optional):
If set, the message won't be edited immediately, and instead
it will be scheduled to be automatically edited at a later
@ -1182,7 +1007,7 @@ class MessageMethods:
Returns
The edited `Message <telethon.tl.custom.message.Message>`,
unless `entity` was a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64` in which
unless `entity` was a :tl:`InputBotInlineMessageID` in which
case this method returns a boolean.
Raises
@ -1208,8 +1033,8 @@ class MessageMethods:
# or
await client.edit_message(message, 'hello!!!')
"""
if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
text = text or message
if isinstance(entity, types.InputBotInlineMessageID):
text = message
message = entity
elif isinstance(entity, types.Message):
text = message # Shift the parameters to the right
@ -1219,12 +1044,9 @@ class MessageMethods:
if formatting_entities is None:
text, formatting_entities = await self._parse_message_text(text, parse_mode)
file_handle, media, image = await self._file_to_media(file,
supports_streaming=supports_streaming,
thumb=thumb,
attributes=attributes,
force_document=force_document)
if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
if isinstance(entity, types.InputBotInlineMessageID):
request = functions.messages.EditInlineBotMessageRequest(
id=entity,
message=text,
@ -1342,8 +1164,7 @@ class MessageMethods:
message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None,
*,
max_id: int = None,
clear_mentions: bool = False,
clear_reactions: bool = False) -> bool:
clear_mentions: bool = False) -> bool:
"""
Marks messages as read and optionally clears mentions.
@ -1377,13 +1198,6 @@ class MessageMethods:
If no message is provided, this will be the only action
taken.
clear_reactions (`bool`):
Whether the reactions badge should be cleared (so that
there are no more reaction notifications) or not for the given entity.
If no message is provided, this will be the only action
taken.
Example
.. code-block:: python
@ -1406,10 +1220,6 @@ class MessageMethods:
entity = await self.get_input_entity(entity)
if clear_mentions:
await self(functions.messages.ReadMentionsRequest(entity))
if max_id is None and not clear_reactions:
return True
if clear_reactions:
await self(functions.messages.ReadReactionsRequest(entity))
if max_id is None:
return True
@ -1428,8 +1238,7 @@ class MessageMethods:
entity: 'hints.EntityLike',
message: 'typing.Optional[hints.MessageIDLike]',
*,
notify: bool = False,
pm_oneside: bool = False
notify: bool = False
):
"""
Pins a message in a chat.
@ -1450,11 +1259,6 @@ class MessageMethods:
notify (`bool`, optional):
Whether the pin should notify people or not.
pm_oneside (`bool`, optional):
Whether the message should be pinned for everyone or not.
By default it has the opposite behaviour of official clients,
and it will pin the message for both sides, in private chats.
Example
.. code-block:: python
@ -1462,7 +1266,7 @@ class MessageMethods:
message = await client.send_message(chat, 'Pinotifying is fun!')
await client.pin_message(chat, message, notify=True)
"""
return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside)
return await self._pin(entity, message, unpin=False, notify=notify)
async def unpin_message(
self: 'TelegramClient',
@ -1494,7 +1298,7 @@ class MessageMethods:
"""
return await self._pin(entity, message, unpin=True, notify=notify)
async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False):
async def _pin(self, entity, message, *, unpin, notify=False):
message = utils.get_message_id(message) or 0
entity = await self.get_input_entity(entity)
if message <= 0: # old behaviour accepted negative IDs to unpin
@ -1506,15 +1310,15 @@ class MessageMethods:
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:
# Unpinning does not produce a service message
if unpin:
return
# Pinning in User chats (just with yourself really) does not produce a service message
if helpers._entity_type(entity) == helpers._EntityType.USER:
return
# Pinning a message that doesn't exist would RPC-error earlier

View File

@ -1,5 +1,4 @@
import abc
import inspect
import re
import asyncio
import collections
@ -7,17 +6,16 @@ import logging
import platform
import time
import typing
import datetime
import pathlib
from .. import utils, version, helpers, __name__ as __base_name__
from .. import version, helpers, __name__ as __base_name__
from ..crypto import rsa
from ..entitycache import EntityCache
from ..extensions import markdown
from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy
from ..sessions import Session, SQLiteSession, MemorySession
from ..statecache import StateCache
from ..tl import functions, types
from ..tl.alltlobjects import LAYER
from .._updates import MessageBox, EntityCache as MbEntityCache, SessionState, ChannelState, Entity, EntityType
DEFAULT_DC_ID = 2
DEFAULT_IPV4_IP = '149.154.167.51'
@ -27,7 +25,8 @@ DEFAULT_PORT = 443
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
_base_log = logging.getLogger(__base_name__)
__default_log__ = logging.getLogger(__base_name__)
__default_log__.addHandler(logging.NullHandler())
# In seconds, how long to wait before disconnecting a exported sender.
@ -91,7 +90,7 @@ class TelegramBaseClient(abc.ABC):
The API ID you obtained from https://my.telegram.org.
api_hash (`str`):
The API hash you obtained from https://my.telegram.org.
The API ID you obtained from https://my.telegram.org.
connection (`telethon.network.connection.common.Connection`, optional):
The connection instance to be used when creating a new connection
@ -193,7 +192,7 @@ class TelegramBaseClient(abc.ABC):
Defaults to `lang_code`.
loop (`asyncio.AbstractEventLoop`, optional):
Asyncio event loop to use. Defaults to `asyncio.get_running_loop()`.
Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`.
This argument is ignored.
base_logger (`str` | `logging.Logger`, optional):
@ -201,29 +200,6 @@ class TelegramBaseClient(abc.ABC):
If a `str` is given, it'll be passed to `logging.getLogger()`. If a
`logging.Logger` is given, it'll be used directly. If something
else or nothing is given, the default logger will be used.
receive_updates (`bool`, optional):
Whether the client will receive updates or not. By default, updates
will be received from Telegram as they occur.
Turning this off means that Telegram will not send updates at all
so event handlers, conversations, and QR login will not work.
However, certain scripts don't need updates, so this will reduce
the amount of bandwidth used.
entity_cache_limit (`int`, optional):
How many users, chats and channels to keep in the in-memory cache
at most. This limit is checked against when processing updates.
When this limit is reached or exceeded, all entities that are not
required for update handling will be flushed to the session file.
Note that this implies that there is a lower bound to the amount
of entities that must be kept in memory.
Setting this limit too low will cause the library to attempt to
flush entities to the session file even if no entities can be
removed from the in-memory cache, which will degrade performance.
"""
# Current TelegramClient version
@ -237,7 +213,7 @@ class TelegramBaseClient(abc.ABC):
def __init__(
self: 'TelegramClient',
session: 'typing.Union[str, pathlib.Path, Session]',
session: 'typing.Union[str, Session]',
api_id: int,
api_hash: str,
*,
@ -259,22 +235,19 @@ class TelegramBaseClient(abc.ABC):
lang_code: str = 'en',
system_lang_code: str = 'en',
loop: asyncio.AbstractEventLoop = None,
base_logger: typing.Union[str, logging.Logger] = None,
receive_updates: bool = True,
catch_up: bool = False,
entity_cache_limit: int = 5000
):
base_logger: typing.Union[str, logging.Logger] = None):
if not api_id or not api_hash:
raise ValueError(
"Your API ID or Hash cannot be empty or None. "
"Refer to telethon.rtfd.io for more information.")
self._use_ipv6 = use_ipv6
self._loop = asyncio.get_event_loop()
if isinstance(base_logger, str):
base_logger = logging.getLogger(base_logger)
elif not isinstance(base_logger, logging.Logger):
base_logger = _base_log
base_logger = __default_log__
class _Loggers(dict):
def __missing__(self, key):
@ -286,9 +259,9 @@ class TelegramBaseClient(abc.ABC):
self._log = _Loggers()
# Determine what session object we have
if isinstance(session, (str, pathlib.Path)):
if isinstance(session, str) or session is None:
try:
session = SQLiteSession(str(session))
session = SQLiteSession(session)
except ImportError:
import warnings
warnings.warn(
@ -299,17 +272,24 @@ class TelegramBaseClient(abc.ABC):
'you use another session storage'
)
session = MemorySession()
elif session is None:
session = MemorySession()
elif not isinstance(session, Session):
raise TypeError(
'The given session must be a str or a Session instance.'
)
# ':' in session.server_address is True if it's an IPv6 address
if (not session.server_address or
(':' in session.server_address) != use_ipv6):
session.set_dc(
DEFAULT_DC_ID,
DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP,
DEFAULT_PORT
)
self.flood_sleep_threshold = flood_sleep_threshold
# TODO Use AsyncClassWrapper(session)
# ChatGetter and SenderGetter can use the in-memory _mb_entity_cache
# ChatGetter and SenderGetter can use the in-memory _entity_cache
# to avoid network access and the need for await in session files.
#
# The session files only wants the entities to persist
@ -317,6 +297,7 @@ class TelegramBaseClient(abc.ABC):
# TODO Session should probably return all cached
# info of entities, not just the input versions
self.session = session
self._entity_cache = EntityCache()
self.api_id = int(api_id)
self.api_hash = api_hash
@ -327,14 +308,14 @@ class TelegramBaseClient(abc.ABC):
# TODO A better fix is obviously avoiding the use of `sock_connect`
#
# See https://github.com/LonamiWebs/Telethon/issues/1337 for details.
if not callable(getattr(self.loop, 'sock_connect', None)):
if not callable(getattr(self._loop, 'sock_connect', None)):
raise TypeError(
'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n'
'Change the event loop in use to use proxies:\n'
'# https://github.com/LonamiWebs/Telethon/issues/1337\n'
'import asyncio\n'
'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format(
self.loop.__class__.__name__
self._loop.__class__.__name__
)
)
@ -387,27 +368,45 @@ class TelegramBaseClient(abc.ABC):
proxy=init_proxy
)
self._sender = MTProtoSender(
self.session.auth_key,
loggers=self._log,
retries=self._connection_retries,
delay=self._retry_delay,
auto_reconnect=self._auto_reconnect,
connect_timeout=self._timeout,
auth_key_callback=self._auth_key_callback,
update_callback=self._handle_update,
auto_reconnect_callback=self._handle_auto_reconnect
)
# Remember flood-waited requests to avoid making them again
self._flood_waited_requests = {}
# Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders
self._borrowed_senders = {}
self._borrow_sender_lock = asyncio.Lock()
self._exported_sessions = {}
self._loop = None # only used as a sanity check
self._updates_error = None
self._updates_handle = None
self._keepalive_handle = None
self._last_request = time.time()
self._no_updates = not receive_updates
self._channel_pts = {}
# Used for non-sequential updates, in order to terminate all pending tasks on disconnect.
self._sequential_updates = sequential_updates
self._event_handler_tasks = set()
if sequential_updates:
self._updates_queue = asyncio.Queue()
self._dispatching_updates_queue = asyncio.Event()
else:
# Use a set of pending instead of a queue so we can properly
# terminate all pending updates on disconnect.
self._updates_queue = set()
self._dispatching_updates_queue = None
self._authorized = None # None = unknown, False = no, True = yes
# Update state (for catching up after a disconnection)
# TODO Get state from channels too
self._state_cache = StateCache(
self.session.get_update_state(0), self._log)
# Some further state for subclasses
self._event_builders = []
@ -432,29 +431,13 @@ class TelegramBaseClient(abc.ABC):
self._phone = None
self._tos = None
# Sometimes we need to know who we are, cache the self peer
self._self_input_peer = None
self._bot = None
# A place to store if channels are a megagroup or not (see `edit_admin`)
self._megagroup_cache = {}
# This is backported from v2 in a very ad-hoc way just to get proper update handling
self._catch_up = catch_up
self._updates_queue = asyncio.Queue()
self._message_box = MessageBox(self._log['messagebox'])
self._mb_entity_cache = MbEntityCache() # required for proper update handling (to know when to getDifference)
self._entity_cache_limit = entity_cache_limit
self._sender = MTProtoSender(
self.session.auth_key,
loggers=self._log,
retries=self._connection_retries,
delay=self._retry_delay,
auto_reconnect=self._auto_reconnect,
connect_timeout=self._timeout,
auth_key_callback=self._auth_key_callback,
updates_queue=self._updates_queue,
auto_reconnect_callback=self._handle_auto_reconnect
)
# endregion
# region Properties
@ -476,7 +459,7 @@ class TelegramBaseClient(abc.ABC):
# Join the task (wait for it to complete)
await task
"""
return helpers.get_running_loop()
return self._loop
@property
def disconnected(self: 'TelegramClient') -> asyncio.Future:
@ -529,26 +512,6 @@ class TelegramBaseClient(abc.ABC):
except OSError:
print('Failed to connect')
"""
if self.session is None:
raise ValueError('TelegramClient instance cannot be reused after logging out')
if self._loop is None:
self._loop = helpers.get_running_loop()
elif self._loop != helpers.get_running_loop():
raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)')
# ':' in session.server_address is True if it's an IPv6 address
if (not self.session.server_address or
(':' in self.session.server_address) != self._use_ipv6):
await utils.maybe_async(
self.session.set_dc(
DEFAULT_DC_ID,
DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP,
DEFAULT_PORT
)
)
await utils.maybe_async(self.session.save())
if not await self._sender.connect(self._connection(
self.session.server_address,
self.session.port,
@ -561,54 +524,15 @@ class TelegramBaseClient(abc.ABC):
return
self.session.auth_key = self._sender.auth_key
await utils.maybe_async(self.session.save())
try:
# See comment when saving entities to understand this hack
self_entity = await utils.maybe_async(self.session.get_input_entity(0))
self_id = self_entity.access_hash
self_user = await utils.maybe_async(self.session.get_input_entity(self_id))
self._mb_entity_cache.set_self_user(self_id, None, self_user.access_hash)
except ValueError:
pass
if self._catch_up:
ss = SessionState(0, 0, False, 0, 0, 0, 0, None)
cs = []
update_states = await utils.maybe_async(self.session.get_update_states())
for entity_id, state in update_states:
if entity_id == 0:
# TODO current session doesn't store self-user info but adding that is breaking on downstream session impls
ss = SessionState(0, 0, False, state.pts, state.qts, int(state.date.timestamp()), state.seq, None)
else:
cs.append(ChannelState(entity_id, state.pts))
self._message_box.load(ss, cs)
for state in cs:
try:
entity = await utils.maybe_async(self.session.get_input_entity(state.channel_id))
except ValueError:
self._log[__name__].warning(
'No access_hash in cache for channel %s, will not catch up', state.channel_id)
else:
self._mb_entity_cache.put(Entity(EntityType.CHANNEL, entity.channel_id, entity.access_hash))
self.session.save()
self._init_request.query = functions.help.GetConfigRequest()
req = self._init_request
if self._no_updates:
req = functions.InvokeWithoutUpdatesRequest(req)
await self._sender.send(functions.InvokeWithLayerRequest(
LAYER, self._init_request
))
await self._sender.send(functions.InvokeWithLayerRequest(LAYER, req))
if self._message_box.is_empty():
me = await self.get_me()
if me:
await self._on_login(me) # also calls GetState to initialize the MessageBox
self._updates_handle = self.loop.create_task(self._update_loop())
self._keepalive_handle = self.loop.create_task(self._keepalive_loop())
self._updates_handle = self._loop.create_task(self._update_loop())
def is_connected(self: 'TelegramClient') -> bool:
"""
@ -633,27 +557,17 @@ class TelegramBaseClient(abc.ABC):
coroutine that you should await on your own code; otherwise
the loop is ran until said coroutine completes.
Event handlers which are currently running will be cancelled before
this function returns (in order to properly clean-up their tasks).
In particular, this means that using ``disconnect`` in a handler
will cause code after the ``disconnect`` to never run. If this is
needed, consider spawning a separate task to do the remaining work.
Example
.. code-block:: python
# You don't need to use this if you used "with client"
await client.disconnect()
"""
if self.loop.is_running():
# Disconnect may be called from an event handler, which would
# cancel itself during itself and never actually complete the
# disconnection. Shield the task to prevent disconnect itself
# from being cancelled. See issue #3942 for more details.
return asyncio.shield(self.loop.create_task(self._disconnect_coro()))
if self._loop.is_running():
return self._disconnect_coro()
else:
try:
self.loop.run_until_complete(self._disconnect_coro())
self._loop.run_until_complete(self._disconnect_coro())
except RuntimeError:
# Python 3.5.x complains when called from
# `__aexit__` and there were pending updates with:
@ -691,61 +605,38 @@ class TelegramBaseClient(abc.ABC):
else:
connection._proxy = proxy
async def _save_states_and_entities(self: 'TelegramClient'):
# As a hack to not need to change the session files, save ourselves with ``id=0`` and ``access_hash`` of our ``id``.
# This way it is possible to determine our own ID by querying for 0. However, whether we're a bot is not saved.
# Piggy-back on an arbitrary TL type with users and chats so the session can understand to read the entities.
# It doesn't matter if we put users in the list of chats.
if self._mb_entity_cache.self_id:
await utils.maybe_async(
self.session.process_entities(
types.contacts.ResolvedPeer(None, [types.InputPeerUser(0, self._mb_entity_cache.self_id)], [])
)
)
ss, cs = self._message_box.session_state()
await utils.maybe_async(self.session.set_update_state(0, types.updates.State(**ss, unread_count=0)))
now = datetime.datetime.now() # any datetime works; channels don't need it
for channel_id, pts in cs.items():
await utils.maybe_async(
self.session.set_update_state(
channel_id, types.updates.State(pts, 0, now, 0, unread_count=0)
)
)
async def _disconnect_coro(self: 'TelegramClient'):
if self.session is None:
return # already logged out and disconnected
await self._disconnect()
# Also clean-up all exported senders because we're done with them
async with self._borrow_sender_lock:
for state, sender in self._borrowed_senders.values():
# Note that we're not checking for `state.should_disconnect()`.
# If the user wants to disconnect the client, ALL connections
# to Telegram (including exported senders) should be closed.
#
# Disconnect should never raise, so there's no try/except.
await sender.disconnect()
# Can't use `mark_disconnected` because it may be borrowed.
state._connected = False
if state.should_disconnect():
# disconnect should never raise
await sender.disconnect()
# If any was borrowed
self._borrowed_senders.clear()
# trio's nurseries would handle this for us, but this is asyncio.
# All tasks spawned in the background should properly be terminated.
if self._event_handler_tasks:
for task in self._event_handler_tasks:
if self._dispatching_updates_queue is None and self._updates_queue:
for task in self._updates_queue:
task.cancel()
await asyncio.wait(self._event_handler_tasks)
self._event_handler_tasks.clear()
await asyncio.wait(self._updates_queue)
self._updates_queue.clear()
await self._save_states_and_entities()
pts, date = self._state_cache[None]
if pts and date:
self.session.set_update_state(0, types.updates.State(
pts=pts,
qts=0,
date=date,
seq=0,
unread_count=0
))
await utils.maybe_async(self.session.close())
self.session.close()
async def _disconnect(self: 'TelegramClient'):
"""
@ -756,8 +647,7 @@ class TelegramBaseClient(abc.ABC):
"""
await self._sender.disconnect()
await helpers._cancel(self._log[__name__],
updates_handle=self._updates_handle,
keepalive_handle=self._keepalive_handle)
updates_handle=self._updates_handle)
async def _switch_dc(self: 'TelegramClient', new_dc):
"""
@ -766,22 +656,22 @@ class TelegramBaseClient(abc.ABC):
self._log[__name__].info('Reconnecting to new data center %s', new_dc)
dc = await self._get_dc(new_dc)
await utils.maybe_async(self.session.set_dc(dc.id, dc.ip_address, dc.port))
self.session.set_dc(dc.id, dc.ip_address, dc.port)
# auth_key's are associated with a server, which has now changed
# so it's not valid anymore. Set to None to force recreating it.
self._sender.auth_key.key = None
self.session.auth_key = None
await utils.maybe_async(self.session.save())
self.session.save()
await self._disconnect()
return await self.connect()
async def _auth_key_callback(self: 'TelegramClient', auth_key):
def _auth_key_callback(self: 'TelegramClient', auth_key):
"""
Callback from the sender whenever it needed to generate a
new authorization key. This means we are not authorized.
"""
self.session.auth_key = auth_key
await utils.maybe_async(self.session.save())
self.session.save()
# endregion
@ -796,27 +686,13 @@ class TelegramBaseClient(abc.ABC):
if cdn and not self._cdn_config:
cls._cdn_config = await self(functions.help.GetCdnConfigRequest())
for pk in cls._cdn_config.public_keys:
if pk.dc_id == dc_id:
rsa.add_key(pk.public_key, old=False)
rsa.add_key(pk.public_key)
try:
return next(
dc for dc in cls._config.dc_options
if dc.id == dc_id
and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn
)
except StopIteration:
self._log[__name__].warning(
'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check',
dc_id, cdn, self._use_ipv6
)
try:
return next(
dc for dc in cls._config.dc_options
if dc.id == dc_id and bool(dc.cdn) == cdn
)
except StopIteration:
raise ValueError(f'Failed to get DC {dc_id} (cdn = {cdn})')
return next(
dc for dc in cls._config.dc_options
if dc.id == dc_id
and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn
)
async def _create_exported_sender(self: 'TelegramClient', dc_id):
"""
@ -904,30 +780,28 @@ class TelegramBaseClient(abc.ABC):
async def _get_cdn_client(self: 'TelegramClient', cdn_redirect):
"""Similar to ._borrow_exported_client, but for CDNs"""
# TODO Implement
raise NotImplementedError
session = self._exported_sessions.get(cdn_redirect.dc_id)
if not session:
dc = await self._get_dc(cdn_redirect.dc_id, cdn=True)
session = await utils.maybe_async(self.session.clone())
await utils.maybe_async(session.set_dc(dc.id, dc.ip_address, dc.port))
session = self.session.clone()
await session.set_dc(dc.id, dc.ip_address, dc.port)
self._exported_sessions[cdn_redirect.dc_id] = session
self._log[__name__].info('Creating new CDN client')
client = self.__class__(
client = TelegramBaseClient(
session, self.api_id, self.api_hash,
proxy=self._proxy,
timeout=self._timeout,
loop=self.loop
proxy=self._sender.connection.conn.proxy,
timeout=self._sender.connection.get_timeout()
)
session.auth_key = self._sender.auth_key
await client._sender.connect(self._connection(
session.server_address,
session.port,
session.dc_id,
loggers=self._log,
proxy=self._proxy,
local_addr=self._local_addr
))
# This will make use of the new RSA keys for this specific CDN.
#
# We won't be calling GetConfigRequest because it's only called
# when needed by ._get_dc, and also it's static so it's likely
# set already. Avoid invoking non-CDN methods by not syncing updates.
client.connect(_sync_updates=False)
return client
# endregion
@ -949,17 +823,16 @@ class TelegramBaseClient(abc.ABC):
executed sequentially on the server. They run in arbitrary
order by default.
flood_sleep_threshold (`int` | `None`, optional):
The flood sleep threshold to use for this request. This overrides
the default value stored in
`client.flood_sleep_threshold <telethon.client.telegrambaseclient.TelegramBaseClient.flood_sleep_threshold>`
Returns:
The result of the request (often a `TLObject`) or a list of
results if more than one request was given.
"""
raise NotImplementedError
@abc.abstractmethod
def _handle_update(self: 'TelegramClient', update):
raise NotImplementedError
@abc.abstractmethod
def _update_loop(self: 'TelegramClient'):
raise NotImplementedError

View File

@ -2,29 +2,17 @@ import asyncio
import inspect
import itertools
import random
import sys
import time
import traceback
import typing
import logging
import warnings
from collections import deque
import sqlite3
from .. import events, utils, errors
from ..events.common import EventBuilder, EventCommon
from ..tl import types, functions
from .._updates import GapError, PrematureEndReason
from ..helpers import get_running_loop
from ..version import __version__
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
Callback = typing.Callable[[typing.Any], typing.Any]
class UpdateMethods:
# region Public methods
@ -33,34 +21,18 @@ class UpdateMethods:
try:
# Make a high-level request to notify that we want updates
await self(functions.updates.GetStateRequest())
result = await self.disconnected
if self._updates_error is not None:
raise self._updates_error
return result
return await self.disconnected
except KeyboardInterrupt:
pass
finally:
await self.disconnect()
async def set_receive_updates(self: 'TelegramClient', receive_updates):
"""
Change the value of `receive_updates`.
This is an `async` method, because in order for Telegram to start
sending updates again, a request must be made.
"""
self._no_updates = not receive_updates
if receive_updates:
await self(functions.updates.GetStateRequest())
def run_until_disconnected(self: 'TelegramClient'):
"""
Runs the event loop until the library is disconnected.
It also notifies Telegram that we want to receive updates
as described in https://core.telegram.org/api/updates.
If an unexpected error occurs during update handling,
the client will disconnect and said error will be raised.
Manual disconnections can be made by calling `disconnect()
<telethon.client.telegrambaseclient.TelegramBaseClient.disconnect>`
@ -129,7 +101,7 @@ class UpdateMethods:
def add_event_handler(
self: 'TelegramClient',
callback: Callback,
callback: callable,
event: EventBuilder = None):
"""
Registers a new event handler callback.
@ -178,7 +150,7 @@ class UpdateMethods:
def remove_event_handler(
self: 'TelegramClient',
callback: Callback,
callback: callable,
event: EventBuilder = None) -> int:
"""
Inverse operation of `add_event_handler()`.
@ -216,7 +188,7 @@ class UpdateMethods:
return found
def list_event_handlers(self: 'TelegramClient')\
-> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]':
-> 'typing.Sequence[typing.Tuple[callable, EventBuilder]]':
"""
Lists all registered event handlers.
@ -249,240 +221,106 @@ class UpdateMethods:
await client.catch_up()
"""
await self._updates_queue.put(types.UpdatesTooLong())
pts, date = self._state_cache[None]
if not pts:
return
self.session.catching_up = True
try:
while True:
d = await self(functions.updates.GetDifferenceRequest(
pts, date, 0
))
if isinstance(d, (types.updates.DifferenceSlice,
types.updates.Difference)):
if isinstance(d, types.updates.Difference):
state = d.state
else:
state = d.intermediate_state
pts, date = state.pts, state.date
self._handle_update(types.Updates(
users=d.users,
chats=d.chats,
date=state.date,
seq=state.seq,
updates=d.other_updates + [
types.UpdateNewMessage(m, 0, 0)
for m in d.new_messages
]
))
# TODO Implement upper limit (max_pts)
# We don't want to fetch updates we already know about.
#
# We may still get duplicates because the Difference
# contains a lot of updates and presumably only has
# the state for the last one, but at least we don't
# unnecessarily fetch too many.
#
# updates.getDifference's pts_total_limit seems to mean
# "how many pts is the request allowed to return", and
# if there is more than that, it returns "too long" (so
# there would be duplicate updates since we know about
# some). This can be used to detect collisions (i.e.
# it would return an update we have already seen).
else:
if isinstance(d, types.updates.DifferenceEmpty):
date = d.date
elif isinstance(d, types.updates.DifferenceTooLong):
pts = d.pts
break
except (ConnectionError, asyncio.CancelledError):
pass
finally:
# TODO Save new pts to session
self._state_cache._pts_date = (pts, date)
self.session.catching_up = False
# endregion
# region Private methods
# It is important to not make _handle_update async because we rely on
# the order that the updates arrive in to update the pts and date to
# be always-increasing. There is also no need to make this async.
def _handle_update(self: 'TelegramClient', update):
self.session.process_entities(update)
self._entity_cache.add(update)
if isinstance(update, (types.Updates, types.UpdatesCombined)):
entities = {utils.get_peer_id(x): x for x in
itertools.chain(update.users, update.chats)}
for u in update.updates:
self._process_update(u, update.updates, entities=entities)
elif isinstance(update, types.UpdateShort):
self._process_update(update.update, None)
else:
self._process_update(update, None)
self._state_cache.update(update)
def _process_update(self: 'TelegramClient', update, others, entities=None):
update._entities = entities or {}
# This part is somewhat hot so we don't bother patching
# update with channel ID/its state. Instead we just pass
# arguments which is faster.
channel_id = self._state_cache.get_channel_id(update)
args = (update, others, channel_id, self._state_cache[channel_id])
if self._dispatching_updates_queue is None:
task = self._loop.create_task(self._dispatch_update(*args))
self._updates_queue.add(task)
task.add_done_callback(lambda _: self._updates_queue.discard(task))
else:
self._updates_queue.put_nowait(args)
if not self._dispatching_updates_queue.is_set():
self._dispatching_updates_queue.set()
self._loop.create_task(self._dispatch_queue_updates())
self._state_cache.update(update)
async def _update_loop(self: 'TelegramClient'):
# If the MessageBox is not empty, the account had to be logged-in to fill in its state.
# This flag is used to propagate the "you got logged-out" error up (but getting logged-out
# can only happen if it was once logged-in).
was_once_logged_in = self._authorized is True or not self._message_box.is_empty()
self._updates_error = None
try:
if self._catch_up:
# User wants to catch up as soon as the client is up and running,
# so this is the best place to do it.
await self.catch_up()
updates_to_dispatch = deque()
while self.is_connected():
if updates_to_dispatch:
if self._sequential_updates:
await self._dispatch_update(updates_to_dispatch.popleft())
else:
while updates_to_dispatch:
# TODO if _dispatch_update fails for whatever reason, it's not logged! this should be fixed
task = self.loop.create_task(self._dispatch_update(updates_to_dispatch.popleft()))
self._event_handler_tasks.add(task)
task.add_done_callback(self._event_handler_tasks.discard)
continue
if len(self._mb_entity_cache) >= self._entity_cache_limit:
self._log[__name__].info(
'In-memory entity cache limit reached (%s/%s), flushing to session',
len(self._mb_entity_cache),
self._entity_cache_limit
)
await self._save_states_and_entities()
self._mb_entity_cache.retain(lambda id: id == self._mb_entity_cache.self_id or id in self._message_box.map)
if len(self._mb_entity_cache) >= self._entity_cache_limit:
warnings.warn('in-memory entities exceed entity_cache_limit after flushing; consider setting a larger limit')
self._log[__name__].info(
'In-memory entity cache at %s/%s after flushing to session',
len(self._mb_entity_cache),
self._entity_cache_limit
)
get_diff = self._message_box.get_difference()
if get_diff:
self._log[__name__].debug('Getting difference for account updates')
try:
diff = await self(get_diff)
except (
errors.ServerError,
errors.TimedOutError,
errors.FloodWaitError,
ValueError
) as e:
# Telegram is having issues
self._log[__name__].info('Cannot get difference since Telegram is having issues: %s', type(e).__name__)
self._message_box.end_difference()
continue
except (errors.UnauthorizedError, errors.AuthKeyError) as e:
# Not logged in or broken authorization key, can't get difference
self._log[__name__].info('Cannot get difference since the account is not logged in: %s', type(e).__name__)
self._message_box.end_difference()
if was_once_logged_in:
self._updates_error = e
await self.disconnect()
break
continue
except (errors.TypeNotFoundError, sqlite3.OperationalError) as e:
# User is likely doing weird things with their account or session and Telegram gets confused as to what layer they use
self._log[__name__].warning('Cannot get difference since the account is likely misusing the session: %s', e)
self._message_box.end_difference()
self._updates_error = e
await self.disconnect()
break
except OSError as e:
# Network is likely down, but it's unclear for how long.
# If disconnect is called this task will be cancelled along with the sleep.
# If disconnect is not called, getting difference should be retried after a few seconds.
self._log[__name__].info('Cannot get difference since the network is down: %s: %s', type(e).__name__, e)
await asyncio.sleep(5)
continue
updates, users, chats = self._message_box.apply_difference(diff, self._mb_entity_cache)
if updates:
self._log[__name__].info('Got difference for account updates')
_preprocess_updates = await self._preprocess_updates(updates, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
continue
get_diff = self._message_box.get_channel_difference(self._mb_entity_cache)
if get_diff:
self._log[__name__].debug('Getting difference for channel %s updates', get_diff.channel.channel_id)
try:
diff = await self(get_diff)
except (errors.UnauthorizedError, errors.AuthKeyError) as e:
# Not logged in or broken authorization key, can't get difference
self._log[__name__].warning(
'Cannot get difference for channel %s since the account is not logged in: %s',
get_diff.channel.channel_id, type(e).__name__
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
if was_once_logged_in:
self._updates_error = e
await self.disconnect()
break
continue
except (errors.TypeNotFoundError, sqlite3.OperationalError) as e:
self._log[__name__].warning(
'Cannot get difference for channel %s since the account is likely misusing the session: %s',
get_diff.channel.channel_id, e
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
self._updates_error = e
await self.disconnect()
break
except (
errors.PersistentTimestampOutdatedError,
errors.PersistentTimestampInvalidError,
errors.ServerError,
errors.TimedOutError,
errors.FloodWaitError,
ValueError
) as e:
# According to Telegram's docs:
# "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)."
# We can treat this as "empty difference" and not update the local pts.
# Then this same call will be retried when another gap is detected or timeout expires.
#
# Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few
# seconds, but if Telegram is having issues it's probably best to wait for it to send another
# update (hinting it may be okay now) and retry then.
#
# This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts.
# Instead we manually extract the previously-known pts and use that.
#
# For PersistentTimestampInvalidError:
# Somehow our pts is either too new or the server does not know about this.
# We treat this as PersistentTimestampOutdatedError for now.
# TODO investigate why/when this happens and if this is the proper solution
self._log[__name__].warning(
'Getting difference for channel updates %s caused %s;'
' ending getting difference prematurely until server issues are resolved',
get_diff.channel.channel_id, type(e).__name__
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.TEMPORARY_SERVER_ISSUES,
self._mb_entity_cache
)
continue
except (errors.ChannelPrivateError, errors.ChannelInvalidError):
# Timeout triggered a get difference, but we have been banned in the channel since then.
# Because we can no longer fetch updates from this channel, we should stop keeping track
# of it entirely.
self._log[__name__].info(
'Account is now banned in %d so we can no longer fetch updates from it',
get_diff.channel.channel_id
)
self._message_box.end_channel_difference(
get_diff,
PrematureEndReason.BANNED,
self._mb_entity_cache
)
continue
except OSError as e:
self._log[__name__].info(
'Cannot get difference for channel %d since the network is down: %s: %s',
get_diff.channel.channel_id, type(e).__name__, e
)
await asyncio.sleep(5)
continue
updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._mb_entity_cache)
if updates:
self._log[__name__].info('Got difference for channel %d updates', get_diff.channel.channel_id)
_preprocess_updates = await self._preprocess_updates(updates, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
continue
deadline = self._message_box.check_deadlines()
deadline_delay = deadline - get_running_loop().time()
if deadline_delay > 0:
# Don't bother sleeping and timing out if the delay is already 0 (pollutes the logs).
try:
updates = await asyncio.wait_for(self._updates_queue.get(), deadline_delay)
except asyncio.TimeoutError:
self._log[__name__].debug('Timeout waiting for updates expired')
continue
else:
continue
processed = []
try:
users, chats = self._message_box.process_updates(updates, self._mb_entity_cache, processed)
except GapError:
continue # get(_channel)_difference will start returning requests
_preprocess_updates = await self._preprocess_updates(processed, users, chats)
updates_to_dispatch.extend(_preprocess_updates)
except asyncio.CancelledError:
pass
except Exception as e:
self._log[__name__].exception(f'Fatal error handling updates (this is a bug in Telethon v{__version__}, please report it)')
self._updates_error = e
await self.disconnect()
async def _preprocess_updates(self, updates, users, chats):
self._mb_entity_cache.extend(users, chats)
await utils.maybe_async(self.session.process_entities(types.contacts.ResolvedPeer(None, users, chats)))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(users, chats)}
for u in updates:
u._entities = entities
return updates
async def _keepalive_loop(self: 'TelegramClient'):
# Pings' ID don't really need to be secure, just "random"
rnd = lambda: random.randrange(-2**63, 2**63)
while self.is_connected():
@ -510,7 +348,7 @@ class UpdateMethods:
# We also don't really care about their result.
# Just send them periodically.
try:
self._sender._keepalive_ping(rnd())
self._sender.send(functions.PingRequest(rnd()))
except (ConnectionError, asyncio.CancelledError):
return
@ -518,18 +356,57 @@ class UpdateMethods:
# inserted because this is a rather expensive operation
# (default's sqlite3 takes ~0.1s to commit changes). Do
# it every minute instead. No-op if there's nothing new.
await self._save_states_and_entities()
self.session.save()
await utils.maybe_async(self.session.save())
# We need to send some content-related request at least hourly
# for Telegram to keep delivering updates, otherwise they will
# just stop even if we're connected. Do so every 30 minutes.
#
# TODO Call getDifference instead since it's more relevant
if time.time() - self._last_request > 30 * 60:
if not await self.is_user_authorized():
# What can be the user doing for so
# long without being logged in...?
continue
async def _dispatch_update(self: 'TelegramClient', update):
# TODO only used for AlbumHack, and MessageBox is not really designed for this
others = None
try:
await self(functions.updates.GetStateRequest())
except (ConnectionError, asyncio.CancelledError):
return
if not self._mb_entity_cache.self_id:
async def _dispatch_queue_updates(self: 'TelegramClient'):
while not self._updates_queue.empty():
await self._dispatch_update(*self._updates_queue.get_nowait())
self._dispatching_updates_queue.clear()
async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date):
if not self._entity_cache.ensure_cached(update):
# We could add a lock to not fetch the same pts twice if we are
# already fetching it. However this does not happen in practice,
# which makes sense, because different updates have different pts.
if self._state_cache.update(update, check_only=True):
# If the update doesn't have pts, fetching won't do anything.
# For example, UpdateUserStatus or UpdateChatUserTyping.
try:
await self._get_difference(update, channel_id, pts_date)
except OSError:
pass # We were disconnected, that's okay
except errors.RPCError:
# There's a high chance the request fails because we lack
# the channel. Because these "happen sporadically" (#1428)
# we should be okay (no flood waits) even if more occur.
pass
except ValueError:
# There is a chance that GetFullChannelRequest and GetDifferenceRequest
# inside the _get_difference() function will end up with
# ValueError("Request was unsuccessful N time(s)") for whatever reasons.
pass
if not self._self_input_peer:
# Some updates require our own ID, so we must make sure
# that the event builder has offline access to it. Calling
# `get_me()` will cache it under `self._mb_entity_cache`.
# `get_me()` will cache it under `self._self_input_peer`.
#
# It will return `None` if we haven't logged in yet which is
# fine, we will just retry next time anyway.
@ -587,7 +464,8 @@ class UpdateMethods:
except Exception as e:
if not isinstance(e, asyncio.CancelledError) or self.is_connected():
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].exception('Unhandled exception on %s', name)
self._log[__name__].exception('Unhandled exception on %s',
name)
async def _dispatch_event(self: 'TelegramClient', event):
"""
@ -628,7 +506,69 @@ class UpdateMethods:
except Exception as e:
if not isinstance(e, asyncio.CancelledError) or self.is_connected():
name = getattr(callback, '__name__', repr(callback))
self._log[__name__].exception('Unhandled exception on %s', name)
self._log[__name__].exception('Unhandled exception on %s',
name)
async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date):
"""
Get the difference for this `channel_id` if any, then load entities.
Calls :tl:`updates.getDifference`, which fills the entities cache
(always done by `__call__`) and lets us know about the full entities.
"""
# Fetch since the last known pts/date before this update arrived,
# in order to fetch this update at full, including its entities.
self._log[__name__].debug('Getting difference for entities '
'for %r', update.__class__)
if channel_id:
# There are reports where we somehow call get channel difference
# with `InputPeerEmpty`. Check our assumptions to better debug
# this when it happens.
assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update)
try:
# Wrap the ID inside a peer to ensure we get a channel back.
where = await self.get_input_entity(types.PeerChannel(channel_id))
except ValueError:
# There's a high chance that this fails, since
# we are getting the difference to fetch entities.
return
if not pts_date:
# First-time, can't get difference. Get pts instead.
result = await self(functions.channels.GetFullChannelRequest(
utils.get_input_channel(where)
))
self._state_cache[channel_id] = result.full_chat.pts
return
result = await self(functions.updates.GetChannelDifferenceRequest(
channel=where,
filter=types.ChannelMessagesFilterEmpty(),
pts=pts_date, # just pts
limit=100,
force=True
))
else:
if not pts_date[0]:
# First-time, can't get difference. Get pts instead.
result = await self(functions.updates.GetStateRequest())
self._state_cache[None] = result.pts, result.date
return
result = await self(functions.updates.GetDifferenceRequest(
pts=pts_date[0],
date=pts_date[1],
qts=0
))
if isinstance(result, (types.updates.Difference,
types.updates.DifferenceSlice,
types.updates.ChannelDifference,
types.updates.ChannelDifferenceTooLong)):
update._entities.update({
utils.get_peer_id(x): x for x in
itertools.chain(result.users, result.chats)
})
async def _handle_auto_reconnect(self: 'TelegramClient'):
# TODO Catch-up
@ -670,8 +610,8 @@ class UpdateMethods:
self._log[__name__].warning('Failed to get missed updates after '
'reconnect: %r', e)
except Exception:
self._log[__name__].exception(
'Unhandled exception while getting update difference after reconnect')
self._log[__name__].exception('Unhandled exception while getting '
'update difference after reconnect')
# endregion

View File

@ -18,6 +18,7 @@ try:
except ImportError:
PIL = None
if typing.TYPE_CHECKING:
from .telegramclient import TelegramClient
@ -35,7 +36,7 @@ class _CacheType:
def _resize_photo_if_needed(
file, is_image, width=2560, height=2560, background=(255, 255, 255)):
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
@ -46,64 +47,40 @@ def _resize_photo_if_needed(
if isinstance(file, bytes):
file = io.BytesIO(file)
if isinstance(file, io.IOBase):
# Pillow seeks to 0 unconditionally later anyway
old_pos = file.tell()
file.seek(0, io.SEEK_END)
before = file.tell()
elif isinstance(file, str) and os.path.exists(file):
# Check if file exists as a path and if so, get its size on disk
before = os.path.getsize(file)
else:
# Would be weird...
before = None
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
if image.mode == 'RGB':
# Check if image is within acceptable bounds, if so, check if the image is at or below 10 MB, or assume it isn't if size is None or 0
if image.width <= width and image.height <= height and (before <= 10000000 if before else False):
return file
image.thumbnail((width, height), PIL.Image.ANTIALIAS)
# If the image is already RGB, don't convert it
# certain modes such as 'P' have no alpha index but can't be saved as JPEG directly
image.thumbnail((width, height), PIL.Image.LANCZOS)
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.
image.thumbnail((width, height), PIL.Image.LANCZOS)
result = PIL.Image.new('RGB', image.size, background)
mask = None
if image.has_transparency_data:
if image.mode == 'RGBA':
mask = image.getchannel('A')
else:
mask = image.convert('RGBA').getchannel('A')
result.paste(image, mask=mask)
result.paste(image, mask=image.split()[alpha_index])
buffer = io.BytesIO()
result.save(buffer, 'JPEG', progressive=True, **kwargs)
result.save(buffer, 'JPEG')
buffer.seek(0)
buffer.name = 'a.jpg'
return buffer
except IOError:
return file
finally:
# The original position might matter
if isinstance(file, io.IOBase):
file.seek(old_pos)
if before is not None:
file.seek(before, io.SEEK_SET)
class UploadMethods:
@ -117,7 +94,6 @@ class UploadMethods:
*,
caption: typing.Union[str, typing.Sequence[str]] = None,
force_document: bool = False,
mime_type: str = None,
file_size: int = None,
clear_draft: bool = False,
progress_callback: 'hints.ProgressCallback' = None,
@ -126,24 +102,14 @@ class UploadMethods:
thumb: 'hints.FileLike' = None,
allow_cache: bool = True,
parse_mode: str = (),
formatting_entities: typing.Optional[
typing.Union[
typing.List[types.TypeMessageEntity], typing.List[typing.List[types.TypeMessageEntity]]
]
] = None,
formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
voice_note: bool = False,
video_note: bool = False,
buttons: typing.Optional['hints.MarkupLike'] = None,
buttons: 'hints.MarkupLike' = None,
silent: bool = None,
background: bool = None,
supports_streaming: bool = False,
schedule: 'hints.DateLike' = None,
comment_to: 'typing.Union[int, types.Message]' = None,
ttl: int = None,
nosound_video: bool = None,
send_as: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = None,
**kwargs) -> typing.Union[typing.List[typing.Any], typing.Any]:
**kwargs) -> 'types.Message':
"""
Sends message with the given file to the specified entity.
@ -210,13 +176,6 @@ class UploadMethods:
the extension of an image file or a video file, it will be
sent as such. Otherwise always as a document.
mime_type (`str`, optional):
Custom mime type to use for the file to be sent (for example,
``audio/mpeg``, ``audio/x-vorbis+ogg``, etc.).
It can change the type of files displayed.
If not set to any value, the mime type will be determined
automatically based on the file's extension.
file_size (`int`, optional):
The size of the file to be uploaded if it needs to be uploaded,
which will be determined automatically if not specified.
@ -242,14 +201,9 @@ class UploadMethods:
Optional JPEG thumbnail (for documents). **Telegram will
ignore this parameter** unless you pass a ``.jpg`` file!
The file must also be small in dimensions and in disk size.
Successful thumbnails were files below 20kB and 320x320px.
The file must also be small in dimensions and in-disk size.
Successful thumbnails were files below 20kb and 200x200px.
Width/height and dimensions/size ratios may be important.
For Telegram to accept a thumbnail, you must provide the
dimensions of the underlying media through ``attributes=``
with :tl:`DocumentAttributesVideo` or by installing the
optional ``hachoir`` dependency.
allow_cache (`bool`, optional):
This parameter currently does nothing, but is kept for
@ -263,11 +217,7 @@ class UploadMethods:
default.
formatting_entities (`list`, optional):
Optional formatting entities for the sent media message. When sending an album,
`formatting_entities` can be a list of lists, where each inner list contains
`types.TypeMessageEntity`. Each inner list will be assigned to the corresponding
file in a pairwise manner with the caption. If provided, the ``parse_mode``
parameter will be ignored.
A list of message formatting entities. When provided, the ``parse_mode`` is ignored.
voice_note (`bool`, optional):
If `True` the audio will be sent as a voice note.
@ -288,9 +238,6 @@ class UploadMethods:
the person has the chat muted). Set it to `True` to alter
this behaviour.
background (`bool`, optional):
Whether the message should be send in background.
supports_streaming (`bool`, optional):
Whether the sent video supports streaming or not. Note that
Telegram only recognizes as streamable some formats like MP4,
@ -303,45 +250,6 @@ class UploadMethods:
it will be scheduled to be automatically sent at a later
time.
comment_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
Similar to ``reply_to``, but replies in the linked group of a
broadcast channel instead (effectively leaving a "comment to"
the specified message).
This parameter takes precedence over ``reply_to``. If there is
no linked chat, `telethon.errors.sgIdInvalidError` is raised.
ttl (`int`. optional):
The Time-To-Live of the file (also known as "self-destruct timer"
or "self-destructing media"). If set, files can only be viewed for
a short period of time before they disappear from the message
history automatically.
The value must be at least 1 second, and at most 60 seconds,
otherwise Telegram will ignore this parameter.
Not all types of media can be used with this parameter, such
as text documents, which will fail with ``TtlMediaInvalidError``.
nosound_video (`bool`, optional):
Only applicable when sending a video file without an audio
track. If set to ``True``, the video will be displayed in
Telegram as a video. If set to ``False``, Telegram will attempt
to display the video as an animated gif. (It may still display
as a video due to other factors.) The value is ignored if set
on non-video files. This is set to ``True`` for albums, as gifs
cannot be sent in albums.
send_as (`entity`):
Unique identifier (int) or username (str) of the chat or channel to send the message as.
You can use this to send the message on behalf of a chat or channel where you have appropriate permissions.
Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat,
This setting applies to the current message and will remain effective for future messages unless explicitly changed.
To set this behavior permanently for all messages, use SaveDefaultSendAs.
message_effect_id (`int`, optional):
Unique identifier of the message effect to be added to the message; for private chats only
Returns
The `Message <telethon.tl.custom.message.Message>` (or messages)
containing the sent file, or messages if a list of them was passed.
@ -399,58 +307,44 @@ class UploadMethods:
if not caption:
caption = ''
if not formatting_entities:
formatting_entities = []
entity = await self.get_input_entity(entity)
if comment_to is not None:
entity, reply_to = await self._get_comment_data(entity, comment_to)
else:
reply_to = utils.get_message_id(reply_to)
# First check if the user passed an iterable, in which case
# we may want to send grouped.
if utils.is_list_like(file):
sent_count = 0
used_callback = None if not progress_callback else (
lambda s, t: progress_callback(sent_count + s, len(file))
)
if utils.is_list_like(caption):
captions = caption
else:
captions = [caption]
# Check that formatting_entities list is valid
if all(utils.is_list_like(obj) for obj in formatting_entities):
formatting_entities = formatting_entities
elif utils.is_list_like(formatting_entities):
formatting_entities = [formatting_entities]
else:
raise TypeError('The formatting_entities argument must be a list or a sequence of lists')
# Check that all entities in all lists are of the correct type
if not all(isinstance(ent, types.TypeMessageEntity) for sublist in formatting_entities for ent in sublist):
raise TypeError('All entities must be instances of <types.TypeMessageEntity>')
result = []
while file:
result += await self._send_album(
entity, file[:10], caption=captions[:10], formatting_entities=formatting_entities[:10],
progress_callback=used_callback, reply_to=reply_to,
entity, file[:10], caption=captions[:10],
progress_callback=progress_callback, reply_to=reply_to,
parse_mode=parse_mode, silent=silent, schedule=schedule,
supports_streaming=supports_streaming, clear_draft=clear_draft,
force_document=force_document, background=background,
send_as=send_as, message_effect_id=message_effect_id
force_document=force_document
)
file = file[10:]
captions = captions[10:]
formatting_entities = formatting_entities[10:]
sent_count += 10
for doc, cap in zip(file, captions):
result.append(await self.send_file(
entity, doc, allow_cache=allow_cache,
caption=cap, force_document=force_document,
progress_callback=progress_callback, reply_to=reply_to,
attributes=attributes, thumb=thumb, voice_note=voice_note,
video_note=video_note, buttons=buttons, silent=silent,
supports_streaming=supports_streaming, schedule=schedule,
clear_draft=clear_draft,
**kwargs
))
return result
if formatting_entities:
entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to)
if formatting_entities is not None:
msg_entities = formatting_entities
else:
caption, msg_entities =\
@ -458,13 +352,11 @@ class UploadMethods:
file_handle, media, image = await self._file_to_media(
file, force_document=force_document,
mime_type=mime_type,
file_size=file_size,
progress_callback=progress_callback,
attributes=attributes, allow_cache=allow_cache, thumb=thumb,
attributes=attributes, allow_cache=allow_cache, thumb=thumb,
voice_note=voice_note, video_note=video_note,
supports_streaming=supports_streaming, ttl=ttl,
nosound_video=nosound_video,
supports_streaming=supports_streaming
)
# e.g. invalid cast from :tl:`MessageMediaWebPage`
@ -472,25 +364,18 @@ class UploadMethods:
raise TypeError('Cannot use {!r} as file'.format(file))
markup = self.build_reply_markup(buttons)
reply_to = None if reply_to is None else types.InputReplyToMessage(reply_to)
request = functions.messages.SendMediaRequest(
entity, media, reply_to=reply_to, message=caption,
entity, media, reply_to_msg_id=reply_to, message=caption,
entities=msg_entities, reply_markup=markup, silent=silent,
schedule_date=schedule, clear_draft=clear_draft,
background=background,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
schedule_date=schedule, clear_draft=clear_draft
)
return self._get_response_message(request, await self(request), entity)
async def _send_album(self: 'TelegramClient', entity, files, caption='',
formatting_entities=None,
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: typing.Optional['hints.EntityLike'] = None,
message_effect_id: typing.Optional[int] = None):
force_document=False):
"""Specialized version of .send_file for albums"""
# We don't care if the user wants to avoid cache, we will use it
# anyway. Why? The cached version will be exactly the same thing
@ -498,51 +383,36 @@ class UploadMethods:
# 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
# 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,)
if not all(isinstance(obj, list) for obj in formatting_entities):
formatting_entities = (formatting_entities,)
captions = []
# If the formatting_entities argument is provided, we don't use parse_mode
if formatting_entities:
# Pop from the end (so reverse)
capt_with_ent = itertools.zip_longest(reversed(caption), reversed(formatting_entities), fillvalue=None)
for msg_caption, msg_entities in capt_with_ent:
captions.append((msg_caption, msg_entities))
else:
for c in reversed(caption): # Pop from the end (so reverse)
captions.append(await self._parse_message_text(c or '', parse_mode))
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)
used_callback = None if not progress_callback else (
# use an integer when sent matches total, to easily determine a file has been fully sent
lambda s, t: progress_callback(sent_count + 1 if s == t else sent_count + s / t, len(files))
)
# Need to upload the media first, but only if they're not cached yet
media = []
for sent_count, file in enumerate(files):
for file in files:
# Albums want :tl:`InputMedia` which, in theory, includes
# :tl:`InputMediaUploadedPhoto`. However, using that will
# :tl:`InputMediaUploadedPhoto`. However using that will
# make it `raise MediaInvalidError`, so we need to upload
# it as media and then convert that to :tl:`InputMediaPhoto`.
fh, fm, _ = await self._file_to_media(
file, supports_streaming=supports_streaming,
force_document=force_document, ttl=ttl,
progress_callback=used_callback, nosound_video=True)
if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)):
force_document=force_document)
if isinstance(fm, types.InputMediaUploadedPhoto):
r = await self(functions.messages.UploadMediaRequest(
entity, media=fm
))
fm = utils.get_input_media(r.photo)
elif isinstance(fm, (types.InputMediaUploadedDocument, types.InputMediaDocumentExternal)):
elif isinstance(fm, types.InputMediaUploadedDocument):
r = await self(functions.messages.UploadMediaRequest(
entity, media=fm
))
@ -563,11 +433,8 @@ class UploadMethods:
# Now we can construct the multi-media request
request = functions.messages.SendMultiMediaRequest(
entity, reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), multi_media=media,
silent=silent, schedule_date=schedule, clear_draft=clear_draft,
background=background,
send_as=await self.get_input_entity(send_as) if send_as else None,
effect=message_effect_id
entity, reply_to_msg_id=reply_to, multi_media=media,
silent=silent, schedule_date=schedule, clear_draft=clear_draft
)
result = await self(request)
@ -638,13 +505,6 @@ class UploadMethods:
A callback function accepting two parameters:
``(sent bytes, total)``.
When sending an album, the callback will receive a number
between 0 and the amount of files as the "sent" parameter,
and the amount of files as the "total". Note that the first
parameter will be a floating point number to indicate progress
within a file (e.g. ``2.5`` means it has sent 50% of the third
file, because it's between 2 and 3).
Returns
:tl:`InputFileBig` if the file size is larger than 10MB,
`InputSizedFile <telethon.tl.custom.inputsizedfile.InputSizedFile>`
@ -703,7 +563,7 @@ class UploadMethods:
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)
file_size, part_count, part_size)
pos = 0
for part_index in range(part_count):
@ -724,16 +584,16 @@ class UploadMethods:
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)
# Encryption part if needed
if key and iv:
part = AES.encrypt_ige(part, key, iv)
# The SavePartRequest is different depending on whether
# the file is too large or not (over or less than 10MB)
if is_big:
@ -766,8 +626,7 @@ class UploadMethods:
self, file, force_document=False, file_size=None,
progress_callback=None, attributes=None, thumb=None,
allow_cache=True, voice_note=False, video_note=False,
supports_streaming=False, mime_type=None, as_image=None,
ttl=None, nosound_video=None):
supports_streaming=False, mime_type=None, as_image=None):
if not file:
return None, None, None
@ -780,7 +639,7 @@ class UploadMethods:
# `aiofiles` do not base `io.IOBase` but do have `read`, so we
# just check for the read attribute to see if it's file-like.
if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig)) \
if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig))\
and not hasattr(file, 'read'):
# The user may pass a Message containing media (or the media,
# or anything similar) that should be treated as a file. Try
@ -796,8 +655,7 @@ class UploadMethods:
force_document=force_document,
voice_note=voice_note,
video_note=video_note,
supports_streaming=supports_streaming,
ttl=ttl
supports_streaming=supports_streaming
), as_image)
except TypeError:
# Can't turn whatever was given into media
@ -816,13 +674,13 @@ class UploadMethods:
)
elif re.match('https?://', file):
if as_image:
media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl)
media = types.InputMediaPhotoExternal(file)
else:
media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl)
media = types.InputMediaDocumentExternal(file)
else:
bot_file = utils.resolve_bot_file_id(file)
if bot_file:
media = utils.get_input_media(bot_file, ttl=ttl)
media = utils.get_input_media(bot_file)
if media:
pass # Already have media, don't check the rest
@ -832,7 +690,7 @@ class UploadMethods:
'an HTTP URL or a valid bot-API-like file ID'.format(file)
)
elif as_image:
media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl)
media = types.InputMediaUploadedPhoto(file_handle)
else:
attributes, mime_type = utils.get_attributes(
file,
@ -841,8 +699,7 @@ class UploadMethods:
force_document=force_document and not is_image,
voice_note=voice_note,
video_note=video_note,
supports_streaming=supports_streaming,
thumb=thumb
supports_streaming=supports_streaming
)
if not thumb:
@ -852,18 +709,12 @@ class UploadMethods:
thumb = str(thumb.absolute())
thumb = await self.upload_file(thumb, file_size=file_size)
# setting `nosound_video` to `True` doesn't affect videos with sound
# instead it prevents sending silent videos as GIFs
nosound_video = nosound_video if mime_type.split("/")[0] == 'video' else None
media = types.InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type,
attributes=attributes,
thumb=thumb,
force_file=force_document and not is_image,
ttl_seconds=ttl,
nosound_video=nosound_video
force_file=force_document and not is_image
)
return file_handle, media, as_image

View File

@ -26,19 +26,12 @@ def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta):
class UserMethods:
async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None):
async def __call__(self: 'TelegramClient', request, ordered=False):
return await self._call(self._sender, request, ordered=ordered)
async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None):
if self._loop is not None and self._loop != helpers.get_running_loop():
raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)')
# if the loop is None it will fail with a connection error later on
if flood_sleep_threshold is None:
flood_sleep_threshold = self.flood_sleep_threshold
requests = list(request) if utils.is_list_like(request) else [request]
request = list(request) if utils.is_list_like(request) else request
for i, r in enumerate(requests):
async def _call(self: 'TelegramClient', sender, request, ordered=False):
requests = (request if utils.is_list_like(request) else (request,))
for r in requests:
if not isinstance(r, TLRequest):
raise _NOT_A_REQUEST()
await r.resolve(self, utils)
@ -49,20 +42,13 @@ class UserMethods:
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:
elif diff <= self.flood_sleep_threshold:
self._log[__name__].info(*_fmt_flood(diff, r, early=True))
await asyncio.sleep(diff)
self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None)
else:
raise errors.FloodWaitError(request=r, capture=diff)
if self._no_updates:
if utils.is_list_like(request):
request[i] = functions.InvokeWithoutUpdatesRequest(r)
else:
# This should only run once as requests should be a list of 1 item
request = functions.InvokeWithoutUpdatesRequest(r)
request_index = 0
last_error = None
self._last_request = time.time()
@ -80,7 +66,8 @@ class UserMethods:
exceptions.append(e)
results.append(None)
continue
await utils.maybe_async(self.session.process_entities(result))
self.session.process_entities(result)
self._entity_cache.add(result)
exceptions.append(None)
results.append(result)
request_index += 1
@ -90,11 +77,11 @@ class UserMethods:
return results
else:
result = await future
await utils.maybe_async(self.session.process_entities(result))
self.session.process_entities(result)
self._entity_cache.add(result)
return result
except (errors.ServerError, errors.RpcCallFailError,
errors.RpcMcgetFailError, errors.InterdcCallErrorError,
errors.TimedOutError,
errors.InterdcCallRichErrorError) as e:
last_error = e
self._log[__name__].warning(
@ -102,8 +89,7 @@ class UserMethods:
e.__class__.__name__, e)
await asyncio.sleep(2)
except (errors.FloodWaitError, errors.FloodPremiumWaitError,
errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e:
except (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e:
last_error = e
if utils.is_list_like(request):
request = request[request_index]
@ -163,17 +149,20 @@ class UserMethods:
me = await client.get_me()
print(me.username)
"""
if input_peer and self._mb_entity_cache.self_id:
return self._mb_entity_cache.get(self._mb_entity_cache.self_id)._as_input_peer()
if input_peer and self._self_input_peer:
return self._self_input_peer
try:
me = (await self(
functions.users.GetUsersRequest([types.InputUserSelf()])))[0]
if not self._mb_entity_cache.self_id:
self._mb_entity_cache.set_self_user(me.id, me.bot, me.access_hash)
self._bot = me.bot
if not self._self_input_peer:
self._self_input_peer = utils.get_input_peer(
me, allow_self=False
)
return utils.get_input_peer(me, allow_self=False) if input_peer else me
return self._self_input_peer if input_peer else me
except errors.UnauthorizedError:
return None
@ -185,7 +174,7 @@ class UserMethods:
This property is used in every update, and some like `updateLoginToken`
occur prior to login, so it gracefully handles when no ID is known yet.
"""
return self._mb_entity_cache.self_id
return self._self_input_peer.user_id if self._self_input_peer else None
async def is_bot(self: 'TelegramClient') -> bool:
"""
@ -199,10 +188,10 @@ class UserMethods:
else:
print('Hello')
"""
if self._mb_entity_cache.self_bot is None:
await self.get_me(input_peer=True)
if self._bot is None:
self._bot = (await self.get_me()).bot
return self._mb_entity_cache.self_bot
return self._bot
async def is_user_authorized(self: 'TelegramClient') -> bool:
"""
@ -228,7 +217,7 @@ class UserMethods:
async def get_entity(
self: 'TelegramClient',
entity: 'hints.EntitiesLike') -> typing.Union['hints.Entity', typing.List['hints.Entity']]:
entity: 'hints.EntitiesLike') -> 'hints.Entity':
"""
Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat`
or :tl:`Channel`. You can also pass a list or iterable of entities,
@ -327,9 +316,7 @@ class UserMethods:
# Merge users, chats and channels into a single dictionary
id_entity = {
# `get_input_entity` might've guessed the type from a non-marked ID,
# so the only way to match that with the input is by not using marks here.
utils.get_peer_id(x, add_mark=False): x
utils.get_peer_id(x): x
for x in itertools.chain(users, chats, channels)
}
@ -342,7 +329,7 @@ class UserMethods:
if isinstance(x, str):
result.append(await self._get_entity_from_string(x))
elif not isinstance(x, types.InputPeerSelf):
result.append(id_entity[utils.get_peer_id(x, add_mark=False)])
result.append(id_entity[utils.get_peer_id(x)])
else:
result.append(next(
u for u in id_entity.values()
@ -425,8 +412,8 @@ class UserMethods:
try:
# 0x2d45687 == crc32(b'Peer')
if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687:
return self._mb_entity_cache.get(utils.get_peer_id(peer, add_mark=False))._as_input_peer()
except AttributeError:
return self._entity_cache[peer]
except (AttributeError, KeyError):
pass
# Then come known strings that take precedence
@ -435,8 +422,7 @@ class UserMethods:
# No InputPeer, cached peer, or known string. Fetch from disk cache
try:
input_entity = await utils.maybe_async(self.session.get_input_entity(peer))
return input_entity
return self.session.get_input_entity(peer)
except ValueError:
pass
@ -473,10 +459,10 @@ class UserMethods:
pass
raise ValueError(
'Could not find the input entity for {} ({}). Please read https://'
'docs.telethon.dev/en/stable/concepts/entities.html to'
'Could not find the input entity for {!r}. Please read https://'
'docs.telethon.dev/en/latest/concepts/entities.html to'
' find out more details.'
.format(peer, type(peer).__name__)
.format(peer)
)
async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'):
@ -575,8 +561,8 @@ class UserMethods:
pass
try:
# Nobody with this username, maybe it's an exact name/title
input_entity = await utils.maybe_async(self.session.get_input_entity(string))
return await self.get_entity(input_entity)
return await self.get_entity(
self.session.get_input_entity(string))
except ValueError:
pass
@ -613,8 +599,6 @@ class UserMethods:
notify.peer = await self.get_input_entity(notify.peer)
return notify
except AttributeError:
pass
return types.InputNotifyPeer(await self.get_input_entity(notify))
return types.InputNotifyPeer(await self.get_input_entity(notify))
# endregion

View File

@ -1 +0,0 @@
from .tl.custom import *

141
telethon/entitycache.py Normal file
View File

@ -0,0 +1,141 @@
import inspect
import itertools
from . import utils
from .tl import types
# Which updates have the following fields?
_has_field = {
('user_id', int): [],
('chat_id', int): [],
('channel_id', int): [],
('peer', 'TypePeer'): [],
('peer', 'TypeDialogPeer'): [],
('message', 'TypeMessage'): [],
}
# Note: We don't bother checking for some rare:
# * `UpdateChatParticipantAdd.inviter_id` integer.
# * `UpdateNotifySettings.peer` dialog peer.
# * `UpdatePinnedDialogs.order` list of dialog peers.
# * `UpdateReadMessagesContents.messages` list of messages.
# * `UpdateChatParticipants.participants` list of participants.
#
# There are also some uninteresting `update.message` of type string.
def _fill():
for name in dir(types):
update = getattr(types, name)
if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e:
cid = update.CONSTRUCTOR_ID
sig = inspect.signature(update.__init__)
for param in sig.parameters.values():
vec = _has_field.get((param.name, param.annotation))
if vec is not None:
vec.append(cid)
# Future-proof check: if the documentation format ever changes
# then we won't be able to pick the update types we are interested
# in, so we must make sure we have at least an update for each field
# which likely means we are doing it right.
if not all(_has_field.values()):
raise RuntimeError('FIXME: Did the init signature or updates change?')
# We use a function to avoid cluttering the globals (with name/update/cid/doc)
_fill()
class EntityCache:
"""
In-memory input entity cache, defaultdict-like behaviour.
"""
def add(self, entities):
"""
Adds the given entities to the cache, if they weren't saved before.
"""
if not utils.is_list_like(entities):
# Invariant: all "chats" and "users" are always iterables,
# and "user" never is (so we wrap it inside a list).
entities = itertools.chain(
getattr(entities, 'chats', []),
getattr(entities, 'users', []),
(hasattr(entities, 'user') and [entities.user]) or []
)
for entity in entities:
try:
pid = utils.get_peer_id(entity)
if pid not in self.__dict__:
# Note: `get_input_peer` already checks for `access_hash`
self.__dict__[pid] = utils.get_input_peer(entity)
except TypeError:
pass
def __getitem__(self, item):
"""
Gets the corresponding :tl:`InputPeer` for the given ID or peer,
or raises ``KeyError`` on any error (i.e. cannot be found).
"""
if not isinstance(item, int) or item < 0:
try:
return self.__dict__[utils.get_peer_id(item)]
except TypeError:
raise KeyError('Invalid key will not have entity') from None
for cls in (types.PeerUser, types.PeerChat, types.PeerChannel):
result = self.__dict__.get(utils.get_peer_id(cls(item)))
if result:
return result
raise KeyError('No cached entity for the given key')
def ensure_cached(
self,
update,
has_user_id=frozenset(_has_field[('user_id', int)]),
has_chat_id=frozenset(_has_field[('chat_id', int)]),
has_channel_id=frozenset(_has_field[('channel_id', int)]),
has_peer=frozenset(_has_field[('peer', 'TypePeer')] + _has_field[('peer', 'TypeDialogPeer')]),
has_message=frozenset(_has_field[('message', 'TypeMessage')])
):
"""
Ensures that all the relevant entities in the given update are cached.
"""
# This method is called pretty often and we want it to have the lowest
# overhead possible. For that, we avoid `isinstance` and constantly
# getting attributes out of `types.` by "caching" the constructor IDs
# in sets inside the arguments, and using local variables.
dct = self.__dict__
cid = update.CONSTRUCTOR_ID
if cid in has_user_id and \
update.user_id not in dct:
return False
if cid in has_chat_id and \
utils.get_peer_id(types.PeerChat(update.chat_id)) not in dct:
return False
if cid in has_channel_id and \
utils.get_peer_id(types.PeerChannel(update.channel_id)) not in dct:
return False
if cid in has_peer and \
utils.get_peer_id(update.peer) not in dct:
return False
if cid in has_message:
x = update.message
y = getattr(x, 'peer_id', None) # handle MessageEmpty
if y and utils.get_peer_id(y) not in dct:
return False
y = getattr(x, 'from_id', None)
if y and utils.get_peer_id(y) not in dct:
return False
# We don't quite worry about entities anywhere else.
# This is enough.
return True

View File

@ -6,7 +6,7 @@ import re
from .common import (
ReadCancelledError, TypeNotFoundError, InvalidChecksumError,
InvalidBufferError, AuthKeyNotFound, SecurityError, CdnFileTamperedError,
InvalidBufferError, SecurityError, CdnFileTamperedError,
AlreadyInConversationError, BadMessageError, MultiError
)
@ -24,8 +24,7 @@ def rpc_message_to_error(rpc_error, request):
:return: the RPCError as a Python exception that represents this error.
"""
# Try to get the error by direct look-up, otherwise regex
# Case-insensitive, for things like "timeout" which don't conform.
cls = rpc_errors_dict.get(rpc_error.error_message.upper(), None)
cls = rpc_errors_dict.get(rpc_error.error_message, None)
if cls:
return cls(request=request)

View File

@ -1,6 +1,5 @@
"""Errors not related to the Telegram API itself"""
import struct
import textwrap
from ..tl import TLRequest
@ -19,8 +18,8 @@ class TypeNotFoundError(Exception):
def __init__(self, invalid_constructor_id, remaining):
super().__init__(
'Could not find a matching Constructor ID for the TLObject '
'that was supposed to be read with ID {:08x}. See the FAQ '
'for more details. '
'that was supposed to be read with ID {:08x}. Most likely, '
'a TLObject was trying to be read when it should not be read. '
'Remaining bytes: {!r}'.format(invalid_constructor_id, remaining))
self.invalid_constructor_id = invalid_constructor_id
@ -59,22 +58,6 @@ class InvalidBufferError(BufferError):
'Invalid response buffer (too short {})'.format(self.payload))
class AuthKeyNotFound(Exception):
"""
The server claims it doesn't know about the authorization key (session
file) currently being used. This might be because it either has never
seen this authorization key, or it used to know about the authorization
key but has forgotten it, either temporarily or permanently (possibly
due to server errors).
If the issue persists, you may need to recreate the session file and login
again. This is not done automatically because it is not possible to know
if the issue is temporary or permanent.
"""
def __init__(self):
super().__init__(textwrap.dedent(self.__class__.__doc__))
class SecurityError(Exception):
"""
Generic security error, mostly used when generating a new AuthKey.

View File

@ -1,15 +1,3 @@
from ..tl import functions
_NESTS_QUERY = (
functions.InvokeAfterMsgRequest,
functions.InvokeAfterMsgsRequest,
functions.InitConnectionRequest,
functions.InvokeWithLayerRequest,
functions.InvokeWithoutUpdatesRequest,
functions.InvokeWithMessagesRangeRequest,
functions.InvokeWithTakeoutRequest,
)
class RPCError(Exception):
"""Base class for all Remote Procedure Call errors."""
code = None
@ -25,15 +13,7 @@ class RPCError(Exception):
@staticmethod
def _fmt_request(request):
n = 0
reason = ''
while isinstance(request, _NESTS_QUERY):
n += 1
reason += request.__class__.__name__ + '('
request = request.query
reason += request.__class__.__name__ + ')' * n
return ' (caused by {})'.format(reason)
return ' (caused by {})'.format(request.__class__.__name__)
def __reduce__(self):
return type(self), (self.request, self.message, self.code)

View File

@ -97,10 +97,8 @@ class Album(EventBuilder):
@classmethod
def build(cls, update, others=None, self_id=None):
# TODO normally we'd only check updates if they come with other updates
# but MessageBox is not designed for this so others will always be None.
# In essence we always rely on AlbumHack rather than returning early if not others.
others = [update]
if not others:
return # We only care about albums which come inside the same Updates
if isinstance(update,
(types.UpdateNewMessage, types.UpdateNewChannelMessage)):
@ -152,15 +150,23 @@ class Album(EventBuilder):
"""
def __init__(self, messages):
message = messages[0]
super().__init__(chat_peer=message.peer_id,
if not message.out and isinstance(message.peer_id, types.PeerUser):
# Incoming message (e.g. from a bot) has peer_id=us, and
# from_id=bot (the actual "chat" from a user's perspective).
chat_peer = message.from_id
else:
chat_peer = message.peer_id
super().__init__(chat_peer=chat_peer,
msg_id=message.id, broadcast=bool(message.post))
SenderGetter.__init__(self, message.sender_id)
self.messages = messages
def _set_client(self, client):
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._mb_entity_cache)
self.sender_id, self._entities, client._entity_cache)
for msg in self.messages:
msg._finish_init(client, self._entities, None)

View File

@ -135,7 +135,7 @@ class CallbackQuery(EventBuilder):
The object returned by the ``data=`` parameter
when creating the event builder, if any. Similar
to ``pattern_match`` for the new message event.
pattern_match (`obj`, optional):
Alias for ``data_match``.
"""
@ -151,7 +151,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._mb_entity_cache)
self.sender_id, self._entities, client._entity_cache)
@property
def id(self):
@ -208,9 +208,8 @@ class CallbackQuery(EventBuilder):
if not getattr(self._input_sender, 'access_hash', True):
# getattr with True to handle the InputPeerSelf() case
try:
self._input_sender = self._client._mb_entity_cache.get(
utils.resolve_id(self._sender_id)[0])._as_input_peer()
except AttributeError:
self._input_sender = self._client._entity_cache[self._sender_id]
except KeyError:
m = await self.get_message()
if m:
self._sender = m._sender
@ -300,7 +299,7 @@ class CallbackQuery(EventBuilder):
"""
Edits the message. Shorthand for
`telethon.client.messages.MessageMethods.edit_message` with
the ``entity`` set to the correct :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64`.
the ``entity`` set to the correct :tl:`InputBotInlineMessageID`.
Returns `True` if the edit was successful.
@ -313,7 +312,7 @@ class CallbackQuery(EventBuilder):
since the message object is normally not present.
"""
self._client.loop.create_task(self.answer())
if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
if isinstance(self.query.msg_id, types.InputBotInlineMessageID):
return await self._client.edit_message(
self.query.msg_id, *args, **kwargs
)
@ -338,8 +337,6 @@ class CallbackQuery(EventBuilder):
This method will likely fail if `via_inline` is `True`.
"""
self._client.loop.create_task(self.answer())
if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)):
raise TypeError('Inline messages cannot be deleted as there is no API request available to do so')
return await self._client.delete_messages(
await self.get_input_chat(), [self.query.msg_id],
*args, **kwargs

View File

@ -11,7 +11,6 @@ class ChatAction(EventBuilder):
* Whenever a new chat is created.
* Whenever a chat's title or photo is changed or removed.
* Whenever a new message is pinned.
* Whenever a user scores in a game.
* Whenever a user joins or is added to the group.
* Whenever a user is removed or leaves a group if it has
less than 50 members or the removed user was a bot.
@ -30,7 +29,6 @@ class ChatAction(EventBuilder):
if event.user_joined:
await event.reply('Welcome to the group!')
"""
@classmethod
def build(cls, update, others=None, self_id=None):
# Rely on specific pin updates for unpins, but otherwise ignore them
@ -56,9 +54,20 @@ class ChatAction(EventBuilder):
kicked_by=True,
users=update.user_id)
# UpdateChannel is sent if we leave a channel, and the update._entities
# set by _process_update would let us make some guesses. However it's
# better not to rely on this. Rely only in MessageActionChatDeleteUser.
elif isinstance(update, types.UpdateChannel):
# We rely on the fact that update._entities is set by _process_update
# This update only has the channel ID, and Telegram *should* have sent
# the entity in the Updates.chats list. If it did, check Channel.left
# to determine what happened.
peer = types.PeerChannel(update.channel_id)
channel = update._entities.get(utils.get_peer_id(peer))
if channel is not None:
if isinstance(channel, types.ChannelForbidden) or channel.left:
return cls.Event(peer,
kicked_by=True)
else:
return cls.Event(peer,
added_by=True)
elif (isinstance(update, (
types.UpdateNewMessage, types.UpdateNewChannelMessage))
@ -77,7 +86,7 @@ class ChatAction(EventBuilder):
users=action.users)
elif isinstance(action, types.MessageActionChatDeleteUser):
return cls.Event(msg,
kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True,
kicked_by=msg.from_id or True,
users=action.user_id)
elif isinstance(action, types.MessageActionChatCreate):
return cls.Event(msg,
@ -104,20 +113,6 @@ class ChatAction(EventBuilder):
elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to:
return cls.Event(msg,
pin_ids=[msg.reply_to_msg_id])
elif isinstance(action, types.MessageActionGameScore):
return cls.Event(msg,
new_score=action.score)
elif isinstance(update, types.UpdateChannelParticipant) \
and bool(update.new_participant) != bool(update.prev_participant):
# If members are hidden, bots will receive this update instead,
# as there won't be a service message. Promotions and demotions
# seem to have both new and prev participant, which are ignored
# by this event.
return cls.Event(types.PeerChannel(update.channel_id),
users=update.user_id,
added_by=update.actor_id if update.new_participant else None,
kicked_by=update.actor_id if update.prev_participant else None)
class Event(EventCommon):
"""
@ -154,16 +149,12 @@ class ChatAction(EventBuilder):
new_title (`str`, optional):
The new title string for the chat, if applicable.
new_score (`str`, optional):
The new score string for the game, if applicable.
unpin (`bool`):
`True` if the existing pin gets unpinned.
"""
def __init__(self, where, new_photo=None,
added_by=None, kicked_by=None, created=None,
users=None, new_title=None, pin_ids=None, pin=None, new_score=None):
users=None, new_title=None, pin_ids=None, pin=None):
if isinstance(where, types.MessageService):
self.action_message = where
where = where.peer_id
@ -213,7 +204,6 @@ class ChatAction(EventBuilder):
self._users = None
self._input_users = None
self.new_title = new_title
self.new_score = new_score
self.unpin = not pin
def _set_client(self, client):
@ -425,10 +415,9 @@ class ChatAction(EventBuilder):
# If missing, try from the entity cache
try:
self._input_users.append(self._client._mb_entity_cache.get(
utils.resolve_id(user_id)[0])._as_input_peer())
self._input_users.append(self._client._entity_cache[user_id])
continue
except AttributeError:
except KeyError:
pass
return self._input_users or []

View File

@ -154,7 +154,7 @@ class EventCommon(ChatGetter, abc.ABC):
self._client = client
if self._chat_peer:
self._chat, self._input_chat = utils._get_entity_pair(
self.chat_id, self._entities, client._mb_entity_cache)
self.chat_id, self._entities, client._entity_cache)
else:
self._chat = self._input_chat = None

View File

@ -4,7 +4,7 @@ import re
import asyncio
from .common import EventBuilder, EventCommon, name_inner_event
from .. import utils, helpers
from .. import utils
from ..tl import types, functions, custom
from ..tl.custom.sendergetter import SenderGetter
@ -99,7 +99,7 @@ class InlineQuery(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._mb_entity_cache)
self.sender_id, self._entities, client._entity_cache)
@property
def id(self):
@ -130,7 +130,7 @@ class InlineQuery(EventBuilder):
and the user's device is able to send it, this will return
the :tl:`GeoPoint` with the position of the user.
"""
return self.query.geo
return
@property
def builder(self):
@ -174,9 +174,9 @@ class InlineQuery(EventBuilder):
gallery (`bool`, optional):
Whether the results should show as a gallery (grid) or not.
next_offset (`str`, optional):
The offset the client will send when the user scrolls the
The offset the client will send when the user scrolls the
results and it repeats the request.
private (`bool`, optional):
@ -242,6 +242,6 @@ class InlineQuery(EventBuilder):
if inspect.isawaitable(obj):
return asyncio.ensure_future(obj)
f = helpers.get_running_loop().create_future()
f = asyncio.get_event_loop().create_future()
f.set_result(obj)
return f

View File

@ -114,8 +114,7 @@ class NewMessage(EventBuilder):
fwd_from=update.fwd_from,
via_bot_id=update.via_bot_id,
reply_to=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
entities=update.entities
))
elif isinstance(update, types.UpdateShortChatMessage):
event = cls.Event(types.Message(
@ -131,12 +130,17 @@ class NewMessage(EventBuilder):
fwd_from=update.fwd_from,
via_bot_id=update.via_bot_id,
reply_to=update.reply_to,
entities=update.entities,
ttl_period=update.ttl_period
entities=update.entities
))
else:
return
# Make messages sent to ourselves outgoing unless they're forwarded.
# This makes it consistent with official client's appearance.
ori = event.message
if ori.peer_id == types.PeerUser(self_id) and not ori.fwd_from:
event.message.out = True
return event
def filter(self, event):

View File

@ -14,7 +14,7 @@ from ..tl.custom.sendergetter import SenderGetter
# in a single place will make it annoying to use (since
# the user needs to check for the existence of `None`).
#
# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUser
# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUserPhoto
def _requires_action(function):
@functools.wraps(function)
@ -51,15 +51,12 @@ class UserUpdate(EventBuilder):
@classmethod
def build(cls, update, others=None, self_id=None):
if isinstance(update, types.UpdateUserStatus):
return cls.Event(types.PeerUser(update.user_id),
return cls.Event(update.user_id,
status=update.status)
elif isinstance(update, types.UpdateChannelUserTyping):
return cls.Event(update.from_id,
chat_peer=types.PeerChannel(update.channel_id),
typing=update.action)
elif isinstance(update, types.UpdateChatUserTyping):
return cls.Event(update.from_id,
chat_peer=types.PeerChat(update.chat_id),
# Unfortunately, we can't know whether `chat_id`'s type
return cls.Event(update.user_id,
chat_id=update.chat_id,
typing=update.action)
elif isinstance(update, types.UpdateUserTyping):
return cls.Event(update.user_id,
@ -85,17 +82,38 @@ class UserUpdate(EventBuilder):
of the typing properties, since they will all be `None`
if the action is not set.
"""
def __init__(self, peer, *, status=None, chat_peer=None, typing=None):
super().__init__(chat_peer or peer)
SenderGetter.__init__(self, utils.get_peer_id(peer))
def __init__(self, user_id, *, status=None, chat_id=None, typing=None):
if chat_id is None:
super().__init__(types.PeerUser(user_id))
else:
# Temporarily set the chat_peer to the ID until ._set_client.
# We need the client to actually figure out its type.
super().__init__(chat_id)
SenderGetter.__init__(self, user_id)
self.status = status
self.action = typing
def _set_client(self, client):
if isinstance(self._chat_peer, int):
try:
chat = client._entity_cache[self._chat_peer]
if isinstance(chat, types.InputPeerChat):
self._chat_peer = types.PeerChat(self._chat_peer)
elif isinstance(chat, types.InputPeerChannel):
self._chat_peer = types.PeerChannel(self._chat_peer)
else:
# Should not happen
self._chat_peer = types.PeerUser(self._chat_peer)
except KeyError:
# Hope for the best. We don't know where this event
# occurred but it was most likely in a channel.
self._chat_peer = types.PeerChannel(self._chat_peer)
super()._set_client(client)
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, self._entities, client._mb_entity_cache)
self.sender_id, self._entities, client._entity_cache)
@property
def user(self):
@ -136,7 +154,6 @@ class UserUpdate(EventBuilder):
"""
return isinstance(self.action, (
types.SendMessageChooseContactAction,
types.SendMessageChooseStickerAction,
types.SendMessageUploadAudioAction,
types.SendMessageUploadDocumentAction,
types.SendMessageUploadPhotoAction,
@ -229,14 +246,6 @@ class UserUpdate(EventBuilder):
"""
return isinstance(self.action, types.SendMessageUploadDocumentAction)
@property
@_requires_action
def sticker(self):
"""
`True` if what's being uploaded is a sticker.
"""
return isinstance(self.action, types.SendMessageChooseStickerAction)
@property
@_requires_action
def photo(self):
@ -246,7 +255,7 @@ class UserUpdate(EventBuilder):
return isinstance(self.action, types.SendMessageUploadPhotoAction)
@property
@_requires_status
@_requires_action
def last_seen(self):
"""
Exact `datetime.datetime` when the user was last seen if known.

View File

@ -1,9 +1,11 @@
"""
This module contains the BinaryReader utility class.
"""
import struct
import os
import time
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone, timedelta
from io import BytesIO
from struct import unpack
from ..errors import TypeNotFoundError
from ..tl.alltlobjects import tlobjects
@ -19,8 +21,7 @@ class BinaryReader:
"""
def __init__(self, data):
self.stream = data or b''
self.position = 0
self.stream = BytesIO(data)
self._last = None # Should come in handy to spot -404 errors
# region Reading
@ -29,35 +30,23 @@ class BinaryReader:
# https://core.telegram.org/mtproto
def read_byte(self):
"""Reads a single byte value."""
value, = struct.unpack_from("<B", self.stream, self.position)
self.position += 1
return value
return self.read(1)[0]
def read_int(self, signed=True):
"""Reads an integer (4 bytes) value."""
fmt = '<i' if signed else '<I'
value, = struct.unpack_from(fmt, self.stream, self.position)
self.position += 4
return value
return int.from_bytes(self.read(4), byteorder='little', signed=signed)
def read_long(self, signed=True):
"""Reads a long integer (8 bytes) value."""
fmt = '<q' if signed else '<Q'
value, = struct.unpack_from(fmt, self.stream, self.position)
self.position += 8
return value
return int.from_bytes(self.read(8), byteorder='little', signed=signed)
def read_float(self):
"""Reads a real floating point (4 bytes) value."""
value, = struct.unpack_from("<f", self.stream, self.position)
self.position += 4
return value
return unpack('<f', self.read(4))[0]
def read_double(self):
"""Reads a real floating point (8 bytes) value."""
value, = struct.unpack_from("<d", self.stream, self.position)
self.position += 8
return value
return unpack('<d', self.read(8))[0]
def read_large_int(self, bits, signed=True):
"""Reads a n-bits long integer value."""
@ -66,12 +55,7 @@ class BinaryReader:
def read(self, length=-1):
"""Read the given amount of bytes, or -1 to read all remaining."""
if length >= 0:
result = self.stream[self.position:self.position + length]
self.position += length
else:
result = self.stream[self.position:]
self.position += len(result)
result = self.stream.read(length)
if (length >= 0) and (len(result) != length):
raise BufferError(
'No more data left to read (need {}, got {}: {}); last read {}'
@ -83,7 +67,7 @@ class BinaryReader:
def get_bytes(self):
"""Gets the byte array representing the current buffer as a whole."""
return self.stream
return self.stream.getvalue()
# endregion
@ -169,24 +153,24 @@ class BinaryReader:
def close(self):
"""Closes the reader, freeing the BytesIO stream."""
self.stream = b''
self.stream.close()
# region Position related
def tell_position(self):
"""Tells the current position on the stream."""
return self.position
return self.stream.tell()
def set_position(self, position):
"""Sets the current position on the stream."""
self.position = position
self.stream.seek(position)
def seek(self, offset):
"""
Seeks the stream position given an offset from the current position.
The offset may be negative.
"""
self.position += offset
self.stream.seek(offset, os.SEEK_CUR)
# endregion

View File

@ -1,22 +1,34 @@
"""
Simple HTML -> Telegram entity parser.
"""
import struct
from collections import deque
from html import escape
from html.parser import HTMLParser
from typing import Iterable, Tuple, List
from typing import Iterable, Optional, Tuple, List
from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text
from ..tl import TLObject
from .. import helpers
from ..tl.types import (
MessageEntityBold, MessageEntityItalic, MessageEntityCode,
MessageEntityPre, MessageEntityEmail, MessageEntityUrl,
MessageEntityTextUrl, MessageEntityMentionName,
MessageEntityUnderline, MessageEntityStrike, MessageEntityBlockquote,
MessageEntityCustomEmoji, TypeMessageEntity
TypeMessageEntity
)
# Helpers from markdown.py
def _add_surrogate(text):
return ''.join(
''.join(chr(y) for y in struct.unpack('<HH', x.encode('utf-16le')))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text
)
def _del_surrogate(text):
return text.encode('utf-16', 'surrogatepass').decode('utf-16')
class HTMLToTelegramParser(HTMLParser):
def __init__(self):
super().__init__()
@ -74,19 +86,11 @@ class HTMLToTelegramParser(HTMLParser):
EntityType = MessageEntityUrl
else:
EntityType = MessageEntityTextUrl
args['url'] = del_surrogate(url)
args['url'] = url
url = None
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
elif tag == 'tg-emoji':
try:
emoji_id = int(attrs['emoji-id'])
except (KeyError, ValueError):
return
EntityType = MessageEntityCustomEmoji
args['document_id'] = emoji_id
if EntityType and tag not in self._building_entities:
self._building_entities[tag] = EntityType(
offset=len(self.text),
@ -129,36 +133,13 @@ def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
return html, []
parser = HTMLToTelegramParser()
parser.feed(add_surrogate(html))
text = strip_text(parser.text, parser.entities)
parser.entities.reverse()
parser.entities.sort(key=lambda entity: entity.offset)
return del_surrogate(text), parser.entities
parser.feed(_add_surrogate(html))
text = helpers.strip_text(parser.text, parser.entities)
return _del_surrogate(text), parser.entities
ENTITY_TO_FORMATTER = {
MessageEntityBold: ('<strong>', '</strong>'),
MessageEntityItalic: ('<em>', '</em>'),
MessageEntityCode: ('<code>', '</code>'),
MessageEntityUnderline: ('<u>', '</u>'),
MessageEntityStrike: ('<del>', '</del>'),
MessageEntityBlockquote: ('<blockquote>', '</blockquote>'),
MessageEntityPre: lambda e, _: (
"<pre>\n"
" <code class='language-{}'>\n"
" ".format(e.language), "{}\n"
" </code>\n"
"</pre>"
),
MessageEntityEmail: lambda _, t: ('<a href="mailto:{}">'.format(t), '</a>'),
MessageEntityUrl: lambda _, t: ('<a href="{}">'.format(t), '</a>'),
MessageEntityTextUrl: lambda e, _: ('<a href="{}">'.format(escape(e.url)), '</a>'),
MessageEntityMentionName: lambda e, _: ('<a href="tg://user?id={}">'.format(e.user_id), '</a>'),
MessageEntityCustomEmoji: lambda e, _: ('<tg-emoji emoji-id="{}">'.format(e.document_id), '</tg-emoji>'),
}
def unparse(text: str, entities: Iterable[TypeMessageEntity]) -> str:
def unparse(text: str, entities: Iterable[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.
@ -172,32 +153,77 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity]) -> str:
elif not entities:
return escape(text)
if isinstance(entities, TLObject):
entities = (entities,)
text = add_surrogate(text)
insert_at = []
text = _add_surrogate(text)
if _length is None:
_length = len(text)
html = []
last_offset = 0
for i, entity in enumerate(entities):
s = entity.offset
e = entity.offset + entity.length
delimiter = ENTITY_TO_FORMATTER.get(type(entity), None)
if delimiter:
if callable(delimiter):
delimiter = delimiter(entity, text[s:e])
insert_at.append((s, i, delimiter[0]))
insert_at.append((e, -i, delimiter[1]))
if entity.offset >= _offset + _length:
break
relative_offset = entity.offset - _offset
if relative_offset > last_offset:
html.append(escape(text[last_offset:relative_offset]))
elif relative_offset < last_offset:
continue
insert_at.sort(key=lambda t: (t[0], t[1]))
next_escape_bound = len(text)
while insert_at:
# Same logic as markdown.py
at, _, what = insert_at.pop()
while within_surrogate(text, at):
at += 1
skip_entity = False
length = entity.length
text = text[:at] + what + escape(text[at:next_escape_bound]) + text[next_escape_bound:]
next_escape_bound = at
# 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 helpers.within_surrogate(text, relative_offset, length=_length):
relative_offset += 1
text = escape(text[:next_escape_bound]) + text[next_escape_bound:]
while helpers.within_surrogate(text, relative_offset + length, length=_length):
length += 1
return del_surrogate(text)
entity_text = unparse(text=text[relative_offset:relative_offset + length],
entities=entities[i + 1:],
_offset=entity.offset, _length=length)
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append('<strong>{}</strong>'.format(entity_text))
elif entity_type == MessageEntityItalic:
html.append('<em>{}</em>'.format(entity_text))
elif entity_type == MessageEntityCode:
html.append('<code>{}</code>'.format(entity_text))
elif entity_type == MessageEntityUnderline:
html.append('<u>{}</u>'.format(entity_text))
elif entity_type == MessageEntityStrike:
html.append('<del>{}</del>'.format(entity_text))
elif entity_type == MessageEntityBlockquote:
html.append('<blockquote>{}</blockquote>'.format(entity_text))
elif entity_type == MessageEntityPre:
if entity.language:
html.append(
"<pre>\n"
" <code class='language-{}'>\n"
" {}\n"
" </code>\n"
"</pre>".format(entity.language, entity_text))
else:
html.append('<pre><code>{}</code></pre>'
.format(entity_text))
elif entity_type == MessageEntityEmail:
html.append('<a href="mailto:{0}">{0}</a>'.format(entity_text))
elif entity_type == MessageEntityUrl:
html.append('<a href="{0}">{0}</a>'.format(entity_text))
elif entity_type == MessageEntityTextUrl:
html.append('<a href="{}">{}</a>'
.format(escape(entity.url), entity_text))
elif entity_type == MessageEntityMentionName:
html.append('<a href="tg://user?id={}">{}</a>'
.format(entity.user_id, entity_text))
else:
skip_entity = True
last_offset = relative_offset + (0 if skip_entity else length)
while helpers.within_surrogate(text, last_offset, length=_length):
last_offset += 1
html.append(escape(text[last_offset:]))
return _del_surrogate(''.join(html))

View File

@ -22,10 +22,14 @@ DEFAULT_DELIMITERS = {
'```': MessageEntityPre
}
DEFAULT_URL_RE = re.compile(r'\[([^]]*?)\]\(([\s\S]*?)\)')
DEFAULT_URL_RE = re.compile(r'\[([\S\s]+?)\]\((.+?)\)')
DEFAULT_URL_FORMAT = '[{0}]({1})'
def overlap(a, b, x, y):
return max(a, x) < min(b, y)
def parse(message, delimiters=None, url_re=None):
"""
Parses the given markdown message and returns its stripped representation
@ -86,8 +90,8 @@ def parse(message, delimiters=None, url_re=None):
for ent in result:
# If the end is after our start, it is affected
if ent.offset + ent.length > i:
# If the old start is before ours and the old end is after ours, we are fully enclosed
if ent.offset <= i and ent.offset + ent.length >= end + len(delim):
# If the old start is also before ours, it is fully enclosed
if ent.offset <= i:
ent.length -= len(delim) * 2
else:
ent.length -= len(delim)
@ -115,7 +119,7 @@ def parse(message, delimiters=None, url_re=None):
message[m.end():]
))
delim_size = m.end() - m.start() - len(m.group(1))
delim_size = m.end() - m.start() - len(m.group())
for ent in result:
# If the end is after our start, it is affected
if ent.offset + ent.length > m.start():
@ -160,13 +164,13 @@ def unparse(text, entities, delimiters=None, url_fmt=None):
text = add_surrogate(text)
delimiters = {v: k for k, v in delimiters.items()}
insert_at = []
for i, entity in enumerate(entities):
for entity in entities:
s = entity.offset
e = entity.offset + entity.length
delimiter = delimiters.get(type(entity), None)
if delimiter:
insert_at.append((s, i, delimiter))
insert_at.append((e, -i, delimiter))
insert_at.append((s, delimiter))
insert_at.append((e, delimiter))
else:
url = None
if isinstance(entity, MessageEntityTextUrl):
@ -174,12 +178,12 @@ def unparse(text, entities, delimiters=None, url_fmt=None):
elif isinstance(entity, MessageEntityMentionName):
url = 'tg://user?id={}'.format(entity.user_id)
if url:
insert_at.append((s, i, '['))
insert_at.append((e, -i, ']({})'.format(url)))
insert_at.append((s, '['))
insert_at.append((e, ']({})'.format(url)))
insert_at.sort(key=lambda t: (t[0], t[1]))
insert_at.sort(key=lambda t: t[0])
while insert_at:
at, _, what = insert_at.pop()
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.

View File

@ -1 +0,0 @@
from .tl.functions import *

View File

@ -7,7 +7,6 @@ import struct
import inspect
import logging
import functools
import sys
from pathlib import Path
from hashlib import sha1
@ -58,81 +57,47 @@ def within_surrogate(text, index, *, length=None):
return (
1 < index < len(text) and # in bounds
'\ud800' <= text[index - 1] <= '\udbff' and # previous is
'\ud800' <= text[index - 1] <= '\udfff' and # previous is
'\ud800' <= text[index] <= '\udfff' # current is
)
def strip_text(text, entities):
"""
Strips whitespace from the given surrogated text modifying the provided
entities, also removing any empty (0-length) entities.
Strips whitespace from the given text modifying the provided entities.
This assumes that the length of entities is greater or equal to 0, and
that no entity is out of bounds.
This assumes that there are no overlapping entities, that their length
is greater or equal to one, and that their length is not out of bounds.
"""
if not entities:
return text.strip()
len_ori = len(text)
text = text.lstrip()
left_offset = len_ori - len(text)
text = text.rstrip()
len_final = len(text)
for i in reversed(range(len(entities))):
e = entities[i]
if e.length == 0:
del entities[i]
continue
if e.offset + e.length > left_offset:
if e.offset >= left_offset:
# 0 1|2 3 4 5 | 0 1|2 3 4 5
# ^ ^ | ^
# lo(2) o(5) | o(2)/lo(2)
e.offset -= left_offset
# |0 1 2 3 | |0 1 2 3
# ^ | ^
# o=o-lo(3=5-2) | o=o-lo(0=2-2)
while text and text[-1].isspace():
e = entities[-1]
if e.offset + e.length == len(text):
if e.length == 1:
del entities[-1]
if not entities:
return text.strip()
else:
# e.offset < left_offset and e.offset + e.length > left_offset
# 0 1 2 3|4 5 6 7 8 9 10
# ^ ^ ^
# o(1) lo(4) o+l(1+9)
e.length = e.offset + e.length - left_offset
e.offset = 0
# |0 1 2 3 4 5 6
# ^ ^
# o(0) o+l=0+o+l-lo(6=0+6=0+1+9-4)
else:
# e.offset + e.length <= left_offset
# 0 1 2 3|4 5
# ^ ^
# o(0) o+l(4)
# lo(4)
del entities[i]
continue
e.length -= 1
text = text[:-1]
if e.offset + e.length <= len_final:
# |0 1 2 3 4 5 6 7 8 9
# ^ ^
# o(1) o+l(1+9)/lf(10)
continue
if e.offset >= len_final:
# |0 1 2 3 4
# ^
# o(5)/lf(5)
del entities[i]
else:
# e.offset < len_final and e.offset + e.length > len_final
# |0 1 2 3 4 5 (6) (7) (8) (9)
# ^ ^ ^
# o(1) lf(6) o+l(1+8)
e.length = len_final - e.offset
# |0 1 2 3 4 5
# ^ ^
# o(1) o+l=o+lf-o=lf(6=1+5=1+6-1)
while text and text[0].isspace():
for i in reversed(range(len(entities))):
e = entities[i]
if e.offset != 0:
e.offset -= 1
continue
if e.length == 1:
del entities[0]
if not entities:
return text.lstrip()
else:
e.length -= 1
text = text[1:]
return text
@ -153,7 +118,7 @@ def retry_range(retries, force_retry=True):
while attempt != retries:
attempt += 1
yield attempt
async def _maybe_await(value):
@ -348,22 +313,21 @@ class _FileStream(io.IOBase):
self._size = os.path.getsize(self._file)
self._stream = open(self._file, 'rb')
self._close_stream = True
return self
if isinstance(self._file, bytes):
elif isinstance(self._file, bytes):
self._size = len(self._file)
self._stream = io.BytesIO(self._file)
self._close_stream = True
return self
if not callable(getattr(self._file, 'read', None)):
elif not callable(getattr(self._file, 'read', None)):
raise TypeError('file description should have a `read` method')
self._name = getattr(self._file, 'name', None)
self._stream = self._file
self._close_stream = False
elif self._size is not None:
self._name = getattr(self._file, 'name', None)
self._stream = self._file
self._close_stream = False
if self._size is None:
else:
if callable(getattr(self._file, 'seekable', None)):
seekable = await _maybe_await(self._file.seekable())
else:
@ -374,6 +338,8 @@ class _FileStream(io.IOBase):
await _maybe_await(self._file.seek(0, os.SEEK_END))
self._size = await _maybe_await(self._file.tell())
await _maybe_await(self._file.seek(pos, os.SEEK_SET))
self._stream = self._file
self._close_stream = False
else:
_log.warning(
'Could not determine file size beforehand so the entire '
@ -423,12 +389,3 @@ class _FileStream(io.IOBase):
pass
# endregion
def get_running_loop():
if sys.version_info >= (3, 7):
try:
return asyncio.get_running_loop()
except RuntimeError:
return asyncio.get_event_loop_policy().get_event_loop()
else:
return asyncio.get_event_loop()

View File

@ -44,12 +44,7 @@ FileLike = typing.Union[
typing.BinaryIO,
types.TypeMessageMedia,
types.TypeInputFile,
types.TypeInputFileLocation,
types.TypeInputMedia,
types.TypePhoto,
types.TypeInputPhoto,
types.TypeDocument,
types.TypeInputDocument
types.TypeInputFileLocation
]
# Can't use `typing.Type` in Python 3.5.2

View File

@ -13,7 +13,7 @@ try:
except ImportError:
python_socks = None
from ...errors import InvalidChecksumError, InvalidBufferError
from ...errors import InvalidChecksumError
from ... import helpers
@ -116,15 +116,9 @@ class Connection(abc.ABC):
# python_socks internal errors are not inherited from
# builtin IOError (just from Exception). Instead of adding those
# in exceptions clauses everywhere through the code, we
# rather monkey-patch them in place. Keep in mind that
# ProxyError takes error_code as keyword argument.
# rather monkey-patch them in place.
class ConnectionErrorExtra(ConnectionError):
def __init__(self, message, error_code=None):
super().__init__(message)
self.error_code = error_code
python_socks._errors.ProxyError = ConnectionErrorExtra
python_socks._errors.ProxyError = ConnectionError
python_socks._errors.ProxyConnectionError = ConnectionError
python_socks._errors.ProxyTimeoutError = ConnectionError
@ -161,7 +155,7 @@ class Connection(abc.ABC):
# Actual TCP connection is performed here.
await asyncio.wait_for(
helpers.get_running_loop().sock_connect(sock=sock, address=address),
asyncio.get_event_loop().sock_connect(sock=sock, address=address),
timeout=timeout
)
@ -196,7 +190,7 @@ class Connection(abc.ABC):
# Actual TCP connection and negotiation performed here.
await asyncio.wait_for(
helpers.get_running_loop().sock_connect(sock=sock, address=address),
asyncio.get_event_loop().sock_connect(sock=sock, address=address),
timeout=timeout
)
@ -250,7 +244,7 @@ class Connection(abc.ABC):
await self._connect(timeout=timeout, ssl=ssl)
self._connected = True
loop = helpers.get_running_loop()
loop = asyncio.get_event_loop()
self._send_task = loop.create_task(self._send_loop())
self._recv_task = loop.create_task(self._recv_loop())
@ -259,9 +253,6 @@ class Connection(abc.ABC):
Disconnects from the server, and clears
pending outgoing and incoming messages.
"""
if not self._connected:
return
self._connected = False
await helpers._cancel(
@ -274,12 +265,7 @@ class Connection(abc.ABC):
self._writer.close()
if sys.version_info >= (3, 7):
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=10)
except asyncio.TimeoutError:
# See issue #3917. For some users, this line was hanging indefinitely.
# The hard timeout is not ideal (connection won't be properly closed),
# but the code will at least be able to procceed.
self._log.warning('Graceful disconnection timed out, forcibly ignoring cleanup')
await self._writer.wait_closed()
except Exception as e:
# Disconnecting should never raise. Seen:
# * OSError: No route to host and
@ -305,10 +291,8 @@ class Connection(abc.ABC):
This method returns a coroutine.
"""
while self._connected:
result, err = await self._recv_queue.get()
if err:
raise err
if result:
result = await self._recv_queue.get()
if result: # None = sentinel value = keep trying
return result
raise ConnectionError('Not connected')
@ -335,31 +319,34 @@ class Connection(abc.ABC):
"""
This loop is constantly putting items on the queue as they're read.
"""
try:
while self._connected:
try:
data = await self._recv()
except asyncio.CancelledError:
break
except (IOError, asyncio.IncompleteReadError) as e:
self._log.warning('Server closed the connection: %s', e)
await self._recv_queue.put((None, e))
await self.disconnect()
except InvalidChecksumError as e:
self._log.warning('Server response had invalid checksum: %s', e)
await self._recv_queue.put((None, e))
except InvalidBufferError as e:
self._log.warning('Server response had invalid buffer: %s', e)
await self._recv_queue.put((None, e))
except Exception as e:
self._log.exception('Unexpected exception in the receive loop')
await self._recv_queue.put((None, e))
await self.disconnect()
while self._connected:
try:
data = await self._recv()
except asyncio.CancelledError:
break
except Exception as e:
if isinstance(e, (IOError, asyncio.IncompleteReadError)):
msg = 'The server closed the connection'
self._log.info(msg)
elif isinstance(e, InvalidChecksumError):
msg = 'The server response had an invalid checksum'
self._log.info(msg)
else:
await self._recv_queue.put((data, None))
finally:
await self.disconnect()
msg = 'Unexpected exception in the receive loop'
self._log.exception(msg)
await self.disconnect()
# Add a sentinel value to unstuck recv
if self._recv_queue.empty():
self._recv_queue.put_nowait(None)
break
try:
await self._recv_queue.put(data)
except asyncio.CancelledError:
break
def _init_conn(self):
"""

View File

@ -2,7 +2,7 @@ import struct
from zlib import crc32
from .connection import Connection, PacketCodec
from ...errors import InvalidChecksumError, InvalidBufferError
from ...errors import InvalidChecksumError
class FullPacketCodec(PacketCodec):
@ -24,18 +24,6 @@ class FullPacketCodec(PacketCodec):
async def read_packet(self, reader):
packet_len_seq = await reader.readexactly(8) # 4 and 4
packet_len, seq = struct.unpack('<ii', packet_len_seq)
if packet_len < 0 and seq < 0:
# It has been observed that the length and seq can be -429,
# followed by the body of 4 bytes also being -429.
# See https://github.com/LonamiWebs/Telethon/issues/4042.
body = await reader.readexactly(4)
raise InvalidBufferError(body)
elif packet_len < 8:
# Currently unknown why packet_len may be less than 8 but not negative.
# Attempting to `readexactly` with less than 0 fails without saying what
# the number was which is less helpful.
raise InvalidBufferError(packet_len_seq)
body = await reader.readexactly(packet_len - 8)
checksum = struct.unpack('<I', body[-4:])[0]
body = body[:-4]

View File

@ -1,6 +1,5 @@
import asyncio
import hashlib
import base64
import os
from .connection import ObfuscatedConnection
@ -99,7 +98,7 @@ class TcpMTProxy(ObfuscatedConnection):
def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None):
# connect to proxy's host and port instead of telegram's ones
proxy_host, proxy_port = self.address_info(proxy)
self._secret = self.normalize_secret(proxy[2])
self._secret = bytes.fromhex(proxy[2])
super().__init__(
proxy_host, proxy_port, dc_id, loggers=loggers)
@ -131,18 +130,6 @@ class TcpMTProxy(ObfuscatedConnection):
raise ValueError("No proxy info specified for MTProxy connection")
return proxy_info[:2]
@staticmethod
def normalize_secret(secret):
if secret[:2] in ("ee", "dd"): # Remove extra bytes
secret = secret[2:]
try:
secret_bytes = bytes.fromhex(secret)
except ValueError:
secret = secret + '=' * (-len(secret) % 4)
secret_bytes = base64.b64decode(secret.encode())
return secret_bytes[:16] # Remove the domain from the secret (until domain support is added)
class ConnectionTcpMTProxyAbridged(TcpMTProxy):
"""

View File

@ -1,8 +1,6 @@
import asyncio
import collections
import struct
import datetime
import time
from . import authenticator
from ..extensions.messagepacker import MessagePacker
@ -12,20 +10,17 @@ from .mtprotostate import MTProtoState
from ..tl.tlobject import TLRequest
from .. import helpers, utils
from ..errors import (
BadMessageError, InvalidBufferError, AuthKeyNotFound, SecurityError,
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, DestroyAuthKeyRequest
from ..tl.types import (
MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts,
MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo, MsgsStateReq,
MsgsStateInfo, MsgsAllInfo, MsgResendReq, upload, DestroySessionOk, DestroySessionNone,
DestroyAuthKeyOk, DestroyAuthKeyNone, DestroyAuthKeyFail
MsgsStateInfo, MsgsAllInfo, MsgResendReq, upload
)
from ..tl import types as _tl
from ..crypto import AuthKey
from ..helpers import retry_range
@ -48,7 +43,7 @@ class MTProtoSender:
def __init__(self, auth_key, *, loggers,
retries=5, delay=1, auto_reconnect=True, connect_timeout=None,
auth_key_callback=None,
updates_queue=None, auto_reconnect_callback=None):
update_callback=None, auto_reconnect_callback=None):
self._connection = None
self._loggers = loggers
self._log = loggers[__name__]
@ -57,10 +52,9 @@ class MTProtoSender:
self._auto_reconnect = auto_reconnect
self._connect_timeout = connect_timeout
self._auth_key_callback = auth_key_callback
self._updates_queue = updates_queue
self._update_callback = update_callback
self._auto_reconnect_callback = auto_reconnect_callback
self._connect_lock = asyncio.Lock()
self._ping = None
# Whether the user has explicitly connected or disconnected.
#
@ -70,7 +64,7 @@ class MTProtoSender:
# pending futures should be cancelled.
self._user_connected = False
self._reconnecting = False
self._disconnected = helpers.get_running_loop().create_future()
self._disconnected = asyncio.get_event_loop().create_future()
self._disconnected.set_result(None)
# We need to join the loops upon disconnection
@ -112,11 +106,6 @@ class MTProtoSender:
MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten,
MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all,
DestroySessionOk.CONSTRUCTOR_ID: self._handle_destroy_session,
DestroySessionNone.CONSTRUCTOR_ID: self._handle_destroy_session,
DestroyAuthKeyOk.CONSTRUCTOR_ID: self._handle_destroy_auth_key,
DestroyAuthKeyNone.CONSTRUCTOR_ID: self._handle_destroy_auth_key,
DestroyAuthKeyFail.CONSTRUCTOR_ID: self._handle_destroy_auth_key,
}
# Public API
@ -228,7 +217,7 @@ class MTProtoSender:
self._log.info('Connecting to %s...', self._connection)
connected = False
for attempt in retry_range(self._retries):
if not connected:
connected = await self._try_connect(attempt)
@ -263,7 +252,7 @@ class MTProtoSender:
await self._disconnect(error=e)
raise e
loop = helpers.get_running_loop()
loop = asyncio.get_event_loop()
self._log.debug('Starting send loop')
self._send_loop_handle = loop.create_task(self._send_loop())
@ -302,7 +291,7 @@ class MTProtoSender:
# notify whenever we change it. This is crucial when we
# switch to different data centers.
if self._auth_key_callback:
await self._auth_key_callback(self.auth_key)
self._auth_key_callback(self.auth_key)
self._log.debug('auth_key generation success!')
return True
@ -369,7 +358,7 @@ class MTProtoSender:
self._state.reset()
retries = self._retries if self._auto_reconnect else 0
attempt = 0
ok = True
# We're already "retrying" to connect, so we don't want to force retries
@ -384,8 +373,11 @@ class MTProtoSender:
except BufferError as e:
# TODO there should probably only be one place to except all these errors
if isinstance(e, InvalidBufferError) and e.code == 404:
self._log.info('Server does not know about the current auth key; the session may need to be recreated')
last_error = AuthKeyNotFound()
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
else:
@ -402,7 +394,7 @@ class MTProtoSender:
self._pending_state.clear()
if self._auto_reconnect_callback:
helpers.get_running_loop().create_task(self._auto_reconnect_callback())
asyncio.get_event_loop().create_task(self._auto_reconnect_callback())
break
else:
@ -410,9 +402,7 @@ class MTProtoSender:
if not ok:
self._log.error('Automatic reconnection failed %d time(s)', attempt)
# There may be no error (e.g. automatic reconnection was turned off).
error = last_error.with_traceback(None) if last_error else None
await self._disconnect(error=error)
await self._disconnect(error=last_error.with_traceback(None))
def _start_reconnect(self, error):
"""Starts a reconnection in the background."""
@ -427,19 +417,7 @@ class MTProtoSender:
# gets stuck.
# TODO It still gets stuck? Investigate where and why.
self._reconnecting = True
helpers.get_running_loop().create_task(self._reconnect(error))
def _keepalive_ping(self, rnd_id):
"""
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))
else:
self._start_reconnect(None)
asyncio.get_event_loop().create_task(self._reconnect(error))
# Loops
@ -505,29 +483,13 @@ class MTProtoSender:
self._log.debug('Receiving items from the network...')
try:
body = await self._connection.recv()
except asyncio.CancelledError:
raise # bypass except Exception
except (IOError, asyncio.IncompleteReadError) as e:
self._log.info('Connection closed while receiving data: %s', e)
self._start_reconnect(e)
return
except InvalidBufferError as e:
if e.code == 429:
self._log.warning('Server indicated flood error at transport level: %s', e)
await self._disconnect(error=e)
else:
self._log.exception('Server sent invalid buffer')
self._start_reconnect(e)
return
except Exception as e:
self._log.exception('Unhandled error while receiving data')
except IOError as e:
self._log.info('Connection closed while receiving data')
self._start_reconnect(e)
return
try:
message = self._state.decrypt_message_data(body)
if message is None:
continue # this message is to be ignored
except TypeNotFoundError as e:
# Received object which we don't know how to deserialize
self._log.info('Type %08x not found, remaining data %r',
@ -541,14 +503,18 @@ class MTProtoSender:
continue
except BufferError as e:
if isinstance(e, InvalidBufferError) and e.code == 404:
self._log.info('Server does not know about the current auth key; the session may need to be recreated')
await self._disconnect(error=AuthKeyNotFound())
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:
self._log.warning('Invalid buffer %s', e)
self._start_reconnect(e)
return
except Exception as e:
self._log.exception('Unhandled error while decrypting data')
self._log.exception('Unhandled error while receiving data')
self._start_reconnect(e)
return
@ -612,20 +578,12 @@ class MTProtoSender:
# However receiving a File() with empty bytes is "common".
# See #658, #759 and #958. They seem to happen in a container
# which contain the real response right after.
#
# But, it might also happen that we get an *error* for no parent request.
# If that's the case attempting to read from body which is None would fail with:
# "BufferError: No more data left to read (need 4, got 0: b''); last read None".
# This seems to be particularly common for "RpcError(error_code=-500, error_message='No workers running')".
if rpc_result.error:
self._log.info('Received error without parent request: %s', rpc_result.error)
else:
try:
with BinaryReader(rpc_result.body) as reader:
if not isinstance(reader.tgread_object(), upload.File):
raise ValueError('Not an upload.File')
except (TypeNotFoundError, ValueError):
self._log.info('Received response without parent request: %s', rpc_result.body)
try:
with BinaryReader(rpc_result.body) as reader:
if not isinstance(reader.tgread_object(), 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:
@ -636,17 +594,11 @@ class MTProtoSender:
if not state.future.cancelled():
state.future.set_exception(error)
else:
try:
with BinaryReader(rpc_result.body) as reader:
result = state.request.read_result(reader)
except Exception as e:
# e.g. TypeNotFoundError, should be propagated to caller
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)
with BinaryReader(rpc_result.body) as reader:
result = state.request.read_result(reader)
if not state.future.cancelled():
state.future.set_result(result)
async def _handle_container(self, message):
"""
@ -673,54 +625,12 @@ class MTProtoSender:
try:
assert message.obj.SUBCLASS_OF_ID == 0x8af52aac # crc32(b'Updates')
except AssertionError:
self._log.warning(
'Note: %s is not an update, not dispatching it %s',
message.obj.__class__.__name__,
message.obj
)
self._log.warning('Note: %s is not an update, not dispatching it %s', message.obj)
return
self._log.debug('Handling update %s', message.obj.__class__.__name__)
self._updates_queue.put_nowait(message.obj)
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,
)), _update_like_ids=frozenset((
_tl.messages.AffectedHistory.CONSTRUCTOR_ID,
_tl.messages.AffectedMessages.CONSTRUCTOR_ID,
_tl.messages.AffectedFoundMessages.CONSTRUCTOR_ID,
))):
try:
if obj.CONSTRUCTOR_ID in _update_ids:
obj._self_outgoing = True # flag to only process, but not dispatch these
self._updates_queue.put_nowait(obj)
elif obj.CONSTRUCTOR_ID in _update_like_ids:
# Ugly "hack" (?) - otherwise bots reliably detect gaps when deleting messages.
#
# Note: the `date` being `None` is used to check for `updatesTooLong`, so epoch
# is used instead. It is still not read, because `updateShort` has no `seq`.
#
# Some requests, such as `readHistory`, also return these types. But the `pts_count`
# seems to be zero, so while this will produce some bogus `updateDeleteMessages`,
# it's still one of the "cleaner" approaches to handling the new `pts`.
# `updateDeleteMessages` is probably the "least-invasive" update that can be used.
upd = _tl.UpdateShort(
_tl.UpdateDeleteMessages([], obj.pts, obj.pts_count),
datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc)
)
upd._self_outgoing = True
self._updates_queue.put_nowait(upd)
elif obj.CONSTRUCTOR_ID == _tl.messages.InvitedUsers.CONSTRUCTOR_ID:
obj.updates._self_outgoing = True
self._updates_queue.put_nowait(obj.updates)
except AttributeError:
pass
if self._update_callback:
self._update_callback(message.obj)
async def _handle_pong(self, message):
"""
@ -731,9 +641,6 @@ class MTProtoSender:
"""
pong = message.obj
self._log.debug('Handling pong for message %d', pong.msg_id)
if self._ping == pong.ping_id:
self._ping = None
state = self._pending_state.pop(pong.msg_id, None)
if state:
state.future.set_result(pong)
@ -846,8 +753,7 @@ class MTProtoSender:
state = self._pending_state.get(msg_id)
if state and isinstance(state.request, LogOutRequest):
del self._pending_state[msg_id]
if not state.future.cancelled():
state.future.set_result(True)
state.future.set_result(True)
async def _handle_future_salts(self, message):
"""
@ -877,42 +783,3 @@ class MTProtoSender:
"""
Handles :tl:`MsgsAllInfo` by doing nothing (yet).
"""
async def _handle_destroy_session(self, message):
"""
Handles both :tl:`DestroySessionOk` and :tl:`DestroySessionNone`.
It behaves pretty much like handling an RPC result.
"""
for msg_id, state in self._pending_state.items():
if isinstance(state.request, DestroySessionRequest)\
and state.request.session_id == message.obj.session_id:
break
else:
return
del self._pending_state[msg_id]
if not state.future.cancelled():
state.future.set_result(message.obj)
async def _handle_destroy_auth_key(self, message):
"""
Handles :tl:`DestroyAuthKeyFail`, :tl:`DestroyAuthKeyNone`, and :tl:`DestroyAuthKeyOk`.
:tl:`DestroyAuthKey` is not intended for users to use, but they still
might, and the response won't come in `rpc_result`, so thhat's worked
around here.
"""
self._log.debug('Handling destroy auth key %s', message.obj)
for msg_id, state in list(self._pending_state.items()):
if isinstance(state.request, DestroyAuthKeyRequest):
del self._pending_state[msg_id]
if not state.future.cancelled():
state.future.set_result(message.obj)
# If the auth key has been destroyed, that pretty much means the
# library can't continue as our auth key will no longer be found
# on the server.
# Even if the library didn't disconnect, the server would (and then
# the library would reconnect and learn about auth key being invalid).
if isinstance(message.obj, DestroyAuthKeyOk):
await self._disconnect(error=AuthKeyNotFound())

View File

@ -2,38 +2,13 @@ import os
import struct
import time
from hashlib import sha256
from collections import deque
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 ..tl.types import BadServerSalt, BadMsgNotification
# N is not specified in https://core.telegram.org/mtproto/security_guidelines#checking-msg-id, but 500 is reasonable
MAX_RECENT_MSG_IDS = 500
MSG_TOO_NEW_DELTA = 30
MSG_TOO_OLD_DELTA = 300
# Something must be wrong if we ignore too many messages at the same time
MAX_CONSECUTIVE_IGNORED = 10
class _OpaqueRequest(TLRequest):
"""
Wraps a serialized request into a type that can be serialized again.
"""
def __init__(self, data: bytes):
self.data = data
def _bytes(self):
return self.data
class MTProtoState:
@ -66,9 +41,6 @@ class MTProtoState:
self.salt = 0
self.id = self._sequence = self._last_msg_id = None
self._recent_remote_ids = deque(maxlen=MAX_RECENT_MSG_IDS)
self._highest_remote_id = 0
self._ignore_count = 0
self.reset()
def reset(self):
@ -79,9 +51,6 @@ class MTProtoState:
self.id = struct.unpack('q', os.urandom(8))[0]
self._sequence = 0
self._last_msg_id = 0
self._recent_remote_ids.clear()
self._highest_remote_id = 0
self._ignore_count = 0
def update_message_id(self, message):
"""
@ -118,10 +87,8 @@ class MTProtoState:
if after_id is None:
body = GzipPacked.gzip_if_smaller(content_related, data)
else:
# 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(InvokeAfterMsgRequest(after_id, data)))
buffer.write(struct.pack('<qii', msg_id, seq_no, len(body)))
buffer.write(body)
@ -152,8 +119,6 @@ class MTProtoState:
"""
Inverse of `encrypt_message_data` for incoming server messages.
"""
now = time.time() # get the time as early as possible, even if other checks make it go unused
if len(body) < 8:
raise InvalidBufferError(body)
@ -176,19 +141,9 @@ class MTProtoState:
reader = BinaryReader(body)
reader.read_long() # remote_salt
if reader.read_long() != self.id:
raise SecurityError('Server replied with a wrong session ID (see FAQ for details)')
raise SecurityError('Server replied with a wrong session ID')
remote_msg_id = reader.read_long()
if remote_msg_id % 2 != 1:
raise SecurityError('Server sent an even msg_id')
# Only perform the (somewhat expensive) check of duplicate if we did receive a lower ID
if remote_msg_id <= self._highest_remote_id and remote_msg_id in self._recent_remote_ids:
self._log.warning('Server resent the older message %d, ignoring', remote_msg_id)
self._count_ignored()
return None
remote_sequence = reader.read_int()
reader.read_int() # msg_len for the inner object, padding ignored
@ -197,45 +152,8 @@ class MTProtoState:
# reader isn't used for anything else after this, it's unnecessary.
obj = reader.tgread_object()
# "Certain client-to-server service messages containing data sent by the client to the
# server (for example, msg_id of a recent client query) may, nonetheless, be processed
# on the client even if the time appears to be "incorrect". This is especially true of
# messages to change server_salt and notifications about invalid time on the client."
#
# This means we skip the time check for certain types of messages.
if obj.CONSTRUCTOR_ID in (BadServerSalt.CONSTRUCTOR_ID, BadMsgNotification.CONSTRUCTOR_ID):
if not self._highest_remote_id and not self.time_offset:
# If the first message we receive is a bad notification, take this opportunity
# to adjust the time offset. Assume it will remain stable afterwards. Updating
# the offset unconditionally would make the next checks pointless.
self.update_time_offset(remote_msg_id)
else:
remote_msg_time = remote_msg_id >> 32
time_delta = (now + self.time_offset) - remote_msg_time
if time_delta > MSG_TOO_OLD_DELTA:
self._log.warning('Server sent a very old message with ID %d, ignoring (see FAQ for details)', remote_msg_id)
self._count_ignored()
return None
if -time_delta > MSG_TOO_NEW_DELTA:
self._log.warning('Server sent a very new message with ID %d, ignoring (see FAQ for details)', remote_msg_id)
self._count_ignored()
return None
self._recent_remote_ids.append(remote_msg_id)
self._highest_remote_id = remote_msg_id
self._ignore_count = 0
return TLMessage(remote_msg_id, remote_sequence, obj)
def _count_ignored(self):
# It's possible that ignoring a message "bricks" the connection,
# but this should not happen unless there's something else wrong.
self._ignore_count += 1
if self._ignore_count >= MAX_CONSECUTIVE_IGNORED:
raise SecurityError('Too many messages had to be ignored consecutively')
def _get_new_msg_id(self):
"""
Generates a new unique message ID based on the current

View File

@ -98,11 +98,6 @@ class Session(ABC):
raise NotImplementedError
@abstractmethod
def get_update_states(self):
"""
Returns an iterable over all known pairs of ``(entity ID, update state)``.
"""
def close(self):
"""
Called on client disconnection. Should be used to

View File

@ -77,9 +77,6 @@ class MemorySession(Session):
def set_update_state(self, entity_id, state):
self._update_states[entity_id] = state
def get_update_states(self):
return self._update_states.items()
def close(self):
pass
@ -133,8 +130,6 @@ class MemorySession(Session):
entities = []
if hasattr(tlo, 'user'):
entities.append(tlo.user)
if hasattr(tlo, 'chat'):
entities.append(tlo.chat)
if hasattr(tlo, 'chats') and utils.is_list_like(tlo.chats):
entities.extend(tlo.chats)
if hasattr(tlo, 'users') and utils.is_list_like(tlo.users):
@ -174,7 +169,7 @@ class MemorySession(Session):
def get_entity_rows_by_id(self, id, exact=True):
try:
if exact:
return next((found_id, hash) for found_id, hash, _, _, _
return next((id, hash) for found_id, hash, _, _, _
in self._entities if found_id == id)
else:
ids = (
@ -182,7 +177,7 @@ class MemorySession(Session):
utils.get_peer_id(PeerChat(id)),
utils.get_peer_id(PeerChannel(id))
)
return next((found_id, hash) for found_id, hash, _, _, _
return next((id, hash) for found_id, hash, _, _, _
in self._entities if found_id in ids)
except StopIteration:
pass

View File

@ -2,7 +2,7 @@ import datetime
import os
import time
from ..tl import types
from telethon.tl import types
from .memory import MemorySession, _SentFileType
from .. import utils
from ..crypto import AuthKey
@ -215,20 +215,6 @@ class SQLiteSession(MemorySession):
entity_id, state.pts, state.qts,
state.date.timestamp(), state.seq)
def get_update_states(self):
c = self._cursor()
try:
rows = c.execute('select id, pts, qts, date, seq from update_state').fetchall()
return ((row[0], types.updates.State(
pts=row[1],
qts=row[2],
date=datetime.datetime.fromtimestamp(row[3], tz=datetime.timezone.utc),
seq=row[4],
unread_count=0)
) for row in rows)
finally:
c.close()
def save(self):
"""Saves the current session object as session_user_id.session"""
# This is a no-op if there are no changes to commit, so there's

164
telethon/statecache.py Normal file
View File

@ -0,0 +1,164 @@
import inspect
from .tl import types
# Which updates have the following fields?
_has_channel_id = []
# TODO EntityCache does the same. Reuse?
def _fill():
for name in dir(types):
update = getattr(types, name)
if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e:
cid = update.CONSTRUCTOR_ID
sig = inspect.signature(update.__init__)
for param in sig.parameters.values():
if param.name == 'channel_id' and param.annotation == int:
_has_channel_id.append(cid)
if not _has_channel_id:
raise RuntimeError('FIXME: Did the init signature or updates change?')
# We use a function to avoid cluttering the globals (with name/update/cid/doc)
_fill()
class StateCache:
"""
In-memory update state cache, defaultdict-like behaviour.
"""
def __init__(self, initial, loggers):
# We only care about the pts and the date. By using a tuple which
# is lightweight and immutable we can easily copy them around to
# each update in case they need to fetch missing entities.
self._logger = loggers[__name__]
if initial:
self._pts_date = initial.pts, initial.date
else:
self._pts_date = None, None
def reset(self):
self.__dict__.clear()
self._pts_date = None, None
# TODO Call this when receiving responses too...?
def update(
self,
update,
*,
channel_id=None,
has_pts=frozenset(x.CONSTRUCTOR_ID for x in (
types.UpdateNewMessage,
types.UpdateDeleteMessages,
types.UpdateReadHistoryInbox,
types.UpdateReadHistoryOutbox,
types.UpdateWebPage,
types.UpdateReadMessagesContents,
types.UpdateEditMessage,
types.updates.State,
types.updates.DifferenceTooLong,
types.UpdateShortMessage,
types.UpdateShortChatMessage,
types.UpdateShortSentMessage
)),
has_date=frozenset(x.CONSTRUCTOR_ID for x in (
types.UpdateUserPhoto,
types.UpdateEncryption,
types.UpdateEncryptedMessagesRead,
types.UpdateChatParticipantAdd,
types.updates.DifferenceEmpty,
types.UpdateShortMessage,
types.UpdateShortChatMessage,
types.UpdateShort,
types.UpdatesCombined,
types.Updates,
types.UpdateShortSentMessage,
)),
has_channel_pts=frozenset(x.CONSTRUCTOR_ID for x in (
types.UpdateChannelTooLong,
types.UpdateNewChannelMessage,
types.UpdateDeleteChannelMessages,
types.UpdateEditChannelMessage,
types.UpdateChannelWebPage,
types.updates.ChannelDifferenceEmpty,
types.updates.ChannelDifferenceTooLong,
types.updates.ChannelDifference
)),
check_only=False
):
"""
Update the state with the given update.
"""
cid = update.CONSTRUCTOR_ID
if check_only:
return cid in has_pts or cid in has_date or cid in has_channel_pts
if cid in has_pts:
if cid in has_date:
self._pts_date = update.pts, update.date
else:
self._pts_date = update.pts, self._pts_date[1]
elif cid in has_date:
self._pts_date = self._pts_date[0], update.date
if cid in has_channel_pts:
if channel_id is None:
channel_id = self.get_channel_id(update)
if channel_id is None:
self._logger.info(
'Failed to retrieve channel_id from %s', update)
else:
self.__dict__[channel_id] = update.pts
def get_channel_id(
self,
update,
has_channel_id=frozenset(_has_channel_id),
# Hardcoded because only some with message are for channels
has_message=frozenset(x.CONSTRUCTOR_ID for x in (
types.UpdateNewChannelMessage,
types.UpdateEditChannelMessage
))
):
"""
Gets the **unmarked** channel ID from this update, if it has any.
Fails for ``*difference`` updates, where ``channel_id``
is supposedly already known from the outside.
"""
cid = update.CONSTRUCTOR_ID
if cid in has_channel_id:
return update.channel_id
elif cid in has_message:
if update.message.peer_id is None:
# Telegram sometimes sends empty messages to give a newer pts:
# UpdateNewChannelMessage(message=MessageEmpty(id), pts=pts, pts_count=1)
# Not sure why, but it's safe to ignore them.
self._logger.debug('Update has None peer_id %s', update)
else:
return update.message.peer_id.channel_id
return None
def __getitem__(self, item):
"""
If `item` is `None`, returns the default ``(pts, date)``.
If it's an **unmarked** channel ID, returns its ``pts``.
If no information is known, ``pts`` will be `None`.
"""
if item is None:
return self._pts_date
else:
return self.__dict__.get(item)
def __setitem__(self, where, value):
if where is None:
self._pts_date = value
else:
self.__dict__[where] = value

View File

@ -14,7 +14,7 @@ import asyncio
import functools
import inspect
from . import events, errors, utils, connection, helpers
from . import events, errors, utils, connection
from .client.account import _TakeoutClient
from .client.telegramclient import TelegramClient
from .tl import types, functions, custom
@ -32,7 +32,7 @@ def _syncify_wrap(t, method_name):
@functools.wraps(method)
def syncified(*args, **kwargs):
coro = method(*args, **kwargs)
loop = helpers.get_running_loop()
loop = asyncio.get_event_loop()
if loop.is_running():
return coro
else:

View File

@ -1,7 +1,4 @@
try:
from isal import igzip as gzip
except ImportError:
import gzip
import gzip
import struct
from .. import TLObject

View File

@ -67,8 +67,7 @@ class AdminLogEvent:
types.ChannelAdminLogEventActionChangeAbout,
types.ChannelAdminLogEventActionChangeTitle,
types.ChannelAdminLogEventActionChangeUsername,
types.ChannelAdminLogEventActionChangeLocation,
types.ChannelAdminLogEventActionChangeHistoryTTL,
types.ChannelAdminLogEventActionChangeLocation
)):
return ori.prev_value
elif isinstance(ori, types.ChannelAdminLogEventActionChangePhoto):
@ -92,16 +91,6 @@ class AdminLogEvent:
return ori.message
elif isinstance(ori, types.ChannelAdminLogEventActionDefaultBannedRights):
return ori.prev_banned_rights
elif isinstance(ori, types.ChannelAdminLogEventActionDiscardGroupCall):
return ori.call
elif isinstance(ori, (
types.ChannelAdminLogEventActionExportedInviteDelete,
types.ChannelAdminLogEventActionExportedInviteRevoke,
types.ChannelAdminLogEventActionParticipantJoinByInvite,
)):
return ori.invite
elif isinstance(ori, types.ChannelAdminLogEventActionExportedInviteEdit):
return ori.prev_invite
@property
def new(self):
@ -116,8 +105,7 @@ class AdminLogEvent:
types.ChannelAdminLogEventActionToggleInvites,
types.ChannelAdminLogEventActionTogglePreHistoryHidden,
types.ChannelAdminLogEventActionToggleSignatures,
types.ChannelAdminLogEventActionChangeLocation,
types.ChannelAdminLogEventActionChangeHistoryTTL,
types.ChannelAdminLogEventActionChangeLocation
)):
return ori.new_value
elif isinstance(ori, types.ChannelAdminLogEventActionChangePhoto):
@ -131,26 +119,12 @@ class AdminLogEvent:
types.ChannelAdminLogEventActionParticipantToggleBan
)):
return ori.new_participant
elif isinstance(ori, (
types.ChannelAdminLogEventActionParticipantInvite,
types.ChannelAdminLogEventActionParticipantVolume,
)):
elif isinstance(ori, types.ChannelAdminLogEventActionParticipantInvite):
return ori.participant
elif isinstance(ori, types.ChannelAdminLogEventActionDefaultBannedRights):
return ori.new_banned_rights
elif isinstance(ori, types.ChannelAdminLogEventActionStopPoll):
return ori.message
elif isinstance(ori, types.ChannelAdminLogEventActionStartGroupCall):
return ori.call
elif isinstance(ori, (
types.ChannelAdminLogEventActionParticipantMute,
types.ChannelAdminLogEventActionParticipantUnmute,
)):
return ori.participant
elif isinstance(ori, types.ChannelAdminLogEventActionToggleGroupCallSetting):
return ori.join_muted
elif isinstance(ori, types.ChannelAdminLogEventActionExportedInviteEdit):
return ori.new_invite
@property
def changed_about(self):
@ -353,121 +327,6 @@ class AdminLogEvent:
return isinstance(self.original.action,
types.ChannelAdminLogEventActionStopPoll)
@property
def started_group_call(self):
"""
Whether a group call was started or not.
If `True`, `new` will be present as :tl:`InputGroupCall`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionStartGroupCall)
@property
def discarded_group_call(self):
"""
Whether a group call was started or not.
If `True`, `old` will be present as :tl:`InputGroupCall`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionDiscardGroupCall)
@property
def user_muted(self):
"""
Whether a participant was muted in the ongoing group call or not.
If `True`, `new` will be present as :tl:`GroupCallParticipant`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionParticipantMute)
@property
def user_unmutted(self):
"""
Whether a participant was unmuted from the ongoing group call or not.
If `True`, `new` will be present as :tl:`GroupCallParticipant`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionParticipantUnmute)
@property
def changed_call_settings(self):
"""
Whether the group call settings were changed or not.
If `True`, `new` will be `True` if new users are muted on join.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionToggleGroupCallSetting)
@property
def changed_history_ttl(self):
"""
Whether the Time To Live of the message history has changed.
Messages sent after this change will have a ``ttl_period`` in seconds
indicating how long they should live for before being auto-deleted.
If `True`, `old` will be the old TTL, and `new` the new TTL, in seconds.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionChangeHistoryTTL)
@property
def deleted_exported_invite(self):
"""
Whether the exported chat invite has been deleted.
If `True`, `old` will be the deleted :tl:`ExportedChatInvite`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionExportedInviteDelete)
@property
def edited_exported_invite(self):
"""
Whether the exported chat invite has been edited.
If `True`, `old` and `new` will be the old and new
:tl:`ExportedChatInvite`, respectively.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionExportedInviteEdit)
@property
def revoked_exported_invite(self):
"""
Whether the exported chat invite has been revoked.
If `True`, `old` will be the revoked :tl:`ExportedChatInvite`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionExportedInviteRevoke)
@property
def joined_by_invite(self):
"""
Whether a new participant has joined with the use of an invite link.
If `True`, `old` will be pre-existing (old) :tl:`ExportedChatInvite`
used to join.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionParticipantJoinByInvite)
@property
def changed_user_volume(self):
"""
Whether a participant's volume in a call has been changed.
If `True`, `new` will be the updated :tl:`GroupCallParticipant`.
"""
return isinstance(self.original.action,
types.ChannelAdminLogEventActionParticipantVolume)
def __str__(self):
return str(self.original)

View File

@ -20,7 +20,7 @@ class Button:
instances instead making them yourself (i.e. don't do ``Button(...)``
but instead use methods line `Button.inline(...) <inline>` etc.
You can use `inline`, `switch_inline`, `url`, `auth`, `buy` and `game`
You can use `inline`, `switch_inline`, `url` and `auth`
together to create inline buttons (under the message).
You can use `text`, `request_location`, `request_phone` and `request_poll`
@ -37,14 +37,11 @@ class Button:
to 128 characters and add the ellipsis () character as
the 129.
"""
def __init__(self, button, *, resize, single_use, selective,
persistent, placeholder):
def __init__(self, button, *, resize, single_use, selective):
self.button = button
self.resize = resize
self.single_use = single_use
self.selective = selective
self.persistent = persistent
self.placeholder = placeholder
@staticmethod
def _is_inline(button):
@ -52,14 +49,10 @@ class Button:
Returns `True` if the button belongs to an inline keyboard.
"""
return isinstance(button, (
types.KeyboardButtonCopy,
types.KeyboardButtonBuy,
types.KeyboardButtonCallback,
types.KeyboardButtonGame,
types.KeyboardButtonSwitchInline,
types.KeyboardButtonUrl,
types.InputKeyboardButtonUrlAuth,
types.KeyboardButtonWebView,
types.InputKeyboardButtonUrlAuth
))
@staticmethod
@ -171,15 +164,11 @@ class Button:
)
@classmethod
def text(cls, text, *, resize=None, single_use=None, selective=None,
persistent=None, placeholder=None):
def text(cls, text, *, resize=None, single_use=None, selective=None):
"""
Creates a new keyboard button with the given text.
Args:
text (`str`):
The title of the button.
resize (`bool`):
If present, the entire keyboard will be reconfigured to
be resized and be smaller if there are not many buttons.
@ -194,77 +183,48 @@ class Button:
users. It will target users that are @mentioned in the text
of the message or to the sender of the message you reply to.
persistent (`bool`):
If present, always show the keyboard when the regular keyboard
is hidden. Defaults to false, in which case the custom keyboard
can be hidden and revealed via the keyboard icon.
placeholder (`str`):
The placeholder to be shown in the input field when the keyboard is active;
1-64 characters
When the user clicks this button, a text message with the same text
as the button will be sent, and can be handled with `events.NewMessage
<telethon.events.newmessage.NewMessage>`. You cannot distinguish
between a button press and the user typing and sending exactly the
same text on their own.
"""
return cls(
types.KeyboardButton(text),
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)
return cls(types.KeyboardButton(text),
resize=resize, single_use=single_use, selective=selective)
@classmethod
def request_location(cls, text, *, resize=None, single_use=None, selective=None,
persistent=None, placeholder=None):
def request_location(cls, text, *,
resize=None, single_use=None, selective=None):
"""
Creates a new keyboard button to request the user's location on click.
``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder``
are documented in `text`.
``resize``, ``single_use`` and ``selective`` are documented in `text`.
When the user clicks this button, a confirmation box will be shown
to the user asking whether they want to share their location with the
bot, and if confirmed a message with geo media will be sent.
"""
return cls(
types.KeyboardButtonRequestGeoLocation(text),
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)
return cls(types.KeyboardButtonRequestGeoLocation(text),
resize=resize, single_use=single_use, selective=selective)
@classmethod
def request_phone(cls, text, *, resize=None, single_use=None,
selective=None, persistent=None, placeholder=None):
def request_phone(cls, text, *,
resize=None, single_use=None, selective=None):
"""
Creates a new keyboard button to request the user's phone on click.
``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder``
are documented in `text`.
``resize``, ``single_use`` and ``selective`` are documented in `text`.
When the user clicks this button, a confirmation box will be shown
to the user asking whether they want to share their phone with the
bot, and if confirmed a message with contact media will be sent.
"""
return cls(
types.KeyboardButtonRequestPhone(text),
resize=resize,
single_use=single_use,
selective=selective,
placeholder=placeholder,
persistent=persistent
)
return cls(types.KeyboardButtonRequestPhone(text),
resize=resize, single_use=single_use, selective=selective)
@classmethod
def request_poll(cls, text, *, force_quiz=False, resize=None, single_use=None,
selective=None, persistent=None, placeholder=None):
def request_poll(cls, text, *, force_quiz=False,
resize=None, single_use=None, selective=None):
"""
Creates a new keyboard button to request the user to create a poll.
@ -276,71 +236,26 @@ class Button:
the votes cannot be retracted. Otherwise, users can vote and retract
the vote, and the pol might be multiple choice.
``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder``
are documented in `text`.
``resize``, ``single_use`` and ``selective`` are documented in `text`.
When the user clicks this button, a screen letting the user create a
poll will be shown, and if they do create one, the poll will be sent.
"""
return cls(
types.KeyboardButtonRequestPoll(text, quiz=force_quiz),
resize=resize,
single_use=single_use,
selective=selective,
persistent=persistent,
placeholder=placeholder
)
return cls(types.KeyboardButtonRequestPoll(text, quiz=force_quiz),
resize=resize, single_use=single_use, selective=selective)
@staticmethod
def clear(selective=None):
def clear():
"""
Clears all keyboard buttons after sending a message with this markup.
When used, no other button should be present or it will be ignored.
``selective`` is as documented in `text`.
"""
return types.ReplyKeyboardHide(selective=selective)
return types.ReplyKeyboardHide()
@staticmethod
def force_reply(single_use=None, selective=None, placeholder=None):
def force_reply():
"""
Forces a reply to the message with this markup. If used,
no other button should be present or it will be ignored.
``single_use``, ``selective`` and ``placeholder`` are as documented in `text`.
"""
return types.ReplyKeyboardForceReply(
single_use=single_use,
selective=selective,
placeholder=placeholder)
@staticmethod
def buy(text):
"""
Creates a new inline button to buy a product.
This can only be used when sending files of type
:tl:`InputMediaInvoice`, and must be the first button.
If the button is not specified, Telegram will automatically
add the button to the message. See the
`Payments API <https://core.telegram.org/api/payments>`__
documentation for more information.
"""
return types.KeyboardButtonBuy(text)
@staticmethod
def game(text):
"""
Creates a new inline button to start playing a game.
This should be used when sending files of type
:tl:`InputMediaGame`, and must be the first button.
See the
`Games <https://core.telegram.org/api/bots/games>`__
documentation for more information on using games.
"""
return types.KeyboardButtonGame(text)
return types.ReplyKeyboardForceReply()

View File

@ -66,9 +66,8 @@ class ChatGetter(abc.ABC):
"""
if self._input_chat is None and self._chat_peer and self._client:
try:
self._input_chat = self._client._mb_entity_cache.get(
utils.get_peer_id(self._chat_peer, add_mark=False))._as_input_peer()
except AttributeError:
self._input_chat = self._client._entity_cache[self._chat_peer]
except KeyError:
pass
return self._input_chat

View File

@ -416,11 +416,16 @@ class Conversation(ChatGetter):
self._last_read = event.max_id
remove_reads = []
for msg_id, pending in list(self._pending_reads.items()):
if msg_id >= self._last_read:
remove_reads.append(msg_id)
pending.set_result(True)
del self._pending_reads[msg_id]
for to_remove in remove_reads:
del self._pending_reads[to_remove]
def _get_message_id(self, message):
if message is not None: # 0 is valid but false-y, check for None
return message if isinstance(message, int) else message.id

View File

@ -1,11 +1,11 @@
import datetime
from .. import TLObject, types
from .. import TLObject
from ..functions.messages import SaveDraftRequest
from ..types import DraftMessage
from ...errors import RPCError
from ...extensions import markdown
from ...utils import get_input_peer, get_peer, get_peer_id
from ...utils import get_input_peer, get_peer
class Draft:
@ -37,7 +37,7 @@ class Draft:
self._raw_text = draft.message
self.date = draft.date
self.link_preview = not draft.no_webpage
self.reply_to_msg_id = draft.reply_to.reply_to_msg_id if isinstance(draft.reply_to, types.InputReplyToMessage) else None
self.reply_to_msg_id = draft.reply_to_msg_id
@property
def entity(self):
@ -53,9 +53,8 @@ class Draft:
"""
if not self._input_entity:
try:
self._input_entity = self._client._mb_entity_cache.get(
get_peer_id(self._peer, add_mark=False))._as_input_peer()
except AttributeError:
self._input_entity = self._client._entity_cache[self._peer]
except KeyError:
pass
return self._input_entity
@ -139,7 +138,7 @@ class Draft:
peer=self._peer,
message=raw_text,
no_webpage=not link_preview,
reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to),
reply_to_msg_id=reply_to,
entities=entities
))

View File

@ -21,12 +21,7 @@ class File:
@property
def id(self):
"""
The old bot-API style ``file_id`` representing this file.
.. warning::
This feature has not been maintained for a long time and
may not work. It will be removed in future versions.
The bot-API style ``file_id`` representing this file.
.. note::
@ -74,9 +69,6 @@ class File:
"""
The width in pixels of this media if it's a photo or a video.
"""
if isinstance(self.media, types.Photo):
return max(getattr(s, 'w', 0) for s in self.media.sizes)
return self._from_attr((
types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'w')
@ -85,9 +77,6 @@ class File:
"""
The height in pixels of this media if it's a photo or a video.
"""
if isinstance(self.media, types.Photo):
return max(getattr(s, 'h', 0) for s in self.media.sizes)
return self._from_attr((
types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'h')
@ -131,11 +120,9 @@ class File:
def size(self):
"""
The size in bytes of this file.
For photos, this is the heaviest thumbnail, as it often repressents the largest dimensions.
"""
if isinstance(self.media, types.Photo):
return max(filter(None, map(utils._photo_size_byte_count, self.media.sizes)), default=None)
return utils._photo_size_byte_count(self.media.sizes[-1])
elif isinstance(self.media, types.Document):
return self.media.size

View File

@ -36,12 +36,12 @@ class Forward(ChatGetter, SenderGetter):
if ty == helpers._EntityType.USER:
sender_id = utils.get_peer_id(original.from_id)
sender, input_sender = utils._get_entity_pair(
sender_id, entities, client._mb_entity_cache)
sender_id, entities, client._entity_cache)
elif ty in (helpers._EntityType.CHAT, helpers._EntityType.CHANNEL):
peer = original.from_id
chat, input_chat = utils._get_entity_pair(
utils.get_peer_id(peer), entities, client._mb_entity_cache)
utils.get_peer_id(peer), entities, client._entity_cache)
# This call resets the client
ChatGetter.__init__(self, peer, chat=chat, input_chat=input_chat)

View File

@ -255,12 +255,6 @@ class InlineBuilder:
contact, file, geo, gif, photo, sticker, venue, video, voice.
It will be automatically set if ``mime_type`` is specified,
and default to ``'file'`` if no matching mime type is found.
you may need to pass ``attributes`` in order to use ``type``
effectively.
attributes (`list`, optional):
Optional attributes that override the inferred ones, like
:tl:`DocumentAttributeFilename` and so on.
include_media (`bool`, optional):
Whether the document file used to display the result should be
@ -309,7 +303,7 @@ class InlineBuilder:
file,
mime_type=mime_type,
attributes=attributes,
force_document=force_document,
force_document=True,
voice_note=voice_note,
video_note=video_note,
allow_cache=use_cache
@ -391,7 +385,7 @@ class InlineBuilder:
'text geo contact game'.split(), args) if x[1]) or 'none')
)
markup = self._client.build_reply_markup(buttons)
markup = self._client.build_reply_markup(buttons, inline_only=True)
if text is not None:
text, msg_entities = await self._client._parse_message_text(
text, parse_mode

View File

@ -102,9 +102,8 @@ class InlineResult:
elif isinstance(self.result, types.BotInlineMediaResult):
return self.result.document
async def click(self, entity=None, reply_to=None, comment_to=None,
silent=False, clear_draft=False, hide_via=False,
background=None):
async def click(self, entity=None, reply_to=None,
silent=False, clear_draft=False, hide_via=False):
"""
Clicks this result and sends the associated `message`.
@ -115,11 +114,6 @@ class InlineResult:
reply_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
If present, the sent message will reply to this ID or message.
comment_to (`int` | `Message <telethon.tl.custom.message.Message>`, optional):
Similar to ``reply_to``, but replies in the linked group of a
broadcast channel instead (effectively leaving a "comment to"
the specified message).
silent (`bool`, optional):
Whether the message should notify people with sound or not.
Defaults to `False` (send with a notification sound unless
@ -129,14 +123,10 @@ class InlineResult:
clear_draft (`bool`, optional):
Whether the draft should be removed after sending the
message from this result or not. Defaults to `False`.
hide_via (`bool`, optional):
Whether the "via @bot" should be hidden or not.
Only works with certain bots (like @bing or @gif).
background (`bool`, optional):
Whether the message should be send in background.
"""
if entity:
entity = await self._client.get_input_entity(entity)
@ -145,20 +135,15 @@ class InlineResult:
else:
raise ValueError('You must provide the entity where the result should be sent to')
if comment_to:
entity, reply_id = await self._client._get_comment_data(entity, comment_to)
else:
reply_id = None if reply_to is None else utils.get_message_id(reply_to)
reply_id = None if reply_to is None else utils.get_message_id(reply_to)
req = functions.messages.SendInlineBotResultRequest(
peer=entity,
query_id=self._query_id,
id=self.result.id,
silent=silent,
background=background,
clear_draft=clear_draft,
hide_via=hide_via,
reply_to=None if reply_id is None else types.InputReplyToMessage(reply_id)
reply_to_msg_id=reply_id
)
return self._client._get_response_message(
req, await self._client(req), entity)

View File

@ -1,17 +1,16 @@
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
import abc
from .chatgetter import ChatGetter
from .sendergetter import SenderGetter
from .messagebutton import MessageButton
from .forward import Forward
from .file import File
from .. import TLObject, types, functions, alltlobjects
from .. import TLObject, types, functions
from ... import utils, errors
# TODO Figure out a way to have the code generator error on missing fields
# Maybe parsing the init function alone if that's possible.
class Message(ChatGetter, SenderGetter, TLObject):
class Message(ChatGetter, SenderGetter, TLObject, abc.ABC):
"""
This custom class aggregates both :tl:`Message` and
:tl:`MessageService` to ease accessing their members.
@ -65,15 +64,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
pinned (`bool`):
Whether this message is currently pinned or not.
noforwards (`bool`):
Whether this message can be forwarded or not.
invert_media (`bool`):
Whether the media in this message should be inverted.
offline (`bool`):
Whether the message was sent by an implicit action, for example, as an away or a greeting business message, or as a scheduled message.
id (`int`):
The ID of this message. This field is *always* present.
Any other member is optional and may be `None`.
@ -96,7 +86,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
The ID of the bot used to send this message
through its inline mode (e.g. "via @like").
reply_to (:tl:`MessageReplyHeader` | :tl:`MessageReplyStoryHeader`):
reply_to (:tl:`MessageReplyHeader`):
The original reply header if this message is replying to another.
date (`datetime`):
@ -150,85 +140,42 @@ class Message(ChatGetter, SenderGetter, TLObject):
(photo albums or video albums), all of them will
have the same value here.
reactions (:tl:`MessageReactions`)
Reactions to this message.
restriction_reason (List[:tl:`RestrictionReason`])
An optional list of reasons why this message was restricted.
If the list is `None`, this message has not been restricted.
ttl_period (`int`):
The Time To Live period configured for this message.
The message should be erased from wherever it's stored (memory, a
local database, etc.) when
``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``.
action (:tl:`MessageAction`):
The message action object of the message for :tl:`MessageService`
instances, which will be `None` for other types of messages.
saved_peer_id (:tl:`Peer`)
"""
# region Initialization
def __init__(
self,
id: int,
peer_id: types.TypePeer,
date: Optional[datetime] = None,
message: Optional[str] = None,
# Copied from Message.__init__ signature
out: Optional[bool] = None,
mentioned: Optional[bool] = None,
media_unread: Optional[bool] = None,
silent: Optional[bool] = None,
post: Optional[bool] = None,
from_scheduled: Optional[bool] = None,
legacy: Optional[bool] = None,
edit_hide: Optional[bool] = None,
pinned: Optional[bool] = None,
noforwards: Optional[bool] = None,
invert_media: Optional[bool] = None,
offline: Optional[bool] = None,
video_processing_pending: Optional[bool] = None,
paid_suggested_post_stars: Optional[bool] = None,
paid_suggested_post_ton: Optional[bool] = None,
from_id: Optional[types.TypePeer] = None,
from_boosts_applied: Optional[int] = None,
saved_peer_id: Optional[types.TypePeer] = None,
fwd_from: Optional[types.TypeMessageFwdHeader] = None,
via_bot_id: Optional[int] = None,
via_business_bot_id: Optional[int] = None,
reply_to: Optional[types.TypeMessageReplyHeader] = None,
media: Optional[types.TypeMessageMedia] = None,
reply_markup: Optional[types.TypeReplyMarkup] = None,
entities: Optional[List[types.TypeMessageEntity]] = None,
views: Optional[int] = None,
forwards: Optional[int] = None,
replies: Optional[types.TypeMessageReplies] = None,
edit_date: Optional[datetime] = None,
post_author: Optional[str] = None,
grouped_id: Optional[int] = None,
reactions: Optional[types.TypeMessageReactions] = None,
restriction_reason: Optional[List[types.TypeRestrictionReason]] = None,
ttl_period: Optional[int] = None,
quick_reply_shortcut_id: Optional[int] = None,
effect: Optional[int] = None,
factcheck: Optional[types.TypeFactCheck] = None,
report_delivery_until_date: Optional[datetime] = None,
paid_message_stars: Optional[int] = None,
suggested_post: Optional[types.TypeSuggestedPost] = None,
# Copied from MessageService.__init__ signature
action: Optional[types.TypeMessageAction] = None,
reactions_are_possible: Optional[bool] = None,
):
# Copied from Message.__init__ body
self.id = id
self.peer_id = peer_id
self.date = date
self.message = message
self.out = bool(out)
# Common to all
self, id,
# Common to Message and MessageService (mandatory)
peer_id=None, date=None,
# Common to Message and MessageService (flags)
out=None, mentioned=None, media_unread=None, silent=None,
post=None, from_id=None, reply_to=None,
# For Message (mandatory)
message=None,
# For Message (flags)
fwd_from=None, via_bot_id=None, media=None, reply_markup=None,
entities=None, views=None, edit_date=None, post_author=None,
grouped_id=None, from_scheduled=None, legacy=None,
edit_hide=None, pinned=None, restriction_reason=None,
forwards=None, replies=None,
# For MessageAction (mandatory)
action=None):
# Common properties to messages, then to service (in the order they're defined in the `.tl`)
self.out = out
self.mentioned = mentioned
self.media_unread = media_unread
self.silent = silent
@ -236,41 +183,26 @@ class Message(ChatGetter, SenderGetter, TLObject):
self.from_scheduled = from_scheduled
self.legacy = legacy
self.edit_hide = edit_hide
self.pinned = pinned
self.noforwards = noforwards
self.invert_media = invert_media
self.offline = offline
self.video_processing_pending = video_processing_pending
self.paid_suggested_post_stars = paid_suggested_post_stars
self.paid_suggested_post_ton = paid_suggested_post_ton
self.id = id
self.from_id = from_id
self.from_boosts_applied = from_boosts_applied
self.saved_peer_id = saved_peer_id
self.peer_id = peer_id
self.fwd_from = fwd_from
self.via_bot_id = via_bot_id
self.via_business_bot_id = via_business_bot_id
self.reply_to = reply_to
self.media = None if isinstance(media, types.MessageMediaEmpty) else media
self.date = date
self.message = message
self.media = None if isinstance(media, types.MessageMediaEmpty) else media
self.reply_markup = reply_markup
self.entities = entities
self.views = views
self.forwards = forwards
self.replies = replies
self.edit_date = edit_date
self.pinned = pinned
self.post_author = post_author
self.grouped_id = grouped_id
self.reactions = reactions
self.restriction_reason = restriction_reason
self.ttl_period = ttl_period
self.quick_reply_shortcut_id = quick_reply_shortcut_id
self.effect = effect
self.factcheck = factcheck
self.report_delivery_until_date = report_delivery_until_date
self.paid_message_stars = paid_message_stars
self.suggested_post = suggested_post
# Copied from MessageService.__init__ body
self.action = action
self.reactions_are_possible = reactions_are_possible
# Convenient storage for custom functions
# TODO This is becoming a bit of bloat
@ -284,7 +216,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
self._via_bot = None
self._via_input_bot = None
self._action_entities = None
self._linked_chat = None
sender_id = None
if from_id is not None:
@ -302,8 +233,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
SenderGetter.__init__(self, sender_id)
self._forward = None
self._reply_to_chat = None
self._reply_to_sender = None
def _finish_init(self, client, entities, input_chat):
"""
@ -312,13 +241,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
known entities.
"""
self._client = client
# Make messages sent to ourselves outgoing unless they're forwarded.
# This makes it consistent with official client's appearance.
if self.peer_id == types.PeerUser(client._self_id) and not self.fwd_from:
self.out = True
cache = client._mb_entity_cache
cache = client._entity_cache
self._sender, self._input_sender = utils._get_entity_pair(
self.sender_id, entities, cache)
@ -353,19 +276,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
self._action_entities = [entities.get(utils.get_peer_id(
types.PeerChat(self.action.chat_id)))]
if self.replies and self.replies.channel_id:
self._linked_chat = entities.get(utils.get_peer_id(
types.PeerChannel(self.replies.channel_id)))
if isinstance(self.reply_to, types.MessageReplyHeader):
if self.reply_to.reply_to_peer_id:
self._reply_to_chat = entities.get(utils.get_peer_id(self.reply_to.reply_to_peer_id))
if self.reply_to.reply_from:
if self.reply_to.reply_from.from_id:
self._reply_to_sender = entities.get(utils.get_peer_id(self.reply_to.reply_from.from_id))
# endregion Initialization
# region Public Properties
@ -424,11 +334,10 @@ class Message(ChatGetter, SenderGetter, TLObject):
@property
def is_reply(self):
"""
`True` if the message is a reply to some other message or story.
`True` if the message is a reply to some other message.
Remember that if the replied-to is a message,
you can access the ID of the message this one is
replying to through `reply_to.reply_to_msg_id`,
Remember that you can access the ID of the message
this one is replying to through `reply_to.reply_to_msg_id`,
and the `Message` object with `get_reply_message()`.
"""
return self.reply_to is not None
@ -441,22 +350,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
"""
return self._forward
@property
def reply_to_chat(self):
"""
The :tl:`Channel` in which the replied-to message was sent,
if this message is a reply in another chat
"""
return self._reply_to_chat
@property
def reply_to_sender(self):
"""
The :tl:`User`, :tl:`Channel`, or whatever other entity that
sent the replied-to message, if this message is a reply in another chat.
"""
return self._reply_to_sender
@property
def buttons(self):
"""
@ -717,11 +610,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
Returns the message ID this message is replying to, if any.
This is equivalent to accessing ``.reply_to.reply_to_msg_id``.
"""
return (
self.reply_to.reply_to_msg_id
if isinstance(self.reply_to, types.MessageReplyHeader)
else None
)
return self.reply_to.reply_to_msg_id if self.reply_to else None
@property
def to_id(self):
@ -787,7 +676,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
The result will be cached after its first use.
"""
if self._reply_message is None and self._client:
if not isinstance(self.reply_to, types.MessageReplyHeader):
if not self.reply_to:
return None
# Bots cannot access other bots' messages by their ID.
@ -846,26 +735,12 @@ class Message(ChatGetter, SenderGetter, TLObject):
async def edit(self, *args, **kwargs):
"""
Edits the message if it's outgoing. Shorthand for
Edits the message iff it's outgoing. Shorthand for
`telethon.client.messages.MessageMethods.edit_message`
with both ``entity`` and ``message`` already set.
Returns
The edited `Message <telethon.tl.custom.message.Message>`,
unless `entity` was a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64` in which
case this method returns a boolean.
Raises
``MessageAuthorRequiredError`` if you're not the author of the
message but tried editing it anyway.
``MessageNotModifiedError`` if the contents of the message were
not modified at all.
``MessageIdInvalidError`` if the ID of the message is invalid
(the ID itself may be correct, but the message with that ID
cannot be edited). For example, when trying to edit messages
with a reply markup (or clear markup) this error will be raised.
Returns `None` if the message was incoming,
or the edited `Message` otherwise.
.. note::
@ -879,6 +754,9 @@ class Message(ChatGetter, SenderGetter, TLObject):
This is generally the most desired and convenient behaviour,
and will work for link previews and message buttons.
"""
if self.fwd_from or not self.out or not self._client:
return None # We assume self.out was patched for our chat
if 'link_preview' not in kwargs:
kwargs['link_preview'] = bool(self.web_preview)
@ -921,7 +799,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
async def click(self, i=None, j=None,
*, text=None, filter=None, data=None, share_phone=None,
share_geo=None, password=None, open_url=None):
share_geo=None):
"""
Calls :tl:`SendVote` with the specified poll option
or `button.click <telethon.tl.custom.messagebutton.MessageButton.click>`
@ -1000,18 +878,6 @@ class Message(ChatGetter, SenderGetter, TLObject):
If the button is pressed without this, `ValueError` is raised.
password (`str`):
When clicking certain buttons (such as BotFather's confirmation
button to transfer ownership), if your account has 2FA enabled,
you need to provide your account's password. Otherwise,
`teltehon.errors.PasswordHashInvalidError` is raised.
open_url (`bool`):
When clicking on an inline keyboard URL button :tl:`KeyboardButtonUrl`
By default it will return URL of the button, passing ``click(open_url=True)``
will lunch the default browser with given URL of the button and
return `True` on success.
Example:
.. code-block:: python
@ -1035,13 +901,19 @@ class Message(ChatGetter, SenderGetter, TLObject):
return
if data:
chat = await self.get_input_chat()
if not chat:
if not await self.get_input_chat():
return None
but = types.KeyboardButtonCallback('', data)
return await MessageButton(self._client, but, chat, None, self.id).click(
share_phone=share_phone, share_geo=share_geo, password=password, open_url=open_url)
try:
return await self._client(
functions.messages.GetBotCallbackAnswerRequest(
peer=self._input_chat,
msg_id=self.id,
data=data
)
)
except errors.BotResponseTimeoutError:
return None
if sum(int(x is not None) for x in (i, text, filter)) >= 2:
raise ValueError('You can only set either of i, text or filter')
@ -1073,7 +945,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
options = find_options()
if options is None:
options = []
return
return await self._client(
functions.messages.SendVoteRequest(
peer=self._input_chat,
@ -1113,8 +985,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
button = find_button()
if button:
return await button.click(
share_phone=share_phone, share_geo=share_geo, password=password, open_url=open_url)
return await button.click(share_phone=share_phone, share_geo=share_geo)
async def mark_read(self):
"""
@ -1127,7 +998,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
await self._client.send_read_acknowledge(
await self.get_input_chat(), max_id=self.id)
async def pin(self, *, notify=False, pm_oneside=False):
async def pin(self, *, notify=False):
"""
Pins the message. Shorthand for
`telethon.client.messages.MessageMethods.pin_message`
@ -1138,7 +1009,7 @@ class Message(ChatGetter, SenderGetter, TLObject):
# That or figure out a way to always set it directly.
if self._client:
return await self._client.pin_message(
await self.get_input_chat(), self.id, notify=notify, pm_oneside=pm_oneside)
await self.get_input_chat(), self.id, notify=notify)
async def unpin(self):
"""
@ -1214,12 +1085,10 @@ class Message(ChatGetter, SenderGetter, TLObject):
bot = self.input_sender
if not bot:
raise ValueError('No input sender')
return bot
else:
try:
return self._client._mb_entity_cache.get(
utils.resolve_id(self.via_bot_id)[0])._as_input_peer()
except AttributeError:
return self._client._entity_cache[self.via_bot_id]
except KeyError:
raise ValueError('No input sender') from None
def _document_by_attribute(self, kind, condition=None):

View File

@ -1,12 +1,6 @@
from .. import types, functions
from ... import password as pwd_mod
from ...errors import BotResponseTimeoutError
try:
import webbrowser
except ModuleNotFoundError:
pass
import sys
import os
import webbrowser
class MessageButton:
@ -65,7 +59,7 @@ class MessageButton:
if isinstance(self.button, types.KeyboardButtonUrl):
return self.button.url
async def click(self, share_phone=None, share_geo=None, *, password=None, open_url=None):
async def click(self, share_phone=None, share_geo=None):
"""
Emulates the behaviour of clicking this button.
@ -79,8 +73,7 @@ class MessageButton:
:tl:`StartBotRequest` will be invoked and the resulting updates
returned.
If it's a :tl:`KeyboardButtonUrl`, the ``URL`` of the button will
be returned. If you pass ``open_url=True`` the URL of the button will
If it's a :tl:`KeyboardButtonUrl`, the URL of the button will
be passed to ``webbrowser.open`` and return `True` on success.
If it's a :tl:`KeyboardButtonRequestPhone`, you must indicate that you
@ -100,13 +93,8 @@ class MessageButton:
return await self._client.send_message(
self._chat, self.button.text, parse_mode=None)
elif isinstance(self.button, types.KeyboardButtonCallback):
if password is not None:
pwd = await self._client(functions.account.GetPasswordRequest())
password = pwd_mod.compute_check(pwd, password)
req = functions.messages.GetBotCallbackAnswerRequest(
peer=self._chat, msg_id=self._msg_id, data=self.button.data,
password=password
peer=self._chat, msg_id=self._msg_id, data=self.button.data
)
try:
return await self._client(req)
@ -117,10 +105,7 @@ class MessageButton:
bot=self._bot, peer=self._chat, start_param=self.button.query
))
elif isinstance(self.button, types.KeyboardButtonUrl):
if open_url:
if "webbrowser" in sys.modules:
return webbrowser.open(self.button.url)
return self.button.url
return webbrowser.open(self.button.url)
elif isinstance(self.button, types.KeyboardButtonGame):
req = functions.messages.GetBotCallbackAnswerRequest(
peer=self._chat, msg_id=self._msg_id, game=True

View File

@ -79,25 +79,15 @@ class ParticipantPermissions:
"""
return isinstance(self.participant, types.ChannelParticipantBanned)
@property
def has_left(self):
"""
Whether the user left the chat.
"""
return isinstance(self.participant, types.ChannelParticipantLeft)
@property
def add_admins(self):
"""
Whether the administrator can add new administrators with the same or
less permissions than them.
"""
if not self.is_admin:
if not self.is_admin or (self.is_chat and not self.is_creator):
return False
if self.is_chat:
return self.is_creator
return self.participant.admin_rights.add_admins
ban_users = property(**_admin_prop('ban_users', """
@ -132,7 +122,3 @@ class ParticipantPermissions:
anonymous = property(**_admin_prop('anonymous', """
Whether the administrator will remain anonymous when sending messages.
"""))
manage_call = property(**_admin_prop('manage_call', """
Whether the user will be able to manage group calls.
"""))

View File

@ -54,7 +54,7 @@ class QRLogin:
The URL simply consists of `token` base64-encoded.
"""
return 'tg://login?token={}'.format(base64.urlsafe_b64encode(self._resp.token).decode('utf-8').rstrip('='))
return 'tg://login?token={}'.format(base64.b64encode(self._resp.token).decode('utf-8'))
@property
def expires(self) -> datetime.datetime:
@ -113,7 +113,7 @@ class QRLogin:
if isinstance(resp, types.auth.LoginTokenSuccess):
user = resp.authorization.user
await self._client._on_login(user)
self._client._on_login(user)
return user
raise TypeError('Login token response was unexpected: {}'.format(resp))

View File

@ -1,7 +1,5 @@
import abc
from ... import utils
class SenderGetter(abc.ABC):
"""
@ -46,16 +44,13 @@ class SenderGetter(abc.ABC):
# in which case we want to force fetch the entire thing because
# the user explicitly called a method. If the user is okay with
# cached information, they may use the property instead.
if (self._sender is None or getattr(self._sender, 'min', None)) \
if (self._sender is None or self._sender.min) \
and await self.get_input_sender():
# self.get_input_sender may refresh in which case the sender may no longer be min
# However it could still incur a cost so the cheap check is done twice instead.
if self._sender is None or getattr(self._sender, 'min', None):
try:
self._sender =\
await self._client.get_entity(self._input_sender)
except ValueError:
await self._refetch_sender()
try:
self._sender =\
await self._client.get_entity(self._input_sender)
except ValueError:
await self._refetch_sender()
return self._sender
@property
@ -71,9 +66,9 @@ class SenderGetter(abc.ABC):
"""
if self._input_sender is None and self._sender_id and self._client:
try:
self._input_sender = self._client._mb_entity_cache.get(
utils.resolve_id(self._sender_id)[0])._as_input_peer()
except AttributeError:
self._input_sender = \
self._client._entity_cache[self._sender_id]
except KeyError:
pass
return self._input_sender

View File

@ -1,20 +0,0 @@
from .. import types, alltlobjects
from ..custom.message import Message as _Message
class MessageEmpty(_Message, types.MessageEmpty):
pass
types.MessageEmpty = MessageEmpty
alltlobjects.tlobjects[MessageEmpty.CONSTRUCTOR_ID] = MessageEmpty
class MessageService(_Message, types.MessageService):
pass
types.MessageService = MessageService
alltlobjects.tlobjects[MessageService.CONSTRUCTOR_ID] = MessageService
class Message(_Message, types.Message):
pass
types.Message = Message
alltlobjects.tlobjects[Message.CONSTRUCTOR_ID] = Message

View File

@ -1 +0,0 @@
from .tl.types import *

View File

@ -4,6 +4,7 @@ to convert between an entity like a User, Chat, etc. into its Input version)
"""
import base64
import binascii
import imghdr
import inspect
import io
import itertools
@ -14,7 +15,6 @@ import os
import pathlib
import re
import struct
import warnings
from collections import namedtuple
from mimetypes import guess_extension
from types import GeneratorType
@ -54,14 +54,20 @@ mimetypes.add_type('audio/flac', '.flac')
mimetypes.add_type('application/x-tgsticker', '.tgs')
USERNAME_RE = re.compile(
r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|\+|joinchat/)?'
r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|joinchat/)?'
)
TG_JOIN_RE = re.compile(
r'tg://(join)\?invite='
)
# The only shorter-than-five-characters usernames are those used for some
# special, very well known bots. This list may be incomplete though:
# "[...] @gif, @vid, @pic, @bing, @wiki, @imdb and @bold [...]"
#
# See https://telegram.org/blog/inline-bots#how-does-it-work
VALID_USERNAME_RE = re.compile(
r'^[a-z](?:(?!__)\w){1,30}[a-z\d]$',
r'^([a-z](?:(?!__)\w){3,30}[a-z\d]'
r'|gif|vid|pic|bing|wiki|imdb|bold|vote|like|coub)$',
re.IGNORECASE
)
@ -96,8 +102,7 @@ def get_display_name(entity):
else:
return ''
elif isinstance(entity, (
types.Chat, types.ChatForbidden, types.Channel, types.ChannelForbidden)):
elif isinstance(entity, (types.Chat, types.ChatForbidden, types.Channel)):
return entity.title
return ''
@ -424,8 +429,7 @@ def get_input_geo(geo):
def get_input_media(
media, *,
is_photo=False, attributes=None, force_document=False,
voice_note=False, video_note=False, supports_streaming=False,
ttl=None
voice_note=False, video_note=False, supports_streaming=False
):
"""
Similar to :meth:`get_input_peer`, but for media.
@ -438,41 +442,37 @@ 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 types.InputMediaPhoto(media)
elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument')
return types.InputMediaDocument(media, ttl_seconds=ttl)
return types.InputMediaDocument(media)
except AttributeError:
_raise_cast_fail(media, 'InputMedia')
if isinstance(media, types.MessageMediaPhoto):
return types.InputMediaPhoto(
id=get_input_photo(media.photo),
spoiler=media.spoiler,
ttl_seconds=ttl or media.ttl_seconds
ttl_seconds=media.ttl_seconds
)
if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)):
return types.InputMediaPhoto(
id=get_input_photo(media),
ttl_seconds=ttl
id=get_input_photo(media)
)
if isinstance(media, types.MessageMediaDocument):
return types.InputMediaDocument(
id=get_input_document(media.document),
spoiler=media.spoiler,
ttl_seconds=ttl or media.ttl_seconds
ttl_seconds=media.ttl_seconds
)
if isinstance(media, (types.Document, types.DocumentEmpty)):
return types.InputMediaDocument(
id=get_input_document(media),
ttl_seconds=ttl
id=get_input_document(media)
)
if isinstance(media, (types.InputFile, types.InputFileBig)):
if is_photo:
return types.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl)
return types.InputMediaUploadedPhoto(file=media)
else:
attrs, mime = get_attributes(
media,
@ -483,8 +483,7 @@ def get_input_media(
supports_streaming=supports_streaming
)
return types.InputMediaUploadedDocument(
file=media, mime_type=mime, attributes=attrs, force_file=force_document,
ttl_seconds=ttl)
file=media, mime_type=mime, attributes=attrs, force_file=force_document)
if isinstance(media, types.MessageMediaGame):
return types.InputMediaGame(id=types.InputGameID(
@ -503,14 +502,6 @@ def get_input_media(
if isinstance(media, types.MessageMediaGeo):
return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo))
if isinstance(media, types.MessageMediaGeoLive):
return types.InputMediaGeoLive(
geo_point=get_input_geo(media.geo),
period=media.period,
heading=media.heading,
proximity_notification_radius=media.proximity_notification_radius,
)
if isinstance(media, types.MessageMediaVenue):
return types.InputMediaVenue(
geo_point=get_input_geo(media.geo),
@ -527,11 +518,12 @@ def get_input_media(
if isinstance(media, (
types.MessageMediaEmpty, types.MessageMediaUnsupported,
types.ChatPhotoEmpty, types.UserProfilePhotoEmpty,
types.ChatPhoto, types.UserProfilePhoto)):
types.ChatPhoto, types.UserProfilePhoto,
types.FileLocationToBeDeprecated)):
return types.InputMediaEmpty()
if isinstance(media, types.Message):
return get_input_media(media.media, is_photo=is_photo, ttl=ttl)
return get_input_media(media.media, is_photo=is_photo)
if isinstance(media, types.MessageMediaPoll):
if media.poll.quiz:
@ -572,30 +564,16 @@ def get_input_message(message):
_raise_cast_fail(message, 'InputMedia')
def get_input_group_call(call):
"""Similar to :meth:`get_input_peer`, but for input calls."""
try:
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)
except AttributeError:
_raise_cast_fail(call, 'InputGroupCall')
def _get_entity_pair(entity_id, entities, cache,
get_input_peer=get_input_peer):
"""
Returns ``(entity, input_entity)`` for the given entity ID.
"""
if not entity_id:
return None, None
entity = entities.get(entity_id)
try:
input_entity = cache.get(resolve_id(entity_id)[0])._as_input_peer()
except AttributeError:
# AttributeError is unlikely, so another TypeError won't hurt
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:
@ -612,9 +590,6 @@ def get_message_id(message):
if isinstance(message, int):
return message
if isinstance(message, types.InputMessageID):
return message.id
try:
if message.SUBCLASS_OF_ID == 0x790009e3:
# hex(crc32(b'Message')) = 0x790009e3
@ -678,7 +653,7 @@ def _get_metadata(file):
def get_attributes(file, *, attributes=None, mime_type=None,
force_document=False, voice_note=False, video_note=False,
supports_streaming=False, thumb=None):
supports_streaming=False):
"""
Get a list of attributes for the given file and
the mime type as a tuple ([attribute], mime_type).
@ -694,18 +669,11 @@ def get_attributes(file, *, attributes=None, mime_type=None,
if is_audio(file):
m = _get_metadata(file)
if m:
if m.has('author'):
performer = m.get('author')
elif m.has('artist'):
performer = m.get('artist')
else:
performer = None
attr_dict[types.DocumentAttributeAudio] = \
types.DocumentAttributeAudio(
voice=voice_note,
title=m.get('title') if m.has('title') else None,
performer=performer,
performer=m.get('author') if m.has('author') else None,
duration=int(m.get('duration').seconds
if m.has('duration') else 0)
)
@ -715,24 +683,12 @@ def get_attributes(file, *, attributes=None, mime_type=None,
if m:
doc = types.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,
w=m.get('width') if m.has('width') else 0,
h=m.get('height') if m.has('height') else 0,
duration=int(m.get('duration').seconds
if m.has('duration') else 1),
if m.has('duration') else 0),
supports_streaming=supports_streaming
)
elif thumb:
t_m = _get_metadata(thumb)
width = 1
height = 1
if t_m and t_m.has("width"):
width = t_m.get("width")
if t_m and t_m.has("height"):
height = t_m.get("height")
doc = types.DocumentAttributeVideo(
0, width, height, round_message=video_note,
supports_streaming=supports_streaming)
else:
doc = types.DocumentAttributeVideo(
0, 1, 1, round_message=video_note,
@ -771,10 +727,7 @@ def sanitize_parse_mode(mode):
if not mode:
return None
if (all(hasattr(mode, x) for x in ('parse', 'unparse'))
and all(callable(x) for x in (mode.parse, mode.unparse))):
return mode
elif callable(mode):
if callable(mode):
class CustomMode:
@staticmethod
def unparse(text, entities):
@ -782,6 +735,9 @@ def sanitize_parse_mode(mode):
CustomMode.parse = mode
return CustomMode
elif (all(hasattr(mode, x) for x in ('parse', 'unparse'))
and all(callable(x) for x in (mode.parse, mode.unparse))):
return mode
elif isinstance(mode, str):
try:
return {
@ -837,6 +793,9 @@ def _get_file_info(location):
thumb_size=location.sizes[-1].type
), _photo_size_byte_count(location.sizes[-1]))
if isinstance(location, types.FileLocationToBeDeprecated):
raise TypeError('Unavailable location cannot be used as input')
_raise_cast_fail(location, 'InputFileLocation')
@ -849,6 +808,12 @@ def _get_extension(file):
return os.path.splitext(file)[-1]
elif isinstance(file, pathlib.Path):
return file.suffix
elif isinstance(file, bytes):
kind = imghdr.what(io.BytesIO(file))
return ('.' + kind) if kind else ''
elif isinstance(file, io.IOBase) and not isinstance(file, io.TextIOBase) and file.seekable():
kind = imghdr.what(file)
return ('.' + kind) if kind is not None else ''
elif getattr(file, 'name', None):
# Note: ``file.name`` works for :tl:`InputFile` and some `IOBase`
return _get_extension(file.name)
@ -911,7 +876,7 @@ def is_list_like(obj):
enough. Things like ``open()`` are also iterable (and probably many
other things), so just support the commonly known list-like objects.
"""
return isinstance(obj, (list, tuple, set, dict, range, GeneratorType))
return isinstance(obj, (list, tuple, set, dict, GeneratorType))
def parse_phone(phone):
@ -1008,7 +973,7 @@ def get_peer_id(peer, add_mark=True):
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:
and channel ID is prefixed with -100:
* ``user_id``
* ``-chat_id``
@ -1034,13 +999,13 @@ def get_peer_id(peer, add_mark=True):
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):
if not (0 < peer.chat_id <= 0x7fffffff):
peer.chat_id = resolve_id(peer.chat_id)[0]
return -peer.chat_id if add_mark else peer.chat_id
else: # if isinstance(peer, types.PeerChannel):
# Check in case the user mixed things up to avoid blowing up
if not (0 < peer.channel_id <= 9999999999):
if not (0 < peer.channel_id <= 0x7fffffff):
peer.channel_id = resolve_id(peer.channel_id)[0]
if not add_mark:
@ -1055,12 +1020,15 @@ def resolve_id(marked_id):
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
# There have been report of chat IDs being 10000xyz, which means their
# marked version is -10000xyz, which in turn looks like a channel but
# it becomes 00xyz (= xyz). Hence, we must assert that there are only
# two zeroes.
m = re.match(r'-100([^0]\d*)', str(marked_id))
if m:
return int(m.group(1)), types.PeerChannel
return -marked_id, types.PeerChat
def _rle_decode(data):
@ -1095,8 +1063,6 @@ def _rle_encode(string):
count = 0
new += bytes([cur])
if count != 0:
new += b'\0' + bytes([count])
return new
@ -1224,6 +1190,10 @@ def resolve_bot_file_id(file_id):
date=None,
sizes=[types.PhotoSize(
type=photo_size,
location=types.FileLocationToBeDeprecated(
volume_id=volume_id,
local_id=local_id
),
w=0,
h=0,
size=0
@ -1286,13 +1256,14 @@ 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 for broadcast channels, 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.
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:
@ -1301,21 +1272,15 @@ def resolve_invite_link(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):
if re.match(r'[a-fA-F\d]{32}', link_hash):
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
return struct.unpack('>LLQ', payload)
except (struct.error, TypeError):
pass
return None, None, None
return None, None, None
def resolve_inline_message_id(inline_msg_id):
@ -1348,7 +1313,10 @@ def get_appropriated_part_size(file_size):
return 128
if file_size <= 786432000: # 750MB
return 256
return 512
if file_size <= 2097152000: # 2000MB
return 512
raise ValueError('File size too large')
def encode_waveform(waveform):
@ -1557,15 +1525,5 @@ def _photo_size_byte_count(size):
return len(size.bytes)
elif isinstance(size, types.PhotoSizeEmpty):
return 0
elif isinstance(size, types.PhotoSizeProgressive):
return max(size.sizes)
else:
return None
async def maybe_async(coro):
result = coro
if inspect.isawaitable(result):
warnings.warn('Using async sessions support is an experimental feature')
result = await result
return result

Some files were not shown because too many files have changed in this diff Show More