Complete migration guide from other bot libraries

This commit is contained in:
Lonami Exo 2023-10-01 13:37:28 +02:00
parent 18895748c4
commit 4df1f4537b
15 changed files with 521 additions and 49 deletions

View File

@ -3,7 +3,7 @@ Telethon
.. epigraph::
⭐️ Thanks **everyone** who has starred the project, it means a lot!
⭐️ Thanks **everyone** who has starred the project, it means a lot!
|logo| **Telethon** is an asyncio_ **Python 3**
MTProto_ library to interact with Telegram_'s API
@ -31,7 +31,7 @@ Installing
.. code-block:: sh
pip install telethon
pip install telethon
Creating a client
@ -47,7 +47,7 @@ Creating a client
api_hash = '0123456789abcdef0123456789abcdef'
async with TelegramClient('session_name', api_id, api_hash) as client:
...
await client.interactive_login()
Doing stuff
@ -58,7 +58,7 @@ Doing stuff
print(await client.get_me())
await client.send_message('username', 'Hello! Talking to you from Telethon')
await client.send_message('username', photo='/home/myself/Pictures/holidays.jpg')
await client.send_photo('username', '/home/myself/Pictures/holidays.jpg')
async for message in client.get_messages('username', 1):
path = await message.download_media()

View File

@ -97,6 +97,15 @@ Do as the prompts say on the terminal, and you will have successfully logged-in!
Once you're done, make sure to :meth:`~Client.disconnect` for a graceful shutdown.
To summarize:
.. code-block:: python
from telethon import Client
client = Client('name', 12345, '0123456789abcdef0123456789abcdef')
await client.connect()
await client.interactive_login()
Manual login
------------

View File

