README.md edited

This commit is contained in:
2024-12-06 10:45:08 +09:00
parent 09e4edee6b
commit 1aa387aa59
13921 changed files with 2057290 additions and 10 deletions

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""Extensions over the Telegram Bot API to facilitate bot making"""
__all__ = (
"AIORateLimiter",
"Application",
"ApplicationBuilder",
"ApplicationHandlerStop",
"BaseHandler",
"BasePersistence",
"BaseRateLimiter",
"BaseUpdateProcessor",
"BusinessConnectionHandler",
"BusinessMessagesDeletedHandler",
"CallbackContext",
"CallbackDataCache",
"CallbackQueryHandler",
"ChatBoostHandler",
"ChatJoinRequestHandler",
"ChatMemberHandler",
"ChosenInlineResultHandler",
"CommandHandler",
"ContextTypes",
"ConversationHandler",
"Defaults",
"DictPersistence",
"ExtBot",
"InlineQueryHandler",
"InvalidCallbackData",
"Job",
"JobQueue",
"MessageHandler",
"MessageReactionHandler",
"PaidMediaPurchasedHandler",
"PersistenceInput",
"PicklePersistence",
"PollAnswerHandler",
"PollHandler",
"PreCheckoutQueryHandler",
"PrefixHandler",
"ShippingQueryHandler",
"SimpleUpdateProcessor",
"StringCommandHandler",
"StringRegexHandler",
"TypeHandler",
"Updater",
"filters",
)
from . import filters
from ._aioratelimiter import AIORateLimiter
from ._application import Application, ApplicationHandlerStop
from ._applicationbuilder import ApplicationBuilder
from ._basepersistence import BasePersistence, PersistenceInput
from ._baseratelimiter import BaseRateLimiter
from ._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor
from ._callbackcontext import CallbackContext
from ._callbackdatacache import CallbackDataCache, InvalidCallbackData
from ._contexttypes import ContextTypes
from ._defaults import Defaults
from ._dictpersistence import DictPersistence
from ._extbot import ExtBot
from ._handlers.basehandler import BaseHandler
from ._handlers.businessconnectionhandler import BusinessConnectionHandler
from ._handlers.businessmessagesdeletedhandler import BusinessMessagesDeletedHandler
from ._handlers.callbackqueryhandler import CallbackQueryHandler
from ._handlers.chatboosthandler import ChatBoostHandler
from ._handlers.chatjoinrequesthandler import ChatJoinRequestHandler
from ._handlers.chatmemberhandler import ChatMemberHandler
from ._handlers.choseninlineresulthandler import ChosenInlineResultHandler
from ._handlers.commandhandler import CommandHandler
from ._handlers.conversationhandler import ConversationHandler
from ._handlers.inlinequeryhandler import InlineQueryHandler
from ._handlers.messagehandler import MessageHandler
from ._handlers.messagereactionhandler import MessageReactionHandler
from ._handlers.paidmediapurchasedhandler import PaidMediaPurchasedHandler
from ._handlers.pollanswerhandler import PollAnswerHandler
from ._handlers.pollhandler import PollHandler
from ._handlers.precheckoutqueryhandler import PreCheckoutQueryHandler
from ._handlers.prefixhandler import PrefixHandler
from ._handlers.shippingqueryhandler import ShippingQueryHandler
from ._handlers.stringcommandhandler import StringCommandHandler
from ._handlers.stringregexhandler import StringRegexHandler
from ._handlers.typehandler import TypeHandler
from ._jobqueue import Job, JobQueue
from ._picklepersistence import PicklePersistence
from ._updater import Updater

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains an implementation of the BaseRateLimiter class based on the aiolimiter
library.
"""
import asyncio
import contextlib
import sys
from collections.abc import AsyncIterator, Coroutine
from typing import Any, Callable, Optional, Union
try:
from aiolimiter import AsyncLimiter
AIO_LIMITER_AVAILABLE = True
except ImportError:
AIO_LIMITER_AVAILABLE = False
from telegram._utils.logging import get_logger
from telegram._utils.types import JSONDict
from telegram.error import RetryAfter
from telegram.ext._baseratelimiter import BaseRateLimiter
# Useful for something like:
# async with group_limiter if group else null_context():
# so we don't have to differentiate between "I'm using a context manager" and "I'm not"
if sys.version_info >= (3, 10):
null_context = contextlib.nullcontext # pylint: disable=invalid-name
else:
@contextlib.asynccontextmanager
async def null_context() -> AsyncIterator[None]:
yield None
_LOGGER = get_logger(__name__, class_name="AIORateLimiter")
class AIORateLimiter(BaseRateLimiter[int]):
"""
Implementation of :class:`~telegram.ext.BaseRateLimiter` using the library
`aiolimiter <https://aiolimiter.readthedocs.io/en/stable>`_.
Important:
If you want to use this class, you must install PTB with the optional requirement
``rate-limiter``, i.e.
.. code-block:: bash
pip install "python-telegram-bot[rate-limiter]"
The rate limiting is applied by combining two levels of throttling and :meth:`process_request`
roughly boils down to::
async with group_limiter(group_id):
async with overall_limiter:
await callback(*args, **kwargs)
Here, ``group_id`` is determined by checking if there is a ``chat_id`` parameter in the
:paramref:`~telegram.ext.BaseRateLimiter.process_request.data`.
The ``overall_limiter`` is applied only if a ``chat_id`` argument is present at all.
Attention:
* Some bot methods accept a ``chat_id`` parameter in form of a ``@username`` for
supergroups and channels. As we can't know which ``@username`` corresponds to which
integer ``chat_id``, these will be treated as different groups, which may lead to
exceeding the rate limit.
* As channels can't be differentiated from supergroups by the ``@username`` or integer
``chat_id``, this also applies the group related rate limits to channels.
* A :exc:`~telegram.error.RetryAfter` exception will halt *all* requests for
:attr:`~telegram.error.RetryAfter.retry_after` + 0.1 seconds. This may be stricter than
necessary in some cases, e.g. the bot may hit a rate limit in one group but might still
be allowed to send messages in another group.
Tip:
With `Bot API 7.1 <https://core.telegram.org/bots/api-changelog#october-31-2024>`_
(PTB v27.1), Telegram introduced the parameter
:paramref:`~telegram.Bot.send_message.allow_paid_broadcast`.
This allows bots to send up to
:tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second by
paying a fee in Telegram Stars.
.. caution::
This class currently doesn't take the
:paramref:`~telegram.Bot.send_message.allow_paid_broadcast` parameter into account.
This means that the rate limiting is applied just like for any other message.
Note:
This class is to be understood as minimal effort reference implementation.
If you would like to handle rate limiting in a more sophisticated, fine-tuned way, we
welcome you to implement your own subclass of :class:`~telegram.ext.BaseRateLimiter`.
Feel free to check out the source code of this class for inspiration.
.. seealso:: :wiki:`Avoiding Flood Limits <Avoiding-flood-limits>`
.. versionadded:: 20.0
Args:
overall_max_rate (:obj:`float`): The maximum number of requests allowed for the entire bot
per :paramref:`overall_time_period`. When set to 0, no rate limiting will be applied.
Defaults to ``30``.
overall_time_period (:obj:`float`): The time period (in seconds) during which the
:paramref:`overall_max_rate` is enforced. When set to 0, no rate limiting will be
applied. Defaults to 1.
group_max_rate (:obj:`float`): The maximum number of requests allowed for requests related
to groups and channels per :paramref:`group_time_period`. When set to 0, no rate
limiting will be applied. Defaults to 20.
group_time_period (:obj:`float`): The time period (in seconds) during which the
:paramref:`group_max_rate` is enforced. When set to 0, no rate limiting will be
applied. Defaults to 60.
max_retries (:obj:`int`): The maximum number of retries to be made in case of a
:exc:`~telegram.error.RetryAfter` exception.
If set to 0, no retries will be made. Defaults to ``0``.
"""
__slots__ = (
"_base_limiter",
"_group_limiters",
"_group_max_rate",
"_group_time_period",
"_max_retries",
"_retry_after_event",
)
def __init__(
self,
overall_max_rate: float = 30,
overall_time_period: float = 1,
group_max_rate: float = 20,
group_time_period: float = 60,
max_retries: int = 0,
) -> None:
if not AIO_LIMITER_AVAILABLE:
raise RuntimeError(
"To use `AIORateLimiter`, PTB must be installed via `pip install "
'"python-telegram-bot[rate-limiter]"`.'
)
if overall_max_rate and overall_time_period:
self._base_limiter: Optional[AsyncLimiter] = AsyncLimiter(
max_rate=overall_max_rate, time_period=overall_time_period
)
else:
self._base_limiter = None
if group_max_rate and group_time_period:
self._group_max_rate: float = group_max_rate
self._group_time_period: float = group_time_period
else:
self._group_max_rate = 0
self._group_time_period = 0
self._group_limiters: dict[Union[str, int], AsyncLimiter] = {}
self._max_retries: int = max_retries
self._retry_after_event = asyncio.Event()
self._retry_after_event.set()
async def initialize(self) -> None:
"""Does nothing."""
async def shutdown(self) -> None:
"""Does nothing."""
def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter":
# Remove limiters that haven't been used for so long that all their capacity is unused
# We only do that if we have a lot of limiters lying around to avoid looping on every call
# This is a minimal effort approach - a full-fledged cache could use a TTL approach
# or at least adapt the threshold dynamically depending on the number of active limiters
if len(self._group_limiters) > 512:
# We copy to avoid modifying the dict while we iterate over it
for key, limiter in self._group_limiters.copy().items():
if key == group_id:
continue
if limiter.has_capacity(limiter.max_rate):
del self._group_limiters[key]
if group_id not in self._group_limiters:
self._group_limiters[group_id] = AsyncLimiter(
max_rate=self._group_max_rate,
time_period=self._group_time_period,
)
return self._group_limiters[group_id]
async def _run_request(
self,
chat: bool,
group: Union[str, int, bool],
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]],
args: Any,
kwargs: dict[str, Any],
) -> Union[bool, JSONDict, list[JSONDict]]:
base_context = self._base_limiter if (chat and self._base_limiter) else null_context()
group_context = (
self._get_group_limiter(group) if group and self._group_max_rate else null_context()
)
async with group_context, base_context:
# In case a retry_after was hit, we wait with processing the request
await self._retry_after_event.wait()
return await callback(*args, **kwargs)
# mypy doesn't understand that the last run of the for loop raises an exception
async def process_request(
self,
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]],
args: Any,
kwargs: dict[str, Any],
endpoint: str, # noqa: ARG002
data: dict[str, Any],
rate_limit_args: Optional[int],
) -> Union[bool, JSONDict, list[JSONDict]]:
"""
Processes a request by applying rate limiting.
See :meth:`telegram.ext.BaseRateLimiter.process_request` for detailed information on the
arguments.
Args:
rate_limit_args (:obj:`None` | :obj:`int`): If set, specifies the maximum number of
retries to be made in case of a :exc:`~telegram.error.RetryAfter` exception.
Defaults to :paramref:`AIORateLimiter.max_retries`.
"""
max_retries = rate_limit_args or self._max_retries
group: Union[int, str, bool] = False
chat: bool = False
chat_id = data.get("chat_id")
if chat_id is not None:
chat = True
# In case user passes integer chat id as string
with contextlib.suppress(ValueError, TypeError):
chat_id = int(chat_id)
if (isinstance(chat_id, int) and chat_id < 0) or isinstance(chat_id, str):
# string chat_id only works for channels and supergroups
# We can't really tell channels from groups though ...
group = chat_id
for i in range(max_retries + 1):
try:
return await self._run_request(
chat=chat, group=group, callback=callback, args=args, kwargs=kwargs
)
except RetryAfter as exc:
if i == max_retries:
_LOGGER.exception(
"Rate limit hit after maximum of %d retries", max_retries, exc_info=exc
)
raise
sleep = exc.retry_after + 0.1
_LOGGER.info("Rate limit hit. Retrying after %f seconds", sleep)
# Make sure we don't allow other requests to be processed
self._retry_after_event.clear()
await asyncio.sleep(sleep)
finally:
# Allow other requests to be processed
self._retry_after_event.set()
return None # type: ignore[return-value]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the BasePersistence class."""
from abc import ABC, abstractmethod
from typing import Generic, NamedTuple, NoReturn, Optional
from telegram._bot import Bot
from telegram.ext._extbot import ExtBot
from telegram.ext._utils.types import BD, CD, UD, CDCData, ConversationDict, ConversationKey
class PersistenceInput(NamedTuple):
"""Convenience wrapper to group boolean input for the :paramref:`~BasePersistence.store_data`
parameter for :class:`BasePersistence`.
Args:
bot_data (:obj:`bool`, optional): Whether the setting should be applied for ``bot_data``.
Defaults to :obj:`True`.
chat_data (:obj:`bool`, optional): Whether the setting should be applied for ``chat_data``.
Defaults to :obj:`True`.
user_data (:obj:`bool`, optional): Whether the setting should be applied for ``user_data``.
Defaults to :obj:`True`.
callback_data (:obj:`bool`, optional): Whether the setting should be applied for
``callback_data``. Defaults to :obj:`True`.
Attributes:
bot_data (:obj:`bool`): Whether the setting should be applied for ``bot_data``.
chat_data (:obj:`bool`): Whether the setting should be applied for ``chat_data``.
user_data (:obj:`bool`): Whether the setting should be applied for ``user_data``.
callback_data (:obj:`bool`): Whether the setting should be applied for ``callback_data``.
"""
bot_data: bool = True
chat_data: bool = True
user_data: bool = True
callback_data: bool = True
class BasePersistence(Generic[UD, CD, BD], ABC):
"""Interface class for adding persistence to your bot.
Subclass this object for different implementations of a persistent bot.
Attention:
The interface provided by this class is intended to be accessed exclusively by
:class:`~telegram.ext.Application`. Calling any of the methods below manually might
interfere with the integration of persistence into :class:`~telegram.ext.Application`.
All relevant methods must be overwritten. This includes:
* :meth:`get_bot_data`
* :meth:`update_bot_data`
* :meth:`refresh_bot_data`
* :meth:`get_chat_data`
* :meth:`update_chat_data`
* :meth:`refresh_chat_data`
* :meth:`drop_chat_data`
* :meth:`get_user_data`
* :meth:`update_user_data`
* :meth:`refresh_user_data`
* :meth:`drop_user_data`
* :meth:`get_callback_data`
* :meth:`update_callback_data`
* :meth:`get_conversations`
* :meth:`update_conversation`
* :meth:`flush`
If you don't actually need one of those methods, a simple :keyword:`pass` is enough.
For example, if you don't store ``bot_data``, you don't need :meth:`get_bot_data`,
:meth:`update_bot_data` or :meth:`refresh_bot_data`.
Note:
You should avoid saving :class:`telegram.Bot` instances. This is because if you change e.g.
the bots token, this won't propagate to the serialized instances and may lead to exceptions.
To prevent this, the implementation may use :attr:`bot` to replace bot instances with a
placeholder before serialization and insert :attr:`bot` back when loading the data.
Since :attr:`bot` will be set when the process starts, this will be the up-to-date bot
instance.
If the persistence implementation does not take care of this, you should make sure not to
store any bot instances in the data that will be persisted. E.g. in case of
:class:`telegram.TelegramObject`, one may call :meth:`set_bot` to ensure that shortcuts like
:meth:`telegram.Message.reply_text` are available.
This class is a :class:`~typing.Generic` class and accepts three type variables:
1. The type of the second argument of :meth:`update_user_data`, which must coincide with the
type of the second argument of :meth:`refresh_user_data` and the values in the dictionary
returned by :meth:`get_user_data`.
2. The type of the second argument of :meth:`update_chat_data`, which must coincide with the
type of the second argument of :meth:`refresh_chat_data` and the values in the dictionary
returned by :meth:`get_chat_data`.
3. The type of the argument of :meth:`update_bot_data`, which must coincide with the
type of the argument of :meth:`refresh_bot_data` and the return value of
:meth:`get_bot_data`.
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Making Your Bot Persistent <Making-your-bot-persistent>`
.. versionchanged:: 20.0
* The parameters and attributes ``store_*_data`` were replaced by :attr:`store_data`.
* ``insert/replace_bot`` was dropped. Serialization of bot instances now needs to be
handled by the specific implementation - see above note.
Args:
store_data (:class:`~telegram.ext.PersistenceInput`, optional): Specifies which kinds of
data will be saved by this persistence instance. By default, all available kinds of
data will be saved.
update_interval (:obj:`int` | :obj:`float`, optional): The
:class:`~telegram.ext.Application` will update
the persistence in regular intervals. This parameter specifies the time (in seconds) to
wait between two consecutive runs of updating the persistence. Defaults to ``60``
seconds.
.. versionadded:: 20.0
Attributes:
store_data (:class:`~telegram.ext.PersistenceInput`): Specifies which kinds of data will
be saved by this persistence instance.
bot (:class:`telegram.Bot`): The bot associated with the persistence.
"""
__slots__ = (
"_update_interval",
"bot",
"store_data",
)
def __init__(
self,
store_data: Optional[PersistenceInput] = None,
update_interval: float = 60,
):
self.store_data: PersistenceInput = store_data or PersistenceInput()
self._update_interval: float = update_interval
self.bot: Bot = None # type: ignore[assignment]
@property
def update_interval(self) -> float:
""":obj:`float`: Time (in seconds) that the :class:`~telegram.ext.Application`
will wait between two consecutive runs of updating the persistence.
.. versionadded:: 20.0
"""
return self._update_interval
@update_interval.setter
def update_interval(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to update_interval after initialization."
)
def set_bot(self, bot: Bot) -> None:
"""Set the Bot to be used by this persistence instance.
Args:
bot (:class:`telegram.Bot`): The bot.
Raises:
:exc:`TypeError`: If :attr:`PersistenceInput.callback_data` is :obj:`True` and the
:paramref:`bot` is not an instance of :class:`telegram.ext.ExtBot`.
"""
if self.store_data.callback_data and (not isinstance(bot, ExtBot)):
raise TypeError("callback_data can only be stored when using telegram.ext.ExtBot.")
self.bot = bot
@abstractmethod
async def get_user_data(self) -> dict[int, UD]:
"""Will be called by :class:`telegram.ext.Application` upon creation with a
persistence object. It should return the ``user_data`` if stored, or an empty
:obj:`dict`. In the latter case, the dictionary should produce values
corresponding to one of the following:
- :obj:`dict`
- The type from :attr:`telegram.ext.ContextTypes.user_data`
if :class:`telegram.ext.ContextTypes` is used.
.. versionchanged:: 20.0
This method may now return a :obj:`dict` instead of a :obj:`collections.defaultdict`
Returns:
dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.user_data`]:
The restored user data.
"""
@abstractmethod
async def get_chat_data(self) -> dict[int, CD]:
"""Will be called by :class:`telegram.ext.Application` upon creation with a
persistence object. It should return the ``chat_data`` if stored, or an empty
:obj:`dict`. In the latter case, the dictionary should produce values
corresponding to one of the following:
- :obj:`dict`
- The type from :attr:`telegram.ext.ContextTypes.chat_data`
if :class:`telegram.ext.ContextTypes` is used.
.. versionchanged:: 20.0
This method may now return a :obj:`dict` instead of a :obj:`collections.defaultdict`
Returns:
dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.chat_data`]:
The restored chat data.
"""
@abstractmethod
async def get_bot_data(self) -> BD:
"""Will be called by :class:`telegram.ext.Application` upon creation with a
persistence object. It should return the ``bot_data`` if stored, or an empty
:obj:`dict`. In the latter case, the :obj:`dict` should produce values
corresponding to one of the following:
- :obj:`dict`
- The type from :attr:`telegram.ext.ContextTypes.bot_data`
if :class:`telegram.ext.ContextTypes` are used.
Returns:
dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`]:
The restored bot data.
"""
@abstractmethod
async def get_callback_data(self) -> Optional[CDCData]:
"""Will be called by :class:`telegram.ext.Application` upon creation with a
persistence object. If callback data was stored, it should be returned.
.. versionadded:: 13.6
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
Returns:
tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]],
dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`,
if no data was stored.
"""
@abstractmethod
async def get_conversations(self, name: str) -> ConversationDict:
"""Will be called by :class:`telegram.ext.Application` when a
:class:`telegram.ext.ConversationHandler` is added if
:attr:`telegram.ext.ConversationHandler.persistent` is :obj:`True`.
It should return the conversations for the handler with :paramref:`name` or an empty
:obj:`dict`.
Args:
name (:obj:`str`): The handlers name.
Returns:
:obj:`dict`: The restored conversations for the handler.
"""
@abstractmethod
async def update_conversation(
self, name: str, key: ConversationKey, new_state: Optional[object]
) -> None:
"""Will be called when a :class:`telegram.ext.ConversationHandler` changes states.
This allows the storage of the new state in the persistence.
Args:
name (:obj:`str`): The handler's name.
key (:obj:`tuple`): The key the state is changed for.
new_state (:class:`object`): The new state for the given key.
"""
@abstractmethod
async def update_user_data(self, user_id: int, data: UD) -> None:
"""Will be called by the :class:`telegram.ext.Application` after a handler has
handled an update.
Args:
user_id (:obj:`int`): The user the data might have been changed for.
data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.user_data`):
The :attr:`telegram.ext.Application.user_data` ``[user_id]``.
"""
@abstractmethod
async def update_chat_data(self, chat_id: int, data: CD) -> None:
"""Will be called by the :class:`telegram.ext.Application` after a handler has
handled an update.
Args:
chat_id (:obj:`int`): The chat the data might have been changed for.
data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.chat_data`):
The :attr:`telegram.ext.Application.chat_data` ``[chat_id]``.
"""
@abstractmethod
async def update_bot_data(self, data: BD) -> None:
"""Will be called by the :class:`telegram.ext.Application` after a handler has
handled an update.
Args:
data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`):
The :attr:`telegram.ext.Application.bot_data`.
"""
@abstractmethod
async def update_callback_data(self, data: CDCData) -> None:
"""Will be called by the :class:`telegram.ext.Application` after a handler has
handled an update.
.. versionadded:: 13.6
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
Args:
data (tuple[list[tuple[:obj:`str`, :obj:`float`, \
dict[:obj:`str`, :obj:`Any`]]], dict[:obj:`str`, :obj:`str`]] | :obj:`None`):
The relevant data to restore :class:`telegram.ext.CallbackDataCache`.
"""
@abstractmethod
async def drop_chat_data(self, chat_id: int) -> None:
"""Will be called by the :class:`telegram.ext.Application`, when using
:meth:`~telegram.ext.Application.drop_chat_data`.
.. versionadded:: 20.0
Args:
chat_id (:obj:`int`): The chat id to delete from the persistence.
"""
@abstractmethod
async def drop_user_data(self, user_id: int) -> None:
"""Will be called by the :class:`telegram.ext.Application`, when using
:meth:`~telegram.ext.Application.drop_user_data`.
.. versionadded:: 20.0
Args:
user_id (:obj:`int`): The user id to delete from the persistence.
"""
@abstractmethod
async def refresh_user_data(self, user_id: int, user_data: UD) -> None:
"""Will be called by the :class:`telegram.ext.Application` before passing the
:attr:`~telegram.ext.Application.user_data` to a callback. Can be used to update data
stored in :attr:`~telegram.ext.Application.user_data` from an external source.
Tip:
This method is expected to edit the object :paramref:`user_data` in-place instead of
returning a new object.
Warning:
When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method
may be called while a handler callback is still running. This might lead to race
conditions.
.. versionadded:: 13.6
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
Args:
user_id (:obj:`int`): The user ID this :attr:`~telegram.ext.Application.user_data` is
associated with.
user_data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.user_data`):
The ``user_data`` of a single user.
"""
@abstractmethod
async def refresh_chat_data(self, chat_id: int, chat_data: CD) -> None:
"""Will be called by the :class:`telegram.ext.Application` before passing the
:attr:`~telegram.ext.Application.chat_data` to a callback. Can be used to update data
stored in :attr:`~telegram.ext.Application.chat_data` from an external source.
Tip:
This method is expected to edit the object :paramref:`chat_data` in-place instead of
returning a new object.
Warning:
When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method
may be called while a handler callback is still running. This might lead to race
conditions.
.. versionadded:: 13.6
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
Args:
chat_id (:obj:`int`): The chat ID this :attr:`~telegram.ext.Application.chat_data` is
associated with.
chat_data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.chat_data`):
The ``chat_data`` of a single chat.
"""
@abstractmethod
async def refresh_bot_data(self, bot_data: BD) -> None:
"""Will be called by the :class:`telegram.ext.Application` before passing the
:attr:`~telegram.ext.Application.bot_data` to a callback. Can be used to update data stored
in :attr:`~telegram.ext.Application.bot_data` from an external source.
Tip:
This method is expected to edit the object :paramref:`bot_data` in-place instead of
returning a new object.
Warning:
When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method
may be called while a handler callback is still running. This might lead to race
conditions.
.. versionadded:: 13.6
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
Args:
bot_data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`):
The ``bot_data``.
"""
@abstractmethod
async def flush(self) -> None:
"""Will be called by :meth:`telegram.ext.Application.stop`. Gives the
persistence a chance to finish up saving or close a database connection gracefully.
.. versionchanged:: 20.0
Changed this method into an :external:func:`~abc.abstractmethod`.
"""

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains a class that allows to rate limit requests to the Bot API."""
from abc import ABC, abstractmethod
from collections.abc import Coroutine
from typing import Any, Callable, Generic, Optional, Union
from telegram._utils.types import JSONDict
from telegram.ext._utils.types import RLARGS
class BaseRateLimiter(ABC, Generic[RLARGS]):
"""
Abstract interface class that allows to rate limit the requests that python-telegram-bot
sends to the Telegram Bot API. An implementation of this class
must implement all abstract methods and properties.
This class is a :class:`~typing.Generic` class and accepts one type variable that specifies
the type of the argument :paramref:`~process_request.rate_limit_args` of
:meth:`process_request` and the methods of :class:`~telegram.ext.ExtBot`.
Hint:
Requests to :meth:`~telegram.Bot.get_updates` are never rate limited.
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Avoiding Flood Limits <Avoiding-flood-limits>`
.. versionadded:: 20.0
"""
__slots__ = ()
@abstractmethod
async def initialize(self) -> None:
"""Initialize resources used by this class. Must be implemented by a subclass."""
@abstractmethod
async def shutdown(self) -> None:
"""Stop & clear resources used by this class. Must be implemented by a subclass."""
@abstractmethod
async def process_request(
self,
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]],
args: Any,
kwargs: dict[str, Any],
endpoint: str,
data: dict[str, Any],
rate_limit_args: Optional[RLARGS],
) -> Union[bool, JSONDict, list[JSONDict]]:
"""
Process a request. Must be implemented by a subclass.
This method must call :paramref:`callback` and return the result of the call.
`When` the callback is called is up to the implementation.
Important:
This method must only return once the result of :paramref:`callback` is known!
If a :exc:`~telegram.error.RetryAfter` error is raised, this method may try to make
a new request by calling the callback again.
Warning:
This method *should not* handle any other exception raised by :paramref:`callback`!
There are basically two different approaches how a rate limiter can be implemented:
1. React only if necessary. In this case, the :paramref:`callback` is called without any
precautions. If a :exc:`~telegram.error.RetryAfter` error is raised, processing requests
is halted for the :attr:`~telegram.error.RetryAfter.retry_after` and finally the
:paramref:`callback` is called again. This approach is often amendable for bots that
don't have a large user base and/or don't send more messages than they get updates.
2. Throttle all outgoing requests. In this case the implementation makes sure that the
requests are spread out over a longer time interval in order to stay below the rate
limits. This approach is often amendable for bots that have a large user base and/or
send more messages than they get updates.
An implementation can use the information provided by :paramref:`data`,
:paramref:`endpoint` and :paramref:`rate_limit_args` to handle each request differently.
Examples:
* It is usually desirable to call :meth:`telegram.Bot.answer_inline_query`
as quickly as possible, while delaying :meth:`telegram.Bot.send_message`
is acceptable.
* There are `different <https://core.telegram.org/bots/faq\
#my-bot-is-hitting-limits-how-do-i-avoid-this>`_ rate limits for group chats and
private chats.
* When sending broadcast messages to a large number of users, these requests can
typically be delayed for a longer time than messages that are direct replies to a
user input.
Args:
callback (Callable[..., :term:`coroutine`]): The coroutine function that must be called
to make the request.
args (tuple[:obj:`object`]): The positional arguments for the :paramref:`callback`
function.
kwargs (dict[:obj:`str`, :obj:`object`]): The keyword arguments for the
:paramref:`callback` function.
endpoint (:obj:`str`): The endpoint that the request is made for, e.g.
``"sendMessage"``.
data (dict[:obj:`str`, :obj:`object`]): The parameters that were passed to the method
of :class:`~telegram.ext.ExtBot`. Any ``api_kwargs`` are included in this and
any :paramref:`~telegram.ext.ExtBot.defaults` are already applied.
Example:
When calling::
await ext_bot.send_message(
chat_id=1,
text="Hello world!",
api_kwargs={"custom": "arg"}
)
then :paramref:`data` will be::
{"chat_id": 1, "text": "Hello world!", "custom": "arg"}
rate_limit_args (:obj:`None` | :class:`object`): Custom arguments passed to the methods
of :class:`~telegram.ext.ExtBot`. Can e.g. be used to specify the priority of
the request.
Returns:
:obj:`bool` | dict[:obj:`str`, :obj:`object`] | :obj:`None`: The result of the
callback function.
"""

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the BaseProcessor class."""
from abc import ABC, abstractmethod
from asyncio import BoundedSemaphore
from contextlib import AbstractAsyncContextManager
from types import TracebackType
from typing import TYPE_CHECKING, Any, Optional, TypeVar, final
if TYPE_CHECKING:
from collections.abc import Awaitable
_BUPT = TypeVar("_BUPT", bound="BaseUpdateProcessor")
class BaseUpdateProcessor(AbstractAsyncContextManager["BaseUpdateProcessor"], ABC):
"""An abstract base class for update processors. You can use this class to implement
your own update processor.
Instances of this class can be used as asyncio context managers, where
.. code:: python
async with processor:
# code
is roughly equivalent to
.. code:: python
try:
await processor.initialize()
# code
finally:
await processor.shutdown()
.. seealso:: :meth:`__aenter__` and :meth:`__aexit__`.
.. seealso:: :wiki:`Concurrency`
.. versionadded:: 20.4
Args:
max_concurrent_updates (:obj:`int`): The maximum number of updates to be processed
concurrently. If this number is exceeded, new updates will be queued until the number
of currently processed updates decreases.
Raises:
:exc:`ValueError`: If :paramref:`max_concurrent_updates` is a non-positive integer.
"""
__slots__ = ("_max_concurrent_updates", "_semaphore")
def __init__(self, max_concurrent_updates: int):
self._max_concurrent_updates = max_concurrent_updates
if self.max_concurrent_updates < 1:
raise ValueError("`max_concurrent_updates` must be a positive integer!")
self._semaphore = BoundedSemaphore(self.max_concurrent_updates)
async def __aenter__(self: _BUPT) -> _BUPT: # noqa: PYI019
"""|async_context_manager| :meth:`initializes <initialize>` the Processor.
Returns:
The initialized Processor instance.
Raises:
:exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown`
is called in this case.
"""
try:
await self.initialize()
except Exception:
await self.shutdown()
raise
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""|async_context_manager| :meth:`shuts down <shutdown>` the Processor."""
await self.shutdown()
@property
def max_concurrent_updates(self) -> int:
""":obj:`int`: The maximum number of updates that can be processed concurrently."""
return self._max_concurrent_updates
@abstractmethod
async def do_process_update(
self,
update: object,
coroutine: "Awaitable[Any]",
) -> None:
"""Custom implementation of how to process an update. Must be implemented by a subclass.
Warning:
This method will be called by :meth:`process_update`. It should *not* be called
manually.
Args:
update (:obj:`object`): The update to be processed.
coroutine (:term:`Awaitable`): The coroutine that will be awaited to process the
update.
"""
@abstractmethod
async def initialize(self) -> None:
"""Initializes the processor so resources can be allocated. Must be implemented by a
subclass.
.. seealso::
:meth:`shutdown`
"""
@abstractmethod
async def shutdown(self) -> None:
"""Shutdown the processor so resources can be freed. Must be implemented by a subclass.
.. seealso::
:meth:`initialize`
"""
@final
async def process_update(
self,
update: object,
coroutine: "Awaitable[Any]",
) -> None:
"""Calls :meth:`do_process_update` with a semaphore to limit the number of concurrent
updates.
Args:
update (:obj:`object`): The update to be processed.
coroutine (:term:`Awaitable`): The coroutine that will be awaited to process the
update.
"""
async with self._semaphore:
await self.do_process_update(update, coroutine)
class SimpleUpdateProcessor(BaseUpdateProcessor):
"""Instance of :class:`telegram.ext.BaseUpdateProcessor` that immediately awaits the
coroutine, i.e. does not apply any additional processing. This is used by default when
:attr:`telegram.ext.ApplicationBuilder.concurrent_updates` is :obj:`int`.
.. versionadded:: 20.4
"""
__slots__ = ()
async def do_process_update(
self,
update: object, # noqa: ARG002
coroutine: "Awaitable[Any]",
) -> None:
"""Immediately awaits the coroutine, i.e. does not apply any additional processing.
Args:
update (:obj:`object`): The update to be processed.
coroutine (:term:`Awaitable`): The coroutine that will be awaited to process the
update.
"""
await coroutine
async def initialize(self) -> None:
"""Does nothing."""
async def shutdown(self) -> None:
"""Does nothing."""

View File

@@ -0,0 +1,429 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the CallbackContext class."""
from collections.abc import Awaitable, Generator
from re import Match
from typing import TYPE_CHECKING, Any, Generic, NoReturn, Optional, TypeVar, Union
from telegram._callbackquery import CallbackQuery
from telegram._update import Update
from telegram._utils.warnings import warn
from telegram.ext._extbot import ExtBot
from telegram.ext._utils.types import BD, BT, CD, UD
if TYPE_CHECKING:
from asyncio import Future, Queue
from telegram.ext import Application, Job, JobQueue
from telegram.ext._utils.types import CCT
_STORING_DATA_WIKI = (
"https://github.com/python-telegram-bot/python-telegram-bot"
"/wiki/Storing-bot%2C-user-and-chat-related-data"
)
# something like poor mans "typing.Self" for py<3.11
ST = TypeVar("ST", bound="CallbackContext[Any, Any, Any, Any]")
class CallbackContext(Generic[BT, UD, CD, BD]):
"""
This is a context object passed to the callback called by :class:`telegram.ext.BaseHandler`
or by the :class:`telegram.ext.Application` in an error handler added by
:attr:`telegram.ext.Application.add_error_handler` or to the callback of a
:class:`telegram.ext.Job`.
Note:
:class:`telegram.ext.Application` will create a single context for an entire update. This
means that if you got 2 handlers in different groups and they both get called, they will
receive the same :class:`CallbackContext` object (of course with proper attributes like
:attr:`matches` differing). This allows you to add custom attributes in a lower handler
group callback, and then subsequently access those attributes in a higher handler group
callback. Note that the attributes on :class:`CallbackContext` might change in the future,
so make sure to use a fairly unique name for the attributes.
Warning:
Do not combine custom attributes with :paramref:`telegram.ext.BaseHandler.block` set to
:obj:`False` or :attr:`telegram.ext.Application.concurrent_updates` set to
:obj:`True`. Due to how those work, it will almost certainly execute the callbacks for an
update out of order, and the attributes that you think you added will not be present.
This class is a :class:`~typing.Generic` class and accepts four type variables:
1. The type of :attr:`bot`. Must be :class:`telegram.Bot` or a subclass of that class.
2. The type of :attr:`user_data` (if :attr:`user_data` is not :obj:`None`).
3. The type of :attr:`chat_data` (if :attr:`chat_data` is not :obj:`None`).
4. The type of :attr:`bot_data` (if :attr:`bot_data` is not :obj:`None`).
Examples:
* :any:`Context Types Bot <examples.contexttypesbot>`
* :any:`Custom Webhook Bot <examples.customwebhookbot>`
.. seealso:: :attr:`telegram.ext.ContextTypes.DEFAULT_TYPE`,
:wiki:`Job Queue <Extensions---JobQueue>`
Args:
application (:class:`telegram.ext.Application`): The application associated with this
context.
chat_id (:obj:`int`, optional): The ID of the chat associated with this object. Used
to provide :attr:`chat_data`.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): The ID of the user associated with this object. Used
to provide :attr:`user_data`.
.. versionadded:: 20.0
Attributes:
coroutine (:term:`awaitable`): Optional. Only present in error handlers if the
error was caused by an awaitable run with :meth:`Application.create_task` or a handler
callback with :attr:`block=False <BaseHandler.block>`.
matches (list[:meth:`re.Match <re.Match.expand>`]): Optional. If the associated update
originated from a :class:`filters.Regex`, this will contain a list of match objects for
every pattern where ``re.search(pattern, string)`` returned a match. Note that filters
short circuit, so combined regex filters will not always be evaluated.
args (list[:obj:`str`]): Optional. Arguments passed to a command if the associated update
is handled by :class:`telegram.ext.CommandHandler`, :class:`telegram.ext.PrefixHandler`
or :class:`telegram.ext.StringCommandHandler`. It contains a list of the words in the
text after the command, using any whitespace string as a delimiter.
error (:exc:`Exception`): Optional. The error that was raised. Only present when passed
to an error handler registered with :attr:`telegram.ext.Application.add_error_handler`.
job (:class:`telegram.ext.Job`): Optional. The job which originated this callback.
Only present when passed to the callback of :class:`telegram.ext.Job` or in error
handlers if the error is caused by a job.
.. versionchanged:: 20.0
:attr:`job` is now also present in error handlers if the error is caused by a job.
"""
__slots__ = (
"__dict__",
"_application",
"_chat_id",
"_user_id",
"args",
"coroutine",
"error",
"job",
"matches",
)
def __init__(
self: ST,
application: "Application[BT, ST, UD, CD, BD, Any]",
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
):
self._application: Application[BT, ST, UD, CD, BD, Any] = application
self._chat_id: Optional[int] = chat_id
self._user_id: Optional[int] = user_id
self.args: Optional[list[str]] = None
self.matches: Optional[list[Match[str]]] = None
self.error: Optional[Exception] = None
self.job: Optional[Job[Any]] = None
self.coroutine: Optional[
Union[Generator[Optional[Future[object]], None, Any], Awaitable[Any]]
] = None
@property
def application(self) -> "Application[BT, ST, UD, CD, BD, Any]":
""":class:`telegram.ext.Application`: The application associated with this context."""
return self._application
@property
def bot_data(self) -> BD:
""":obj:`ContextTypes.bot_data`: Optional. An object that can be used to keep any data in.
For each update it will be the same :attr:`ContextTypes.bot_data`. Defaults to :obj:`dict`.
.. seealso:: :wiki:`Storing Bot, User and Chat Related Data\
<Storing-bot%2C-user-and-chat-related-data>`
"""
return self.application.bot_data
@bot_data.setter
def bot_data(self, _: object) -> NoReturn:
raise AttributeError(
f"You can not assign a new value to bot_data, see {_STORING_DATA_WIKI}"
)
@property
def chat_data(self) -> Optional[CD]:
""":obj:`ContextTypes.chat_data`: Optional. An object that can be used to keep any data in.
For each update from the same chat id it will be the same :obj:`ContextTypes.chat_data`.
Defaults to :obj:`dict`.
Warning:
When a group chat migrates to a supergroup, its chat id will change and the
``chat_data`` needs to be transferred. For details see our
:wiki:`wiki page <Storing-bot,-user-and-chat-related-data#chat-migration>`.
.. seealso:: :wiki:`Storing Bot, User and Chat Related Data\
<Storing-bot%2C-user-and-chat-related-data>`
.. versionchanged:: 20.0
The chat data is now also present in error handlers if the error is caused by a job.
"""
if self._chat_id is not None:
return self._application.chat_data[self._chat_id]
return None
@chat_data.setter
def chat_data(self, _: object) -> NoReturn:
raise AttributeError(
f"You can not assign a new value to chat_data, see {_STORING_DATA_WIKI}"
)
@property
def user_data(self) -> Optional[UD]:
""":obj:`ContextTypes.user_data`: Optional. An object that can be used to keep any data in.
For each update from the same user it will be the same :obj:`ContextTypes.user_data`.
Defaults to :obj:`dict`.
.. seealso:: :wiki:`Storing Bot, User and Chat Related Data\
<Storing-bot%2C-user-and-chat-related-data>`
.. versionchanged:: 20.0
The user data is now also present in error handlers if the error is caused by a job.
"""
if self._user_id is not None:
return self._application.user_data[self._user_id]
return None
@user_data.setter
def user_data(self, _: object) -> NoReturn:
raise AttributeError(
f"You can not assign a new value to user_data, see {_STORING_DATA_WIKI}"
)
async def refresh_data(self) -> None:
"""If :attr:`application` uses persistence, calls
:meth:`telegram.ext.BasePersistence.refresh_bot_data` on :attr:`bot_data`,
:meth:`telegram.ext.BasePersistence.refresh_chat_data` on :attr:`chat_data` and
:meth:`telegram.ext.BasePersistence.refresh_user_data` on :attr:`user_data`, if
appropriate.
Will be called by :meth:`telegram.ext.Application.process_update` and
:meth:`telegram.ext.Job.run`.
.. versionadded:: 13.6
"""
if self.application.persistence:
if self.application.persistence.store_data.bot_data:
await self.application.persistence.refresh_bot_data(self.bot_data)
if self.application.persistence.store_data.chat_data and self._chat_id is not None:
await self.application.persistence.refresh_chat_data(
chat_id=self._chat_id,
chat_data=self.chat_data, # type: ignore[arg-type]
)
if self.application.persistence.store_data.user_data and self._user_id is not None:
await self.application.persistence.refresh_user_data(
user_id=self._user_id,
user_data=self.user_data, # type: ignore[arg-type]
)
def drop_callback_data(self, callback_query: CallbackQuery) -> None:
"""
Deletes the cached data for the specified callback query.
.. versionadded:: 13.6
Note:
Will *not* raise exceptions in case the data is not found in the cache.
*Will* raise :exc:`KeyError` in case the callback query can not be found in the cache.
.. seealso:: :wiki:`Arbitrary callback_data <Arbitrary-callback_data>`
Args:
callback_query (:class:`telegram.CallbackQuery`): The callback query.
Raises:
KeyError | RuntimeError: :exc:`KeyError`, if the callback query can not be found in
the cache and :exc:`RuntimeError`, if the bot doesn't allow for arbitrary
callback data.
"""
if isinstance(self.bot, ExtBot):
if self.bot.callback_data_cache is None:
raise RuntimeError(
"This telegram.ext.ExtBot instance does not use arbitrary callback data."
)
self.bot.callback_data_cache.drop_data(callback_query)
else:
raise RuntimeError( # noqa: TRY004
"telegram.Bot does not allow for arbitrary callback data."
)
@classmethod
def from_error(
cls: type["CCT"],
update: object,
error: Exception,
application: "Application[BT, CCT, UD, CD, BD, Any]",
job: Optional["Job[Any]"] = None,
coroutine: Optional[
Union[Generator[Optional["Future[object]"], None, Any], Awaitable[Any]]
] = None,
) -> "CCT":
"""
Constructs an instance of :class:`telegram.ext.CallbackContext` to be passed to the error
handlers.
.. seealso:: :meth:`telegram.ext.Application.add_error_handler`
.. versionchanged:: 20.0
Removed arguments ``async_args`` and ``async_kwargs``.
Args:
update (:obj:`object` | :class:`telegram.Update`): The update associated with the
error. May be :obj:`None`, e.g. for errors in job callbacks.
error (:obj:`Exception`): The error.
application (:class:`telegram.ext.Application`): The application associated with this
context.
job (:class:`telegram.ext.Job`, optional): The job associated with the error.
.. versionadded:: 20.0
coroutine (:term:`awaitable`, optional): The awaitable associated
with this error if the error was caused by a coroutine run with
:meth:`Application.create_task` or a handler callback with
:attr:`block=False <BaseHandler.block>`.
.. versionadded:: 20.0
.. versionchanged:: 20.2
Accepts :class:`asyncio.Future` and generator-based coroutine functions.
Returns:
:class:`telegram.ext.CallbackContext`
"""
# update and job will never be present at the same time
if update is not None:
self = cls.from_update(update, application)
elif job is not None:
self = cls.from_job(job, application)
else:
self = cls(application) # type: ignore
self.error = error
self.coroutine = coroutine
return self
@classmethod
def from_update(
cls: type["CCT"],
update: object,
application: "Application[Any, CCT, Any, Any, Any, Any]",
) -> "CCT":
"""
Constructs an instance of :class:`telegram.ext.CallbackContext` to be passed to the
handlers.
.. seealso:: :meth:`telegram.ext.Application.add_handler`
Args:
update (:obj:`object` | :class:`telegram.Update`): The update.
application (:class:`telegram.ext.Application`): The application associated with this
context.
Returns:
:class:`telegram.ext.CallbackContext`
"""
if isinstance(update, Update):
chat = update.effective_chat
user = update.effective_user
chat_id = chat.id if chat else None
user_id = user.id if user else None
return cls(application, chat_id=chat_id, user_id=user_id) # type: ignore
return cls(application) # type: ignore
@classmethod
def from_job(
cls: type["CCT"],
job: "Job[CCT]",
application: "Application[Any, CCT, Any, Any, Any, Any]",
) -> "CCT":
"""
Constructs an instance of :class:`telegram.ext.CallbackContext` to be passed to a
job callback.
.. seealso:: :meth:`telegram.ext.JobQueue`
Args:
job (:class:`telegram.ext.Job`): The job.
application (:class:`telegram.ext.Application`): The application associated with this
context.
Returns:
:class:`telegram.ext.CallbackContext`
"""
self = cls(application, chat_id=job.chat_id, user_id=job.user_id) # type: ignore
self.job = job
return self
def update(self, data: dict[str, object]) -> None:
"""Updates ``self.__slots__`` with the passed data.
Args:
data (dict[:obj:`str`, :obj:`object`]): The data.
"""
for key, value in data.items():
setattr(self, key, value)
@property
def bot(self) -> BT:
""":class:`telegram.Bot`: The bot associated with this context."""
return self._application.bot
@property
def job_queue(self) -> Optional["JobQueue[ST]"]:
"""
:class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the
:class:`telegram.ext.Application`.
.. seealso:: :wiki:`Job Queue <Extensions---JobQueue>`
"""
if self._application._job_queue is None: # pylint: disable=protected-access
warn(
"No `JobQueue` set up. To use `JobQueue`, you must install PTB via "
'`pip install "python-telegram-bot[job-queue]"`.',
stacklevel=2,
)
return self._application._job_queue # pylint: disable=protected-access
@property
def update_queue(self) -> "Queue[object]":
"""
:class:`asyncio.Queue`: The :class:`asyncio.Queue` instance used by the
:class:`telegram.ext.Application` and (usually) the :class:`telegram.ext.Updater`
associated with this context.
"""
return self._application.update_queue
@property
def match(self) -> Optional[Match[str]]:
"""
:meth:`re.Match <re.Match.expand>`: The first match from :attr:`matches`.
Useful if you are only filtering using a single regex filter.
Returns :obj:`None` if :attr:`matches` is empty.
"""
try:
return self.matches[0] # type: ignore[index] # pylint: disable=unsubscriptable-object
except (IndexError, TypeError):
return None