@ -17,13 +17,13 @@ Quoting their main page:
.. epigraph::
The Bot API is an HTTP-based interface created for developers keen on building bots for Telegram.
The :term:`Bot API` is an HTTP-based interface created for developers keen on building bots for Telegram.
To learn how to create and set up a bot, please consult our
`Introduction to Bots <https://core.telegram.org/bots>`_
and `Bot FAQ <https://core.telegram.org/bots/faq>`_.
Bot API is simply an HTTP endpoint offering a custom HTTP API.
:term:`Bot API` is simply an HTTP endpoint offering a custom HTTP API.
Underneath, it uses `tdlib <https://core.telegram.org/tdlib>`_ to talk to Telegram's servers.
You can configure your bot details via `@BotFather <https://t.me/BotFather>`_.
@ -51,6 +51,19 @@ This name was chosen because it gives you "raw" access to the MTProto API.
Telethon's :class:`Client` and other custom types are implemented using the :term:`Raw API`.
Why is an API ID and hash needed for bots with MTProto?
-------------------------------------------------------
When talking to Telegram's API directly, you need an API ID and hash to sign in to their servers.
API access is forbidden without an API ID, and the sign in can only be done with the API hash.
When using the :term:`Bot API`, that layer talks to the MTProto API underneath.
To do so, it uses its own private API ID and hash.
When you cut on the intermediary, you need to provide your own.
In a similar manner, the authorization key which remembers that you logged-in must be kept locally.
Advantages of MTProto over Bot API
----------------------------------
@ -109,9 +122,306 @@ and you were making API requests manually, or if you used a wrapper library like
or `pyTelegramBotAPI <https://pytba.readthedocs.io/en/latest/index.html>`.
You will surely be pleased with Telethon!
If you were using an asynchronous library like `aiohttp <https://docs.aiohttp.org/en/stable>`_
or a wrapper like `aiogram <https://docs.aiohttp.org/en/stable>`_, the switch will be even easier.
If you were using an asynchronous library like `aiohttp <https://docs.aiohttp.org/en/stable/>`_
or a wrapper like `aiogram <https://docs.aiogram.dev/en/latest/>`_, the switch will be even easier.
Migrating from TODO
^^^^^^^^^^^^^^^^^^^
Migrating from PTB v13.x
^^^^^^^^^^^^^^^^^^^^^^^^
Using one of the examples from their v13 wiki with the ``.ext`` module:
.. code-block:: python
from telegram import Update
from telegram.ext import Updater, CallbackContext, CommandHandler
updater = Updater(token='TOKEN', use_context=True)
dispatcher = updater.dispatcher
def start(update: Update, context: CallbackContext):
context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!")
start_handler = CommandHandler('start', start)
dispatcher.add_handler(start_handler)
updater.start_polling()
The code creates an ``Updater`` instance.
This will take care of polling updates for the bot associated with the given token.
Then, a ``CommandHandler`` using our ``start`` function is added to the dispatcher.
At the end, we block, telling the updater to do its job.
In Telethon:
.. code-block:: python
import asyncio
from telethon import Client
from telethon.events import NewMessage, filters
updater = Client('bot', api_id, api_hash)
async def start(update: NewMessage):
await update.client.send_message(chat=update.chat.id, text="I'm a bot, please talk to me!")
start_filter = filters.Command('/start')
updater.add_event_handler(start, NewMessage, start_filter)
async def main():
async with updater:
await updater.interactive_login('TOKEN')
await updater.run_until_disconnected()
asyncio.run(main())
Key differences:
* Telethon only has a :class:`~telethon.Client`, not separate ``Bot`` or ``Updater`` classes.
* There is no separate dispatcher. The :class:`~telethon.Client` is capable of dispatching updates.
* Telethon handlers only have one parameter, the event.
* There is no context, but the :attr:`~telethon.events.Event.client` property exists in all events.
* Handler types are :mod:`~telethon.events.filters` and don't have a ``Handler`` suffix.
* Telethon must define the update type (:class:`~telethon.events.NewMessage`) and filter.
* The setup to run the client (and dispatch updates) is a bit more involved with :mod:`asyncio`.
Here's the above code in idiomatic Telethon:
.. code-block:: python
import asyncio
from telethon import Client, events
from telethon.events import filters
client = Client('bot', api_id, api_hash)
@client.on(events.NewMessage, filters.Command('/start'))
async def start(event):
await event.respond("I'm a bot, please talk to me!")
async def main():
async with client:
await client.interactive_login('TOKEN')
await client.run_until_disconnected()
asyncio.run(main())
Events can be added using decorators and methods such as :meth:`types.Message.respond` help reduce the verbosity.
Migrating from PTB v20.x
^^^^^^^^^^^^^^^^^^^^^^^^
Using one of the examples from their v13 wiki with the ``.ext`` module:
.. code-block:: python
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!")
if __name__ == '__main__':
application = ApplicationBuilder().token('TOKEN').build()
start_handler = CommandHandler('start', start)
application.add_handler(start_handler)
application.run_polling()
No need to import the :mod:`asyncio` module directly!
Now instead there are builders to help set stuff up.
In Telethon:
.. code-block:: python
import asyncio
from telethon import Client
from telethon.events import NewMessage, filters
async def start(update: NewMessage):
await update.client.send_message(chat=update.chat.id, text="I'm a bot, please talk to me!")
async def main():
application = Client('bot', api_id, api_hash)
start_filter = filters.Command('/start')
application.add_event_handler(start, NewMessage, start_filter)
async with application:
await application.interactive_login('TOKEN')
await application.run_until_disconnected()
asyncio.run(main())
Key differences:
* No builders. Telethon tries to get out of your way on how you structure your code.
* The client must be connected before it can run, hence the ``async with``.
Here's the above code in idiomatic Telethon:
.. code-block:: python
import asyncio
from telethon import Client, events
from telethon.events import filters
@client.on(events.NewMessage, filters.Command('/start'))
async def start(event):
await event.respond("I'm a bot, please talk to me!")
async def main():
async with Client('bot', api_id, api_hash) as client:
await client.interactive_login('TOKEN')
client.add_event_handler(start, NewMessage, filters.Command('/start'))
await client.run_until_disconnected()
asyncio.run(main())
Note how the client can be created and started in the same line.
This makes it easy to have clean disconnections once the script exits.
Migrating from asynchronous TeleBot
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Using one of the examples from their v4 pyTelegramBotAPI documentation:
.. code-block:: python
from telebot.async_telebot import AsyncTeleBot
bot = AsyncTeleBot('TOKEN')
# Handle '/start' and '/help'
@bot.message_handler(commands=['help', 'start'])
async def send_welcome(message):
await bot.reply_to(message, """\
Hi there, I am EchoBot.
I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\
""")
# Handle all other messages with content_type 'text' (content_types defaults to ['text'])
@bot.message_handler(func=lambda message: True)
async def echo_message(message):
await bot.reply_to(message, message.text)
import asyncio
asyncio.run(bot.polling())
This showcases a command handler and a catch-all echo handler, both added with decorators.
In Telethon:
.. code-block:: python
from telethon import Client, events
from telethon.events.filters import Any, Command, TextOnly
bot = Client('bot', api_id, api_hash)
# Handle '/start' and '/help'
@bot.on(events.NewMessage, Any(Command('/help'), Command('/start')))
async def send_welcome(message: NewMessage):
await message.reply("""\
Hi there, I am EchoBot.
I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\
""")
# Handle all other messages with only 'text'
@bot.on(events.NewMessage, TextOnly())
async def echo_message(message: NewMessage):
await message.reply(message.text)
import asyncio
async def main():
async with bot:
await bot.interactive_login('TOKEN')
await bot.run_until_disconnected()
asyncio.run(main())
Key differences:
* The handler type is defined using the event type instead of being a specific method in the client.
* Filters are also separate instances instead of being tied to specific event types.
* The ``reply_to`` helper is in the message, not the client instance.
* Setup is a bit more involved because the connection is not implicit.
For the most part, it's a 1-to-1 translation and the result is idiomatic Telethon.
Migrating from aiogram
``````````````````````
Using one of the examples from their v3 documentation with logging and comments removed:
.. code-block:: python
import asyncio
from aiogram import Bot, Dispatcher, types
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart
from aiogram.types import Message
from aiogram.utils.markdown import hbold
dp = Dispatcher()
@dp.message(CommandStart())
async def command_start_handler(message: Message) -> None:
await message.answer(f"Hello, {hbold(message.from_user.full_name)}!")
@dp.message()
async def echo_handler(message: types.Message) -> None:
try:
await message.send_copy(chat_id=message.chat.id)
except TypeError:
await message.answer("Nice try!")
async def main() -> None:
bot = Bot(TOKEN, parse_mode=ParseMode.HTML)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
We can see a specific handler for the ``/start`` command and a catch-all echo handler:
In Telethon:
.. code-block:: python
import asyncio, html
from telethon import Client, RpcError, types, events
from telethon.events.filters import Command
from telethon.types import Message
client = Client("bot", api_id, api_hash)
@client.on(events.NewMessage, Command("/start"))
async def command_start_handler(message: Message) -> None:
await message.respond(html=f"Hello, <b>{html.escape(message.sender.full_name)}</b>!")
@dp.message()
async def echo_handler(message: types.Message) -> None:
try:
await message.respond(message)
except RpcError:
await message.respond("Nice try!")
async def main() -> None:
async with bot:
await bot.interactive_login(TOKEN)
await bot.run_until_disconnected()
if __name__ == "__main__":
asyncio.run(main())
Key differences:
* There is no separate dispatcher. Handlers are added to the client.
* There is no specific handler for the ``/start`` command.
* The ``answer`` method is for callback queries. Messages have :meth:`~types.Message.respond`.
* Telethon doesn't have functions to format messages. Instead, markdown or HTML are used.
* Telethon cannot have a default parse mode. Instead, it should be specified when responding.
* Telethon doesn't have ``send_copy``. Instead, :meth:`Client.send_message` accepts :class:`~types.Message`.
* If sending a message fails, the error will be :class:`RpcError`, because it comes from Telegram.

View File

@ -43,10 +43,10 @@ In the schema definitions, there are two boxed types, :tl:`User` and :tl:`Chat`.
A boxed :tl:`User` can only be the bare :tl:`user`, but the boxed :tl:`Chat` can be either a bare :tl:`chat` or a bare :tl:`channel`.
A bare :tl:`chat` always refers to small groups.
A bare :tl:`channel` can have either the ``broadcast`` or the ``megagroup`` flag set to ``True``.
A bare :tl:`channel` can have either the ``broadcast`` or the ``megagroup`` flag set to :data:`True`.
A bare :tl:`channel` with the ``broadcast`` flag set to ``True`` is known as a broadcast channel.
A bare :tl:`channel` with the ``megagroup`` flag set to ``True`` is known as a supergroup.
A bare :tl:`channel` with the ``broadcast`` flag set to :data:`True` is known as a broadcast channel.
A bare :tl:`channel` with the ``megagroup`` flag set to :data:`True` is known as a supergroup.
A bare :tl:`chat` with has less features than a bare :tl:`channel` ``megagroup``.
Official clients are very good at hiding this difference.

View File

@ -44,6 +44,7 @@ Glossary
.. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept.
Bot API
HTTP Bot API
Telegram's simplified HTTP API to control bot accounts only.