View File

@@ -0,0 +1,467 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the CallbackDataCache class."""
import time
from collections.abc import MutableMapping
from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from uuid import uuid4
try:
from cachetools import LRUCache
CACHE_TOOLS_AVAILABLE = True
except ImportError:
CACHE_TOOLS_AVAILABLE = False
import contextlib
from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, User
from telegram._utils.datetime import to_float_timestamp
from telegram.error import TelegramError
from telegram.ext._utils.types import CDCData
if TYPE_CHECKING:
from telegram.ext import ExtBot
class InvalidCallbackData(TelegramError):
"""
Raised when the received callback data has been tampered with or deleted from cache.
Examples:
:any:`Arbitrary Callback Data Bot <examples.arbitrarycallbackdatabot>`
.. seealso:: :wiki:`Arbitrary callback_data <Arbitrary-callback_data>`
.. versionadded:: 13.6
Args:
callback_data (:obj:`int`, optional): The button data of which the callback data could not
be found.
Attributes:
callback_data (:obj:`int`): Optional. The button data of which the callback data could not
be found.
"""
__slots__ = ("callback_data",)
def __init__(self, callback_data: Optional[str] = None) -> None:
super().__init__(
"The object belonging to this callback_data was deleted or the callback_data was "
"manipulated."
)
self.callback_data: Optional[str] = callback_data
def __reduce__(self) -> tuple[type, tuple[Optional[str]]]: # type: ignore[override]
"""Defines how to serialize the exception for pickle. See
:py:meth:`object.__reduce__` for more info.
Returns:
:obj:`tuple`
"""
return self.__class__, (self.callback_data,)
class _KeyboardData:
__slots__ = ("access_time", "button_data", "keyboard_uuid")
def __init__(
self,
keyboard_uuid: str,
access_time: Optional[float] = None,
button_data: Optional[dict[str, object]] = None,
):
self.keyboard_uuid = keyboard_uuid
self.button_data = button_data or {}
self.access_time = access_time or time.time()
def update_access_time(self) -> None:
"""Updates the access time with the current time."""
self.access_time = time.time()
def to_tuple(self) -> tuple[str, float, dict[str, object]]:
"""Gives a tuple representation consisting of the keyboard uuid, the access time and the
button data.
"""
return self.keyboard_uuid, self.access_time, self.button_data
class CallbackDataCache:
"""A custom cache for storing the callback data of a :class:`telegram.ext.ExtBot`. Internally,
it keeps two mappings with fixed maximum size:
* One for mapping the data received in callback queries to the cached objects
* One for mapping the IDs of received callback queries to the cached objects
The second mapping allows to manually drop data that has been cached for keyboards of messages
sent via inline mode.
If necessary, will drop the least recently used items.
Important:
If you want to use this class, you must install PTB with the optional requirement
``callback-data``, i.e.
.. code-block:: bash
pip install "python-telegram-bot[callback-data]"
Examples:
:any:`Arbitrary Callback Data Bot <examples.arbitrarycallbackdatabot>`
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Arbitrary callback_data <Arbitrary-callback_data>`
.. versionadded:: 13.6
.. versionchanged:: 20.0
To use this class, PTB must be installed via
``pip install "python-telegram-bot[callback-data]"``.
Args:
bot (:class:`telegram.ext.ExtBot`): The bot this cache is for.
maxsize (:obj:`int`, optional): Maximum number of items in each of the internal mappings.
Defaults to ``1024``.
persistent_data (tuple[list[tuple[:obj:`str`, :obj:`float`, \
dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]], optional): \
Data to initialize the cache with, as returned by \
:meth:`telegram.ext.BasePersistence.get_callback_data`.
Attributes:
bot (:class:`telegram.ext.ExtBot`): The bot this cache is for.
"""
__slots__ = ("_callback_queries", "_keyboard_data", "_maxsize", "bot")
def __init__(
self,
bot: "ExtBot[Any]",
maxsize: int = 1024,
persistent_data: Optional[CDCData] = None,
):
if not CACHE_TOOLS_AVAILABLE:
raise RuntimeError(
"To use `CallbackDataCache`, PTB must be installed via `pip install "
'"python-telegram-bot[callback-data]"`.'
)
self.bot: ExtBot[Any] = bot
self._maxsize: int = maxsize
self._keyboard_data: MutableMapping[str, _KeyboardData] = LRUCache(maxsize=maxsize)
self._callback_queries: MutableMapping[str, str] = LRUCache(maxsize=maxsize)
if persistent_data:
self.load_persistence_data(persistent_data)
def load_persistence_data(self, persistent_data: CDCData) -> None:
"""Loads data into the cache.
Warning:
This method is not intended to be called by users directly.
.. versionadded:: 20.0
Args:
persistent_data (tuple[list[tuple[:obj:`str`, :obj:`float`, \
dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]], optional): \
Data to load, as returned by \
:meth:`telegram.ext.BasePersistence.get_callback_data`.
"""
keyboard_data, callback_queries = persistent_data
for key, value in callback_queries.items():
self._callback_queries[key] = value
for uuid, access_time, data in keyboard_data:
self._keyboard_data[uuid] = _KeyboardData(
keyboard_uuid=uuid, access_time=access_time, button_data=data
)
@property
def maxsize(self) -> int:
""":obj:`int`: The maximum size of the cache.
.. versionchanged:: 20.0
This property is now read-only.
"""
return self._maxsize
@property
def persistence_data(self) -> CDCData:
"""tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]],
dict[:obj:`str`, :obj:`str`]]: The data that needs to be persisted to allow
caching callback data across bot reboots.
"""
# While building a list/dict from the LRUCaches has linear runtime (in the number of
# entries), the runtime is bounded by maxsize and it has the big upside of not throwing a
# highly customized data structure at users trying to implement a custom persistence class
return [data.to_tuple() for data in self._keyboard_data.values()], dict(
self._callback_queries.items()
)
def process_keyboard(self, reply_markup: InlineKeyboardMarkup) -> InlineKeyboardMarkup:
"""Registers the reply markup to the cache. If any of the buttons have
:attr:`~telegram.InlineKeyboardButton.callback_data`, stores that data and builds a new
keyboard with the correspondingly replaced buttons. Otherwise, does nothing and returns
the original reply markup.
Args:
reply_markup (:class:`telegram.InlineKeyboardMarkup`): The keyboard.
Returns:
:class:`telegram.InlineKeyboardMarkup`: The keyboard to be passed to Telegram.
"""
keyboard_uuid = uuid4().hex
keyboard_data = _KeyboardData(keyboard_uuid)
# Built a new nested list of buttons by replacing the callback data if needed
buttons = [
[
(
# We create a new button instead of replacing callback_data in case the
# same object is used elsewhere
InlineKeyboardButton(
btn.text,
callback_data=self.__put_button(btn.callback_data, keyboard_data),
)
if btn.callback_data
else btn
)
for btn in column
]
for column in reply_markup.inline_keyboard
]
if not keyboard_data.button_data:
# If we arrive here, no data had to be replaced and we can return the input
return reply_markup
self._keyboard_data[keyboard_uuid] = keyboard_data
return InlineKeyboardMarkup(buttons)
@staticmethod
def __put_button(callback_data: object, keyboard_data: _KeyboardData) -> str:
"""Stores the data for a single button in :attr:`keyboard_data`.
Returns the string that should be passed instead of the callback_data, which is
``keyboard_uuid + button_uuids``.
"""
uuid = uuid4().hex
keyboard_data.button_data[uuid] = callback_data
return f"{keyboard_data.keyboard_uuid}{uuid}"
def __get_keyboard_uuid_and_button_data(
self, callback_data: str
) -> Union[tuple[str, object], tuple[None, InvalidCallbackData]]:
keyboard, button = self.extract_uuids(callback_data)
try:
# we get the values before calling update() in case KeyErrors are raised
# we don't want to update in that case
keyboard_data = self._keyboard_data[keyboard]
button_data = keyboard_data.button_data[button]
# Update the timestamp for the LRU
keyboard_data.update_access_time()
except KeyError:
return None, InvalidCallbackData(callback_data)
return keyboard, button_data
@staticmethod
def extract_uuids(callback_data: str) -> tuple[str, str]:
"""Extracts the keyboard uuid and the button uuid from the given :paramref:`callback_data`.
Args:
callback_data (:obj:`str`): The
:paramref:`~telegram.InlineKeyboardButton.callback_data` as present in the button.
Returns:
(:obj:`str`, :obj:`str`): Tuple of keyboard and button uuid
"""
# Extract the uuids as put in __put_button
return callback_data[:32], callback_data[32:]
def process_message(self, message: Message) -> None:
"""Replaces the data in the inline keyboard attached to the message with the cached
objects, if necessary. If the data could not be found,
:class:`telegram.ext.InvalidCallbackData` will be inserted.
Note:
Checks :attr:`telegram.Message.via_bot` and :attr:`telegram.Message.from_user` to check
if the reply markup (if any) was actually sent by this cache's bot. If it was not, the
message will be returned unchanged.
Note that this will fail for channel posts, as :attr:`telegram.Message.from_user` is
:obj:`None` for those! In the corresponding reply markups the callback data will be
replaced by :class:`telegram.ext.InvalidCallbackData`.
Warning:
* Does *not* consider :attr:`telegram.Message.reply_to_message` and
:attr:`telegram.Message.pinned_message`. Pass them to this method separately.
* *In place*, i.e. the passed :class:`telegram.Message` will be changed!
Args:
message (:class:`telegram.Message`): The message.
"""
self.__process_message(message)
def __process_message(self, message: Message) -> Optional[str]:
"""As documented in process_message, but returns the uuid of the attached keyboard, if any,
which is relevant for process_callback_query.
**IN PLACE**
"""
if not message.reply_markup:
return None
if message.via_bot:
sender: Optional[User] = message.via_bot
elif message.from_user:
sender = message.from_user
else:
sender = None
if sender is not None and sender != self.bot.bot:
return None
keyboard_uuid = None
for row in message.reply_markup.inline_keyboard:
for button in row:
if button.callback_data:
button_data = cast(str, button.callback_data)
keyboard_id, callback_data = self.__get_keyboard_uuid_and_button_data(
button_data
)
# update_callback_data makes sure that the _id_attrs are updated
button.update_callback_data(callback_data)
# This is lazy loaded. The firsts time we find a button
# we load the associated keyboard - afterwards, there is
if not keyboard_uuid and not isinstance(callback_data, InvalidCallbackData):
keyboard_uuid = keyboard_id
return keyboard_uuid
def process_callback_query(self, callback_query: CallbackQuery) -> None:
"""Replaces the data in the callback query and the attached messages keyboard with the
cached objects, if necessary. If the data could not be found,
:class:`telegram.ext.InvalidCallbackData` will be inserted.
If :attr:`telegram.CallbackQuery.data` or :attr:`telegram.CallbackQuery.message` is
present, this also saves the callback queries ID in order to be able to resolve it to the
stored data.
Note:
Also considers inserts data into the buttons of
:attr:`telegram.Message.reply_to_message` and :attr:`telegram.Message.pinned_message`
if necessary.
Warning:
*In place*, i.e. the passed :class:`telegram.CallbackQuery` will be changed!
Args:
callback_query (:class:`telegram.CallbackQuery`): The callback query.
"""
mapped = False
if callback_query.data:
data = callback_query.data
# Get the cached callback data for the CallbackQuery
keyboard_uuid, button_data = self.__get_keyboard_uuid_and_button_data(data)
with callback_query._unfrozen():
callback_query.data = button_data # type: ignore[assignment]
# Map the callback queries ID to the keyboards UUID for later use
if not mapped and not isinstance(button_data, InvalidCallbackData):
self._callback_queries[callback_query.id] = keyboard_uuid # type: ignore
mapped = True
# Get the cached callback data for the inline keyboard attached to the
# CallbackQuery.
if isinstance(callback_query.message, Message):
self.__process_message(callback_query.message)
for maybe_message in (
callback_query.message.pinned_message,
callback_query.message.reply_to_message,
):
if isinstance(maybe_message, Message):
self.__process_message(maybe_message)
def drop_data(self, callback_query: CallbackQuery) -> None:
"""Deletes the data for the specified callback query.
Note:
Will *not* raise exceptions in case the callback data is not found in the cache.
*Will* raise :exc:`KeyError` in case the callback query can not be found in the
cache.
Args:
callback_query (:class:`telegram.CallbackQuery`): The callback query.
Raises:
KeyError: If the callback query can not be found in the cache
"""
try:
keyboard_uuid = self._callback_queries.pop(callback_query.id)
self.__drop_keyboard(keyboard_uuid)
except KeyError as exc:
raise KeyError("CallbackQuery was not found in cache.") from exc
def __drop_keyboard(self, keyboard_uuid: str) -> None:
with contextlib.suppress(KeyError):
self._keyboard_data.pop(keyboard_uuid)
def clear_callback_data(self, time_cutoff: Optional[Union[float, datetime]] = None) -> None:
"""Clears the stored callback data.
Args:
time_cutoff (:obj:`float` | :obj:`datetime.datetime`, optional): Pass a UNIX timestamp
or a :obj:`datetime.datetime` to clear only entries which are older.
|tz-naive-dtms|
"""
self.__clear(self._keyboard_data, time_cutoff=time_cutoff)
def clear_callback_queries(self) -> None:
"""Clears the stored callback query IDs."""
self.__clear(self._callback_queries)
def __clear(
self, mapping: MutableMapping, time_cutoff: Optional[Union[float, datetime]] = None
) -> None:
if not time_cutoff:
mapping.clear()
return
if isinstance(time_cutoff, datetime):
effective_cutoff = to_float_timestamp(
time_cutoff, tzinfo=self.bot.defaults.tzinfo if self.bot.defaults else None
)
else:
effective_cutoff = time_cutoff
# We need a list instead of a generator here, as the list doesn't change it's size
# during the iteration
to_drop = [key for key, data in mapping.items() if data.access_time < effective_cutoff]
for key in to_drop:
mapping.pop(key)

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the auxiliary class ContextTypes."""
from typing import Any, Generic, overload
from telegram.ext._callbackcontext import CallbackContext
from telegram.ext._extbot import ExtBot
from telegram.ext._utils.types import BD, CCT, CD, UD
ADict = dict[Any, Any]
class ContextTypes(Generic[CCT, UD, CD, BD]):
"""
Convenience class to gather customizable types of the :class:`telegram.ext.CallbackContext`
interface.
Examples:
:any:`ContextTypes Bot <examples.contexttypesbot>`
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Storing Bot, User and Chat Related Data <Storing-bot%2C-user-and-chat-related-data>`
.. versionadded:: 13.6
Args:
context (:obj:`type`, optional): Determines the type of the ``context`` argument of all
(error-)handler callbacks and job callbacks. Must be a subclass of
:class:`telegram.ext.CallbackContext`. Defaults to
:class:`telegram.ext.CallbackContext`.
bot_data (:obj:`type`, optional): Determines the type of
:attr:`context.bot_data <CallbackContext.bot_data>` of all (error-)handler callbacks
and job callbacks. Defaults to :obj:`dict`. Must support instantiating without
arguments.
chat_data (:obj:`type`, optional): Determines the type of
:attr:`context.chat_data <CallbackContext.chat_data>` of all (error-)handler callbacks
and job callbacks. Defaults to :obj:`dict`. Must support instantiating without
arguments.
user_data (:obj:`type`, optional): Determines the type of
:attr:`context.user_data <CallbackContext.user_data>` of all (error-)handler callbacks
and job callbacks. Defaults to :obj:`dict`. Must support instantiating without
arguments.
"""
DEFAULT_TYPE = CallbackContext[ExtBot[None], ADict, ADict, ADict]
"""Shortcut for the type annotation for the ``context`` argument that's correct for the
default settings, i.e. if :class:`telegram.ext.ContextTypes` is not used.
Example:
.. code:: python
async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
...
.. versionadded: 20.0
"""
__slots__ = ("_bot_data", "_chat_data", "_context", "_user_data")
# overload signatures generated with
# https://gist.github.com/Bibo-Joshi/399382cda537fb01bd86b13c3d03a956
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, ADict, ADict], ADict, ADict, ADict]", # pylint: disable=line-too-long # noqa: E501
): ...
@overload
def __init__(self: "ContextTypes[CCT, ADict, ADict, ADict]", context: type[CCT]): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], UD, ADict, ADict], UD, ADict, ADict]",
user_data: type[UD],
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, CD, ADict], ADict, CD, ADict]",
chat_data: type[CD],
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, ADict, BD], ADict, ADict, BD]",
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CCT, UD, ADict, ADict]", context: type[CCT], user_data: type[UD]
): ...
@overload
def __init__(
self: "ContextTypes[CCT, ADict, CD, ADict]", context: type[CCT], chat_data: type[CD]
): ...
@overload
def __init__(
self: "ContextTypes[CCT, ADict, ADict, BD]", context: type[CCT], bot_data: type[BD]
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], UD, CD, ADict], UD, CD, ADict]",
user_data: type[UD],
chat_data: type[CD],
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], UD, ADict, BD], UD, ADict, BD]",
user_data: type[UD],
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, CD, BD], ADict, CD, BD]",
chat_data: type[CD],
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CCT, UD, CD, ADict]",
context: type[CCT],
user_data: type[UD],
chat_data: type[CD],
): ...
@overload
def __init__(
self: "ContextTypes[CCT, UD, ADict, BD]",
context: type[CCT],
user_data: type[UD],
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CCT, ADict, CD, BD]",
context: type[CCT],
chat_data: type[CD],
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CallbackContext[ExtBot[Any], UD, CD, BD], UD, CD, BD]",
user_data: type[UD],
chat_data: type[CD],
bot_data: type[BD],
): ...
@overload
def __init__(
self: "ContextTypes[CCT, UD, CD, BD]",
context: type[CCT],
user_data: type[UD],
chat_data: type[CD],
bot_data: type[BD],
): ...
def __init__( # type: ignore[misc]
self,
context: "type[CallbackContext[ExtBot[Any], ADict, ADict, ADict]]" = CallbackContext,
bot_data: type[ADict] = dict,
chat_data: type[ADict] = dict,
user_data: type[ADict] = dict,
):
if not issubclass(context, CallbackContext):
raise TypeError("context must be a subclass of CallbackContext.")
# We make all those only accessible via properties because we don't currently support
# changing this at runtime, so overriding the attributes doesn't make sense
self._context = context
self._bot_data = bot_data
self._chat_data = chat_data
self._user_data = user_data
@property
def context(self) -> type[CCT]:
"""The type of the ``context`` argument of all (error-)handler callbacks and job
callbacks.
"""
return self._context # type: ignore[return-value]
@property
def bot_data(self) -> type[BD]:
"""The type of :attr:`context.bot_data <CallbackContext.bot_data>` of all (error-)handler
callbacks and job callbacks.
"""
return self._bot_data # type: ignore[return-value]
@property
def chat_data(self) -> type[CD]:
"""The type of :attr:`context.chat_data <CallbackContext.chat_data>` of all (error-)handler
callbacks and job callbacks.
"""
return self._chat_data # type: ignore[return-value]
@property
def user_data(self) -> type[UD]:
"""The type of :attr:`context.user_data <CallbackContext.user_data>` of all (error-)handler
callbacks and job callbacks.
"""
return self._user_data # type: ignore[return-value]

View File

@@ -0,0 +1,413 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the class Defaults, which allows passing default values to Application."""
import datetime
from typing import Any, NoReturn, Optional, final
from telegram import LinkPreviewOptions
from telegram._utils.datetime import UTC
from telegram._utils.types import ODVInput
from telegram._utils.warnings import warn
from telegram.warnings import PTBDeprecationWarning
@final
class Defaults:
"""Convenience Class to gather all parameters with a (user defined) default value
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Adding Defaults to Your Bot <Adding-defaults-to-your-bot>`
.. versionchanged:: 20.0
Removed the argument and attribute ``timeout``. Specify default timeout behavior for the
networking backend directly via :class:`telegram.ext.ApplicationBuilder` instead.
Parameters:
parse_mode (:obj:`str`, optional): |parse_mode|
disable_notification (:obj:`bool`, optional): |disable_notification|
disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in this
message. Mutually exclusive with :paramref:`link_preview_options`.
.. deprecated:: 20.8
Use :paramref:`link_preview_options` instead. This parameter will be removed in
future versions.
allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|.
Will be used for :attr:`telegram.ReplyParameters.allow_sending_without_reply`.
quote (:obj:`bool`, optional): |reply_quote|
.. deprecated:: 20.8
Use :paramref:`do_quote` instead. This parameter will be removed in future
versions.
tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time)
inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed
somewhere, it will be assumed to be in :paramref:`tzinfo`. If the
:class:`telegram.ext.JobQueue` is used, this must be a timezone provided
by the ``pytz`` module. Defaults to ``pytz.utc``, if available, and
:attr:`datetime.timezone.utc` otherwise.
block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block`
parameter
of handlers and error handlers registered through :meth:`Application.add_handler` and
:meth:`Application.add_error_handler`. Defaults to :obj:`True`.
protect_content (:obj:`bool`, optional): |protect_content|
.. versionadded:: 20.0
link_preview_options (:class:`telegram.LinkPreviewOptions`, optional):
Link preview generation options for all outgoing messages. Mutually exclusive with
:paramref:`disable_web_page_preview`.
This object is used for the corresponding parameter of
:meth:`telegram.Bot.send_message`, :meth:`telegram.Bot.edit_message_text`,
and :class:`telegram.InputTextMessageContent` if not specified. If a value is specified
for the corresponding parameter, only those parameters of
:class:`telegram.LinkPreviewOptions` will be overridden that are not
explicitly set.
Example:
.. code-block:: python
from telegram import LinkPreviewOptions
from telegram.ext import Defaults, ExtBot
defaults = Defaults(
link_preview_options=LinkPreviewOptions(show_above_text=True)
)
chat_id = 123
async def main():
async with ExtBot("Token", defaults=defaults) as bot:
# The link preview will be shown above the text.
await bot.send_message(chat_id, "https://python-telegram-bot.org")
# The link preview will be shown below the text.
await bot.send_message(
chat_id,
"https://python-telegram-bot.org",
link_preview_options=LinkPreviewOptions(show_above_text=False)
)
# The link preview will be shown above the text, but the preview will
# show Telegram.
await bot.send_message(
chat_id,
"https://python-telegram-bot.org",
link_preview_options=LinkPreviewOptions(url="https://telegram.org")
)
.. versionadded:: 20.8
do_quote(:obj:`bool`, optional): |reply_quote|
.. versionadded:: 20.8
"""
__slots__ = (
"_allow_sending_without_reply",
"_api_defaults",
"_block",
"_disable_notification",
"_do_quote",
"_link_preview_options",
"_parse_mode",
"_protect_content",
"_tzinfo",
)
def __init__(
self,
parse_mode: Optional[str] = None,
disable_notification: Optional[bool] = None,
disable_web_page_preview: Optional[bool] = None,
quote: Optional[bool] = None,
tzinfo: datetime.tzinfo = UTC,
block: bool = True,
allow_sending_without_reply: Optional[bool] = None,
protect_content: Optional[bool] = None,
link_preview_options: Optional["LinkPreviewOptions"] = None,
do_quote: Optional[bool] = None,
):
self._parse_mode: Optional[str] = parse_mode
self._disable_notification: Optional[bool] = disable_notification
self._allow_sending_without_reply: Optional[bool] = allow_sending_without_reply
self._tzinfo: datetime.tzinfo = tzinfo
self._block: bool = block
self._protect_content: Optional[bool] = protect_content
if disable_web_page_preview is not None and link_preview_options is not None:
raise ValueError(
"`disable_web_page_preview` and `link_preview_options` are mutually exclusive."
)
if quote is not None and do_quote is not None:
raise ValueError("`quote` and `do_quote` are mutually exclusive")
if disable_web_page_preview is not None:
warn(
PTBDeprecationWarning(
"20.8",
"`Defaults.disable_web_page_preview` is deprecated. Use "
"`Defaults.link_preview_options` instead.",
),
stacklevel=2,
)
self._link_preview_options: Optional[LinkPreviewOptions] = LinkPreviewOptions(
is_disabled=disable_web_page_preview
)
else:
self._link_preview_options = link_preview_options
if quote is not None:
warn(
PTBDeprecationWarning(
"20.8", "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead."
),
stacklevel=2,
)
self._do_quote: Optional[bool] = quote
else:
self._do_quote = do_quote
# Gather all defaults that actually have a default value
self._api_defaults = {}
for kwarg in (
"allow_sending_without_reply",
"disable_notification",
"do_quote",
"explanation_parse_mode",
"link_preview_options",
"parse_mode",
"text_parse_mode",
"protect_content",
"question_parse_mode",
):
value = getattr(self, kwarg)
if value is not None:
self._api_defaults[kwarg] = value
def __hash__(self) -> int:
"""Builds a hash value for this object such that the hash of two objects is equal if and
only if the objects are equal in terms of :meth:`__eq__`.
Returns:
:obj:`int` The hash value of the object.
"""
return hash(
(
self._parse_mode,
self._disable_notification,
self.disable_web_page_preview,
self._allow_sending_without_reply,
self.quote,
self._tzinfo,
self._block,
self._protect_content,
)
)
def __eq__(self, other: object) -> bool:
"""Defines equality condition for the :class:`Defaults` object.
Two objects of this class are considered to be equal if all their parameters
are identical.
Returns:
:obj:`True` if both objects have all parameters identical. :obj:`False` otherwise.
"""
if isinstance(other, Defaults):
return all(getattr(self, attr) == getattr(other, attr) for attr in self.__slots__)
return False
@property
def api_defaults(self) -> dict[str, Any]: # skip-cq: PY-D0003
return self._api_defaults
@property
def parse_mode(self) -> Optional[str]:
""":obj:`str`: Optional. Send Markdown or HTML, if you want Telegram apps to show
bold, italic, fixed-width text or URLs in your bot's message.
"""
return self._parse_mode
@parse_mode.setter
def parse_mode(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to parse_mode after initialization.")
@property
def explanation_parse_mode(self) -> Optional[str]:
""":obj:`str`: Optional. Alias for :attr:`parse_mode`, used for
the corresponding parameter of :meth:`telegram.Bot.send_poll`.
"""
return self._parse_mode
@explanation_parse_mode.setter
def explanation_parse_mode(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to explanation_parse_mode after initialization."
)
@property
def quote_parse_mode(self) -> Optional[str]:
""":obj:`str`: Optional. Alias for :attr:`parse_mode`, used for
the corresponding parameter of :meth:`telegram.ReplyParameters`.
"""
return self._parse_mode
@quote_parse_mode.setter
def quote_parse_mode(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to quote_parse_mode after initialization."
)
@property
def text_parse_mode(self) -> Optional[str]:
""":obj:`str`: Optional. Alias for :attr:`parse_mode`, used for
the corresponding parameter of :class:`telegram.InputPollOption` and
:meth:`telegram.Bot.send_gift`.
.. versionadded:: 21.2
"""
return self._parse_mode
@text_parse_mode.setter
def text_parse_mode(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to text_parse_mode after initialization."
)
@property
def question_parse_mode(self) -> Optional[str]:
""":obj:`str`: Optional. Alias for :attr:`parse_mode`, used for
the corresponding parameter of :meth:`telegram.Bot.send_poll`.
.. versionadded:: 21.2
"""
return self._parse_mode
@question_parse_mode.setter
def question_parse_mode(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to question_parse_mode after initialization."
)
@property
def disable_notification(self) -> Optional[bool]:
""":obj:`bool`: Optional. Sends the message silently. Users will
receive a notification with no sound.
"""
return self._disable_notification
@disable_notification.setter
def disable_notification(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to disable_notification after initialization."
)
@property
def disable_web_page_preview(self) -> ODVInput[bool]:
""":obj:`bool`: Optional. Disables link previews for links in all outgoing
messages.
.. deprecated:: 20.8
Use :attr:`link_preview_options` instead. This attribute will be removed in future
versions.
"""
return self._link_preview_options.is_disabled if self._link_preview_options else None
@disable_web_page_preview.setter
def disable_web_page_preview(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to disable_web_page_preview after initialization."
)
@property
def allow_sending_without_reply(self) -> Optional[bool]:
""":obj:`bool`: Optional. Pass :obj:`True`, if the message
should be sent even if the specified replied-to message is not found.
"""
return self._allow_sending_without_reply
@allow_sending_without_reply.setter
def allow_sending_without_reply(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to allow_sending_without_reply after initialization."
)
@property
def quote(self) -> Optional[bool]:
""":obj:`bool`: Optional. |reply_quote|
.. deprecated:: 20.8
Use :attr:`do_quote` instead. This attribute will be removed in future
versions.
"""
return self._do_quote if self._do_quote is not None else None
@quote.setter
def quote(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to quote after initialization.")
@property
def tzinfo(self) -> datetime.tzinfo:
""":obj:`tzinfo`: A timezone to be used for all date(time) objects appearing
throughout PTB.
"""
return self._tzinfo
@tzinfo.setter
def tzinfo(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to tzinfo after initialization.")
@property
def block(self) -> bool:
""":obj:`bool`: Optional. Default setting for the :paramref:`BaseHandler.block` parameter
of handlers and error handlers registered through :meth:`Application.add_handler` and
:meth:`Application.add_error_handler`.
"""
return self._block
@block.setter
def block(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to block after initialization.")
@property
def protect_content(self) -> Optional[bool]:
""":obj:`bool`: Optional. Protects the contents of the sent message from forwarding and
saving.
.. versionadded:: 20.0
"""
return self._protect_content
@protect_content.setter
def protect_content(self, _: object) -> NoReturn:
raise AttributeError(
"You can't assign a new value to protect_content after initialization."
)
@property
def link_preview_options(self) -> Optional["LinkPreviewOptions"]:
""":class:`telegram.LinkPreviewOptions`: Optional. Link preview generation options for all
outgoing messages.
.. versionadded:: 20.8
"""
return self._link_preview_options
@property
def do_quote(self) -> Optional[bool]:
""":obj:`bool`: Optional. |reply_quote|
.. versionadded:: 20.8
"""
return self._do_quote

View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the DictPersistence class."""
import json
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Optional, cast
from telegram.ext import BasePersistence, PersistenceInput
from telegram.ext._utils.types import CDCData, ConversationDict, ConversationKey
if TYPE_CHECKING:
from telegram._utils.types import JSONDict
class DictPersistence(BasePersistence[dict[Any, Any], dict[Any, Any], dict[Any, Any]]):
"""Using Python's :obj:`dict` and :mod:`json` for making your bot persistent.
Attention:
The interface provided by this class is intended to be accessed exclusively by
:class:`~telegram.ext.Application`. Calling any of the methods below manually might
interfere with the integration of persistence into :class:`~telegram.ext.Application`.
Note:
* Data managed by :class:`DictPersistence` is in-memory only and will be lost when the bot
shuts down. This is, because :class:`DictPersistence` is mainly intended as starting
point for custom persistence classes that need to JSON-serialize the stored data before
writing them to file/database.
* This implementation of :class:`BasePersistence` does not handle data that cannot be
serialized by :func:`json.dumps`.
.. seealso:: :wiki:`Making Your Bot Persistent <Making-your-bot-persistent>`
.. versionchanged:: 20.0
The parameters and attributes ``store_*_data`` were replaced by :attr:`store_data`.
Args:
store_data (:class:`~telegram.ext.PersistenceInput`, optional): Specifies which kinds of
data will be saved by this persistence instance. By default, all available kinds of
data will be saved.
user_data_json (:obj:`str`, optional): JSON string that will be used to reconstruct
user_data on creating this persistence. Default is ``""``.
chat_data_json (:obj:`str`, optional): JSON string that will be used to reconstruct
chat_data on creating this persistence. Default is ``""``.
bot_data_json (:obj:`str`, optional): JSON string that will be used to reconstruct
bot_data on creating this persistence. Default is ``""``.
conversations_json (:obj:`str`, optional): JSON string that will be used to reconstruct
conversation on creating this persistence. Default is ``""``.
callback_data_json (:obj:`str`, optional): JSON string that will be used to reconstruct
callback_data on creating this persistence. Default is ``""``.
.. versionadded:: 13.6
update_interval (:obj:`int` | :obj:`float`, optional): The
:class:`~telegram.ext.Application` will update
the persistence in regular intervals. This parameter specifies the time (in seconds) to
wait between two consecutive runs of updating the persistence. Defaults to 60 seconds.
.. versionadded:: 20.0
Attributes:
store_data (:class:`~telegram.ext.PersistenceInput`): Specifies which kinds of data will
be saved by this persistence instance.
"""
__slots__ = (
"_bot_data",
"_bot_data_json",
"_callback_data",
"_callback_data_json",
"_chat_data",
"_chat_data_json",
"_conversations",
"_conversations_json",
"_user_data",
"_user_data_json",
)
def __init__(
self,
store_data: Optional[PersistenceInput] = None,
user_data_json: str = "",
chat_data_json: str = "",
bot_data_json: str = "",
conversations_json: str = "",
callback_data_json: str = "",
update_interval: float = 60,
):
super().__init__(store_data=store_data, update_interval=update_interval)
self._user_data = None
self._chat_data = None
self._bot_data = None
self._callback_data = None
self._conversations = None
self._user_data_json: Optional[str] = None
self._chat_data_json: Optional[str] = None
self._bot_data_json: Optional[str] = None
self._callback_data_json: Optional[str] = None
self._conversations_json: Optional[str] = None
if user_data_json:
try:
self._user_data = self._decode_user_chat_data_from_json(user_data_json)
self._user_data_json = user_data_json
except (ValueError, AttributeError) as exc:
raise TypeError("Unable to deserialize user_data_json. Not valid JSON") from exc
if chat_data_json:
try:
self._chat_data = self._decode_user_chat_data_from_json(chat_data_json)
self._chat_data_json = chat_data_json
except (ValueError, AttributeError) as exc:
raise TypeError("Unable to deserialize chat_data_json. Not valid JSON") from exc
if bot_data_json:
try:
self._bot_data = json.loads(bot_data_json)
self._bot_data_json = bot_data_json
except (ValueError, AttributeError) as exc:
raise TypeError("Unable to deserialize bot_data_json. Not valid JSON") from exc
if not isinstance(self._bot_data, dict):
raise TypeError("bot_data_json must be serialized dict")
if callback_data_json:
try:
data = json.loads(callback_data_json)
except (ValueError, AttributeError) as exc:
raise TypeError(
"Unable to deserialize callback_data_json. Not valid JSON"
) from exc
# We are a bit more thorough with the checking of the format here, because it's
# more complicated than for the other things
try:
if data is None:
self._callback_data = None
else:
self._callback_data = cast(
CDCData,
([(one, float(two), three) for one, two, three in data[0]], data[1]),
)
self._callback_data_json = callback_data_json
except (ValueError, IndexError) as exc:
raise TypeError("callback_data_json is not in the required format") from exc
if self._callback_data is not None and (
not all(
isinstance(entry[2], dict) and isinstance(entry[0], str)
for entry in self._callback_data[0]
)
or not isinstance(self._callback_data[1], dict)
):
raise TypeError("callback_data_json is not in the required format")
if conversations_json:
try:
self._conversations = self._decode_conversations_from_json(conversations_json)
self._conversations_json = conversations_json
except (ValueError, AttributeError) as exc:
raise TypeError(
"Unable to deserialize conversations_json. Not valid JSON"
) from exc
@property
def user_data(self) -> Optional[dict[int, dict[Any, Any]]]:
""":obj:`dict`: The user_data as a dict."""
return self._user_data
@property
def user_data_json(self) -> str:
""":obj:`str`: The user_data serialized as a JSON-string."""
if self._user_data_json:
return self._user_data_json
return json.dumps(self.user_data)
@property
def chat_data(self) -> Optional[dict[int, dict[Any, Any]]]:
""":obj:`dict`: The chat_data as a dict."""
return self._chat_data
@property
def chat_data_json(self) -> str:
""":obj:`str`: The chat_data serialized as a JSON-string."""
if self._chat_data_json:
return self._chat_data_json
return json.dumps(self.chat_data)
@property
def bot_data(self) -> Optional[dict[Any, Any]]:
""":obj:`dict`: The bot_data as a dict."""
return self._bot_data
@property
def bot_data_json(self) -> str:
""":obj:`str`: The bot_data serialized as a JSON-string."""
if self._bot_data_json:
return self._bot_data_json
return json.dumps(self.bot_data)
@property
def callback_data(self) -> Optional[CDCData]:
"""tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \
dict[:obj:`str`, :obj:`str`]]: The metadata on the stored callback data.
.. versionadded:: 13.6
"""
return self._callback_data
@property
def callback_data_json(self) -> str:
""":obj:`str`: The metadata on the stored callback data as a JSON-string.
.. versionadded:: 13.6
"""
if self._callback_data_json:
return self._callback_data_json
return json.dumps(self.callback_data)
@property
def conversations(self) -> Optional[dict[str, ConversationDict]]:
""":obj:`dict`: The conversations as a dict."""
return self._conversations
@property
def conversations_json(self) -> str:
""":obj:`str`: The conversations serialized as a JSON-string."""
if self._conversations_json:
return self._conversations_json
if self.conversations:
return self._encode_conversations_to_json(self.conversations)
return json.dumps(self.conversations)
async def get_user_data(self) -> dict[int, dict[object, object]]:
"""Returns the user_data created from the ``user_data_json`` or an empty :obj:`dict`.
Returns:
:obj:`dict`: The restored user data.
"""
if self.user_data is None:
self._user_data = {}
return deepcopy(self.user_data) # type: ignore[arg-type]
async def get_chat_data(self) -> dict[int, dict[object, object]]:
"""Returns the chat_data created from the ``chat_data_json`` or an empty :obj:`dict`.
Returns:
:obj:`dict`: The restored chat data.
"""
if self.chat_data is None:
self._chat_data = {}
return deepcopy(self.chat_data) # type: ignore[arg-type]
async def get_bot_data(self) -> dict[object, object]:
"""Returns the bot_data created from the ``bot_data_json`` or an empty :obj:`dict`.
Returns:
:obj:`dict`: The restored bot data.
"""
if self.bot_data is None:
self._bot_data = {}
return deepcopy(self.bot_data) # type: ignore[arg-type]
async def get_callback_data(self) -> Optional[CDCData]:
"""Returns the callback_data created from the ``callback_data_json`` or :obj:`None`.
.. versionadded:: 13.6
Returns:
tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \
dict[:obj:`str`, :obj:`str`]]: The restored metadata or :obj:`None`, \
if no data was stored.
"""
if self.callback_data is None:
self._callback_data = None
return None
return deepcopy(self.callback_data)
async def get_conversations(self, name: str) -> ConversationDict:
"""Returns the conversations created from the ``conversations_json`` or an empty
:obj:`dict`.
Returns:
:obj:`dict`: The restored conversations data.
"""
if self.conversations is None:
self._conversations = {}
return self.conversations.get(name, {}).copy() # type: ignore[union-attr]
async def update_conversation(
self, name: str, key: ConversationKey, new_state: Optional[object]
) -> None:
"""Will update the conversations for the given handler.
Args:
name (:obj:`str`): The handler's name.
key (:obj:`tuple`): The key the state is changed for.
new_state (:obj:`tuple` | :class:`object`): The new state for the given key.
"""
if not self._conversations:
self._conversations = {}
if self._conversations.setdefault(name, {}).get(key) == new_state:
return
self._conversations[name][key] = new_state
self._conversations_json = None
async def update_user_data(self, user_id: int, data: dict[Any, Any]) -> None:
"""Will update the user_data (if changed).
Args:
user_id (:obj:`int`): The user the data might have been changed for.
data (:obj:`dict`): The :attr:`telegram.ext.Application.user_data` ``[user_id]``.
"""
if self._user_data is None:
self._user_data = {}
if self._user_data.get(user_id) == data:
return
self._user_data[user_id] = data
self._user_data_json = None
async def update_chat_data(self, chat_id: int, data: dict[Any, Any]) -> None:
"""Will update the chat_data (if changed).
Args:
chat_id (:obj:`int`): The chat the data might have been changed for.
data (:obj:`dict`): The :attr:`telegram.ext.Application.chat_data` ``[chat_id]``.
"""
if self._chat_data is None:
self._chat_data = {}
if self._chat_data.get(chat_id) == data:
return
self._chat_data[chat_id] = data
self._chat_data_json = None
async def update_bot_data(self, data: dict[Any, Any]) -> None:
"""Will update the bot_data (if changed).
Args:
data (:obj:`dict`): The :attr:`telegram.ext.Application.bot_data`.
"""
if self._bot_data == data:
return
self._bot_data = data
self._bot_data_json = None
async def update_callback_data(self, data: CDCData) -> None:
"""Will update the callback_data (if changed).
.. versionadded:: 13.6
Args:
data (tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \
dict[:obj:`str`, :obj:`str`]]): The relevant data to restore
:class:`telegram.ext.CallbackDataCache`.
"""
if self._callback_data == data:
return
self._callback_data = data
self._callback_data_json = None
async def drop_chat_data(self, chat_id: int) -> None:
"""Will delete the specified key from the :attr:`chat_data`.
.. versionadded:: 20.0
Args:
chat_id (:obj:`int`): The chat id to delete from the persistence.
"""
if self._chat_data is None:
return
self._chat_data.pop(chat_id, None)
self._chat_data_json = None
async def drop_user_data(self, user_id: int) -> None:
"""Will delete the specified key from the :attr:`user_data`.
.. versionadded:: 20.0
Args:
user_id (:obj:`int`): The user id to delete from the persistence.
"""
if self._user_data is None:
return
self._user_data.pop(user_id, None)
self._user_data_json = None
async def refresh_user_data(self, user_id: int, user_data: dict[Any, Any]) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_user_data`
"""
async def refresh_chat_data(self, chat_id: int, chat_data: dict[Any, Any]) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_chat_data`
"""
async def refresh_bot_data(self, bot_data: dict[Any, Any]) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_bot_data`
"""
async def flush(self) -> None:
"""Does nothing.
.. versionadded:: 20.0
.. seealso:: :meth:`telegram.ext.BasePersistence.flush`
"""
@staticmethod
def _encode_conversations_to_json(conversations: dict[str, ConversationDict]) -> str:
"""Helper method to encode a conversations dict (that uses tuples as keys) to a
JSON-serializable way. Use :meth:`self._decode_conversations_from_json` to decode.
Args:
conversations (:obj:`dict`): The conversations dict to transform to JSON.
Returns:
:obj:`str`: The JSON-serialized conversations dict
"""
tmp: dict[str, JSONDict] = {}
for handler, states in conversations.items():
tmp[handler] = {}
for key, state in states.items():
tmp[handler][json.dumps(key)] = state
return json.dumps(tmp)
@staticmethod
def _decode_conversations_from_json(json_string: str) -> dict[str, ConversationDict]:
"""Helper method to decode a conversations dict (that uses tuples as keys) from a
JSON-string created with :meth:`self._encode_conversations_to_json`.
Args:
json_string (:obj:`str`): The conversations dict as JSON string.
Returns:
:obj:`dict`: The conversations dict after decoding
"""
tmp = json.loads(json_string)
conversations: dict[str, ConversationDict] = {}
for handler, states in tmp.items():
conversations[handler] = {}
for key, state in states.items():
conversations[handler][tuple(json.loads(key))] = state
return conversations
@staticmethod
def _decode_user_chat_data_from_json(data: str) -> dict[int, dict[object, object]]:
"""Helper method to decode chat or user data (that uses ints as keys) from a
JSON-string.
Args:
data (:obj:`str`): The user/chat_data dict as JSON string.
Returns:
:obj:`dict`: The user/chat_data defaultdict after decoding
"""
tmp: dict[int, dict[object, object]] = {}
decoded_data = json.loads(data)
for user, user_data in decoded_data.items():
int_user_id = int(user)
tmp[int_user_id] = {}
for key, value in user_data.items():
try:
_id = int(key)
except ValueError:
_id = key
tmp[int_user_id][_id] = value
return tmp

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the base class for handlers as used by the Application."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import DVType
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
UT = TypeVar("UT")
class BaseHandler(Generic[UT, CCT, RT], ABC):
"""The base class for all update handlers. Create custom handlers by inheriting from it.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
This class is a :class:`~typing.Generic` class and accepts three type variables:
1. The type of the updates that this handler will handle. Must coincide with the type of the
first argument of :paramref:`callback`. :meth:`check_update` must only accept
updates of this type.
2. The type of the second argument of :paramref:`callback`. Must coincide with the type of the
parameters :paramref:`handle_update.context` and
:paramref:`collect_additional_context.context` as well as the second argument of
:paramref:`callback`. Must be either :class:`~telegram.ext.CallbackContext` or a subclass
of that class.
.. tip::
For this type variable, one should usually provide a :class:`~typing.TypeVar` that is
also used for the mentioned method arguments. That way, a type checker can check whether
this handler fits the definition of the :class:`~Application`.
3. The return type of the :paramref:`callback` function accepted by this handler.
.. seealso:: :wiki:`Types of Handlers <Types-of-Handlers>`
.. versionchanged:: 20.0
* The attribute ``run_async`` is now :paramref:`block`.
* This class was previously named ``Handler``.
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way.
"""
__slots__ = (
"block",
"callback",
)
def __init__(
self: "BaseHandler[UT, CCT, RT]",
callback: HandlerCallback[UT, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
):
self.callback: HandlerCallback[UT, CCT, RT] = callback
self.block: DVType[bool] = block
def __repr__(self) -> str:
"""Give a string representation of the handler in the form ``ClassName[callback=...]``.
As this class doesn't implement :meth:`object.__str__`, the default implementation
will be used, which is equivalent to :meth:`__repr__`.
Returns:
:obj:`str`
"""
try:
callback_name = self.callback.__qualname__
except AttributeError:
callback_name = repr(self.callback)
return build_repr_with_selected_attrs(self, callback=callback_name)
@abstractmethod
def check_update(self, update: object) -> Optional[Union[bool, object]]:
"""
This method is called to determine if an update should be handled by
this handler instance. It should always be overridden.
Note:
Custom updates types can be handled by the application. Therefore, an implementation of
this method should always check the type of :paramref:`update`.
Args:
update (:obj:`object` | :class:`telegram.Update`): The update to be tested.
Returns:
Either :obj:`None` or :obj:`False` if the update should not be handled. Otherwise an
object that will be passed to :meth:`handle_update` and
:meth:`collect_additional_context` when the update gets handled.
"""
async def handle_update(
self,
update: UT,
application: "Application[Any, CCT, Any, Any, Any, Any]",
check_result: object,
context: CCT,
) -> RT:
"""
This method is called if it was determined that an update should indeed
be handled by this instance. Calls :attr:`callback` along with its respectful
arguments. To work with the :class:`telegram.ext.ConversationHandler`, this method
returns the value returned from :attr:`callback`.
Note that it can be overridden if needed by the subclassing handler.
Args:
update (:obj:`str` | :class:`telegram.Update`): The update to be handled.
application (:class:`telegram.ext.Application`): The calling application.
check_result (:class:`object`): The result from :meth:`check_update`.
context (:class:`telegram.ext.CallbackContext`): The context as provided by
the application.
"""
self.collect_additional_context(context, update, application, check_result)
return await self.callback(update, context)
def collect_additional_context(
self,
context: CCT,
update: UT,
application: "Application[Any, CCT, Any, Any, Any, Any]",
check_result: Any,
) -> None:
"""Prepares additional arguments for the context. Override if needed.
Args:
context (:class:`telegram.ext.CallbackContext`): The context object.
update (:class:`telegram.Update`): The update to gather chat/user id from.
application (:class:`telegram.ext.Application`): The calling application.
check_result: The result (return value) from :meth:`check_update`.
"""

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the BusinessConnectionHandler class."""
from typing import Optional, TypeVar
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
class BusinessConnectionHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram
:attr:`Business Connections <telegram.Update.business_connection>`.
.. versionadded:: 21.1
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only
those which are from the specified user ID(s).
username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only
those which are from the specified username(s).
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = (
"_user_ids",
"_usernames",
)
def __init__(
self: "BusinessConnectionHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
user_id: Optional[SCT[int]] = None,
username: Optional[SCT[str]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self._user_ids = parse_chat_id(user_id)
self._usernames = parse_username(username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if isinstance(update, Update) and update.business_connection:
if not self._user_ids and not self._usernames:
return True
if update.business_connection.user.id in self._user_ids:
return True
return update.business_connection.user.username in self._usernames
return False

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the BusinessMessagesDeletedHandler class."""
from typing import Optional, TypeVar
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
class BusinessMessagesDeletedHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle
:attr:`deleted Telegram Business messages <telegram.Update.deleted_business_messages>`.
.. versionadded:: 21.1
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only
those which are from the specified chat ID(s).
username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only
those which are from the specified username(s).
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = (
"_chat_ids",
"_usernames",
)
def __init__(
self: "BusinessMessagesDeletedHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
chat_id: Optional[SCT[int]] = None,
username: Optional[SCT[str]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self._chat_ids = parse_chat_id(chat_id)
self._usernames = parse_username(username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if isinstance(update, Update) and update.deleted_business_messages:
if not self._chat_ids and not self._usernames:
return True
if update.deleted_business_messages.chat.id in self._chat_ids:
return True
return update.deleted_business_messages.chat.username in self._usernames
return False

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the CallbackQueryHandler class."""
import asyncio
import re
from re import Match, Pattern
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class CallbackQueryHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram
:attr:`callback queries <telegram.Update.callback_query>`. Optionally based on a regex.
Read the documentation of the :mod:`re` module for more information.
Note:
* If your bot allows arbitrary objects as
:paramref:`~telegram.InlineKeyboardButton.callback_data`, it may happen that the
original :attr:`~telegram.InlineKeyboardButton.callback_data` for the incoming
:class:`telegram.CallbackQuery` can not be found. This is the case when either a
malicious client tempered with the :attr:`telegram.CallbackQuery.data` or the data was
simply dropped from cache or not persisted. In these
cases, an instance of :class:`telegram.ext.InvalidCallbackData` will be set as
:attr:`telegram.CallbackQuery.data`.
.. versionadded:: 13.6
* If neither :paramref:`pattern` nor :paramref:`game_pattern` is set, `any`
``CallbackQuery`` will be handled. If only :paramref:`pattern` is set, queries with
:attr:`~telegram.CallbackQuery.game_short_name` will `not` be considered and vice versa.
If both patterns are set, queries with either :attr:
`~telegram.CallbackQuery.game_short_name` or :attr:`~telegram.CallbackQuery.data`
matching the defined pattern will be handled
.. versionadded:: 21.5
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
pattern (:obj:`str` | :func:`re.Pattern <re.compile>` | :obj:`callable` | :obj:`type`, \
optional):
Pattern to test :attr:`telegram.CallbackQuery.data` against. If a string or a regex
pattern is passed, :func:`re.match` is used on :attr:`telegram.CallbackQuery.data` to
determine if an update should be handled by this handler. If your bot allows arbitrary
objects as :paramref:`~telegram.InlineKeyboardButton.callback_data`, non-strings will
be accepted. To filter arbitrary objects you may pass:
- a callable, accepting exactly one argument, namely the
:attr:`telegram.CallbackQuery.data`. It must return :obj:`True` or
:obj:`False`/:obj:`None` to indicate, whether the update should be handled.
- a :obj:`type`. If :attr:`telegram.CallbackQuery.data` is an instance of that type
(or a subclass), the update will be handled.
If :attr:`telegram.CallbackQuery.data` is :obj:`None`, the
:class:`telegram.CallbackQuery` update will not be handled.
.. seealso:: :wiki:`Arbitrary callback_data <Arbitrary-callback_data>`
.. versionchanged:: 13.6
Added support for arbitrary callback data.
game_pattern (:obj:`str` | :func:`re.Pattern <re.compile>` | optional)
Pattern to test :attr:`telegram.CallbackQuery.game_short_name` against. If a string or
a regex pattern is passed, :func:`re.match` is used on
:attr:`telegram.CallbackQuery.game_short_name` to determine if an update should be
handled by this handler.
.. versionadded:: 21.5
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
pattern (:func:`re.Pattern <re.compile>` | :obj:`callable` | :obj:`type`): Optional.
Regex pattern, callback or type to test :attr:`telegram.CallbackQuery.data` against.
.. versionchanged:: 13.6
Added support for arbitrary callback data.
game_pattern (:func:`re.Pattern <re.compile>`): Optional.
Regex pattern to test :attr:`telegram.CallbackQuery.game_short_name`
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("game_pattern", "pattern")
def __init__(
self: "CallbackQueryHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
pattern: Optional[
Union[str, Pattern[str], type, Callable[[object], Optional[bool]]]
] = None,
game_pattern: Optional[Union[str, Pattern[str]]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
if callable(pattern) and asyncio.iscoroutinefunction(pattern):
raise TypeError(
"The `pattern` must not be a coroutine function! Use an ordinary function instead."
)
if isinstance(pattern, str):
pattern = re.compile(pattern)
if isinstance(game_pattern, str):
game_pattern = re.compile(game_pattern)
self.pattern: Optional[
Union[str, Pattern[str], type, Callable[[object], Optional[bool]]]
] = pattern
self.game_pattern: Optional[Union[str, Pattern[str]]] = game_pattern
def check_update(self, update: object) -> Optional[Union[bool, object]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
# pylint: disable=too-many-return-statements
if not (isinstance(update, Update) and update.callback_query):
return None
callback_data = update.callback_query.data
game_short_name = update.callback_query.game_short_name
if not any([self.pattern, self.game_pattern]):
return True
# we check for .data or .game_short_name from update to filter based on whats coming
# this gives xor-like behavior
if callback_data:
if not self.pattern:
return False
if isinstance(self.pattern, type):
return isinstance(callback_data, self.pattern)
if callable(self.pattern):
return self.pattern(callback_data)
if not isinstance(callback_data, str):
return False
if match := re.match(self.pattern, callback_data):
return match
elif game_short_name:
if not self.game_pattern:
return False
if match := re.match(self.game_pattern, game_short_name):
return match
else:
return True
return False
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Union[bool, Match[str]],
) -> None:
"""Add the result of ``re.match(pattern, update.callback_query.data)`` to
:attr:`CallbackContext.matches` as list with one element.
"""
if self.pattern:
check_result = cast(Match, check_result)
context.matches = [check_result]

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ChatBoostHandler class."""
from typing import Final, Optional
from telegram import Update
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, RT, HandlerCallback
class ChatBoostHandler(BaseHandler[Update, CCT, RT]):
"""
Handler class to handle Telegram updates that contain a chat boost.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
.. versionadded:: 20.8
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
chat_boost_types (:obj:`int`, optional): Pass one of
:attr:`CHAT_BOOST`, :attr:`REMOVED_CHAT_BOOST` or
:attr:`ANY_CHAT_BOOST` to specify if this handler should handle only updates with
:attr:`telegram.Update.chat_boost`,
:attr:`telegram.Update.removed_chat_boost` or both. Defaults to
:attr:`CHAT_BOOST`.
chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow
only those which happen in the specified chat ID(s).
chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow
only those which happen in the specified username(s).
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
chat_boost_types (:obj:`int`): Optional. Specifies if this handler should handle only
updates with :attr:`telegram.Update.chat_boost`,
:attr:`telegram.Update.removed_chat_boost` or both.
block (:obj:`bool`): Determines whether the callback will run in a blocking way.
"""
__slots__ = (
"_chat_ids",
"_chat_usernames",
"chat_boost_types",
)
CHAT_BOOST: Final[int] = -1
""" :obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_boost`."""
REMOVED_CHAT_BOOST: Final[int] = 0
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.removed_chat_boost`."""
ANY_CHAT_BOOST: Final[int] = 1
""":obj:`int`: Used as a constant to handle both :attr:`telegram.Update.chat_boost`
and :attr:`telegram.Update.removed_chat_boost`."""
def __init__(
self: "ChatBoostHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
chat_boost_types: int = CHAT_BOOST,
chat_id: Optional[int] = None,
chat_username: Optional[str] = None,
block: bool = True,
):
super().__init__(callback, block=block)
self.chat_boost_types: int = chat_boost_types
self._chat_ids = parse_chat_id(chat_id)
self._chat_usernames = parse_username(chat_username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not isinstance(update, Update):
return False
if not (update.chat_boost or update.removed_chat_boost):
return False
if self.chat_boost_types == self.CHAT_BOOST and not update.chat_boost:
return False
if self.chat_boost_types == self.REMOVED_CHAT_BOOST and not update.removed_chat_boost:
return False
if not any((self._chat_ids, self._chat_usernames)):
return True
# Extract chat and user IDs and usernames from the update for comparison
chat_id = chat.id if (chat := update.effective_chat) else None
chat_username = chat.username if chat else None
return bool(self._chat_ids and (chat_id in self._chat_ids)) or bool(
self._chat_usernames and (chat_username in self._chat_usernames)
)

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ChatJoinRequestHandler class."""
from typing import Optional
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import RT, SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, HandlerCallback
class ChatJoinRequestHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain
:attr:`telegram.Update.chat_join_request`.
Note:
If neither of :paramref:`username` and the :paramref:`chat_id` are passed, this handler
accepts *any* join request. Otherwise, this handler accepts all requests to join chats
for which the chat ID is listed in :paramref:`chat_id` or the username is listed in
:paramref:`username`, or both.
.. versionadded:: 20.0
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
.. versionadded:: 13.8
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only
those which are asking to join the specified chat ID(s).
.. versionadded:: 20.0
username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only
those which are asking to join the specified username(s).
.. versionadded:: 20.0
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way..
"""
__slots__ = (
"_chat_ids",
"_usernames",
)
def __init__(
self: "ChatJoinRequestHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
chat_id: Optional[SCT[int]] = None,
username: Optional[SCT[str]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self._chat_ids = parse_chat_id(chat_id)
self._usernames = parse_username(username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if isinstance(update, Update) and update.chat_join_request:
if not self._chat_ids and not self._usernames:
return True
if update.chat_join_request.chat.id in self._chat_ids:
return True
return update.chat_join_request.from_user.username in self._usernames
return False

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ChatMemberHandler class."""
from typing import Final, Optional, TypeVar
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
class ChatMemberHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain a chat member update.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
:any:`Chat Member Bot <examples.chatmemberbot>`
.. versionadded:: 13.4
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
chat_member_types (:obj:`int`, optional): Pass one of :attr:`MY_CHAT_MEMBER`,
:attr:`CHAT_MEMBER` or :attr:`ANY_CHAT_MEMBER` to specify if this handler should handle
only updates with :attr:`telegram.Update.my_chat_member`,
:attr:`telegram.Update.chat_member` or both. Defaults to :attr:`MY_CHAT_MEMBER`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters chat member updates from
specified chat ID(s) only.
.. versionadded:: 21.3
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
chat_member_types (:obj:`int`): Optional. Specifies if this handler should handle
only updates with :attr:`telegram.Update.my_chat_member`,
:attr:`telegram.Update.chat_member` or both.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = (
"_chat_ids",
"chat_member_types",
)
MY_CHAT_MEMBER: Final[int] = -1
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.my_chat_member`."""
CHAT_MEMBER: Final[int] = 0
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_member`."""
ANY_CHAT_MEMBER: Final[int] = 1
""":obj:`int`: Used as a constant to handle both :attr:`telegram.Update.my_chat_member`
and :attr:`telegram.Update.chat_member`."""
def __init__(
self: "ChatMemberHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
chat_member_types: int = MY_CHAT_MEMBER,
block: DVType[bool] = DEFAULT_TRUE,
chat_id: Optional[SCT[int]] = None,
):
super().__init__(callback, block=block)
self.chat_member_types: Optional[int] = chat_member_types
self._chat_ids = parse_chat_id(chat_id)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not isinstance(update, Update):
return False
if not (update.my_chat_member or update.chat_member):
return False
if (
self._chat_ids
and update.effective_chat
and update.effective_chat.id not in self._chat_ids
):
return False
if self.chat_member_types == self.ANY_CHAT_MEMBER:
return True
if self.chat_member_types == self.CHAT_MEMBER:
return bool(update.chat_member)
return bool(update.my_chat_member)

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ChosenInlineResultHandler class."""
import re
from re import Match, Pattern
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
if TYPE_CHECKING:
from telegram.ext import Application
class ChosenInlineResultHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain
:attr:`telegram.Update.chosen_inline_result`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`, optional): Regex pattern. If not
:obj:`None`, :func:`re.match`
is used on :attr:`telegram.ChosenInlineResult.result_id` to determine if an update
should be handled by this handler. This is accessible in the callback as
:attr:`telegram.ext.CallbackContext.matches`.
.. versionadded:: 13.6
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
pattern (`Pattern`): Optional. Regex pattern to test
:attr:`telegram.ChosenInlineResult.result_id` against.
.. versionadded:: 13.6
"""
__slots__ = ("pattern",)
def __init__(
self: "ChosenInlineResultHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
pattern: Optional[Union[str, Pattern[str]]] = None,
):
super().__init__(callback, block=block)
if isinstance(pattern, str):
pattern = re.compile(pattern)
self.pattern: Optional[Union[str, Pattern[str]]] = pattern
def check_update(self, update: object) -> Optional[Union[bool, object]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool` | :obj:`re.match`
"""
if isinstance(update, Update) and update.chosen_inline_result:
if self.pattern:
if match := re.match(self.pattern, update.chosen_inline_result.result_id):
return match
else:
return True
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Union[bool, Match[str]],
) -> None:
"""This function adds the matched regex pattern result to
:attr:`telegram.ext.CallbackContext.matches`.
"""
if self.pattern:
check_result = cast(Match, check_result)
context.matches = [check_result]

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the CommandHandler class."""
import re
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
from telegram import MessageEntity, Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext import filters as filters_module
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, FilterDataDict, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class CommandHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram commands.
Commands are Telegram messages that start with a :attr:`telegram.MessageEntity.BOT_COMMAND`
(so with ``/``, optionally followed by an ``@`` and the bot's name and/or some additional
text). The handler will add a :obj:`list` to the :class:`CallbackContext` named
:attr:`CallbackContext.args`. It will contain a list of strings, which is the text following
the command split on single or consecutive whitespace characters.
By default, the handler listens to messages as well as edited messages. To change this behavior
use :attr:`~filters.UpdateType.EDITED_MESSAGE <telegram.ext.filters.UpdateType.EDITED_MESSAGE>`
in the filter argument.
Note:
:class:`CommandHandler` does *not* handle (edited) channel posts and does *not* handle
commands that are part of a caption. Please use :class:`~telegram.ext.MessageHandler`
with a suitable combination of filters (e.g.
:attr:`telegram.ext.filters.UpdateType.CHANNEL_POSTS`,
:attr:`telegram.ext.filters.CAPTION` and :class:`telegram.ext.filters.Regex`) to handle
those messages.
Note:
If you want to support a different entity in the beginning, e.g. if a command message is
wrapped in a :attr:`telegram.MessageEntity.CODE`, use the
:class:`telegram.ext.PrefixHandler`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
* :any:`Timer Bot <examples.timerbot>`
* :any:`Error Handler Bot <examples.errorhandlerbot>`
.. versionchanged:: 20.0
* Renamed the attribute ``command`` to :attr:`commands`, which now is always a
:class:`frozenset`
* Updating the commands this handler listens to is no longer possible.
Args:
command (:obj:`str` | Collection[:obj:`str`]):
The command or list of commands this handler should listen for. Case-insensitive.
Limitations are the same as for :attr:`telegram.BotCommand.command`.
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
filters (:class:`telegram.ext.filters.BaseFilter`, optional): A filter inheriting from
:class:`telegram.ext.filters.BaseFilter`. Standard filters can be found in
:mod:`telegram.ext.filters`. Filters can be combined using bitwise
operators (``&`` for :keyword:`and`, ``|`` for :keyword:`or`, ``~`` for :keyword:`not`)
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
has_args (:obj:`bool` | :obj:`int`, optional):
Determines whether the command handler should process the update or not.
If :obj:`True`, the handler will process any non-zero number of args.
If :obj:`False`, the handler will only process if there are no args.
if :obj:`int`, the handler will only process if there are exactly that many args.
Defaults to :obj:`None`, which means the handler will process any or no args.
.. versionadded:: 20.5
Raises:
:exc:`ValueError`: When the command is too long or has illegal chars.
Attributes:
commands (frozenset[:obj:`str`]): The set of commands this handler should listen for.
callback (:term:`coroutine function`): The callback function for this handler.
filters (:class:`telegram.ext.filters.BaseFilter`): Optional. Only allow updates with these
filters.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
has_args (:obj:`bool` | :obj:`int` | None):
Optional argument, otherwise all implementations of :class:`CommandHandler` will break.
Defaults to :obj:`None`, which means the handler will process any args or no args.
.. versionadded:: 20.5
"""
__slots__ = ("commands", "filters", "has_args")
def __init__(
self: "CommandHandler[CCT, RT]",
command: SCT[str],
callback: HandlerCallback[Update, CCT, RT],
filters: Optional[filters_module.BaseFilter] = None,
block: DVType[bool] = DEFAULT_TRUE,
has_args: Optional[Union[bool, int]] = None,
):
super().__init__(callback, block=block)
if isinstance(command, str):
commands = frozenset({command.lower()})
else:
commands = frozenset(x.lower() for x in command)
for comm in commands:
if not re.match(r"^[\da-z_]{1,32}$", comm):
raise ValueError(f"Command `{comm}` is not a valid bot command")
self.commands: frozenset[str] = commands
self.filters: filters_module.BaseFilter = (
filters if filters is not None else filters_module.UpdateType.MESSAGES
)
self.has_args: Optional[Union[bool, int]] = has_args
if (isinstance(self.has_args, int)) and (self.has_args < 0):
raise ValueError("CommandHandler argument has_args cannot be a negative integer")
def _check_correct_args(self, args: list[str]) -> Optional[bool]:
"""Determines whether the args are correct for this handler. Implemented in check_update().
Args:
args (:obj:`list`): The args for the handler.
Returns:
:obj:`bool`: Whether the args are valid for this handler.
"""
return bool(
(self.has_args is None)
or (self.has_args is True and args)
or (self.has_args is False and not args)
or (isinstance(self.has_args, int) and len(args) == self.has_args)
)
def check_update(
self, update: object
) -> Optional[Union[bool, tuple[list[str], Optional[Union[bool, FilterDataDict]]]]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`list`: The list of args for the handler.
"""
if isinstance(update, Update) and update.effective_message:
message = update.effective_message
if (
message.entities
and message.entities[0].type == MessageEntity.BOT_COMMAND
and message.entities[0].offset == 0
and message.text
and message.get_bot()
):
command = message.text[1 : message.entities[0].length]
args = message.text.split()[1:]
command_parts = command.split("@")
command_parts.append(message.get_bot().username)
if not (
command_parts[0].lower() in self.commands
and command_parts[1].lower() == message.get_bot().username.lower()
):
return None
if not self._check_correct_args(args):
return None
filter_result = self.filters.check_update(update)
if filter_result:
return args, filter_result
return False
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[Union[bool, tuple[list[str], Optional[bool]]]],
) -> None:
"""Add text after the command to :attr:`CallbackContext.args` as list, split on single
whitespaces and add output of data filters to :attr:`CallbackContext` as well.
"""
if isinstance(check_result, tuple):
context.args = check_result[0]
if isinstance(check_result[1], dict):
context.update(check_result[1])

View File

@@ -0,0 +1,960 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ConversationHandler."""
import asyncio
import datetime
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, Generic, NoReturn, Optional, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE, DefaultValue
from telegram._utils.logging import get_logger
from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import DVType
from telegram._utils.warnings import warn
from telegram.ext._application import ApplicationHandlerStop
from telegram.ext._extbot import ExtBot
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._handlers.callbackqueryhandler import CallbackQueryHandler
from telegram.ext._handlers.choseninlineresulthandler import ChosenInlineResultHandler
from telegram.ext._handlers.inlinequeryhandler import InlineQueryHandler
from telegram.ext._handlers.stringcommandhandler import StringCommandHandler
from telegram.ext._handlers.stringregexhandler import StringRegexHandler
from telegram.ext._handlers.typehandler import TypeHandler
from telegram.ext._utils.trackingdict import TrackingDict
from telegram.ext._utils.types import CCT, ConversationDict, ConversationKey
if TYPE_CHECKING:
from telegram.ext import Application, Job, JobQueue
_CheckUpdateType = tuple[object, ConversationKey, BaseHandler[Update, CCT, object], object]
_LOGGER = get_logger(__name__, class_name="ConversationHandler")
@dataclass
class _ConversationTimeoutContext(Generic[CCT]):
"""Used as a datastore for conversation timeouts. Passed in the
:paramref:`JobQueue.run_once.data` parameter. See :meth:`_trigger_timeout`.
"""
__slots__ = ("application", "callback_context", "conversation_key", "update")
conversation_key: ConversationKey
update: Update
application: "Application[Any, CCT, Any, Any, Any, JobQueue]"
callback_context: CCT
@dataclass
class PendingState:
"""Thin wrapper around :class:`asyncio.Task` to handle block=False handlers. Note that this is
a public class of this module, since :meth:`Application.update_persistence` needs to access it.
It's still hidden from users, since this module itself is private.
"""
__slots__ = ("old_state", "task")
task: asyncio.Task
old_state: object
def done(self) -> bool:
return self.task.done()
def resolve(self) -> object:
"""Returns the new state of the :class:`ConversationHandler` if available. If there was an
exception during the task execution, then return the old state. If both the new and old
state are :obj:`None`, return `CH.END`. If only the new state is :obj:`None`, return the
old state.
Raises:
:exc:`RuntimeError`: If the current task has not yet finished.
"""
if not self.task.done():
raise RuntimeError("New state is not yet available")
exc = self.task.exception()
if exc:
_LOGGER.exception(
"Task function raised exception. Falling back to old state %s",
self.old_state,
)
return self.old_state
res = self.task.result()
if res is None and self.old_state is None:
res = ConversationHandler.END
elif res is None:
# returning None from a callback means that we want to stay in the old state
return self.old_state
return res
class ConversationHandler(BaseHandler[Update, CCT, object]):
"""
A handler to hold a conversation with a single or multiple users through Telegram updates by
managing three collections of other handlers.
Warning:
:class:`ConversationHandler` heavily relies on incoming updates being processed one by one.
When using this handler, :attr:`telegram.ext.ApplicationBuilder.concurrent_updates` should
be set to :obj:`False`.
Note:
:class:`ConversationHandler` will only accept updates that are (subclass-)instances of
:class:`telegram.Update`. This is, because depending on the :attr:`per_user` and
:attr:`per_chat`, :class:`ConversationHandler` relies on
:attr:`telegram.Update.effective_user` and/or :attr:`telegram.Update.effective_chat` in
order to determine which conversation an update should belong to. For
:attr:`per_message=True <per_message>`, :class:`ConversationHandler` uses
:attr:`update.callback_query.message.message_id <telegram.Message.message_id>` when
:attr:`per_chat=True <per_chat>` and
:attr:`update.callback_query.inline_message_id <.CallbackQuery.inline_message_id>` when
:attr:`per_chat=False <per_chat>`. For a more detailed explanation, please see our `FAQ`_.
Finally, :class:`ConversationHandler`, does *not* handle (edited) channel posts.
.. _`FAQ`: https://github.com/python-telegram-bot/python-telegram-bot/wiki\
/Frequently-Asked-Questions#what-do-the-per_-settings-in-conversation handler-do
The first collection, a :obj:`list` named :attr:`entry_points`, is used to initiate the
conversation, for example with a :class:`telegram.ext.CommandHandler` or
:class:`telegram.ext.MessageHandler`.
The second collection, a :obj:`dict` named :attr:`states`, contains the different conversation
steps and one or more associated handlers that should be used if the user sends a message when
the conversation with them is currently in that state. Here you can also define a state for
:attr:`TIMEOUT` to define the behavior when :attr:`conversation_timeout` is exceeded, and a
state for :attr:`WAITING` to define behavior when a new update is received while the previous
:attr:`block=False <block>` handler is not finished.
The third collection, a :obj:`list` named :attr:`fallbacks`, is used if the user is currently
in a conversation but the state has either no associated handler or the handler that is
associated to the state is inappropriate for the update, for example if the update contains a
command, but a regular text message is expected. You could use this for a ``/cancel`` command
or to let the user know their message was not recognized.
To change the state of conversation, the callback function of a handler must return the new
state after responding to the user. If it does not return anything (returning :obj:`None` by
default), the state will not change. If an entry point callback function returns :obj:`None`,
the conversation ends immediately after the execution of this callback function.
To end the conversation, the callback function must return :attr:`END` or ``-1``. To
handle the conversation timeout, use handler :attr:`TIMEOUT` or ``-2``.
Finally, :class:`telegram.ext.ApplicationHandlerStop` can be used in conversations as described
in its documentation.
Note:
In each of the described collections of handlers, a handler may in turn be a
:class:`ConversationHandler`. In that case, the child :class:`ConversationHandler` should
have the attribute :attr:`map_to_parent` which allows returning to the parent conversation
at specified states within the child conversation.
Note that the keys in :attr:`map_to_parent` must not appear as keys in :attr:`states`
attribute or else the latter will be ignored. You may map :attr:`END` to one of the parents
states to continue the parent conversation after the child conversation has ended or even
map a state to :attr:`END` to end the *parent* conversation from within the child
conversation. For an example on nested :class:`ConversationHandler` s, see
:any:`examples.nestedconversationbot`.
Examples:
* :any:`Conversation Bot <examples.conversationbot>`
* :any:`Conversation Bot 2 <examples.conversationbot2>`
* :any:`Nested Conversation Bot <examples.nestedconversationbot>`
* :any:`Persistent Conversation Bot <examples.persistentconversationbot>`
Args:
entry_points (list[:class:`telegram.ext.BaseHandler`]): A list of :obj:`BaseHandler`
objects that
can trigger the start of the conversation. The first handler whose :meth:`check_update`
method returns :obj:`True` will be used. If all return :obj:`False`, the update is not
handled.
states (dict[:obj:`object`, list[:class:`telegram.ext.BaseHandler`]]): A :obj:`dict` that
defines the different states of conversation a user can be in and one or more
associated :obj:`BaseHandler` objects that should be used in that state. The first
handler whose :meth:`check_update` method returns :obj:`True` will be used.
fallbacks (list[:class:`telegram.ext.BaseHandler`]): A list of handlers that might be used
if the user is in a conversation, but every handler for their current state returned
:obj:`False` on :meth:`check_update`. The first handler which :meth:`check_update`
method returns :obj:`True` will be used. If all return :obj:`False`, the update is not
handled.
allow_reentry (:obj:`bool`, optional): If set to :obj:`True`, a user that is currently in a
conversation can restart the conversation by triggering one of the entry points.
Default is :obj:`False`.
per_chat (:obj:`bool`, optional): If the conversation key should contain the Chat's ID.
Default is :obj:`True`.
per_user (:obj:`bool`, optional): If the conversation key should contain the User's ID.
Default is :obj:`True`.
per_message (:obj:`bool`, optional): If the conversation key should contain the Message's
ID. Default is :obj:`False`.
conversation_timeout (:obj:`float` | :obj:`datetime.timedelta`, optional): When this
handler is inactive more than this timeout (in seconds), it will be automatically
ended. If this value is ``0`` or :obj:`None` (default), there will be no timeout. The
last received update and the corresponding :class:`context <.CallbackContext>` will be
handled by *ALL* the handler's whose :meth:`check_update` method returns :obj:`True`
that are in the state :attr:`ConversationHandler.TIMEOUT`.
Caution:
* This feature relies on the :attr:`telegram.ext.Application.job_queue` being set
and hence requires that the dependencies that :class:`telegram.ext.JobQueue`
relies on are installed.
* Using :paramref:`conversation_timeout` with nested conversations is currently
not supported. You can still try to use it, but it will likely behave
differently from what you expect.
name (:obj:`str`, optional): The name for this conversation handler. Required for
persistence.
persistent (:obj:`bool`, optional): If the conversation's dict for this handler should be
saved. :paramref:`name` is required and persistence has to be set in
:attr:`Application <.Application.persistence>`.
.. versionchanged:: 20.0
Was previously named as ``persistence``.
map_to_parent (dict[:obj:`object`, :obj:`object`], optional): A :obj:`dict` that can be
used to instruct a child conversation handler to transition into a mapped state on
its parent conversation handler in place of a specified nested state.
block (:obj:`bool`, optional): Pass :obj:`False` or :obj:`True` to set a default value for
the :attr:`BaseHandler.block` setting of all handlers (in :attr:`entry_points`,
:attr:`states` and :attr:`fallbacks`). The resolution order for checking if a handler
should be run non-blocking is:
1. :attr:`telegram.ext.BaseHandler.block` (if set)
2. the value passed to this parameter (if any)
3. :attr:`telegram.ext.Defaults.block` (if defaults are used)
.. seealso:: :wiki:`Concurrency`
.. versionchanged:: 20.0
No longer overrides the handlers settings. Resolution order was changed.
Raises:
:exc:`ValueError`: If :paramref:`persistent` is used but :paramref:`name` was not set, or
when :attr:`per_message`, :attr:`per_chat`, :attr:`per_user` are all :obj:`False`.
Attributes:
block (:obj:`bool`): Determines whether the callback will run in a blocking way. Always
:obj:`True` since conversation handlers handle any non-blocking callbacks internally.
"""
__slots__ = (
"_allow_reentry",
"_block",
"_child_conversations",
"_conversation_timeout",
"_conversations",
"_entry_points",
"_fallbacks",
"_map_to_parent",
"_name",
"_per_chat",
"_per_message",
"_per_user",
"_persistent",
"_states",
"_timeout_jobs_lock",
"timeout_jobs",
)
END: Final[int] = -1
""":obj:`int`: Used as a constant to return when a conversation is ended."""
TIMEOUT: Final[int] = -2
""":obj:`int`: Used as a constant to handle state when a conversation is timed out
(exceeded :attr:`conversation_timeout`).
"""
WAITING: Final[int] = -3
""":obj:`int`: Used as a constant to handle state when a conversation is still waiting on the
previous :attr:`block=False <block>` handler to finish."""
# pylint: disable=super-init-not-called
def __init__(
self: "ConversationHandler[CCT]",
entry_points: list[BaseHandler[Update, CCT, object]],
states: dict[object, list[BaseHandler[Update, CCT, object]]],
fallbacks: list[BaseHandler[Update, CCT, object]],
allow_reentry: bool = False,
per_chat: bool = True,
per_user: bool = True,
per_message: bool = False,
conversation_timeout: Optional[Union[float, datetime.timedelta]] = None,
name: Optional[str] = None,
persistent: bool = False,
map_to_parent: Optional[dict[object, object]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
# these imports need to be here because of circular import error otherwise
from telegram.ext import ( # pylint: disable=import-outside-toplevel
PollAnswerHandler,
PollHandler,
PreCheckoutQueryHandler,
ShippingQueryHandler,
)
# self.block is what the Application checks and we want it to always run CH in a blocking
# way so that CH can take care of any non-blocking logic internally
self.block: DVType[bool] = True
# Store the actual setting in a protected variable instead
self._block: DVType[bool] = block
self._entry_points: list[BaseHandler[Update, CCT, object]] = entry_points
self._states: dict[object, list[BaseHandler[Update, CCT, object]]] = states
self._fallbacks: list[BaseHandler[Update, CCT, object]] = fallbacks
self._allow_reentry: bool = allow_reentry
self._per_user: bool = per_user
self._per_chat: bool = per_chat
self._per_message: bool = per_message
self._conversation_timeout: Optional[Union[float, datetime.timedelta]] = (
conversation_timeout
)
self._name: Optional[str] = name
self._map_to_parent: Optional[dict[object, object]] = map_to_parent
# if conversation_timeout is used, this dict is used to schedule a job which runs when the
# conv has timed out.
self.timeout_jobs: dict[ConversationKey, Job[Any]] = {}
self._timeout_jobs_lock = asyncio.Lock()
self._conversations: ConversationDict = {}
self._child_conversations: set[ConversationHandler] = set()
if persistent and not self.name:
raise ValueError("Conversations can't be persistent when handler is unnamed.")
self._persistent: bool = persistent
if not any((self.per_user, self.per_chat, self.per_message)):
raise ValueError("'per_user', 'per_chat' and 'per_message' can't all be 'False'")
if self.per_message and not self.per_chat:
warn(
"If 'per_message=True' is used, 'per_chat=True' should also be used, "
"since message IDs are not globally unique.",
stacklevel=2,
)
all_handlers: list[BaseHandler[Update, CCT, object]] = []
all_handlers.extend(entry_points)
all_handlers.extend(fallbacks)
for state_handlers in states.values():
all_handlers.extend(state_handlers)
self._child_conversations.update(
handler for handler in all_handlers if isinstance(handler, ConversationHandler)
)
# this link will be added to all warnings tied to per_* setting
per_faq_link = (
" Read this FAQ entry to learn more about the per_* settings: "
"https://github.com/python-telegram-bot/python-telegram-bot/wiki"
"/Frequently-Asked-Questions#what-do-the-per_-settings-in-conversationhandler-do."
)
# this loop is going to warn the user about handlers which can work unexpectedly
# in conversations
for handler in all_handlers:
if isinstance(handler, (StringCommandHandler, StringRegexHandler)):
warn(
"The `ConversationHandler` only handles updates of type `telegram.Update`. "
f"{handler.__class__.__name__} handles updates of type `str`.",
stacklevel=2,
)
elif isinstance(handler, TypeHandler) and not issubclass(handler.type, Update):
warn(
"The `ConversationHandler` only handles updates of type `telegram.Update`."
f" The TypeHandler is set to handle {handler.type.__name__}.",
stacklevel=2,
)
elif isinstance(handler, PollHandler):
warn(
"PollHandler will never trigger in a conversation since it has no information "
"about the chat or the user who voted in it. Do you mean the "
"`PollAnswerHandler`?",
stacklevel=2,
)
elif self.per_chat and (
isinstance(
handler,
(
ShippingQueryHandler,
InlineQueryHandler,
ChosenInlineResultHandler,
PreCheckoutQueryHandler,
PollAnswerHandler,
),
)
):
warn(
f"Updates handled by {handler.__class__.__name__} only have information about "
"the user, so this handler won't ever be triggered if `per_chat=True`."
f"{per_faq_link}",
stacklevel=2,
)
elif self.per_message and not isinstance(handler, CallbackQueryHandler):
warn(
"If 'per_message=True', all entry points, state handlers, and fallbacks"
" must be 'CallbackQueryHandler', since no other handlers "
f"have a message context.{per_faq_link}",
stacklevel=2,
)
elif not self.per_message and isinstance(handler, CallbackQueryHandler):
warn(
"If 'per_message=False', 'CallbackQueryHandler' will not be "
f"tracked for every message.{per_faq_link}",
stacklevel=2,
)
if self.conversation_timeout and isinstance(handler, self.__class__):
warn(
"Using `conversation_timeout` with nested conversations is currently not "
"supported. You can still try to use it, but it will likely behave "
"differently from what you expect.",
stacklevel=2,
)
def __repr__(self) -> str:
"""Give a string representation of the ConversationHandler in the form
``ConversationHandler[name=..., states={...}]``.
If there are more than 3 states, only the first 3 states are listed.
As this class doesn't implement :meth:`object.__str__`, the default implementation
will be used, which is equivalent to :meth:`__repr__`.
Returns:
:obj:`str`
"""
truncation_threshold = 3
states = dict(list(self.states.items())[:truncation_threshold])
states_string = str(states)
if len(self.states) > truncation_threshold:
states_string = states_string[:-1] + ", ...}"
return build_repr_with_selected_attrs(
self,
name=self.name,
states=states_string,
)
@property
def entry_points(self) -> list[BaseHandler[Update, CCT, object]]:
"""list[:class:`telegram.ext.BaseHandler`]: A list of :obj:`BaseHandler` objects that can
trigger the start of the conversation.
"""
return self._entry_points
@entry_points.setter
def entry_points(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to entry_points after initialization."
)
@property
def states(self) -> dict[object, list[BaseHandler[Update, CCT, object]]]:
"""dict[:obj:`object`, list[:class:`telegram.ext.BaseHandler`]]: A :obj:`dict` that
defines the different states of conversation a user can be in and one or more
associated :obj:`BaseHandler` objects that should be used in that state.
"""
return self._states
@states.setter
def states(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to states after initialization.")
@property
def fallbacks(self) -> list[BaseHandler[Update, CCT, object]]:
"""list[:class:`telegram.ext.BaseHandler`]: A list of handlers that might be used if
the user is in a conversation, but every handler for their current state returned
:obj:`False` on :meth:`check_update`.
"""
return self._fallbacks
@fallbacks.setter
def fallbacks(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to fallbacks after initialization.")
@property
def allow_reentry(self) -> bool:
""":obj:`bool`: Determines if a user can restart a conversation with an entry point."""
return self._allow_reentry
@allow_reentry.setter
def allow_reentry(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to allow_reentry after initialization."
)
@property
def per_user(self) -> bool:
""":obj:`bool`: If the conversation key should contain the User's ID."""
return self._per_user
@per_user.setter
def per_user(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to per_user after initialization.")
@property
def per_chat(self) -> bool:
""":obj:`bool`: If the conversation key should contain the Chat's ID."""
return self._per_chat
@per_chat.setter
def per_chat(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to per_chat after initialization.")
@property
def per_message(self) -> bool:
""":obj:`bool`: If the conversation key should contain the message's ID."""
return self._per_message
@per_message.setter
def per_message(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to per_message after initialization.")
@property
def conversation_timeout(
self,
) -> Optional[Union[float, datetime.timedelta]]:
""":obj:`float` | :obj:`datetime.timedelta`: Optional. When this
handler is inactive more than this timeout (in seconds), it will be automatically
ended.
"""
return self._conversation_timeout
@conversation_timeout.setter
def conversation_timeout(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to conversation_timeout after initialization."
)
@property
def name(self) -> Optional[str]:
""":obj:`str`: Optional. The name for this :class:`ConversationHandler`."""
return self._name
@name.setter
def name(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to name after initialization.")
@property
def persistent(self) -> bool:
""":obj:`bool`: Optional. If the conversations dict for this handler should be
saved. :attr:`name` is required and persistence has to be set in
:attr:`Application <.Application.persistence>`.
"""
return self._persistent
@persistent.setter
def persistent(self, _: object) -> NoReturn:
raise AttributeError("You can not assign a new value to persistent after initialization.")
@property
def map_to_parent(self) -> Optional[dict[object, object]]:
"""dict[:obj:`object`, :obj:`object`]: Optional. A :obj:`dict` that can be
used to instruct a nested :class:`ConversationHandler` to transition into a mapped state on
its parent :class:`ConversationHandler` in place of a specified nested state.
"""
return self._map_to_parent
@map_to_parent.setter
def map_to_parent(self, _: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to map_to_parent after initialization."
)
async def _initialize_persistence(
self, application: "Application"
) -> dict[str, TrackingDict[ConversationKey, object]]:
"""Initializes the persistence for this handler and its child conversations.
While this method is marked as protected, we expect it to be called by the
Application/parent conversations. It's just protected to hide it from users.
Args:
application (:class:`telegram.ext.Application`): The application.
Returns:
A dict {conversation.name -> TrackingDict}, which contains all dict of this
conversation and possible child conversations.
"""
if not (self.persistent and self.name and application.persistence):
raise RuntimeError(
"This handler is not persistent, has no name or the application has no "
"persistence!"
)
current_conversations = self._conversations
self._conversations = cast(
TrackingDict[ConversationKey, object],
TrackingDict(),
)
# In the conversation already processed updates
self._conversations.update(current_conversations)
# above might be partly overridden but that's okay since we warn about that in
# add_handler
stored_data = await application.persistence.get_conversations(self.name)
self._conversations.update_no_track(stored_data)
# Since CH.END is stored as normal state, we need to properly parse it here in order to
# actually end the conversation, i.e. delete the key from the _conversations dict
# This also makes sure that these entries are deleted from the persisted data on the next
# run of Application.update_persistence
for key, state in stored_data.items():
if state == self.END:
self._update_state(new_state=self.END, key=key)
out = {self.name: self._conversations}
for handler in self._child_conversations:
out.update(
await handler._initialize_persistence( # pylint: disable=protected-access
application=application
)
)
return out
def _get_key(self, update: Update) -> ConversationKey:
"""Builds the conversation key associated with the update."""
chat = update.effective_chat
user = update.effective_user
key: list[Union[int, str]] = []
if self.per_chat:
if chat is None:
raise RuntimeError("Can't build key for update without effective chat!")
key.append(chat.id)
if self.per_user:
if user is None:
raise RuntimeError("Can't build key for update without effective user!")
key.append(user.id)
if self.per_message:
if update.callback_query is None:
raise RuntimeError("Can't build key for update without CallbackQuery!")
if update.callback_query.inline_message_id:
key.append(update.callback_query.inline_message_id)
else:
key.append(update.callback_query.message.message_id) # type: ignore[union-attr]
return tuple(key)
async def _schedule_job_delayed(
self,
new_state: asyncio.Task,
application: "Application[Any, CCT, Any, Any, Any, JobQueue]",
update: Update,
context: CCT,
conversation_key: ConversationKey,
) -> None:
try:
effective_new_state = await new_state
except Exception as exc:
_LOGGER.debug(
"Non-blocking handler callback raised exception. Not scheduling conversation "
"timeout.",
exc_info=exc,
)
return None
return self._schedule_job(
new_state=effective_new_state,
application=application,
update=update,
context=context,
conversation_key=conversation_key,
)
def _schedule_job(
self,
new_state: object,
application: "Application[Any, CCT, Any, Any, Any, JobQueue]",
update: Update,
context: CCT,
conversation_key: ConversationKey,
) -> None:
"""Schedules a job which executes :meth:`_trigger_timeout` upon conversation timeout."""
if new_state == self.END:
return
try:
# both job_queue & conversation_timeout are checked before calling _schedule_job
j_queue = application.job_queue
self.timeout_jobs[conversation_key] = j_queue.run_once( # type: ignore[union-attr]
self._trigger_timeout,
self.conversation_timeout, # type: ignore[arg-type]
data=_ConversationTimeoutContext(conversation_key, update, application, context),
)
except Exception as exc:
_LOGGER.exception("Failed to schedule timeout.", exc_info=exc)
# pylint: disable=too-many-return-statements
def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]:
"""
Determines whether an update should be handled by this conversation handler, and if so in
which state the conversation currently is.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not isinstance(update, Update):
return None
# Ignore messages in channels
if update.channel_post or update.edited_channel_post:
return None
if self.per_chat and not update.effective_chat:
return None
if self.per_user and not update.effective_user:
return None
if self.per_message and not update.callback_query:
return None
if update.callback_query and self.per_chat and not update.callback_query.message:
return None
key = self._get_key(update)
state = self._conversations.get(key)
check: Optional[object] = None
# Resolve futures
if isinstance(state, PendingState):
_LOGGER.debug("Waiting for asyncio Task to finish ...")
# check if future is finished or not
if state.done():
res = state.resolve()
# Special case if an error was raised in a non-blocking entry-point
if state.old_state is None and state.task.exception():
self._conversations.pop(key, None)
state = None
else:
self._update_state(res, key)
state = self._conversations.get(key)
# if not then handle WAITING state instead
else:
handlers = self.states.get(self.WAITING, [])
for handler_ in handlers:
check = handler_.check_update(update)
if check is not None and check is not False:
return self.WAITING, key, handler_, check
return None
_LOGGER.debug("Selecting conversation %s with state %s", str(key), str(state))
handler: Optional[BaseHandler] = None
# Search entry points for a match
if state is None or self.allow_reentry:
for entry_point in self.entry_points:
check = entry_point.check_update(update)
if check is not None and check is not False:
handler = entry_point
break
else:
if state is None:
return None
# Get the handler list for current state, if we didn't find one yet and we're still here
if state is not None and handler is None:
for candidate in self.states.get(state, []):
check = candidate.check_update(update)
if check is not None and check is not False:
handler = candidate
break
# Find a fallback handler if all other handlers fail
else:
for fallback in self.fallbacks:
check = fallback.check_update(update)
if check is not None and check is not False:
handler = fallback
break
else:
return None
return state, key, handler, check # type: ignore[return-value]
async def handle_update( # type: ignore[override]
self,
update: Update,
application: "Application[Any, CCT, Any, Any, Any, Any]",
check_result: _CheckUpdateType[CCT],
context: CCT,
) -> Optional[object]:
"""Send the update to the callback for the current state and BaseHandler
Args:
check_result: The result from :meth:`check_update`. For this handler it's a tuple of
the conversation state, key, handler, and the handler's check result.
update (:class:`telegram.Update`): Incoming telegram update.
application (:class:`telegram.ext.Application`): Application that originated the
update.
context (:class:`telegram.ext.CallbackContext`): The context as provided by
the application.
"""
current_state, conversation_key, handler, handler_check_result = check_result
raise_dp_handler_stop = False
async with self._timeout_jobs_lock:
# Remove the old timeout job (if present)
timeout_job = self.timeout_jobs.pop(conversation_key, None)
if timeout_job is not None:
timeout_job.schedule_removal()
# Resolution order of "block":
# 1. Setting of the selected handler
# 2. Setting of the ConversationHandler
# 3. Default values of the bot
if handler.block is not DEFAULT_TRUE:
block = handler.block
elif self._block is not DEFAULT_TRUE:
block = self._block
elif isinstance(application.bot, ExtBot) and application.bot.defaults is not None:
block = application.bot.defaults.block
else:
block = DefaultValue.get_value(handler.block)
try: # Now create task or await the callback
if block:
new_state: object = await handler.handle_update(
update, application, handler_check_result, context
)
else:
new_state = application.create_task(
coroutine=handler.handle_update(
update, application, handler_check_result, context
),
update=update,
name=f"ConversationHandler:{update.update_id}:handle_update:non_blocking_cb",
)
except ApplicationHandlerStop as exception:
new_state = exception.state
raise_dp_handler_stop = True
async with self._timeout_jobs_lock:
if self.conversation_timeout:
if application.job_queue is None:
warn(
"Ignoring `conversation_timeout` because the Application has no JobQueue.",
stacklevel=1,
)
elif not application.job_queue.scheduler.running:
warn(
"Ignoring `conversation_timeout` because the Applications JobQueue is "
"not running.",
stacklevel=1,
)
elif isinstance(new_state, asyncio.Task):
# Add the new timeout job
# checking if the new state is self.END is done in _schedule_job
application.create_task(
self._schedule_job_delayed(
new_state, application, update, context, conversation_key
),
update=update,
name=f"ConversationHandler:{update.update_id}:handle_update:timeout_job",
)
else:
self._schedule_job(new_state, application, update, context, conversation_key)
if isinstance(self.map_to_parent, dict) and new_state in self.map_to_parent:
self._update_state(self.END, conversation_key, handler)
if raise_dp_handler_stop:
raise ApplicationHandlerStop(self.map_to_parent.get(new_state))
return self.map_to_parent.get(new_state)
if current_state != self.WAITING:
self._update_state(new_state, conversation_key, handler)
if raise_dp_handler_stop:
# Don't pass the new state here. If we're in a nested conversation, the parent is
# expecting None as return value.
raise ApplicationHandlerStop
# Signals a possible parent conversation to stay in the current state
return None
def _update_state(
self, new_state: object, key: ConversationKey, handler: Optional[BaseHandler] = None
) -> None:
if new_state == self.END:
if key in self._conversations:
# If there is no key in conversations, nothing is done.
del self._conversations[key]
elif isinstance(new_state, asyncio.Task):
self._conversations[key] = PendingState(
old_state=self._conversations.get(key), task=new_state
)
elif new_state is not None:
if new_state not in self.states:
warn(
f"{repr(handler.callback.__name__) if handler is not None else 'BaseHandler'} "
f"returned state {new_state} which is unknown to the "
f"ConversationHandler{' ' + self.name if self.name is not None else ''}.",
stacklevel=2,
)
self._conversations[key] = new_state
async def _trigger_timeout(self, context: CCT) -> None:
"""This is run whenever a conversation has timed out. Also makes sure that all handlers
which are in the :attr:`TIMEOUT` state and whose :meth:`BaseHandler.check_update` returns
:obj:`True` is handled.
"""
job = cast("Job", context.job)
ctxt = cast(_ConversationTimeoutContext, job.data)
_LOGGER.debug(
"Conversation timeout was triggered for conversation %s!", ctxt.conversation_key
)
callback_context = ctxt.callback_context
async with self._timeout_jobs_lock:
found_job = self.timeout_jobs.get(ctxt.conversation_key)
if found_job is not job:
# The timeout has been cancelled in handle_update
return
del self.timeout_jobs[ctxt.conversation_key]
# Now run all handlers which are in TIMEOUT state
handlers = self.states.get(self.TIMEOUT, [])
for handler in handlers:
check = handler.check_update(ctxt.update)
if check is not None and check is not False:
try:
await handler.handle_update(
ctxt.update, ctxt.application, check, callback_context
)
except ApplicationHandlerStop:
warn(
"ApplicationHandlerStop in TIMEOUT state of "
"ConversationHandler has no effect. Ignoring.",
stacklevel=2,
)
self._update_state(self.END, ctxt.conversation_key)

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the InlineQueryHandler class."""
import re
from re import Match, Pattern
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class InlineQueryHandler(BaseHandler[Update, CCT, RT]):
"""
BaseHandler class to handle Telegram updates that contain a
:attr:`telegram.Update.inline_query`.
Optionally based on a regex. Read the documentation of the :mod:`re` module for more
information.
Warning:
* When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
* :attr:`telegram.InlineQuery.chat_type` will not be set for inline queries from secret
chats and may not be set for inline queries coming from third-party clients. These
updates won't be handled, if :attr:`chat_types` is passed.
Examples:
:any:`Inline Bot <examples.inlinebot>`
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`, optional): Regex pattern.
If not :obj:`None`, :func:`re.match` is used on :attr:`telegram.InlineQuery.query`
to determine if an update should be handled by this handler.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
chat_types (list[:obj:`str`], optional): List of allowed chat types. If passed, will only
handle inline queries with the appropriate :attr:`telegram.InlineQuery.chat_type`.
.. versionadded:: 13.5
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`): Optional. Regex pattern to test
:attr:`telegram.InlineQuery.query` against.
chat_types (list[:obj:`str`]): Optional. List of allowed chat types.
.. versionadded:: 13.5
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("chat_types", "pattern")
def __init__(
self: "InlineQueryHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
pattern: Optional[Union[str, Pattern[str]]] = None,
block: DVType[bool] = DEFAULT_TRUE,
chat_types: Optional[list[str]] = None,
):
super().__init__(callback, block=block)
if isinstance(pattern, str):
pattern = re.compile(pattern)
self.pattern: Optional[Union[str, Pattern[str]]] = pattern
self.chat_types: Optional[list[str]] = chat_types
def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]:
"""
Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool` | :obj:`re.match`
"""
if isinstance(update, Update) and update.inline_query:
if (self.chat_types is not None) and (
update.inline_query.chat_type not in self.chat_types
):
return False
if (
self.pattern
and update.inline_query.query
and (match := re.match(self.pattern, update.inline_query.query))
):
return match
if not self.pattern:
return True
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[Union[bool, Match[str]]],
) -> None:
"""Add the result of ``re.match(pattern, update.inline_query.query)`` to
:attr:`CallbackContext.matches` as list with one element.
"""
if self.pattern:
check_result = cast(Match, check_result)
context.matches = [check_result]

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the MessageHandler class."""
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext import filters as filters_module
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class MessageHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram messages. They might contain text, media or status
updates.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
filters (:class:`telegram.ext.filters.BaseFilter`): A filter inheriting from
:class:`telegram.ext.filters.BaseFilter`. Standard filters can be found in
:mod:`telegram.ext.filters`. Filters can be combined using bitwise
operators (& for and, | for or, ~ for not). Passing :obj:`None` is a shortcut
to passing :class:`telegram.ext.filters.ALL`.
.. seealso:: :wiki:`Advanced Filters <Extensions---Advanced-Filters>`
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
filters (:class:`telegram.ext.filters.BaseFilter`): Only allow updates with these Filters.
See :mod:`telegram.ext.filters` for a full list of all available filters.
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("filters",)
def __init__(
self: "MessageHandler[CCT, RT]",
filters: Optional[filters_module.BaseFilter],
callback: HandlerCallback[Update, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self.filters: filters_module.BaseFilter = (
filters if filters is not None else filters_module.ALL
)
def check_update(self, update: object) -> Optional[Union[bool, dict[str, list[Any]]]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if isinstance(update, Update):
return self.filters.check_update(update) or False
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[Union[bool, dict[str, object]]],
) -> None:
"""Adds possible output of data filters to the :class:`CallbackContext`."""
if isinstance(check_result, dict):
context.update(check_result)

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the MessageReactionHandler class."""
from typing import Final, Optional
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import RT, SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, HandlerCallback
class MessageReactionHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain a message reaction.
Note:
The following rules apply to both ``username`` and the ``chat_id`` param groups,
respectively:
* If none of them are passed, the handler does not filter the update for that specific
attribute.
* If a chat ID **or** a username is passed, the updates will be filtered with that
specific attribute.
* If a chat ID **and** a username are passed, an update containing **any** of them will be
filtered.
* :attr:`telegram.MessageReactionUpdated.actor_chat` is *not* considered for
:paramref:`user_id` and :paramref:`user_username` filtering.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
.. versionadded:: 20.8
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
message_reaction_types (:obj:`int`, optional): Pass one of
:attr:`MESSAGE_REACTION_UPDATED`, :attr:`MESSAGE_REACTION_COUNT_UPDATED` or
:attr:`MESSAGE_REACTION` to specify if this handler should handle only updates with
:attr:`telegram.Update.message_reaction`,
:attr:`telegram.Update.message_reaction_count` or both. Defaults to
:attr:`MESSAGE_REACTION`.
chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow
only those which happen in the specified chat ID(s).
chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow
only those which happen in the specified username(s).
user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow
only those which are set by the specified chat ID(s) (this can be the chat itself in
the case of anonymous users, see the
:paramref:`telegram.MessageReactionUpdated.actor_chat`).
user_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow
only those which are set by the specified username(s) (this can be the chat itself in
the case of anonymous users, see the
:paramref:`telegram.MessageReactionUpdated.actor_chat`).
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
message_reaction_types (:obj:`int`): Optional. Specifies if this handler should handle only
updates with :attr:`telegram.Update.message_reaction`,
:attr:`telegram.Update.message_reaction_count` or both.
block (:obj:`bool`): Determines whether the callback will run in a blocking way.
"""
__slots__ = (
"_chat_ids",
"_chat_usernames",
"_user_ids",
"_user_usernames",
"message_reaction_types",
)
MESSAGE_REACTION_UPDATED: Final[int] = -1
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.message_reaction`."""
MESSAGE_REACTION_COUNT_UPDATED: Final[int] = 0
""":obj:`int`: Used as a constant to handle only
:attr:`telegram.Update.message_reaction_count`."""
MESSAGE_REACTION: Final[int] = 1
""":obj:`int`: Used as a constant to handle both :attr:`telegram.Update.message_reaction`
and :attr:`telegram.Update.message_reaction_count`."""
def __init__(
self: "MessageReactionHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
chat_id: Optional[SCT[int]] = None,
chat_username: Optional[SCT[str]] = None,
user_id: Optional[SCT[int]] = None,
user_username: Optional[SCT[str]] = None,
message_reaction_types: int = MESSAGE_REACTION,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self.message_reaction_types: int = message_reaction_types
self._chat_ids = parse_chat_id(chat_id)
self._chat_usernames = parse_username(chat_username)
if (user_id or user_username) and message_reaction_types in (
self.MESSAGE_REACTION,
self.MESSAGE_REACTION_COUNT_UPDATED,
):
raise ValueError(
"You can not filter for users and include anonymous reactions. Set "
"`message_reaction_types` to MESSAGE_REACTION_UPDATED."
)
self._user_ids = parse_chat_id(user_id)
self._user_usernames = parse_username(user_username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not isinstance(update, Update):
return False
if not (update.message_reaction or update.message_reaction_count):
return False
if (
self.message_reaction_types == self.MESSAGE_REACTION_UPDATED
and update.message_reaction_count
):
return False
if (
self.message_reaction_types == self.MESSAGE_REACTION_COUNT_UPDATED
and update.message_reaction
):
return False
if not any((self._chat_ids, self._chat_usernames, self._user_ids, self._user_usernames)):
return True
# Extract chat and user IDs and usernames from the update for comparison
chat_id = chat.id if (chat := update.effective_chat) else None
chat_username = chat.username if chat else None
user_id = user.id if (user := update.effective_user) else None
user_username = user.username if user else None
return (
bool(self._chat_ids and (chat_id in self._chat_ids))
or bool(self._chat_usernames and (chat_username in self._chat_usernames))
or bool(self._user_ids and (user_id in self._user_ids))
or bool(self._user_usernames and (user_username in self._user_usernames))
)

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PaidMediaPurchased class."""
from typing import Optional
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils._update_parsing import parse_chat_id, parse_username
from telegram.ext._utils.types import CCT, RT, HandlerCallback
class PaidMediaPurchasedHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram
:attr:`purchased paid media <telegram.Update.purchased_paid_media>`.
.. versionadded:: 21.6
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only
those which are from the specified user ID(s).
username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only
those which are from the specified username(s).
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = (
"_user_ids",
"_usernames",
)
def __init__(
self: "PaidMediaPurchasedHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
user_id: Optional[SCT[int]] = None,
username: Optional[SCT[str]] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self._user_ids = parse_chat_id(user_id)
self._usernames = parse_username(username)
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not isinstance(update, Update) or not update.purchased_paid_media:
return False
if not self._user_ids and not self._usernames:
return True
if update.purchased_paid_media.from_user.id in self._user_ids:
return True
return update.purchased_paid_media.from_user.username in self._usernames

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PollAnswerHandler class."""
from telegram import Update
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, RT
class PollAnswerHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain a
:attr:`poll answer <telegram.Update.poll_answer>`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
:any:`Poll Bot <examples.pollbot>`
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way..
"""
__slots__ = ()
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
return isinstance(update, Update) and bool(update.poll_answer)

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PollHandler class."""
from telegram import Update
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, RT
class PollHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram updates that contain a
:attr:`poll <telegram.Update.poll>`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
:any:`Poll Bot <examples.pollbot>`
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way..
"""
__slots__ = ()
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
return isinstance(update, Update) and bool(update.poll)

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PreCheckoutQueryHandler class."""
import re
from re import Pattern
from typing import Optional, TypeVar, Union
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
class PreCheckoutQueryHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram :attr:`telegram.Update.pre_checkout_query`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
:any:`Payment Bot <examples.paymentbot>`
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`, optional): Optional. Regex pattern
to test :attr:`telegram.PreCheckoutQuery.invoice_payload` against.
.. versionadded:: 20.8
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way..
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`, optional): Optional. Regex pattern
to test :attr:`telegram.PreCheckoutQuery.invoice_payload` against.
.. versionadded:: 20.8
"""
__slots__ = ("pattern",)
def __init__(
self: "PreCheckoutQueryHandler[CCT, RT]",
callback: HandlerCallback[Update, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
pattern: Optional[Union[str, Pattern[str]]] = None,
):
super().__init__(callback, block=block)
self.pattern: Optional[Pattern[str]] = re.compile(pattern) if pattern is not None else None
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if isinstance(update, Update) and update.pre_checkout_query:
invoice_payload = update.pre_checkout_query.invoice_payload
if self.pattern:
if self.pattern.match(invoice_payload):
return True
else:
return True
return False

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PrefixHandler class."""
import itertools
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import SCT, DVType
from telegram.ext import filters as filters_module
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class PrefixHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle custom prefix commands.
This is an intermediate handler between :class:`MessageHandler` and :class:`CommandHandler`.
It supports configurable commands with the same options as :class:`CommandHandler`. It will
respond to every combination of :paramref:`prefix` and :paramref:`command`.
It will add a :obj:`list` to the :class:`CallbackContext` named :attr:`CallbackContext.args`,
containing a list of strings, which is the text following the command split on single or
consecutive whitespace characters.
Examples:
Single prefix and command:
.. code:: python
PrefixHandler("!", "test", callback) # will respond to '!test'.
Multiple prefixes, single command:
.. code:: python
PrefixHandler(["!", "#"], "test", callback) # will respond to '!test' and '#test'.
Multiple prefixes and commands:
.. code:: python
PrefixHandler(
["!", "#"], ["test", "help"], callback
) # will respond to '!test', '#test', '!help' and '#help'.
By default, the handler listens to messages as well as edited messages. To change this behavior
use :attr:`~filters.UpdateType.EDITED_MESSAGE <telegram.ext.filters.UpdateType.EDITED_MESSAGE>`
Note:
* :class:`PrefixHandler` does *not* handle (edited) channel posts.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
.. versionchanged:: 20.0
* :class:`PrefixHandler` is no longer a subclass of :class:`CommandHandler`.
* Removed the attributes ``command`` and ``prefix``. Instead, the new :attr:`commands`
contains all commands that this handler listens to as a :class:`frozenset`, which
includes the prefixes.
* Updating the prefixes and commands this handler listens to is no longer possible.
Args:
prefix (:obj:`str` | Collection[:obj:`str`]):
The prefix(es) that will precede :paramref:`command`.
command (:obj:`str` | Collection[:obj:`str`]):
The command or list of commands this handler should listen for. Case-insensitive.
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
filters (:class:`telegram.ext.filters.BaseFilter`, optional): A filter inheriting from
:class:`telegram.ext.filters.BaseFilter`. Standard filters can be found in
:mod:`telegram.ext.filters`. Filters can be combined using bitwise
operators (``&`` for :keyword:`and`, ``|`` for :keyword:`or`, ``~`` for :keyword:`not`)
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
commands (frozenset[:obj:`str`]): The commands that this handler will listen for, i.e. the
combinations of :paramref:`prefix` and :paramref:`command`.
callback (:term:`coroutine function`): The callback function for this handler.
filters (:class:`telegram.ext.filters.BaseFilter`): Optional. Only allow updates with these
Filters.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
# 'prefix' is a class property, & 'command' is included in the superclass, so they're left out.
__slots__ = ("commands", "filters")
def __init__(
self: "PrefixHandler[CCT, RT]",
prefix: SCT[str],
command: SCT[str],
callback: HandlerCallback[Update, CCT, RT],
filters: Optional[filters_module.BaseFilter] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback=callback, block=block)
prefixes = {prefix.lower()} if isinstance(prefix, str) else {x.lower() for x in prefix}
commands = {command.lower()} if isinstance(command, str) else {x.lower() for x in command}
self.commands: frozenset[str] = frozenset(
p + c for p, c in itertools.product(prefixes, commands)
)
self.filters: filters_module.BaseFilter = (
filters if filters is not None else filters_module.UpdateType.MESSAGES
)
def check_update(
self, update: object
) -> Optional[Union[bool, tuple[list[str], Optional[Union[bool, dict[Any, Any]]]]]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`list`: The list of args for the handler.
"""
if isinstance(update, Update) and update.effective_message:
message = update.effective_message
if message.text:
text_list = message.text.split()
if text_list[0].lower() not in self.commands:
return None
filter_result = self.filters.check_update(update)
if filter_result:
return text_list[1:], filter_result
return False
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[Union[bool, tuple[list[str], Optional[bool]]]],
) -> None:
"""Add text after the command to :attr:`CallbackContext.args` as list, split on single
whitespaces and add output of data filters to :attr:`CallbackContext` as well.
"""
if isinstance(check_result, tuple):
context.args = check_result[0]
if isinstance(check_result[1], dict):
context.update(check_result[1])

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the ShippingQueryHandler class."""
from telegram import Update
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, RT
class ShippingQueryHandler(BaseHandler[Update, CCT, RT]):
"""Handler class to handle Telegram :attr:`telegram.Update.shipping_query`.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Examples:
:any:`Payment Bot <examples.paymentbot>`
Args:
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: Update, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the callback will run in a blocking way..
"""
__slots__ = ()
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:class:`telegram.Update` | :obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
return isinstance(update, Update) and bool(update.shipping_query)

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the StringCommandHandler class."""
from typing import TYPE_CHECKING, Any, Optional
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, RT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
class StringCommandHandler(BaseHandler[str, CCT, RT]):
"""Handler class to handle string commands. Commands are string updates that start with
``/``. The handler will add a :obj:`list` to the
:class:`CallbackContext` named :attr:`CallbackContext.args`. It will contain a list of strings,
which is the text following the command split on single whitespace characters.
Note:
This handler is not used to handle Telegram :class:`telegram.Update`, but strings manually
put in the queue. For example to send messages with the bot using command line or API.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
command (:obj:`str`): The command this handler should listen for.
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: str, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
command (:obj:`str`): The command this handler should listen for.
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("command",)
def __init__(
self: "StringCommandHandler[CCT, RT]",
command: str,
callback: HandlerCallback[str, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self.command: str = command
def check_update(self, update: object) -> Optional[list[str]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:obj:`object`): The incoming update.
Returns:
list[:obj:`str`]: List containing the text command split on whitespace.
"""
if isinstance(update, str) and update.startswith("/"):
args = update[1:].split(" ")
if args[0] == self.command:
return args[1:]
return None
def collect_additional_context(
self,
context: CCT,
update: str, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[list[str]],
) -> None:
"""Add text after the command to :attr:`CallbackContext.args` as list, split on single
whitespaces.
"""
context.args = check_result

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the StringRegexHandler class."""
import re
from re import Match, Pattern
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
RT = TypeVar("RT")
class StringRegexHandler(BaseHandler[str, CCT, RT]):
"""Handler class to handle string updates based on a regex which checks the update content.
Read the documentation of the :mod:`re` module for more information. The :func:`re.match`
function is used to determine if an update should be handled by this handler.
Note:
This handler is not used to handle Telegram :class:`telegram.Update`, but strings manually
put in the queue. For example to send messages with the bot using command line or API.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`): The regex pattern.
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: str, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
pattern (:obj:`str` | :func:`re.Pattern <re.compile>`): The regex pattern.
callback (:term:`coroutine function`): The callback function for this handler.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("pattern",)
def __init__(
self: "StringRegexHandler[CCT, RT]",
pattern: Union[str, Pattern[str]],
callback: HandlerCallback[str, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
if isinstance(pattern, str):
pattern = re.compile(pattern)
self.pattern: Union[str, Pattern[str]] = pattern
def check_update(self, update: object) -> Optional[Match[str]]:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:obj:`object`): The incoming update.
Returns:
:obj:`None` | :obj:`re.match`
"""
if isinstance(update, str) and (match := re.match(self.pattern, update)):
return match
return None
def collect_additional_context(
self,
context: CCT,
update: str, # noqa: ARG002
application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002
check_result: Optional[Match[str]],
) -> None:
"""Add the result of ``re.match(pattern, update)`` to :attr:`CallbackContext.matches` as
list with one element.
"""
if self.pattern and check_result:
context.matches = [check_result]

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the TypeHandler class."""
from typing import Optional, TypeVar
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
UT = TypeVar("UT")
# If this is written directly next to the type variable mypy gets confused with [valid-type]. This
# could be reported to them, but I doubt they would change this since we override a builtin type
GenericUT = type[UT]
class TypeHandler(BaseHandler[UT, CCT, RT]):
"""Handler class to handle updates of custom types.
Warning:
When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
Args:
type (:external:class:`type`): The :external:class:`type` of updates this handler should
process, as determined by :obj:`isinstance`
callback (:term:`coroutine function`): The callback function for this handler. Will be
called when :meth:`check_update` has determined that an update should be processed by
this handler. Callback signature::
async def callback(update: object, context: CallbackContext)
The return value of the callback is usually ignored except for the special case of
:class:`telegram.ext.ConversationHandler`.
strict (:obj:`bool`, optional): Use ``type`` instead of :obj:`isinstance`.
Default is :obj:`False`.
block (:obj:`bool`, optional): Determines whether the return value of the callback should
be awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`.
.. seealso:: :wiki:`Concurrency`
Attributes:
type (:external:class:`type`): The :external:class:`type` of updates this handler should
process.
callback (:term:`coroutine function`): The callback function for this handler.
strict (:obj:`bool`): Use :external:class:`type` instead of :obj:`isinstance`. Default is
:obj:`False`.
block (:obj:`bool`): Determines whether the return value of the callback should be
awaited before processing the next handler in
:meth:`telegram.ext.Application.process_update`.
"""
__slots__ = ("strict", "type")
def __init__(
self: "TypeHandler[UT, CCT, RT]",
type: GenericUT[UT], # pylint: disable=redefined-builtin
callback: HandlerCallback[UT, CCT, RT],
strict: bool = False,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self.type: GenericUT[UT] = type
self.strict: Optional[bool] = strict
def check_update(self, update: object) -> bool:
"""Determines whether an update should be passed to this handler's :attr:`callback`.
Args:
update (:obj:`object`): Incoming update.
Returns:
:obj:`bool`
"""
if not self.strict:
return isinstance(update, self.type)
return type(update) is self.type # pylint: disable=unidiomatic-typecheck

View File

@@ -0,0 +1,987 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the classes JobQueue and Job."""
import asyncio
import datetime
import weakref
from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload
try:
import pytz
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.schedulers.asyncio import AsyncIOScheduler
APS_AVAILABLE = True
except ImportError:
APS_AVAILABLE = False
from telegram._utils.logging import get_logger
from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import JSONDict
from telegram.ext._extbot import ExtBot
from telegram.ext._utils.types import CCT, JobCallback
if TYPE_CHECKING:
if APS_AVAILABLE:
from apscheduler.job import Job as APSJob
from telegram.ext import Application
_ALL_DAYS = tuple(range(7))
_LOGGER = get_logger(__name__, class_name="JobQueue")
class JobQueue(Generic[CCT]):
"""This class allows you to periodically perform tasks with the bot. It is a convenience
wrapper for the APScheduler library.
This class is a :class:`~typing.Generic` class and accepts one type variable that specifies
the type of the argument ``context`` of the job callbacks (:paramref:`~run_once.callback`) of
:meth:`run_once` and the other scheduling methods.
Important:
If you want to use this class, you must install PTB with the optional requirement
``job-queue``, i.e.
.. code-block:: bash
pip install "python-telegram-bot[job-queue]"
Examples:
:any:`Timer Bot <examples.timerbot>`
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Job Queue <Extensions---JobQueue>`
.. versionchanged:: 20.0
To use this class, PTB must be installed via
``pip install "python-telegram-bot[job-queue]"``.
Attributes:
scheduler (:class:`apscheduler.schedulers.asyncio.AsyncIOScheduler`): The scheduler.
Warning:
This scheduler is configured by :meth:`set_application`. Additional configuration
settings can be made by users. However, calling
:meth:`~apscheduler.schedulers.base.BaseScheduler.configure` will delete any
previous configuration settings. Therefore, please make sure to pass the values
returned by :attr:`scheduler_configuration` to the method call in addition to your
custom values.
Alternatively, you can also use methods like
:meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using
:meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether.
.. versionchanged:: 20.0
Uses :class:`~apscheduler.schedulers.asyncio.AsyncIOScheduler` instead of
:class:`~apscheduler.schedulers.background.BackgroundScheduler`
"""
__slots__ = ("_application", "_executor", "scheduler")
_CRON_MAPPING = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
def __init__(self) -> None:
if not APS_AVAILABLE:
raise RuntimeError(
"To use `JobQueue`, PTB must be installed via `pip install "
'"python-telegram-bot[job-queue]"`.'
)
self._application: Optional[weakref.ReferenceType[Application]] = None
self._executor = AsyncIOExecutor()
self.scheduler: "AsyncIOScheduler" = AsyncIOScheduler( # noqa: UP037
**self.scheduler_configuration
)
def __repr__(self) -> str:
"""Give a string representation of the JobQueue in the form ``JobQueue[application=...]``.
As this class doesn't implement :meth:`object.__str__`, the default implementation
will be used, which is equivalent to :meth:`__repr__`.
Returns:
:obj:`str`
"""
return build_repr_with_selected_attrs(self, application=self.application)
@property
def application(self) -> "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]":
"""The application this JobQueue is associated with."""
if self._application is None:
raise RuntimeError("No application was set for this JobQueue.")
application = self._application()
if application is not None:
return application
raise RuntimeError("The application instance is no longer alive.")
@property
def scheduler_configuration(self) -> JSONDict:
"""Provides configuration values that are used by :class:`JobQueue` for :attr:`scheduler`.
Tip:
Since calling
:meth:`scheduler.configure() <apscheduler.schedulers.base.BaseScheduler.configure>`
deletes any previous setting, please make sure to pass these values to the method call
in addition to your custom values:
.. code-block:: python
scheduler.configure(..., **job_queue.scheduler_configuration)
Alternatively, you can also use methods like
:meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using
:meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether.
.. versionadded:: 20.7
Returns:
dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary.
"""
timezone: object = pytz.utc
if (
self._application
and isinstance(self.application.bot, ExtBot)
and self.application.bot.defaults
):
timezone = self.application.bot.defaults.tzinfo or pytz.utc
return {
"timezone": timezone,
"executors": {"default": self._executor},
}
def _tz_now(self) -> datetime.datetime:
return datetime.datetime.now(self.scheduler.timezone)
@overload
def _parse_time_input(self, time: None, shift_day: bool = False) -> None: ...
@overload
def _parse_time_input(
self,
time: Union[float, datetime.timedelta, datetime.datetime, datetime.time],
shift_day: bool = False,
) -> datetime.datetime: ...
def _parse_time_input(
self,
time: Union[float, datetime.timedelta, datetime.datetime, datetime.time, None],
shift_day: bool = False,
) -> Optional[datetime.datetime]:
if time is None:
return None
if isinstance(time, (int, float)):
return self._tz_now() + datetime.timedelta(seconds=time)
if isinstance(time, datetime.timedelta):
return self._tz_now() + time
if isinstance(time, datetime.time):
date_time = datetime.datetime.combine(
datetime.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time
)
if date_time.tzinfo is None:
date_time = self.scheduler.timezone.localize(date_time)
if shift_day and date_time <= datetime.datetime.now(pytz.utc):
date_time += datetime.timedelta(days=1)
return date_time
return time
def set_application(
self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]"
) -> None:
"""Set the application to be used by this JobQueue.
Args:
application (:class:`telegram.ext.Application`): The application.
"""
self._application = weakref.ref(application)
self.scheduler.configure(**self.scheduler_configuration)
@staticmethod
async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None:
"""This method is used as a callback for the APScheduler jobs.
More precisely, the ``func`` argument of :class:`apscheduler.job.Job` is set to this method
and the ``arg`` argument (representing positional arguments to ``func``) is set to a tuple
containing the :class:`JobQueue` itself and the :class:`~telegram.ext.Job` instance.
Tip:
This method is a static method rather than a bound method. This makes the arguments
more transparent and allows for easier handling of PTBs integration of APScheduler
when utilizing advanced features of APScheduler.
Hint:
This method is effectively a wrapper for :meth:`telegram.ext.Job.run`.
.. versionadded:: 20.4
Args:
job_queue (:class:`JobQueue`): The job queue that created the job.
job (:class:`~telegram.ext.Job`): The job to run.
"""
await job.run(job_queue.application)
def run_once(
self,
callback: JobCallback[CCT],
when: Union[float, datetime.timedelta, datetime.datetime, datetime.time],
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
job_kwargs: Optional[JSONDict] = None,
) -> "Job[CCT]":
"""Creates a new :class:`Job` instance that runs once and adds it to the queue.
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
when (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
:obj:`datetime.datetime` | :obj:`datetime.time`):
Time in or at which the job should run. This parameter will be interpreted
depending on its type.
* :obj:`int` or :obj:`float` will be interpreted as "seconds from now" in which the
job should run.
* :obj:`datetime.timedelta` will be interpreted as "time from now" in which the
job should run.
* :obj:`datetime.datetime` will be interpreted as a specific date and time at
which the job should run. If the timezone (:attr:`datetime.datetime.tzinfo`) is
:obj:`None`, the default timezone of the bot will be used, which is UTC unless
:attr:`telegram.ext.Defaults.tzinfo` is used.
* :obj:`datetime.time` will be interpreted as a specific time of day at which the
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (:attr:`datetime.time.tzinfo`) is :obj:`None`, the
default timezone of the bot will be used, which is UTC unless
:attr:`telegram.ext.Defaults.tzinfo` is used.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
Returns:
:class:`telegram.ext.Job`: The new :class:`Job` instance that has been added to the job
queue.
"""
if not job_kwargs:
job_kwargs = {}
name = name or callback.__name__
job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id)
date_time = self._parse_time_input(when, shift_day=True)
j = self.scheduler.add_job(
self.job_callback,
name=name,
trigger="date",
run_date=date_time,
args=(self, job),
timezone=date_time.tzinfo or self.scheduler.timezone,
**job_kwargs,
)
job._job = j # pylint: disable=protected-access
return job
def run_repeating(
self,
callback: JobCallback[CCT],
interval: Union[float, datetime.timedelta],
first: Optional[Union[float, datetime.timedelta, datetime.datetime, datetime.time]] = None,
last: Optional[Union[float, datetime.timedelta, datetime.datetime, datetime.time]] = None,
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
job_kwargs: Optional[JSONDict] = None,
) -> "Job[CCT]":
"""Creates a new :class:`Job` instance that runs at specified intervals and adds it to the
queue.
Note:
For a note about DST, please see the documentation of `APScheduler`_.
.. _`APScheduler`: https://apscheduler.readthedocs.io/en/stable/modules/triggers/cron.html
#daylight-saving-time-behavior
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
interval (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta`): The interval in which
the job will run. If it is an :obj:`int` or a :obj:`float`, it will be interpreted
as seconds.
first (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
:obj:`datetime.datetime` | :obj:`datetime.time`, optional):
Time in or at which the job should run. This parameter will be interpreted
depending on its type.
* :obj:`int` or :obj:`float` will be interpreted as "seconds from now" in which the
job should run.
* :obj:`datetime.timedelta` will be interpreted as "time from now" in which the
job should run.
* :obj:`datetime.datetime` will be interpreted as a specific date and time at
which the job should run. If the timezone (:attr:`datetime.datetime.tzinfo`) is
:obj:`None`, the default timezone of the bot will be used.
* :obj:`datetime.time` will be interpreted as a specific time of day at which the
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (:attr:`datetime.time.tzinfo`) is :obj:`None`, the
default timezone of the bot will be used, which is UTC unless
:attr:`telegram.ext.Defaults.tzinfo` is used.
Defaults to :paramref:`interval`
Note:
Setting :paramref:`first` to ``0``, ``datetime.datetime.now()`` or another
value that indicates that the job should run immediately will not work due
to how the APScheduler library works. If you want to run a job immediately,
we recommend to use an approach along the lines of::
job = context.job_queue.run_repeating(callback, interval=5)
await job.run(context.application)
.. seealso:: :meth:`telegram.ext.Job.run`
last (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
:obj:`datetime.datetime` | :obj:`datetime.time`, optional):
Latest possible time for the job to run. This parameter will be interpreted
depending on its type. See :paramref:`first` for details.
If :paramref:`last` is :obj:`datetime.datetime` or :obj:`datetime.time` type
and ``last.tzinfo`` is :obj:`None`, the default timezone of the bot will be
assumed, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
Defaults to :obj:`None`.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
Returns:
:class:`telegram.ext.Job`: The new :class:`Job` instance that has been added to the job
queue.
"""
if not job_kwargs:
job_kwargs = {}
name = name or callback.__name__
job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id)
dt_first = self._parse_time_input(first)
dt_last = self._parse_time_input(last)
if dt_last and dt_first and dt_last < dt_first:
raise ValueError("'last' must not be before 'first'!")
if isinstance(interval, datetime.timedelta):
interval = interval.total_seconds()
j = self.scheduler.add_job(
self.job_callback,
trigger="interval",
args=(self, job),
start_date=dt_first,
end_date=dt_last,
seconds=interval,
name=name,
**job_kwargs,
)
job._job = j # pylint: disable=protected-access
return job
def run_monthly(
self,
callback: JobCallback[CCT],
when: datetime.time,
day: int,
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
job_kwargs: Optional[JSONDict] = None,
) -> "Job[CCT]":
"""Creates a new :class:`Job` that runs on a monthly basis and adds it to the queue.
.. versionchanged:: 20.0
The ``day_is_strict`` argument was removed. Instead one can now pass ``-1`` to the
:paramref:`day` parameter to have the job run on the last day of the month.
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
when (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(``when.tzinfo``) is :obj:`None`, the default timezone of the bot will be used,
which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
day (:obj:`int`): Defines the day of the month whereby the job would run. It should
be within the range of ``1`` and ``31``, inclusive. If a month has fewer days than
this number, the job will not run in this month. Passing ``-1`` leads to the job
running on the last day of the month.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
Returns:
:class:`telegram.ext.Job`: The new :class:`Job` instance that has been added to the job
queue.
"""
if not job_kwargs:
job_kwargs = {}
name = name or callback.__name__
job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id)
j = self.scheduler.add_job(
self.job_callback,
trigger="cron",
args=(self, job),
name=name,
day="last" if day == -1 else day,
hour=when.hour,
minute=when.minute,
second=when.second,
timezone=when.tzinfo or self.scheduler.timezone,
**job_kwargs,
)
job._job = j # pylint: disable=protected-access
return job
def run_daily(
self,
callback: JobCallback[CCT],
time: datetime.time,
days: tuple[int, ...] = _ALL_DAYS,
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
job_kwargs: Optional[JSONDict] = None,
) -> "Job[CCT]":
"""Creates a new :class:`Job` that runs on a daily basis and adds it to the queue.
Note:
For a note about DST, please see the documentation of `APScheduler`_.
.. _`APScheduler`: https://apscheduler.readthedocs.io/en/stable/modules/triggers/cron.html
#daylight-saving-time-behavior
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(:obj:`datetime.time.tzinfo`) is :obj:`None`, the default timezone of the bot will
be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
days (tuple[:obj:`int`], optional): Defines on which days of the week the job should
run (where ``0-6`` correspond to sunday - saturday). By default, the job will run
every day.
.. versionchanged:: 20.0
Changed day of the week mapping of 0-6 from monday-sunday to sunday-saturday.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
Returns:
:class:`telegram.ext.Job`: The new :class:`Job` instance that has been added to the job
queue.
"""
if not job_kwargs:
job_kwargs = {}
name = name or callback.__name__
job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id)
j = self.scheduler.add_job(
self.job_callback,
name=name,
args=(self, job),
trigger="cron",
day_of_week=",".join([self._CRON_MAPPING[d] for d in days]),
hour=time.hour,
minute=time.minute,
second=time.second,
timezone=time.tzinfo or self.scheduler.timezone,
**job_kwargs,
)
job._job = j # pylint: disable=protected-access
return job
def run_custom(
self,
callback: JobCallback[CCT],
job_kwargs: JSONDict,
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
) -> "Job[CCT]":
"""Creates a new custom defined :class:`Job`.
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
job_kwargs (:obj:`dict`): Arbitrary keyword arguments. Used as arguments for
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job`.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
Returns:
:class:`telegram.ext.Job`: The new :class:`Job` instance that has been added to the job
queue.
"""
name = name or callback.__name__
job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id)
j = self.scheduler.add_job(self.job_callback, args=(self, job), name=name, **job_kwargs)
job._job = j # pylint: disable=protected-access
return job
async def start(self) -> None:
# this method async just in case future versions need that
"""Starts the :class:`~telegram.ext.JobQueue`."""
if not self.scheduler.running:
self.scheduler.start()
async def stop(self, wait: bool = True) -> None:
"""Shuts down the :class:`~telegram.ext.JobQueue`.
Args:
wait (:obj:`bool`, optional): Whether to wait until all currently running jobs
have finished. Defaults to :obj:`True`.
"""
# the interface methods of AsyncIOExecutor are currently not really asyncio-compatible
# so we apply some small tweaks here to try and smoothen the integration into PTB
# TODO: When APS 4.0 hits, we should be able to remove the tweaks
if wait:
# Unfortunately AsyncIOExecutor just cancels them all ...
await asyncio.gather(
*self._executor._pending_futures, # pylint: disable=protected-access
return_exceptions=True,
)
if self.scheduler.running:
self.scheduler.shutdown(wait=wait)
# scheduler.shutdown schedules a task in the event loop but immediately returns
# so give it a tiny bit of time to actually shut down.
await asyncio.sleep(0.01)
def jobs(self) -> tuple["Job[CCT]", ...]:
"""Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`.
Returns:
tuple[:class:`Job`]: Tuple of all *scheduled* jobs.
"""
return tuple(Job.from_aps_job(job) for job in self.scheduler.get_jobs())
def get_jobs_by_name(self, name: str) -> tuple["Job[CCT]", ...]:
"""Returns a tuple of all *pending/scheduled* jobs with the given name that are currently
in the :class:`JobQueue`.
Returns:
tuple[:class:`Job`]: Tuple of all *pending* or *scheduled* jobs matching the name.
"""
return tuple(job for job in self.jobs() if job.name == name)
class Job(Generic[CCT]):
"""This class is a convenience wrapper for the jobs held in a :class:`telegram.ext.JobQueue`.
With the current backend APScheduler, :attr:`job` holds a :class:`apscheduler.job.Job`
instance.
Objects of this class are comparable in terms of equality. Two objects of this class are
considered equal, if their :class:`id <apscheduler.job.Job>` is equal.
This class is a :class:`~typing.Generic` class and accepts one type variable that specifies
the type of the argument ``context`` of :paramref:`callback`.
Important:
If you want to use this class, you must install PTB with the optional requirement
``job-queue``, i.e.
.. code-block:: bash
pip install "python-telegram-bot[job-queue]"
Note:
All attributes and instance methods of :attr:`job` are also directly available as
attributes/methods of the corresponding :class:`telegram.ext.Job` object.
Warning:
This class should not be instantiated manually.
Use the methods of :class:`telegram.ext.JobQueue` to schedule jobs.
.. seealso:: :wiki:`Job Queue <Extensions---JobQueue>`
.. versionchanged:: 20.0
* Removed argument and attribute ``job_queue``.
* Renamed ``Job.context`` to :attr:`Job.data`.
* Removed argument ``job``
* To use this class, PTB must be installed via
``pip install "python-telegram-bot[job-queue]"``.
Args:
callback (:term:`coroutine function`): The callback function that should be executed by the
new job. Callback signature::
async def callback(context: CallbackContext)
data (:obj:`object`, optional): Additional data needed for the :paramref:`callback`
function. Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:obj:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat that this job is associated with.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user that this job is associated with.
.. versionadded:: 20.0
Attributes:
callback (:term:`coroutine function`): The callback function that should be executed by the
new job.
data (:obj:`object`): Optional. Additional data needed for the :attr:`callback` function.
name (:obj:`str`): Optional. The name of the new job.
chat_id (:obj:`int`): Optional. Chat id of the chat that this job is associated with.
.. versionadded:: 20.0
user_id (:obj:`int`): Optional. User id of the user that this job is associated with.
.. versionadded:: 20.0
"""
__slots__ = (
"_enabled",
"_job",
"_removed",
"callback",
"chat_id",
"data",
"name",
"user_id",
)
def __init__(
self,
callback: JobCallback[CCT],
data: Optional[object] = None,
name: Optional[str] = None,
chat_id: Optional[int] = None,
user_id: Optional[int] = None,
):
if not APS_AVAILABLE:
raise RuntimeError(
"To use `Job`, PTB must be installed via `pip install "
'"python-telegram-bot[job-queue]"`.'
)
self.callback: JobCallback[CCT] = callback
self.data: Optional[object] = data
self.name: Optional[str] = name or callback.__name__
self.chat_id: Optional[int] = chat_id
self.user_id: Optional[int] = user_id
self._removed = False
self._enabled = False
self._job = cast("APSJob", None)
def __getattr__(self, item: str) -> object:
"""Overrides :py:meth:`object.__getattr__` to get specific attribute of the
:class:`telegram.ext.Job` object or of its attribute :class:`apscheduler.job.Job`,
if exists.
Args:
item (:obj:`str`): The name of the attribute.
Returns:
:object: The value of the attribute.
Raises:
:exc:`AttributeError`: If the attribute does not exist in both
:class:`telegram.ext.Job` and :class:`apscheduler.job.Job` objects.
"""
try:
return getattr(self.job, item)
except AttributeError as exc:
raise AttributeError(
f"Neither 'telegram.ext.Job' nor 'apscheduler.job.Job' has attribute '{item}'"
) from exc
def __eq__(self, other: object) -> bool:
"""Defines equality condition for the :class:`telegram.ext.Job` object.
Two objects of this class are considered to be equal if their
:class:`id <apscheduler.job.Job>` are equal.
Returns:
:obj:`True` if both objects have :paramref:`id` parameters identical.
:obj:`False` otherwise.
"""
if isinstance(other, self.__class__):
return self.id == other.id
return False
def __hash__(self) -> int:
"""Builds a hash value for this object such that the hash of two objects is
equal if and only if the objects are equal in terms of :meth:`__eq__`.
Returns:
:obj:`int`: The hash value of the object.
"""
return hash(self.id)
def __repr__(self) -> str:
"""Give a string representation of the job in the form
``Job[id=..., name=..., callback=..., trigger=...]``.
As this class doesn't implement :meth:`object.__str__`, the default implementation
will be used, which is equivalent to :meth:`__repr__`.
Returns:
:obj:`str`
"""
return build_repr_with_selected_attrs(
self,
id=self.job.id,
name=self.name,
callback=self.callback.__name__,
trigger=self.job.trigger,
)
@property
def job(self) -> "APSJob":
""":class:`apscheduler.job.Job`: The APS Job this job is a wrapper for.
.. versionchanged:: 20.0
This property is now read-only.
"""
return self._job
@property
def removed(self) -> bool:
""":obj:`bool`: Whether this job is due to be removed."""
return self._removed
@property
def enabled(self) -> bool:
""":obj:`bool`: Whether this job is enabled."""
return self._enabled
@enabled.setter
def enabled(self, status: bool) -> None:
if status:
self.job.resume()
else:
self.job.pause()
self._enabled = status
@property
def next_t(self) -> Optional[datetime.datetime]:
"""
:class:`datetime.datetime`: Datetime for the next job execution.
Datetime is localized according to :attr:`datetime.datetime.tzinfo`.
If job is removed or already ran it equals to :obj:`None`.
Warning:
This attribute is only available, if the :class:`telegram.ext.JobQueue` this job
belongs to is already started. Otherwise APScheduler raises an :exc:`AttributeError`.
"""
return self.job.next_run_time
@classmethod
def from_aps_job(cls, aps_job: "APSJob") -> "Job[CCT]":
"""Provides the :class:`telegram.ext.Job` that is associated with the given APScheduler
job.
Tip:
This method can be useful when using advanced APScheduler features along with
:class:`telegram.ext.JobQueue`.
.. versionadded:: 20.4
Args:
aps_job (:class:`apscheduler.job.Job`): The APScheduler job
Returns:
:class:`telegram.ext.Job`
"""
ext_job = aps_job.args[1]
ext_job._job = aps_job # pylint: disable=protected-access
return ext_job
async def run(
self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]"
) -> None:
"""Executes the callback function independently of the jobs schedule. Also calls
:meth:`telegram.ext.Application.update_persistence`.
.. versionchanged:: 20.0
Calls :meth:`telegram.ext.Application.update_persistence`.
Args:
application (:class:`telegram.ext.Application`): The application this job is associated
with.
"""
# We shield the task such that the job isn't cancelled mid-run
await asyncio.shield(self._run(application))
async def _run(
self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]"
) -> None:
try:
try:
context = application.context_types.context.from_job(self, application)
except Exception as exc:
_LOGGER.critical(
"Error while building CallbackContext for job %s. Job will not be run.",
self._job,
exc_info=exc,
)
return
await context.refresh_data()
await self.callback(context)
except Exception as exc:
await application.create_task(
application.process_error(None, exc, job=self),
name=f"Job:{self.id}:run:process_error",
)
finally:
# This is internal logic of application - let's keep it private for now
application._mark_for_persistence_update(job=self) # pylint: disable=protected-access
def schedule_removal(self) -> None:
"""
Schedules this job for removal from the :class:`JobQueue`. It will be removed without
executing its callback function again.
"""
self.job.remove()
self._removed = True

View File

@@ -0,0 +1,562 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the PicklePersistence class."""
import pickle
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, Optional, TypeVar, Union, cast, overload
from telegram import Bot, TelegramObject
from telegram._utils.types import FilePathInput
from telegram._utils.warnings import warn
from telegram.ext import BasePersistence, PersistenceInput
from telegram.ext._contexttypes import ContextTypes
from telegram.ext._utils.types import BD, CD, UD, CDCData, ConversationDict, ConversationKey
_REPLACED_KNOWN_BOT = "a known bot replaced by PTB's PicklePersistence"
_REPLACED_UNKNOWN_BOT = "an unknown bot replaced by PTB's PicklePersistence"
TelegramObj = TypeVar("TelegramObj", bound=TelegramObject)
def _all_subclasses(cls: type[TelegramObj]) -> set[type[TelegramObj]]:
"""Gets all subclasses of the specified object, recursively. from
https://stackoverflow.com/a/3862957/9706202
"""
subclasses = cls.__subclasses__()
return set(subclasses).union([s for c in subclasses for s in _all_subclasses(c)])
def _reconstruct_to(cls: type[TelegramObj], kwargs: dict) -> TelegramObj:
"""
This method is used for unpickling. The data, which is in the form a dictionary, is
converted back into a class. Works mostly the same as :meth:`TelegramObject.__setstate__`.
This function should be kept in place for backwards compatibility even if the pickling logic
is changed, since `_custom_reduction` places references to this function into the pickled data.
"""
obj = cls.__new__(cls)
obj.__setstate__(kwargs)
return obj
def _custom_reduction(cls: TelegramObj) -> tuple[Callable, tuple[type[TelegramObj], dict]]:
"""
This method is used for pickling. The bot attribute is preserved so _BotPickler().persistent_id
works as intended.
"""
data = cls._get_attrs(include_private=True) # pylint: disable=protected-access
# MappingProxyType is not pickable, so we convert it to a dict
# no need to convert back to MPT in _reconstruct_to, since it's done in __setstate__
data["api_kwargs"] = dict(data["api_kwargs"]) # type: ignore[arg-type]
return _reconstruct_to, (cls.__class__, data)
class _BotPickler(pickle.Pickler):
__slots__ = ("_bot",)
def __init__(self, bot: Bot, *args: Any, **kwargs: Any):
self._bot = bot
super().__init__(*args, **kwargs)
def reducer_override(
self, obj: TelegramObj
) -> tuple[Callable, tuple[type[TelegramObj], dict]]:
"""
This method is used for pickling. The bot attribute is preserved so
_BotPickler().persistent_id works as intended.
"""
if not isinstance(obj, TelegramObject):
return NotImplemented
return _custom_reduction(obj)
def persistent_id(self, obj: object) -> Optional[str]:
"""Used to 'mark' the Bot, so it can be replaced later. See
https://docs.python.org/3/library/pickle.html#pickle.Pickler.persistent_id for more info
"""
if obj is self._bot:
return _REPLACED_KNOWN_BOT
if isinstance(obj, Bot):
warn(
"Unknown bot instance found. Will be replaced by `None` during unpickling",
stacklevel=2,
)
return _REPLACED_UNKNOWN_BOT
return None # pickles as usual
class _BotUnpickler(pickle.Unpickler):
__slots__ = ("_bot",)
def __init__(self, bot: Bot, *args: Any, **kwargs: Any):
self._bot = bot
super().__init__(*args, **kwargs)
def persistent_load(self, pid: str) -> Optional[Bot]:
"""Replaces the bot with the current bot if known, else it is replaced by :obj:`None`."""
if pid == _REPLACED_KNOWN_BOT:
return self._bot
if pid == _REPLACED_UNKNOWN_BOT:
return None
raise pickle.UnpicklingError("Found unknown persistent id when unpickling!")
class PicklePersistence(BasePersistence[UD, CD, BD]):
"""Using python's builtin :mod:`pickle` for making your bot persistent.
Attention:
The interface provided by this class is intended to be accessed exclusively by
:class:`~telegram.ext.Application`. Calling any of the methods below manually might
interfere with the integration of persistence into :class:`~telegram.ext.Application`.
Note:
This implementation of :class:`BasePersistence` uses the functionality of the pickle module
to support serialization of bot instances. Specifically any reference to
:attr:`~BasePersistence.bot` will be replaced by a placeholder before pickling and
:attr:`~BasePersistence.bot` will be inserted back when loading the data.
Examples:
:any:`Persistent Conversation Bot <examples.persistentconversationbot>`
.. seealso:: :wiki:`Making Your Bot Persistent <Making-your-bot-persistent>`
.. versionchanged:: 20.0
* The parameters and attributes ``store_*_data`` were replaced by :attr:`store_data`.
* The parameter and attribute ``filename`` were replaced by :attr:`filepath`.
* :attr:`filepath` now also accepts :obj:`pathlib.Path` as argument.
Args:
filepath (:obj:`str` | :obj:`pathlib.Path`): The filepath for storing the pickle files.
When :attr:`single_file` is :obj:`False` this will be used as a prefix.
store_data (:class:`~telegram.ext.PersistenceInput`, optional): Specifies which kinds of
data will be saved by this persistence instance. By default, all available kinds of
data will be saved.
single_file (:obj:`bool`, optional): When :obj:`False` will store 5 separate files of
`filename_user_data`, `filename_bot_data`, `filename_chat_data`,
`filename_callback_data` and `filename_conversations`. Default is :obj:`True`.
on_flush (:obj:`bool`, optional): When :obj:`True` will only save to file when
:meth:`flush` is called and keep data in memory until that happens. When
:obj:`False` will store data on any transaction *and* on call to :meth:`flush`.
Default is :obj:`False`.
context_types (:class:`telegram.ext.ContextTypes`, optional): Pass an instance
of :class:`telegram.ext.ContextTypes` to customize the types used in the
``context`` interface. If not passed, the defaults documented in
:class:`telegram.ext.ContextTypes` will be used.
.. versionadded:: 13.6
update_interval (:obj:`int` | :obj:`float`, optional): The
:class:`~telegram.ext.Application` will update
the persistence in regular intervals. This parameter specifies the time (in seconds) to
wait between two consecutive runs of updating the persistence. Defaults to 60 seconds.
.. versionadded:: 20.0
Attributes:
filepath (:obj:`str` | :obj:`pathlib.Path`): The filepath for storing the pickle files.
When :attr:`single_file` is :obj:`False` this will be used as a prefix.
store_data (:class:`~telegram.ext.PersistenceInput`): Specifies which kinds of data will
be saved by this persistence instance.
single_file (:obj:`bool`): Optional. When :obj:`False` will store 5 separate files of
`filename_user_data`, `filename_bot_data`, `filename_chat_data`,
`filename_callback_data` and `filename_conversations`. Default is :obj:`True`.
on_flush (:obj:`bool`): Optional. When :obj:`True` will only save to file when
:meth:`flush` is called and keep data in memory until that happens. When
:obj:`False` will store data on any transaction *and* on call to :meth:`flush`.
Default is :obj:`False`.
context_types (:class:`telegram.ext.ContextTypes`): Container for the types used
in the ``context`` interface.
.. versionadded:: 13.6
"""
__slots__ = (
"bot_data",
"callback_data",
"chat_data",
"context_types",
"conversations",
"filepath",
"on_flush",
"single_file",
"user_data",
)
@overload
def __init__(
self: "PicklePersistence[dict[Any, Any], dict[Any, Any], dict[Any, Any]]",
filepath: FilePathInput,
store_data: Optional[PersistenceInput] = None,
single_file: bool = True,
on_flush: bool = False,
update_interval: float = 60,
): ...
@overload
def __init__(
self: "PicklePersistence[UD, CD, BD]",
filepath: FilePathInput,
store_data: Optional[PersistenceInput] = None,
single_file: bool = True,
on_flush: bool = False,
update_interval: float = 60,
context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None,
): ...
def __init__(
self,
filepath: FilePathInput,
store_data: Optional[PersistenceInput] = None,
single_file: bool = True,
on_flush: bool = False,
update_interval: float = 60,
context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None,
):
super().__init__(store_data=store_data, update_interval=update_interval)
self.filepath: Path = Path(filepath)
self.single_file: Optional[bool] = single_file
self.on_flush: Optional[bool] = on_flush
self.user_data: Optional[dict[int, UD]] = None
self.chat_data: Optional[dict[int, CD]] = None
self.bot_data: Optional[BD] = None
self.callback_data: Optional[CDCData] = None
self.conversations: Optional[dict[str, dict[tuple[Union[int, str], ...], object]]] = None
self.context_types: ContextTypes[Any, UD, CD, BD] = cast(
ContextTypes[Any, UD, CD, BD], context_types or ContextTypes()
)
def _load_singlefile(self) -> None:
try:
with self.filepath.open("rb") as file:
data = _BotUnpickler(self.bot, file).load()
self.user_data = data["user_data"]
self.chat_data = data["chat_data"]
# For backwards compatibility with files not containing bot data
self.bot_data = data.get("bot_data", self.context_types.bot_data())
self.callback_data = data.get("callback_data", {})
self.conversations = data["conversations"]
except OSError:
self.conversations = {}
self.user_data = {}
self.chat_data = {}
self.bot_data = self.context_types.bot_data()
self.callback_data = None
except pickle.UnpicklingError as exc:
filename = self.filepath.name
raise TypeError(f"File {filename} does not contain valid pickle data") from exc
except Exception as exc:
raise TypeError(f"Something went wrong unpickling {self.filepath.name}") from exc
def _load_file(self, filepath: Path) -> Any:
try:
with filepath.open("rb") as file:
return _BotUnpickler(self.bot, file).load()
except OSError:
return None
except pickle.UnpicklingError as exc:
raise TypeError(f"File {filepath.name} does not contain valid pickle data") from exc
except Exception as exc:
raise TypeError(f"Something went wrong unpickling {filepath.name}") from exc
def _dump_singlefile(self) -> None:
data = {
"conversations": self.conversations,
"user_data": self.user_data,
"chat_data": self.chat_data,
"bot_data": self.bot_data,
"callback_data": self.callback_data,
}
with self.filepath.open("wb") as file:
_BotPickler(self.bot, file, protocol=pickle.HIGHEST_PROTOCOL).dump(data)
def _dump_file(self, filepath: Path, data: object) -> None:
with filepath.open("wb") as file:
_BotPickler(self.bot, file, protocol=pickle.HIGHEST_PROTOCOL).dump(data)
async def get_user_data(self) -> dict[int, UD]:
"""Returns the user_data from the pickle file if it exists or an empty :obj:`dict`.
Returns:
dict[:obj:`int`, :obj:`dict`]: The restored user data.
"""
if self.user_data:
pass
elif not self.single_file:
data = self._load_file(Path(f"{self.filepath}_user_data"))
if not data:
data = {}
self.user_data = data
else:
self._load_singlefile()
return deepcopy(self.user_data) # type: ignore[arg-type]
async def get_chat_data(self) -> dict[int, CD]:
"""Returns the chat_data from the pickle file if it exists or an empty :obj:`dict`.
Returns:
dict[:obj:`int`, :obj:`dict`]: The restored chat data.
"""
if self.chat_data:
pass
elif not self.single_file:
data = self._load_file(Path(f"{self.filepath}_chat_data"))
if not data:
data = {}
self.chat_data = data
else:
self._load_singlefile()
return deepcopy(self.chat_data) # type: ignore[arg-type]
async def get_bot_data(self) -> BD:
"""Returns the bot_data from the pickle file if it exists or an empty object of type
:obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`.
Returns:
:obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`: The restored bot data.
"""
if self.bot_data:
pass
elif not self.single_file:
data = self._load_file(Path(f"{self.filepath}_bot_data"))
if not data:
data = self.context_types.bot_data()
self.bot_data = data
else:
self._load_singlefile()
return deepcopy(self.bot_data) # type: ignore[return-value]
async def get_callback_data(self) -> Optional[CDCData]:
"""Returns the callback data from the pickle file if it exists or :obj:`None`.
.. versionadded:: 13.6
Returns:
tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]],
dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`,
if no data was stored.
"""
if self.callback_data:
pass
elif not self.single_file:
data = self._load_file(Path(f"{self.filepath}_callback_data"))
if not data:
data = None
self.callback_data = data
else:
self._load_singlefile()
if self.callback_data is None:
return None
return deepcopy(self.callback_data)
async def get_conversations(self, name: str) -> ConversationDict:
"""Returns the conversations from the pickle file if it exists or an empty dict.
Args:
name (:obj:`str`): The handlers name.
Returns:
:obj:`dict`: The restored conversations for the handler.
"""
if self.conversations:
pass
elif not self.single_file:
data = self._load_file(Path(f"{self.filepath}_conversations"))
if not data:
data = {name: {}}
self.conversations = data
else:
self._load_singlefile()
return self.conversations.get(name, {}).copy() # type: ignore[union-attr]
async def update_conversation(
self, name: str, key: ConversationKey, new_state: Optional[object]
) -> None:
"""Will update the conversations for the given handler and depending on :attr:`on_flush`
save the pickle file.
Args:
name (:obj:`str`): The handler's name.
key (:obj:`tuple`): The key the state is changed for.
new_state (:class:`object`): The new state for the given key.
"""
if not self.conversations:
self.conversations = {}
if self.conversations.setdefault(name, {}).get(key) == new_state:
return
self.conversations[name][key] = new_state
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_conversations"), self.conversations)
else:
self._dump_singlefile()
async def update_user_data(self, user_id: int, data: UD) -> None:
"""Will update the user_data and depending on :attr:`on_flush` save the pickle file.
Args:
user_id (:obj:`int`): The user the data might have been changed for.
data (:obj:`dict`): The :attr:`telegram.ext.Application.user_data` ``[user_id]``.
"""
if self.user_data is None:
self.user_data = {}
if self.user_data.get(user_id) == data:
return
self.user_data[user_id] = data
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_user_data"), self.user_data)
else:
self._dump_singlefile()
async def update_chat_data(self, chat_id: int, data: CD) -> None:
"""Will update the chat_data and depending on :attr:`on_flush` save the pickle file.
Args:
chat_id (:obj:`int`): The chat the data might have been changed for.
data (:obj:`dict`): The :attr:`telegram.ext.Application.chat_data` ``[chat_id]``.
"""
if self.chat_data is None:
self.chat_data = {}
if self.chat_data.get(chat_id) == data:
return
self.chat_data[chat_id] = data
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_chat_data"), self.chat_data)
else:
self._dump_singlefile()
async def update_bot_data(self, data: BD) -> None:
"""Will update the bot_data and depending on :attr:`on_flush` save the pickle file.
Args:
data (:obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`): The
:attr:`telegram.ext.Application.bot_data`.
"""
if self.bot_data == data:
return
self.bot_data = data
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_bot_data"), self.bot_data)
else:
self._dump_singlefile()
async def update_callback_data(self, data: CDCData) -> None:
"""Will update the callback_data (if changed) and depending on :attr:`on_flush` save the
pickle file.
.. versionadded:: 13.6
Args:
data (tuple[list[tuple[:obj:`str`, :obj:`float`, \
dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]]):
The relevant data to restore :class:`telegram.ext.CallbackDataCache`.
"""
if self.callback_data == data:
return
self.callback_data = data
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_callback_data"), self.callback_data)
else:
self._dump_singlefile()
async def drop_chat_data(self, chat_id: int) -> None:
"""Will delete the specified key from the ``chat_data`` and depending on
:attr:`on_flush` save the pickle file.
.. versionadded:: 20.0
Args:
chat_id (:obj:`int`): The chat id to delete from the persistence.
"""
if self.chat_data is None:
return
self.chat_data.pop(chat_id, None)
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_chat_data"), self.chat_data)
else:
self._dump_singlefile()
async def drop_user_data(self, user_id: int) -> None:
"""Will delete the specified key from the ``user_data`` and depending on
:attr:`on_flush` save the pickle file.
.. versionadded:: 20.0
Args:
user_id (:obj:`int`): The user id to delete from the persistence.
"""
if self.user_data is None:
return
self.user_data.pop(user_id, None)
if not self.on_flush:
if not self.single_file:
self._dump_file(Path(f"{self.filepath}_user_data"), self.user_data)
else:
self._dump_singlefile()
async def refresh_user_data(self, user_id: int, user_data: UD) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_user_data`
"""
async def refresh_chat_data(self, chat_id: int, chat_data: CD) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_chat_data`
"""
async def refresh_bot_data(self, bot_data: BD) -> None:
"""Does nothing.
.. versionadded:: 13.6
.. seealso:: :meth:`telegram.ext.BasePersistence.refresh_bot_data`
"""
async def flush(self) -> None:
"""Will save all data in memory to pickle file(s)."""
if self.single_file:
if (
self.user_data
or self.chat_data
or self.bot_data
or self.callback_data
or self.conversations
):
self._dump_singlefile()
else:
if self.user_data:
self._dump_file(Path(f"{self.filepath}_user_data"), self.user_data)
if self.chat_data:
self._dump_file(Path(f"{self.filepath}_chat_data"), self.chat_data)
if self.bot_data:
self._dump_file(Path(f"{self.filepath}_bot_data"), self.bot_data)
if self.callback_data:
self._dump_file(Path(f"{self.filepath}_callback_data"), self.callback_data)
if self.conversations:
self._dump_file(Path(f"{self.filepath}_conversations"), self.conversations)

View File

@@ -0,0 +1,897 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains the class Updater, which tries to make creating Telegram bots intuitive."""
import asyncio
import contextlib
import ssl
from collections.abc import Coroutine
from pathlib import Path
from types import TracebackType
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DEFAULT_NONE, DefaultValue
from telegram._utils.logging import get_logger
from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import DVType, ODVInput
from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut
try:
from telegram.ext._utils.webhookhandler import WebhookAppClass, WebhookServer
WEBHOOKS_AVAILABLE = True
except ImportError:
WEBHOOKS_AVAILABLE = False
if TYPE_CHECKING:
from socket import socket
from telegram import Bot
_UpdaterType = TypeVar("_UpdaterType", bound="Updater") # pylint: disable=invalid-name
_LOGGER = get_logger(__name__)
class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
"""This class fetches updates for the bot either via long polling or by starting a webhook
server. Received updates are enqueued into the :attr:`update_queue` and may be fetched from
there to handle them appropriately.
Instances of this class can be used as asyncio context managers, where
.. code:: python
async with updater:
# code
is roughly equivalent to
.. code:: python
try:
await updater.initialize()
# code
finally:
await updater.shutdown()
.. seealso:: :meth:`__aenter__` and :meth:`__aexit__`.
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Builder Pattern <Builder-Pattern>`
.. versionchanged:: 20.0
* Removed argument and attribute ``user_sig_handler``
* The only arguments and attributes are now :attr:`bot` and :attr:`update_queue` as now
the sole purpose of this class is to fetch updates. The entry point to a PTB application
is now :class:`telegram.ext.Application`.
Args:
bot (:class:`telegram.Bot`): The bot used with this Updater.
update_queue (:class:`asyncio.Queue`): Queue for the updates.
Attributes:
bot (:class:`telegram.Bot`): The bot used with this Updater.
update_queue (:class:`asyncio.Queue`): Queue for the updates.
"""
__slots__ = (
"__lock",
"__polling_cleanup_cb",
"__polling_task",
"__polling_task_stop_event",
"_httpd",
"_initialized",
"_last_update_id",
"_running",
"bot",
"update_queue",
)
def __init__(
self,
bot: "Bot",
update_queue: "asyncio.Queue[object]",
):
self.bot: Bot = bot
self.update_queue: asyncio.Queue[object] = update_queue
self._last_update_id = 0
self._running = False
self._initialized = False
self._httpd: Optional[WebhookServer] = None
self.__lock = asyncio.Lock()
self.__polling_task: Optional[asyncio.Task] = None
self.__polling_task_stop_event: asyncio.Event = asyncio.Event()
self.__polling_cleanup_cb: Optional[Callable[[], Coroutine[Any, Any, None]]] = None
async def __aenter__(self: _UpdaterType) -> _UpdaterType: # noqa: PYI019
"""
|async_context_manager| :meth:`initializes <initialize>` the Updater.
Returns:
The initialized Updater instance.
Raises:
:exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown`
is called in this case.
"""
try:
await self.initialize()
except Exception:
await self.shutdown()
raise
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""|async_context_manager| :meth:`shuts down <shutdown>` the Updater."""
# Make sure not to return `True` so that exceptions are not suppressed
# https://docs.python.org/3/reference/datamodel.html?#object.__aexit__
await self.shutdown()
def __repr__(self) -> str:
"""Give a string representation of the updater in the form ``Updater[bot=...]``.
As this class doesn't implement :meth:`object.__str__`, the default implementation
will be used, which is equivalent to :meth:`__repr__`.
Returns:
:obj:`str`
"""
return build_repr_with_selected_attrs(self, bot=self.bot)
@property
def running(self) -> bool:
return self._running
async def initialize(self) -> None:
"""Initializes the Updater & the associated :attr:`bot` by calling
:meth:`telegram.Bot.initialize`.
.. seealso::
:meth:`shutdown`
"""
if self._initialized:
_LOGGER.debug("This Updater is already initialized.")
return
await self.bot.initialize()
self._initialized = True
async def shutdown(self) -> None:
"""
Shutdown the Updater & the associated :attr:`bot` by calling :meth:`telegram.Bot.shutdown`.
.. seealso::
:meth:`initialize`
Raises:
:exc:`RuntimeError`: If the updater is still running.
"""
if self.running:
raise RuntimeError("This Updater is still running!")
if not self._initialized:
_LOGGER.debug("This Updater is already shut down. Returning.")
return
await self.bot.shutdown()
self._initialized = False
_LOGGER.debug("Shut down of Updater complete")
async def start_polling(
self,
poll_interval: float = 0.0,
timeout: int = 10, # noqa: ASYNC109
bootstrap_retries: int = -1,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
allowed_updates: Optional[list[str]] = None,
drop_pending_updates: Optional[bool] = None,
error_callback: Optional[Callable[[TelegramError], None]] = None,
) -> "asyncio.Queue[object]":
"""Starts polling updates from Telegram.
.. versionchanged:: 20.0
Removed the ``clean`` argument in favor of :paramref:`drop_pending_updates`.
Args:
poll_interval (:obj:`float`, optional): Time to wait between polling updates from
Telegram in seconds. Default is ``0.0``.
timeout (:obj:`int`, optional): Passed to
:paramref:`telegram.Bot.get_updates.timeout`. Defaults to ``10`` seconds.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely (default)
* 0 - no retries
* > 0 - retry up to X times
read_timeout (:obj:`float`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
.. versionchanged:: 20.7
Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of
``2``.
.. deprecated:: 20.7
Deprecated in favor of setting the timeout via
:meth:`telegram.ext.ApplicationBuilder.get_updates_read_timeout` or
:paramref:`telegram.Bot.get_updates_request`.
write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.write_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
.. deprecated:: 20.7
Deprecated in favor of setting the timeout via
:meth:`telegram.ext.ApplicationBuilder.get_updates_write_timeout` or
:paramref:`telegram.Bot.get_updates_request`.
connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.connect_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
.. deprecated:: 20.7
Deprecated in favor of setting the timeout via
:meth:`telegram.ext.ApplicationBuilder.get_updates_connect_timeout` or
:paramref:`telegram.Bot.get_updates_request`.
pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.pool_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
.. deprecated:: 20.7
Deprecated in favor of setting the timeout via
:meth:`telegram.ext.ApplicationBuilder.get_updates_pool_timeout` or
:paramref:`telegram.Bot.get_updates_request`.
allowed_updates (list[:obj:`str`], optional): Passed to
:meth:`telegram.Bot.get_updates`.
drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on
Telegram servers before actually starting to poll. Default is :obj:`False`.
.. versionadded :: 13.4
error_callback (Callable[[:exc:`telegram.error.TelegramError`], :obj:`None`], \
optional): Callback to handle :exc:`telegram.error.TelegramError` s that occur
while calling :meth:`telegram.Bot.get_updates` during polling. Defaults to
:obj:`None`, in which case errors will be logged. Callback signature::
def callback(error: telegram.error.TelegramError)
Note:
The :paramref:`error_callback` must *not* be a :term:`coroutine function`! If
asynchronous behavior of the callback is wanted, please schedule a task from
within the callback.
Returns:
:class:`asyncio.Queue`: The update queue that can be filled from the main thread.
Raises:
:exc:`RuntimeError`: If the updater is already running or was not initialized.
"""
# We refrain from issuing deprecation warnings for the timeout parameters here, as we
# already issue them in `Application`. This means that there are no warnings when using
# `Updater` without `Application`, but this is a rather special use case.
if error_callback and asyncio.iscoroutinefunction(error_callback):
raise TypeError(
"The `error_callback` must not be a coroutine function! Use an ordinary function "
"instead. "
)
async with self.__lock:
if self.running:
raise RuntimeError("This Updater is already running!")
if not self._initialized:
raise RuntimeError("This Updater was not initialized via `Updater.initialize`!")
self._running = True
try:
# Create & start tasks
polling_ready = asyncio.Event()
await self._start_polling(
poll_interval=poll_interval,
timeout=timeout,
read_timeout=read_timeout,
write_timeout=write_timeout,
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
bootstrap_retries=bootstrap_retries,
drop_pending_updates=drop_pending_updates,
allowed_updates=allowed_updates,
ready=polling_ready,
error_callback=error_callback,
)
_LOGGER.debug("Waiting for polling to start")
await polling_ready.wait()
_LOGGER.debug("Polling updates from Telegram started")
except Exception:
self._running = False
raise
return self.update_queue
async def _start_polling(
self,
poll_interval: float,
timeout: int, # noqa: ASYNC109
read_timeout: ODVInput[float],
write_timeout: ODVInput[float],
connect_timeout: ODVInput[float],
pool_timeout: ODVInput[float],
bootstrap_retries: int,
drop_pending_updates: Optional[bool],
allowed_updates: Optional[list[str]],
ready: asyncio.Event,
error_callback: Optional[Callable[[TelegramError], None]],
) -> None:
_LOGGER.debug("Updater started (polling)")
# the bootstrapping phase does two things:
# 1) make sure there is no webhook set
# 2) apply drop_pending_updates
await self._bootstrap(
bootstrap_retries,
drop_pending_updates=drop_pending_updates,
webhook_url="",
allowed_updates=None,
)
_LOGGER.debug("Bootstrap done")
async def polling_action_cb() -> bool:
try:
updates = await self.bot.get_updates(
offset=self._last_update_id,
timeout=timeout,
read_timeout=read_timeout,
connect_timeout=connect_timeout,
write_timeout=write_timeout,
pool_timeout=pool_timeout,
allowed_updates=allowed_updates,
)
except TelegramError:
# TelegramErrors should be processed by the network retry loop
raise
except Exception as exc:
# Other exceptions should not. Let's log them for now.
_LOGGER.critical(
"Something went wrong processing the data received from Telegram. "
"Received data was *not* processed!",
exc_info=exc,
)
return True
if updates:
if not self.running:
_LOGGER.critical(
"Updater stopped unexpectedly. Pulled updates will be ignored and pulled "
"again on restart."
)
else:
for update in updates:
await self.update_queue.put(update)
self._last_update_id = updates[-1].update_id + 1 # Add one to 'confirm' it
return True # Keep fetching updates & don't quit. Polls with poll_interval.
def default_error_callback(exc: TelegramError) -> None:
_LOGGER.exception("Exception happened while polling for updates.", exc_info=exc)
# Start task that runs in background, pulls
# updates from Telegram and inserts them in the update queue of the
# Application.
self.__polling_task = asyncio.create_task(
self._network_loop_retry(
action_cb=polling_action_cb,
on_err_cb=error_callback or default_error_callback,
description="getting Updates",
interval=poll_interval,
stop_event=self.__polling_task_stop_event,
),
name="Updater:start_polling:polling_task",
)
# Prepare a cleanup callback to await on _stop_polling
# Calling get_updates one more time with the latest `offset` parameter ensures that
# all updates that where put into the update queue are also marked as "read" to TG,
# so we do not receive them again on the next startup
# We define this here so that we can use the same parameters as in the polling task
async def _get_updates_cleanup() -> None:
_LOGGER.debug(
"Calling `get_updates` one more time to mark all fetched updates as read."
)
try:
await self.bot.get_updates(
offset=self._last_update_id,
# We don't want to do long polling here!
timeout=0,
read_timeout=read_timeout,
connect_timeout=connect_timeout,
write_timeout=write_timeout,
pool_timeout=pool_timeout,
allowed_updates=allowed_updates,
)
except TelegramError:
_LOGGER.exception(
"Error while calling `get_updates` one more time to mark all fetched updates "
"as read: %s. Suppressing error to ensure graceful shutdown. When polling for "
"updates is restarted, updates may be fetched again. Please adjust timeouts "
"via `ApplicationBuilder` or the parameter `get_updates_request` of `Bot`.",
)
self.__polling_cleanup_cb = _get_updates_cleanup
if ready is not None:
ready.set()
async def start_webhook(
self,
listen: DVType[str] = DEFAULT_IP,
port: DVType[int] = DEFAULT_80,
url_path: str = "",
cert: Optional[Union[str, Path]] = None,
key: Optional[Union[str, Path]] = None,
bootstrap_retries: int = 0,
webhook_url: Optional[str] = None,
allowed_updates: Optional[list[str]] = None,
drop_pending_updates: Optional[bool] = None,
ip_address: Optional[str] = None,
max_connections: int = 40,
secret_token: Optional[str] = None,
unix: Optional[Union[str, Path, "socket"]] = None,
) -> "asyncio.Queue[object]":
"""
Starts a small http server to listen for updates via webhook. If :paramref:`cert`
and :paramref:`key` are not provided, the webhook will be started directly on
``http://listen:port/url_path``, so SSL can be handled by another
application. Else, the webhook will be started on
``https://listen:port/url_path``. Also calls :meth:`telegram.Bot.set_webhook` as required.
Important:
If you want to use this method, you must install PTB with the optional requirement
``webhooks``, i.e.
.. code-block:: bash
pip install "python-telegram-bot[webhooks]"
.. seealso:: :wiki:`Webhooks`
.. versionchanged:: 13.4
:meth:`start_webhook` now *always* calls :meth:`telegram.Bot.set_webhook`, so pass
``webhook_url`` instead of calling ``updater.bot.set_webhook(webhook_url)`` manually.
.. versionchanged:: 20.0
* Removed the ``clean`` argument in favor of :paramref:`drop_pending_updates` and
removed the deprecated argument ``force_event_loop``.
Args:
listen (:obj:`str`, optional): IP-Address to listen on. Defaults to
`127.0.0.1 <https://en.wikipedia.org/wiki/Localhost>`_.
port (:obj:`int`, optional): Port the bot should be listening on. Must be one of
:attr:`telegram.constants.SUPPORTED_WEBHOOK_PORTS` unless the bot is running
behind a proxy. Defaults to ``80``.
url_path (:obj:`str`, optional): Path inside url (http(s)://listen:port/<url_path>).
Defaults to ``''``.
cert (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL certificate file.
key (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL key file.
drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on
Telegram servers before actually starting to poll. Default is :obj:`False`.
.. versionadded :: 13.4
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely
* 0 - no retries (default)
* > 0 - retry up to X times
webhook_url (:obj:`str`, optional): Explicitly specify the webhook url. Useful behind
NAT, reverse proxy, etc. Default is derived from :paramref:`listen`,
:paramref:`port`, :paramref:`url_path`, :paramref:`cert`, and :paramref:`key`.
ip_address (:obj:`str`, optional): Passed to :meth:`telegram.Bot.set_webhook`.
Defaults to :obj:`None`.
.. versionadded :: 13.4
allowed_updates (list[:obj:`str`], optional): Passed to
:meth:`telegram.Bot.set_webhook`. Defaults to :obj:`None`.
max_connections (:obj:`int`, optional): Passed to
:meth:`telegram.Bot.set_webhook`. Defaults to ``40``.
.. versionadded:: 13.6
secret_token (:obj:`str`, optional): Passed to :meth:`telegram.Bot.set_webhook`.
Defaults to :obj:`None`.
When added, the web server started by this call will expect the token to be set in
the ``X-Telegram-Bot-Api-Secret-Token`` header of an incoming request and will
raise a :class:`http.HTTPStatus.FORBIDDEN <http.HTTPStatus>` error if either the
header isn't set or it is set to a wrong token.
.. versionadded:: 20.0
unix (:class:`pathlib.Path` | :obj:`str` | :class:`socket.socket`, optional): Can be
either:
* the path to the unix socket file as :class:`pathlib.Path` or :obj:`str`. This
will be passed to `tornado.netutil.bind_unix_socket <https://www.tornadoweb.org/
en/stable/netutil.html#tornado.netutil.bind_unix_socket>`_ to create the socket.
If the Path does not exist, the file will be created.
* or the socket itself. This option allows you to e.g. restrict the permissions of
the socket for improved security. Note that you need to pass the correct family,
type and socket options yourself.
Caution:
This parameter is a replacement for the default TCP bind. Therefore, it is
mutually exclusive with :paramref:`listen` and :paramref:`port`. When using
this param, you must also run a reverse proxy to the unix socket and set the
appropriate :paramref:`webhook_url`.
.. versionadded:: 20.8
.. versionchanged:: 21.1
Added support to pass a socket instance itself.
Returns:
:class:`queue.Queue`: The update queue that can be filled from the main thread.
Raises:
:exc:`RuntimeError`: If the updater is already running or was not initialized.
"""
if not WEBHOOKS_AVAILABLE:
raise RuntimeError(
"To use `start_webhook`, PTB must be installed via `pip install "
'"python-telegram-bot[webhooks]"`.'
)
# unix has special requirements what must and mustn't be set when using it
if unix:
error_msg = (
"You can not pass unix and {0}, only use one. Unix if you want to "
"initialize a unix socket, or {0} for a standard TCP server."
)
if not isinstance(listen, DefaultValue):
raise RuntimeError(error_msg.format("listen"))
if not isinstance(port, DefaultValue):
raise RuntimeError(error_msg.format("port"))
if not webhook_url:
raise RuntimeError(
"Since you set unix, you also need to set the URL to the webhook "
"of the proxy you run in front of the unix socket."
)
async with self.__lock:
if self.running:
raise RuntimeError("This Updater is already running!")
if not self._initialized:
raise RuntimeError("This Updater was not initialized via `Updater.initialize`!")
self._running = True
try:
# Create & start tasks
webhook_ready = asyncio.Event()
await self._start_webhook(
listen=DefaultValue.get_value(listen),
port=DefaultValue.get_value(port),
url_path=url_path,
cert=cert,
key=key,
bootstrap_retries=bootstrap_retries,
drop_pending_updates=drop_pending_updates,
webhook_url=webhook_url,
allowed_updates=allowed_updates,
ready=webhook_ready,
ip_address=ip_address,
max_connections=max_connections,
secret_token=secret_token,
unix=unix,
)
_LOGGER.debug("Waiting for webhook server to start")
await webhook_ready.wait()
_LOGGER.debug("Webhook server started")
except Exception:
self._running = False
raise
# Return the update queue so the main thread can insert updates
return self.update_queue
async def _start_webhook(
self,
listen: str,
port: int,
url_path: str,
bootstrap_retries: int,
allowed_updates: Optional[list[str]],
cert: Optional[Union[str, Path]] = None,
key: Optional[Union[str, Path]] = None,
drop_pending_updates: Optional[bool] = None,
webhook_url: Optional[str] = None,
ready: Optional[asyncio.Event] = None,
ip_address: Optional[str] = None,
max_connections: int = 40,
secret_token: Optional[str] = None,
unix: Optional[Union[str, Path, "socket"]] = None,
) -> None:
_LOGGER.debug("Updater thread started (webhook)")
if not url_path.startswith("/"):
url_path = f"/{url_path}"
# Create Tornado app instance
app = WebhookAppClass(url_path, self.bot, self.update_queue, secret_token)
# Form SSL Context
# An SSLError is raised if the private key does not match with the certificate
# Note that we only use the SSL certificate for the WebhookServer, if the key is also
# present. This is because the WebhookServer may not actually be in charge of performing
# the SSL handshake, e.g. in case a reverse proxy is used
if cert is not None and key is not None:
try:
ssl_ctx: Optional[ssl.SSLContext] = ssl.create_default_context(
ssl.Purpose.CLIENT_AUTH
)
ssl_ctx.load_cert_chain(cert, key) # type: ignore[union-attr]
except ssl.SSLError as exc:
raise TelegramError("Invalid SSL Certificate") from exc
else:
ssl_ctx = None
# Create and start server
self._httpd = WebhookServer(listen, port, app, ssl_ctx, unix)
if not webhook_url:
webhook_url = self._gen_webhook_url(
protocol="https" if ssl_ctx else "http",
listen=DefaultValue.get_value(listen),
port=port,
url_path=url_path,
)
# We pass along the cert to the webhook if present.
await self._bootstrap(
# Passing a Path or string only works if the bot is running against a local bot API
# server, so let's read the contents
cert=Path(cert).read_bytes() if cert else None,
max_retries=bootstrap_retries,
drop_pending_updates=drop_pending_updates,
webhook_url=webhook_url,
allowed_updates=allowed_updates,
ip_address=ip_address,
max_connections=max_connections,
secret_token=secret_token,
)
await self._httpd.serve_forever(ready=ready)
@staticmethod
def _gen_webhook_url(protocol: str, listen: str, port: int, url_path: str) -> str:
# TODO: double check if this should be https in any case - the docs of start_webhook
# say differently!
return f"{protocol}://{listen}:{port}{url_path}"
async def _network_loop_retry(
self,
action_cb: Callable[..., Coroutine],
on_err_cb: Callable[[TelegramError], None],
description: str,
interval: float,
stop_event: Optional[asyncio.Event],
) -> None:
"""Perform a loop calling `action_cb`, retrying after network errors.
Stop condition for loop: `self.running` evaluates :obj:`False` or return value of
`action_cb` evaluates :obj:`False`.
Args:
action_cb (:term:`coroutine function`): Network oriented callback function to call.
on_err_cb (:obj:`callable`): Callback to call when TelegramError is caught. Receives
the exception object as a parameter.
description (:obj:`str`): Description text to use for logs and exception raised.
interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to
`action_cb`.
stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the
loop. Setting the event will make the loop exit even if `action_cb` is currently
running.
"""
async def do_action() -> bool:
if not stop_event:
return await action_cb()
action_cb_task = asyncio.create_task(action_cb())
stop_task = asyncio.create_task(stop_event.wait())
done, pending = await asyncio.wait(
(action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED
)
with contextlib.suppress(asyncio.CancelledError):
for task in pending:
task.cancel()
if stop_task in done:
_LOGGER.debug("Network loop retry %s was cancelled", description)
return False
return action_cb_task.result()
_LOGGER.debug("Start network loop retry %s", description)
cur_interval = interval
while self.running:
try:
if not await do_action():
break
except RetryAfter as exc:
_LOGGER.info("%s", exc)
cur_interval = 0.5 + exc.retry_after
except TimedOut as toe:
_LOGGER.debug("Timed out %s: %s", description, toe)
# If failure is due to timeout, we should retry asap.
cur_interval = 0
except InvalidToken:
_LOGGER.exception("Invalid token; aborting")
raise
except TelegramError as telegram_exc:
on_err_cb(telegram_exc)
# increase waiting times on subsequent errors up to 30secs
cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval)
else:
cur_interval = interval
if cur_interval:
await asyncio.sleep(cur_interval)
async def _bootstrap(
self,
max_retries: int,
webhook_url: Optional[str],
allowed_updates: Optional[list[str]],
drop_pending_updates: Optional[bool] = None,
cert: Optional[bytes] = None,
bootstrap_interval: float = 1,
ip_address: Optional[str] = None,
max_connections: int = 40,
secret_token: Optional[str] = None,
) -> None:
"""Prepares the setup for fetching updates: delete or set the webhook and drop pending
updates if appropriate. If there are unsuccessful attempts, this will retry as specified by
:paramref:`max_retries`.
"""
retries = 0
async def bootstrap_del_webhook() -> bool:
_LOGGER.debug("Deleting webhook")
if drop_pending_updates:
_LOGGER.debug("Dropping pending updates from Telegram server")
await self.bot.delete_webhook(drop_pending_updates=drop_pending_updates)
return False
async def bootstrap_set_webhook() -> bool:
_LOGGER.debug("Setting webhook")
if drop_pending_updates:
_LOGGER.debug("Dropping pending updates from Telegram server")
await self.bot.set_webhook(
url=webhook_url, # type: ignore[arg-type]
certificate=cert,
allowed_updates=allowed_updates,
ip_address=ip_address,
drop_pending_updates=drop_pending_updates,
max_connections=max_connections,
secret_token=secret_token,
)
return False
def bootstrap_on_err_cb(exc: Exception) -> None:
# We need this since retries is an immutable object otherwise and the changes
# wouldn't propagate outside of thi function
nonlocal retries
if not isinstance(exc, InvalidToken) and (max_retries < 0 or retries < max_retries):
retries += 1
_LOGGER.warning(
"Failed bootstrap phase; try=%s max_retries=%s", retries, max_retries
)
else:
_LOGGER.error("Failed bootstrap phase after %s retries (%s)", retries, exc)
raise exc
# Dropping pending updates from TG can be efficiently done with the drop_pending_updates
# parameter of delete/start_webhook, even in the case of polling. Also, we want to make
# sure that no webhook is configured in case of polling, so we just always call
# delete_webhook for polling
if drop_pending_updates or not webhook_url:
await self._network_loop_retry(
bootstrap_del_webhook,
bootstrap_on_err_cb,
"bootstrap del webhook",
bootstrap_interval,
stop_event=None,
)
# Reset the retries counter for the next _network_loop_retry call
retries = 0
# Restore/set webhook settings, if needed. Again, we don't know ahead if a webhook is set,
# so we set it anyhow.
if webhook_url:
await self._network_loop_retry(
bootstrap_set_webhook,
bootstrap_on_err_cb,
"bootstrap set webhook",
bootstrap_interval,
stop_event=None,
)
async def stop(self) -> None:
"""Stops the polling/webhook.
.. seealso::
:meth:`start_polling`, :meth:`start_webhook`
Raises:
:exc:`RuntimeError`: If the updater is not running.
"""
async with self.__lock:
if not self.running:
raise RuntimeError("This Updater is not running!")
_LOGGER.debug("Stopping Updater")
self._running = False
await self._stop_httpd()
await self._stop_polling()
_LOGGER.debug("Updater.stop() is complete")
async def _stop_httpd(self) -> None:
"""Stops the Webhook server by calling ``WebhookServer.shutdown()``"""
if self._httpd:
_LOGGER.debug("Waiting for current webhook connection to be closed.")
await self._httpd.shutdown()
self._httpd = None
async def _stop_polling(self) -> None:
"""Stops the polling task by awaiting it."""
if self.__polling_task:
_LOGGER.debug("Waiting background polling task to finish up.")
self.__polling_task_stop_event.set()
with contextlib.suppress(asyncio.CancelledError):
await self.__polling_task
# It only fails in rare edge-cases, e.g. when `stop()` is called directly
# after start_polling(), but lets better be safe than sorry ...
self.__polling_task = None
self.__polling_task_stop_event.clear()
if self.__polling_cleanup_cb:
await self.__polling_cleanup_cb()
self.__polling_cleanup_cb = None
else:
_LOGGER.warning(
"No polling cleanup callback defined. The last fetched updates may be "
"fetched again on the next polling start."
)

View File

@@ -0,0 +1,17 @@
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains helper functions related to parsing updates and their contents.
.. versionadded:: 20.8
Warning:
Contents of this module are intended to be used internally by the library and *not* by the
user. Changes to this module are not considered breaking changes and may not be documented in
the changelog.
"""
from typing import Optional
from telegram._utils.types import SCT
def parse_chat_id(chat_id: Optional[SCT[int]]) -> frozenset[int]:
"""Accepts a chat id or collection of chat ids and returns a frozenset of chat ids."""
if chat_id is None:
return frozenset()
if isinstance(chat_id, int):
return frozenset({chat_id})
return frozenset(chat_id)
def parse_username(username: Optional[SCT[str]]) -> frozenset[str]:
"""Accepts a username or collection of usernames and returns a frozenset of usernames.
Strips the leading ``@`` if present.
"""
if username is None:
return frozenset()
if isinstance(username, str):
return frozenset({username.removeprefix("@")})
return frozenset(usr.removeprefix("@") for usr in username)

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains helper functions related to inspecting the program stack.
.. versionadded:: 20.0
Warning:
Contents of this module are intended to be used internally by the library and *not* by the
user. Changes to this module are not considered breaking changes and may not be documented in
the changelog.
"""
from pathlib import Path
from types import FrameType
from typing import Optional
from telegram._utils.logging import get_logger
_LOGGER = get_logger(__name__)
def was_called_by(frame: Optional[FrameType], caller: Path) -> bool:
"""Checks if the passed frame was called by the specified file.
Example:
.. code:: pycon
>>> was_called_by(inspect.currentframe(), Path(__file__))
True
Arguments:
frame (:obj:`FrameType`): The frame - usually the return value of
``inspect.currentframe()``. If :obj:`None` is passed, the return value will be
:obj:`False`.
caller (:obj:`pathlib.Path`): File that should be the caller.
Returns:
:obj:`bool`: Whether the frame was called by the specified file.
"""
if frame is None:
return False
try:
return _was_called_by(frame, caller)
except Exception as exc:
_LOGGER.debug(
"Failed to check if frame was called by `caller`. Assuming that it was not.",
exc_info=exc,
)
return False
def _was_called_by(frame: FrameType, caller: Path) -> bool:
# https://stackoverflow.com/a/57712700/10606962
if Path(frame.f_code.co_filename).resolve() == caller:
return True
while frame.f_back:
frame = frame.f_back
if Path(frame.f_code.co_filename).resolve() == caller:
return True
return False

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains a mutable mapping that keeps track of the keys that where accessed.
.. versionadded:: 20.0
Warning:
Contents of this module are intended to be used internally by the library and *not* by the
user. Changes to this module are not considered breaking changes and may not be documented in
the changelog.
"""
from collections import UserDict
from collections.abc import Mapping
from typing import Final, Generic, Optional, TypeVar, Union
from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
_VT = TypeVar("_VT")
_KT = TypeVar("_KT")
_T = TypeVar("_T")
class TrackingDict(UserDict, Generic[_KT, _VT]):
"""Mutable mapping that keeps track of which keys where accessed with write access.
Read-access is not tracked.
Note:
* ``setdefault()`` and ``pop`` are considered writing only depending on whether the
key is present
* deleting values is considered writing
"""
DELETED: Final = object()
"""Special marker indicating that an entry was deleted."""
__slots__ = ("_write_access_keys",)
def __init__(self) -> None:
super().__init__()
self._write_access_keys: set[_KT] = set()
def __setitem__(self, key: _KT, value: _VT) -> None:
self.__track_write(key)
super().__setitem__(key, value)
def __delitem__(self, key: _KT) -> None:
self.__track_write(key)
super().__delitem__(key)
def __track_write(self, key: Union[_KT, set[_KT]]) -> None:
if isinstance(key, set):
self._write_access_keys |= key
else:
self._write_access_keys.add(key)
def pop_accessed_keys(self) -> set[_KT]:
"""Returns all keys that were write-accessed since the last time this method was called."""
out = self._write_access_keys
self._write_access_keys = set()
return out
def pop_accessed_write_items(self) -> list[tuple[_KT, _VT]]:
"""
Returns all keys & corresponding values as set of tuples that were write-accessed since
the last time this method was called. If a key was deleted, the value will be
:attr:`DELETED`.
"""
keys = self.pop_accessed_keys()
return [(key, self.get(key, self.DELETED)) for key in keys]
def mark_as_accessed(self, key: _KT) -> None:
"""Use this method have the key returned again in the next call to
:meth:`pop_accessed_write_items` or :meth:`pop_accessed_keys`
"""
self._write_access_keys.add(key)
# Override methods to track access
def update_no_track(self, mapping: Mapping[_KT, _VT]) -> None:
"""Like ``update``, but doesn't count towards write access."""
for key, value in mapping.items():
self.data[key] = value
# Mypy seems a bit inconsistent about what it wants as types for `default` and return value
# so we just ignore a bit
def pop( # type: ignore[override]
self,
key: _KT,
default: _VT = DEFAULT_NONE, # type: ignore[assignment]
) -> _VT:
if key in self:
self.__track_write(key)
if isinstance(default, DefaultValue):
return super().pop(key)
return super().pop(key, default=default)
def clear(self) -> None:
self.__track_write(set(super().keys()))
super().clear()
# Mypy seems a bit inconsistent about what it wants as types for `default` and return value
# so we just ignore a bit
def setdefault(self: "TrackingDict[_KT, _T]", key: _KT, default: Optional[_T] = None) -> _T:
if key in self:
return self[key]
self.__track_write(key)
self[key] = default # type: ignore[assignment]
return default # type: ignore[return-value]

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains custom typing aliases.
.. versionadded:: 13.6
Warning:
Contents of this module are intended to be used internally by the library and *not* by the
user. Changes to this module are not considered breaking changes and may not be documented in
the changelog.
"""
from collections.abc import Coroutine, MutableMapping
from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union
if TYPE_CHECKING:
from typing import Optional
from telegram import Bot
from telegram.ext import BaseRateLimiter, CallbackContext, JobQueue
CCT = TypeVar("CCT", bound="CallbackContext[Any, Any, Any, Any]")
"""An instance of :class:`telegram.ext.CallbackContext` or a custom subclass.
.. versionadded:: 13.6
"""
RT = TypeVar("RT")
UT = TypeVar("UT")
HandlerCallback = Callable[[UT, CCT], Coroutine[Any, Any, RT]]
"""Type of a handler callback
.. versionadded:: 20.0
"""
JobCallback = Callable[[CCT], Coroutine[Any, Any, Any]]
"""Type of a job callback
.. versionadded:: 20.0
"""
ConversationKey = tuple[Union[int, str], ...]
ConversationDict = MutableMapping[ConversationKey, object]
"""dict[tuple[:obj:`int` | :obj:`str`, ...], Optional[:obj:`object`]]:
Dicts as maintained by the :class:`telegram.ext.ConversationHandler`.
.. versionadded:: 13.6
"""
CDCData = tuple[list[tuple[str, float, dict[str, Any]]], dict[str, str]]
"""tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \
dict[:obj:`str`, :obj:`str`]]: Data returned by
:attr:`telegram.ext.CallbackDataCache.persistence_data`.
.. versionadded:: 13.6
"""
BT = TypeVar("BT", bound="Bot")
"""Type of the bot.
.. versionadded:: 20.0
"""
UD = TypeVar("UD")
"""Type of the user data for a single user.
.. versionadded:: 13.6
"""
CD = TypeVar("CD")
"""Type of the chat data for a single user.
.. versionadded:: 13.6
"""
BD = TypeVar("BD")
"""Type of the bot data.
.. versionadded:: 13.6
"""
JQ = TypeVar("JQ", bound=Union[None, "JobQueue"])
"""Type of the job queue.
.. versionadded:: 20.0"""
RL = TypeVar("RL", bound="Optional[BaseRateLimiter]")
"""Type of the rate limiter.
.. versionadded:: 20.0"""
RLARGS = TypeVar("RLARGS")
"""Type of the rate limiter arguments.
.. versionadded:: 20.0"""
FilterDataDict = dict[str, list[Any]]

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
# pylint: disable=missing-module-docstring
import asyncio
import json
from http import HTTPStatus
from pathlib import Path
from socket import socket
from ssl import SSLContext
from types import TracebackType
from typing import TYPE_CHECKING, Optional, Union
# Instead of checking for ImportError here, we do that in `updater.py`, where we import from
# this module. Doing it here would be tricky, as the classes below subclass tornado classes
import tornado.web
from tornado.httpserver import HTTPServer
try:
from tornado.netutil import bind_unix_socket
UNIX_AVAILABLE = True
except ImportError:
UNIX_AVAILABLE = False
from telegram import Update
from telegram._utils.logging import get_logger
from telegram.ext._extbot import ExtBot
if TYPE_CHECKING:
from telegram import Bot
# This module is not visible to users, so we log as Updater
_LOGGER = get_logger(__name__, class_name="Updater")
class WebhookServer:
"""Thin wrapper around ``tornado.httpserver.HTTPServer``."""
__slots__ = (
"_http_server",
"_server_lock",
"_shutdown_lock",
"is_running",
"listen",
"port",
"unix",
)
def __init__(
self,
listen: str,
port: int,
webhook_app: "WebhookAppClass",
ssl_ctx: Optional[SSLContext],
unix: Optional[Union[str, Path, socket]] = None,
):
if unix and not UNIX_AVAILABLE:
raise RuntimeError("This OS does not support binding unix sockets.")
self._http_server = HTTPServer(webhook_app, ssl_options=ssl_ctx)
self.listen = listen
self.port = port
self.is_running = False
self.unix = None
if unix and isinstance(unix, socket):
self.unix = unix
elif unix:
self.unix = bind_unix_socket(str(unix))
self._server_lock = asyncio.Lock()
self._shutdown_lock = asyncio.Lock()
async def serve_forever(self, ready: Optional[asyncio.Event] = None) -> None:
async with self._server_lock:
if self.unix:
self._http_server.add_socket(self.unix)
else:
self._http_server.listen(self.port, address=self.listen)
self.is_running = True
if ready is not None:
ready.set()
_LOGGER.debug("Webhook Server started.")
async def shutdown(self) -> None:
async with self._shutdown_lock:
if not self.is_running:
_LOGGER.debug("Webhook Server is already shut down. Returning")
return
self.is_running = False
self._http_server.stop()
await self._http_server.close_all_connections()
_LOGGER.debug("Webhook Server stopped")
class WebhookAppClass(tornado.web.Application):
"""Application used in the Webserver"""
def __init__(
self,
webhook_path: str,
bot: "Bot",
update_queue: asyncio.Queue,
secret_token: Optional[str] = None,
):
self.shared_objects = {
"bot": bot,
"update_queue": update_queue,
"secret_token": secret_token,
}
handlers = [(rf"{webhook_path}/?", TelegramHandler, self.shared_objects)]
tornado.web.Application.__init__(self, handlers) # type: ignore
def log_request(self, handler: tornado.web.RequestHandler) -> None:
"""Overrides the default implementation since we have our own logging setup."""
# pylint: disable=abstract-method
class TelegramHandler(tornado.web.RequestHandler):
"""BaseHandler that processes incoming requests from Telegram"""
__slots__ = ("bot", "secret_token", "update_queue")
SUPPORTED_METHODS = ("POST",) # type: ignore[assignment]
def initialize(self, bot: "Bot", update_queue: asyncio.Queue, secret_token: str) -> None:
"""Initialize for each request - that's the interface provided by tornado"""
# pylint: disable=attribute-defined-outside-init
self.bot = bot
self.update_queue = update_queue
self.secret_token = secret_token
if secret_token:
_LOGGER.debug(
"The webhook server has a secret token, expecting it in incoming requests now"
)
def set_default_headers(self) -> None:
"""Sets default headers"""
self.set_header("Content-Type", 'application/json; charset="utf-8"')
async def post(self) -> None:
"""Handle incoming POST request"""
_LOGGER.debug("Webhook triggered")
self._validate_post()
json_string = self.request.body.decode()
data = json.loads(json_string)
self.set_status(HTTPStatus.OK)
_LOGGER.debug("Webhook received data: %s", json_string)
try:
update = Update.de_json(data, self.bot)
except Exception as exc:
_LOGGER.critical(
"Something went wrong processing the data received from Telegram. "
"Received data was *not* processed! Received data was: %r",
data,
exc_info=exc,
)
raise tornado.web.HTTPError(
HTTPStatus.BAD_REQUEST, reason="Update could not be processed"
) from exc
if update:
_LOGGER.debug(
"Received Update with ID %d on Webhook",
# For some reason pylint thinks update is a general TelegramObject
update.update_id, # pylint: disable=no-member
)
# handle arbitrary callback data, if necessary
if isinstance(self.bot, ExtBot):
self.bot.insert_callback_data(update)
await self.update_queue.put(update)
def _validate_post(self) -> None:
"""Only accept requests with content type JSON"""
ct_header = self.request.headers.get("Content-Type", None)
if ct_header != "application/json":
raise tornado.web.HTTPError(HTTPStatus.FORBIDDEN)
# verifying that the secret token is the one the user set when the user set one
if self.secret_token is not None:
token = self.request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if not token:
_LOGGER.debug("Request did not include the secret token")
raise tornado.web.HTTPError(
HTTPStatus.FORBIDDEN, reason="Request did not include the secret token"
)
if token != self.secret_token:
_LOGGER.debug("Request had the wrong secret token: %s", token)
raise tornado.web.HTTPError(
HTTPStatus.FORBIDDEN, reason="Request had the wrong secret token"
)
def log_exception(
self,
typ: Optional[type[BaseException]],
value: Optional[BaseException],
tb: Optional[TracebackType],
) -> None:
"""Override the default logging and instead use our custom logging."""
_LOGGER.debug(
"%s - %s",
self.request.remote_ip,
"Exception in TelegramHandler",
exc_info=(typ, value, tb) if typ and value and tb else value,
)

File diff suppressed because it is too large Load Diff