View File

@ -45,7 +45,7 @@ For this reason, filters cannot be asynchronous.
This reduces the chance a filter will do slow IO and potentially fail.
A filter is simply a callable function that takes an event as input and returns a boolean.
If the filter returns ``True``, the handler will be called.
If the filter returns :data:`True`, the handler will be called.
Using this knowledge, you can create custom filters too.
If you need state, you can use a class with a ``__call__`` method defined:
@ -74,3 +74,39 @@ You can use :func:`isinstance` if your filter can only deal with certain types o
If you need to perform asynchronous operations, you can't use a filter.
Instead, manually check for those conditions inside your handler.
Setting priority on handlers
----------------------------
There is no explicit way to define a different priority for different handlers.
Instead, the library will call all your handlers in the order you added them.
This means that, if you want a "catch-all" handler, it should be registered last.
By default, the library will stop calling the rest of handlers after one is called:
.. code-block:: python
@client.on(events.NewMessage)
async def first(event):
print('This is always called on new messages!')
@client.on(events.NewMessage)
async def second(event):
print('This will never be called, because "first" already ran.')
This is often the desired behaviour if you're using filters.
If you have more complicated filters executed *inside* the handler,
Telethon believes your handler completed and will stop calling the rest.
If that's the case, you can instruct Telethon to check all your handlers:
.. code-block:: python
client = Client(..., check_all_handlers=True)
# ^^^^^^^^^^^^^^^^^^^^^^^
# Now the code above will call both handlers
If you need a more complicated setup, consider sorting all your handlers beforehand.
Then, use :meth:`Client.add_event_handler` on all of them to ensure the correct order.

View File

@ -306,6 +306,20 @@ This means getting those identifiers is up to you, and you can handle it in a wa
Behaviour changes in events
---------------------------
Events produced by the client itself will now also be received as updates.
This means, for example, that your :class:`events.NewMessage` handlers will run when you use :meth:`Client.send_message`.
This is needed to properly handle updates.
In v1, there was a backwards-compatibility hack that flagged results from the client as their "own".
But in some rare cases, it was possible to still receive messages sent by the client itself in v1.
The hack has been removed so now the library will consistently deliver all updates.
``events.StopPropagation`` no longer exists.
In v1, all handlers were always called.
Now handlers are called in order until the filter for one returns :data:`True`.
The default behaviour is that handlers after that one are not called.
This behaviour can be changed with the ``check_all_handlers`` flag in :class:`Client` constructor.
:class:`events.CallbackQuery` no longer also handles "inline bot callback queries".
This was a hacky workaround.

View File

@ -125,14 +125,20 @@ async def sign_in(
return await complete_login(self, result)
async def interactive_login(self: Client) -> User:
async def interactive_login(
self: Client,
phone_or_token: Optional[str] = None,
*,
password: Optional[str] = None,
) -> User:
if me := await self.get_me():
return me
phone_or_token = ""
while not re.match(r"\+?[\s()]*\d", phone_or_token):
print("Please enter your phone (+1 23...) or bot token (12:abcd...)")
phone_or_token = input(": ").strip()
if not phone_or_token:
phone_or_token = ""
while not re.match(r"\+?[\s()]*\d", phone_or_token):
print("Please enter your phone (+1 23...) or bot token (12:abcd...)")
phone_or_token = input(": ").strip()
# Bot flow
if re.match(r"\d+:", phone_or_token):
@ -160,16 +166,21 @@ async def interactive_login(self: Client) -> User:
break
if isinstance(user_or_token, PasswordToken):
while True:
print("Please enter your password (prompt is hidden; type and press enter)")
password = getpass.getpass(": ")
try:
user = await self.check_password(user_or_token, password)
except RpcError as e:
if e.name.startswith("PASSWORD"):
print("Invalid password:", e)
else:
raise
if password:
user = await self.check_password(user_or_token, password)
else:
while True:
print(
"Please enter your password (prompt is hidden; type and press enter)"
)
password = getpass.getpass(": ")
try:
user = await self.check_password(user_or_token, password)
except RpcError as e:
if e.name.startswith("PASSWORD"):
print("Invalid password:", e)
else:
raise
else:
user = user_or_token

View File

@ -168,6 +168,14 @@ class Client:
:param update_queue_limit:
Maximum amount of updates to keep in memory before dropping them.
:param check_all_handlers:
Whether to always check all event handlers or stop early.
The library will call event handlers in the order they were added.
By default, the library stops checking handlers as soon as a filter returns :data:`True`.
By setting ``check_all_handlers=True``, the library will keep calling handlers after the first match.
"""
def __init__(
@ -175,6 +183,7 @@ class Client:
session: Optional[Union[str, Path, Storage]],
api_id: int,
api_hash: Optional[str] = None,
check_all_handlers: bool = False,
) -> None:
self._sender: Optional[Sender] = None
self._sender_lock = asyncio.Lock()
@ -190,6 +199,7 @@ class Client:
api_id=api_id,
api_hash=api_hash or "",
)
self._message_box = MessageBox()
self._chat_hashes = ChatHashCache(None)
self._last_update_limit_warn: Optional[float] = None
@ -197,10 +207,10 @@ class Client:
Tuple[abcs.Update, Dict[int, Chat]]
] = asyncio.Queue(maxsize=self._config.update_queue_limit or 0)
self._dispatcher: Optional[asyncio.Task[None]] = None
self._downloader_map = object()
self._handlers: Dict[
Type[Event], List[Tuple[Callable[[Any], Awaitable[Any]], Optional[Filter]]]
] = {}
self._shortcircuit_handlers = not check_all_handlers
if self_user := self._config.session.user:
self._dc_id = self_user.dc
@ -795,11 +805,19 @@ class Client:
"""
return await inline_query(self, bot, query, chat=chat)
async def interactive_login(self) -> User:
async def interactive_login(
self, phone_or_token: Optional[str] = None, *, password: Optional[str] = None
) -> User:
"""
Begin an interactive login if needed.
If the account was already logged-in, this method simply returns :term:`yourself`.
:param phone_or_token:
Bypass the phone number or bot token prompt, and use this value instead.
:param password:
Bypass the 2FA password prompt, and use this value instead.
:return: The user corresponding to :term:`yourself`.
.. rubric:: Example
@ -809,11 +827,14 @@ class Client:
me = await client.interactive_login()
print('Logged in as:', me.full_name)
# or, to make sure you're logged-in as a bot
await client.interactive_login('1234:ab56cd78ef90)
.. seealso::
In-depth explanation for :doc:`/basic/signing-in`.
"""
return await interactive_login(self)
return await interactive_login(self, phone_or_token, password=password)
async def is_authorized(self) -> bool:
"""
@ -1210,7 +1231,7 @@ class Client:
async def send_message(
self,
chat: ChatLike,
text: Optional[str] = None,
text: Optional[Union[str, Message]] = None,
*,
markdown: Optional[str] = None,
html: Optional[str] = None,
@ -1225,6 +1246,8 @@ class Client:
:param text:
Message text, with no formatting.
When given a :class:`Message` instance, a copy of the message will be sent.
:param text_markdown:
Message text, parsed as CommonMark.

View File

@ -124,16 +124,21 @@ def extend_update_queue(
async def dispatcher(client: Client) -> None:
while client.connected:
update, chat_map = await client._updates.get()
for event_cls, handlers in client._handlers.items():
if event := event_cls._try_from_update(client, update, chat_map):
for handler, filter in handlers:
if not filter or filter(event):
try:
await handler(event)
except asyncio.CancelledError:
raise
except Exception:
# TODO proper logger
name = getattr(handler, "__name__", repr(handler))
logging.exception("Unhandled exception on %s", name)
try:
await dispatch_next(client)
except asyncio.CancelledError:
raise
except Exception:
# TODO proper logger
logging.exception("Unhandled exception in event handler")
async def dispatch_next(client: Client) -> None:
update, chat_map = await client._updates.get()
for event_cls, handlers in client._handlers.items():
if event := event_cls._try_from_update(client, update, chat_map):
for handler, filter in handlers:
if not filter or filter(event):
await handler(event)
if client._shortcircuit_handlers:
return

View File

@ -15,6 +15,13 @@ class Event(metaclass=NoPublicConstructor):
The base type of all events.
"""
@property
def client(self) -> Client:
"""
The :class:`~telethon.Client` that received this update.
"""
return self._client
@classmethod
@abc.abstractmethod
def _try_from_update(

View File

@ -1,6 +1,6 @@
from .combinators import All, Any, Not
from .common import Chats, Filter, Senders
from .messages import Command, Forward, Incoming, Outgoing, Reply, Text
from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text, TextOnly
__all__ = [
"All",
@ -12,7 +12,9 @@ __all__ = [
"Command",
"Forward",
"Incoming",
"Media",
"Outgoing",
"Reply",
"Text",
"TextOnly",
]

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Optional, Union
from typing import TYPE_CHECKING, Literal, Optional, Union
from ..event import Event
@ -18,6 +18,9 @@ class Text:
The match, if any, is discarded. If you need to access captured groups,
you need to manually perform the check inside the handler instead.
Note that the caption text in messages with media is also searched.
If you want to filter based on media, use :class:`TextOnly` or :class:`Media`.
"""
__slots__ = ("_pattern",)
@ -131,3 +134,50 @@ class Reply:
def __call__(self, event: Event) -> bool:
return getattr(event, "reply", None) is not None
class TextOnly:
"""
Filter by messages with some text and no media.
Note that link previews are only considered media if they have a photo or document.
"""
MediaTypes = Union[Literal["photo"], Literal["audio"], Literal["video"]]
class Media:
"""
Filter by the media type in the message.
By default, this filter will pass if the message has any media.
Note that link previews are only considered media if they have a photo or document.
When you specify one or more media types, *only* those types will be considered.
You can use literal strings or the constants defined by the filter.
"""
PHOTO = "photo"
AUDIO = "audio"
VIDEO = "video"
__slots__ = "_types"
def __init__(self, types: Optional[MediaTypes] = None) -> None:
self._types = types
@property
def types(self) -> MediaTypes:
"""
The media types being checked.
"""
return self._types
def __call__(self, event: Event) -> bool:
if self._types is None:
return getattr(event, "file", None) is not None
else:
return any(getattr(event, ty, None) is not None for ty in self._types)

View File

@ -21,7 +21,7 @@ def del_surrogate(text: str) -> str:
def within_surrogate(text: str, index: int, *, length: Optional[int] = None) -> bool:
"""
`True` if ``index`` is within a surrogate (before and after it, not at!).
:data:`True` if ``index`` is within a surrogate (before and after it, not at!).
"""
if length is None:
length = len(text)

View File

@ -15,11 +15,13 @@ from .._impl.client.events.filters import (
Filter,
Forward,
Incoming,
Media,
Not,
Outgoing,
Reply,
Senders,
Text,
TextOnly,
)
__all__ = [
@ -30,9 +32,11 @@ __all__ = [
"Filter",
"Forward",
"Incoming",
"Media",
"Not",
"Outgoing",
"Reply",
"Senders",
"Text",
"TextOnly",
]