Added version 4

This commit is contained in:
2026-01-30 11:47:06 +03:00
parent 05aea043b6
commit 801844807e
2205 changed files with 2048 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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",
"CallbackContext",
"CallbackDataCache",
"CallbackQueryHandler",
"ChatJoinRequestHandler",
"ChatMemberHandler",
"ChosenInlineResultHandler",
"CommandHandler",
"ContextTypes",
"ConversationHandler",
"Defaults",
"DictPersistence",
"ExtBot",
"filters",
"InlineQueryHandler",
"InvalidCallbackData",
"Job",
"JobQueue",
"MessageHandler",
"PersistenceInput",
"PicklePersistence",
"PollAnswerHandler",
"PollHandler",
"PreCheckoutQueryHandler",
"PrefixHandler",
"ShippingQueryHandler",
"StringCommandHandler",
"StringRegexHandler",
"TypeHandler",
"Updater",
)
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 ._callbackcontext import CallbackContext
from ._callbackdatacache import CallbackDataCache, InvalidCallbackData
from ._callbackqueryhandler import CallbackQueryHandler
from ._chatjoinrequesthandler import ChatJoinRequestHandler
from ._chatmemberhandler import ChatMemberHandler
from ._choseninlineresulthandler import ChosenInlineResultHandler
from ._commandhandler import CommandHandler
from ._contexttypes import ContextTypes
from ._conversationhandler import ConversationHandler
from ._defaults import Defaults
from ._dictpersistence import DictPersistence
from ._extbot import ExtBot
from ._handler import BaseHandler
from ._inlinequeryhandler import InlineQueryHandler
from ._jobqueue import Job, JobQueue
from ._messagehandler import MessageHandler
from ._picklepersistence import PicklePersistence
from ._pollanswerhandler import PollAnswerHandler
from ._pollhandler import PollHandler
from ._precheckoutqueryhandler import PreCheckoutQueryHandler
from ._prefixhandler import PrefixHandler
from ._shippingqueryhandler import ShippingQueryHandler
from ._stringcommandhandler import StringCommandHandler
from ._stringregexhandler import StringRegexHandler
from ._typehandler import TypeHandler
from ._updater import Updater

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import Any, AsyncIterator, Callable, Coroutine, Dict, List, 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.
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: # skipcq: PTC-W0062
async with 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, # skipcq: PYL-W0613
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 exc
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,428 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 Dict, 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): # skipcq: PYL-E0239
"""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__ = (
"bot",
"store_data",
"_update_interval",
)
def __init__(
self,
store_data: 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, value: 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.
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.
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.
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,141 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import Any, Callable, Coroutine, Dict, Generic, List, 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,430 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import (
TYPE_CHECKING,
Any,
Awaitable,
Dict,
Generator,
Generic,
List,
Match,
NoReturn,
Optional,
Type,
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"
)
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-%E2%80%93-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__ = (
"_application",
"_chat_id",
"_user_id",
"args",
"matches",
"error",
"job",
"coroutine",
"__dict__",
)
def __init__(
self: "CCT",
application: "Application[BT, CCT, UD, CD, BD, Any]",
chat_id: int = None,
user_id: int = None,
):
self._application: Application[BT, CCT, 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[CCT]"] = None
self.coroutine: Optional[
Union[Generator[Optional["Future[object]"], None, Any], Awaitable[Any]]
] = None
@property
def application(self) -> "Application[BT, CCT, 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, value: 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, value: 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, value: 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("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: "Job[Any]" = None,
coroutine: 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[CCT]"]:
"""
:class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the
:class:`telegram.ext.Application`.
.. seealso:: :wiki:`Job Queue <Extensions-%E2%80%93-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,455 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Optional, Tuple, Union, cast
from uuid import uuid4
try:
from cachetools import LRUCache
CACHE_TOOLS_AVAILABLE = True
except ImportError:
CACHE_TOOLS_AVAILABLE = False
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: 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]
return self.__class__, (self.callback_data,)
class _KeyboardData:
__slots__ = ("keyboard_uuid", "button_data", "access_time")
def __init__(
self, keyboard_uuid: str, access_time: float = None, button_data: 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__ = ("bot", "_maxsize", "_keyboard_data", "_callback_queries")
def __init__(
self,
bot: "ExtBot[Any]",
maxsize: int = 1024,
persistent_data: 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()
return keyboard, button_data
except KeyError:
return None, InvalidCallbackData(callback_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 callback_query.message:
self.__process_message(callback_query.message)
for message in (
callback_query.message.pinned_message,
callback_query.message.reply_to_message,
):
if message:
self.__process_message(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:
try:
self._keyboard_data.pop(keyboard_uuid)
except KeyError:
return
def clear_callback_data(self, time_cutoff: 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.
For timezone naive :obj:`datetime.datetime` objects, the default timezone of the
bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is
used.
"""
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: 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,170 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import TYPE_CHECKING, Any, Callable, Match, Optional, Pattern, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler 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]):
"""BaseHandler 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
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.
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.
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,
callback: HandlerCallback[Update, CCT, RT],
pattern: Union[str, Pattern[str], type, Callable[[object], Optional[bool]]] = 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)
self.pattern: Optional[
Union[str, Pattern[str], type, Callable[[object], Optional[bool]]]
] = 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 isinstance(update, Update) and update.callback_query:
callback_data = update.callback_query.data
if self.pattern:
if callback_data is None:
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
match = re.match(self.pattern, callback_data)
if match:
return match
else:
return True
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,128 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 FrozenSet, Optional
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import RT, SCT, DVType
from telegram.ext._handler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
class ChatJoinRequestHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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,
callback: HandlerCallback[Update, CCT, RT],
chat_id: SCT[int] = None,
username: SCT[str] = None,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self._chat_ids = self._parse_chat_id(chat_id)
self._usernames = self._parse_username(username)
@staticmethod
def _parse_chat_id(chat_id: Optional[SCT[int]]) -> FrozenSet[int]:
if chat_id is None:
return frozenset()
if isinstance(chat_id, int):
return frozenset({chat_id})
return frozenset(chat_id)
@staticmethod
def _parse_username(username: Optional[SCT[str]]) -> FrozenSet[str]:
if username is None:
return frozenset()
if isinstance(username, str):
return frozenset({username[1:] if username.startswith("@") else username})
return frozenset({usr[1:] if usr.startswith("@") else usr for usr in 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
if update.chat_join_request.from_user.username in self._usernames:
return True
return False
return False

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 ClassVar, Optional, TypeVar
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
class ChatMemberHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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`
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_member_types",)
MY_CHAT_MEMBER: ClassVar[int] = -1
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.my_chat_member`."""
CHAT_MEMBER: ClassVar[int] = 0
""":obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_member`."""
ANY_CHAT_MEMBER: ClassVar[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,
callback: HandlerCallback[Update, CCT, RT],
chat_member_types: int = MY_CHAT_MEMBER,
block: DVType[bool] = DEFAULT_TRUE,
):
super().__init__(callback, block=block)
self.chat_member_types: Optional[int] = chat_member_types
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):
if not (update.my_chat_member or update.chat_member):
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)
return False

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-2023
# 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 typing import TYPE_CHECKING, Any, Match, Optional, Pattern, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler 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]):
"""BaseHandler 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,
callback: HandlerCallback[Update, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
pattern: 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:
match = re.match(self.pattern, update.chosen_inline_result.result_id)
if match:
return match
else:
return True
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,181 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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, FrozenSet, List, Optional, Tuple, 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._handler 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]):
"""BaseHandler class to handle Telegram commands.
Commands are Telegram messages that start 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.
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`
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`.
"""
__slots__ = ("commands", "filters")
def __init__(
self,
command: SCT[str],
callback: HandlerCallback[Update, CCT, RT],
filters: filters_module.BaseFilter = None,
block: DVType[bool] = DEFAULT_TRUE,
):
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
)
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
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, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,242 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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, Dict, Generic, Type, 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__ = ("_context", "_bot_data", "_chat_data", "_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 ValueError("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,939 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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,
ClassVar,
Dict,
Generic,
List,
NoReturn,
Optional,
Set,
Tuple,
Union,
cast,
)
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE, DefaultValue
from telegram._utils.logging import get_logger
from telegram._utils.types import DVType
from telegram._utils.warnings import warn
from telegram.ext._application import ApplicationHandlerStop
from telegram.ext._callbackqueryhandler import CallbackQueryHandler
from telegram.ext._choseninlineresulthandler import ChosenInlineResultHandler
from telegram.ext._extbot import ExtBot
from telegram.ext._handler import BaseHandler
from telegram.ext._inlinequeryhandler import InlineQueryHandler
from telegram.ext._stringcommandhandler import StringCommandHandler
from telegram.ext._stringregexhandler import StringRegexHandler
from telegram.ext._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]
_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__ = ("conversation_key", "update", "application", "callback_context")
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__ = ("task", "old_state")
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]):
"""
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: ClassVar[int] = -1
""":obj:`int`: Used as a constant to return when a conversation is ended."""
TIMEOUT: ClassVar[int] = -2
""":obj:`int`: Used as a constant to handle state when a conversation is timed out
(exceeded :attr:`conversation_timeout`).
"""
WAITING: ClassVar[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,
entry_points: List[BaseHandler[Update, CCT]],
states: Dict[object, List[BaseHandler[Update, CCT]]],
fallbacks: List[BaseHandler[Update, CCT]],
allow_reentry: bool = False,
per_chat: bool = True,
per_user: bool = True,
per_message: bool = False,
conversation_timeout: Union[float, datetime.timedelta] = None,
name: str = None,
persistent: bool = False,
map_to_parent: 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]] = entry_points
self._states: Dict[object, List[BaseHandler[Update, CCT]]] = states
self._fallbacks: List[BaseHandler[Update, CCT]] = 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]] = []
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 "
f"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,
)
@property
def entry_points(self) -> List[BaseHandler[Update, CCT]]:
"""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, value: 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]]]:
"""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, value: object) -> NoReturn:
raise AttributeError("You can not assign a new value to states after initialization.")
@property
def fallbacks(self) -> List[BaseHandler[Update, CCT]]:
"""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, value: 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, value: 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, value: 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, value: 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, value: 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, value: 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, value: 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, value: 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, value: 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
self._conversations.update_no_track(
await application.persistence.get_conversations(self.name)
)
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,
)
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,
)
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: 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,243 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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, Dict, NoReturn, Optional
from telegram._utils.datetime import UTC
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.
allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|
quote (:obj:`bool`, optional): If set to :obj:`True`, the reply is sent as an actual reply
to the message. If ``reply_to_message_id`` is passed, this parameter will
be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats.
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
"""
__slots__ = (
"_tzinfo",
"_disable_web_page_preview",
"_block",
"_quote",
"_disable_notification",
"_allow_sending_without_reply",
"_parse_mode",
"_api_defaults",
"_protect_content",
)
def __init__(
self,
parse_mode: str = None,
disable_notification: bool = None,
disable_web_page_preview: bool = None,
quote: bool = None,
tzinfo: datetime.tzinfo = UTC,
block: bool = True,
allow_sending_without_reply: bool = None,
protect_content: bool = None,
):
self._parse_mode: Optional[str] = parse_mode
self._disable_notification: Optional[bool] = disable_notification
self._disable_web_page_preview: Optional[bool] = disable_web_page_preview
self._allow_sending_without_reply: Optional[bool] = allow_sending_without_reply
self._quote: Optional[bool] = quote
self._tzinfo: datetime.tzinfo = tzinfo
self._block: bool = block
self._protect_content: Optional[bool] = protect_content
# Gather all defaults that actually have a default value
self._api_defaults = {}
for kwarg in (
"parse_mode",
"explanation_parse_mode",
"disable_notification",
"disable_web_page_preview",
"allow_sending_without_reply",
"protect_content",
):
value = getattr(self, kwarg)
if value is not None:
self._api_defaults[kwarg] = value
@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, value: 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, value: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to explanation_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, value: object) -> NoReturn:
raise AttributeError(
"You can not assign a new value to disable_notification after initialization."
)
@property
def disable_web_page_preview(self) -> Optional[bool]:
""":obj:`bool`: Optional. Disables link previews for links in this
message.
"""
return self._disable_web_page_preview
@disable_web_page_preview.setter
def disable_web_page_preview(self, value: 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, value: 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. If set to :obj:`True`, the reply is sent as an actual reply
to the message. If ``reply_to_message_id`` is passed, this parameter will
be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats.
"""
return self._quote
@quote.setter
def quote(self, value: 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, value: 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, value: 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, value: object) -> NoReturn:
raise AttributeError(
"You can't assign a new value to protect_content after initialization."
)
def __hash__(self) -> int:
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:
if isinstance(other, Defaults):
return all(getattr(self, attr) == getattr(other, attr) for attr in self.__slots__)
return False
def __ne__(self, other: object) -> bool:
return not self == other

View File

@@ -0,0 +1,479 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 Any, Dict, Optional, cast
from telegram._utils.types import JSONDict
from telegram.ext import BasePersistence, PersistenceInput
from telegram.ext._utils.types import CDCData, ConversationDict, ConversationKey
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__ = (
"_user_data",
"_chat_data",
"_bot_data",
"_callback_data",
"_conversations",
"_user_data_json",
"_chat_data_json",
"_bot_data_json",
"_callback_data_json",
"_conversations_json",
)
def __init__(
self,
store_data: 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,158 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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.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], 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 two 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`.
.. 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__ = (
"callback",
"block",
)
def __init__(
self,
callback: HandlerCallback[UT, CCT, RT],
block: DVType[bool] = DEFAULT_TRUE,
):
self.callback: HandlerCallback[UT, CCT, RT] = callback
self.block: DVType[bool] = block
@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,141 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import TYPE_CHECKING, Any, List, Match, Optional, Pattern, TypeVar, Union, cast
from telegram import Update
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler 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]):
"""
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__ = ("pattern", "chat_types")
def __init__(
self,
callback: HandlerCallback[Update, CCT, RT],
pattern: Union[str, Pattern[str]] = None,
block: DVType[bool] = DEFAULT_TRUE,
chat_types: 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:
if update.inline_query.query:
match = re.match(self.pattern, update.inline_query.query)
if match:
return match
else:
return True
return None
def collect_additional_context(
self,
context: CCT,
update: Update, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,841 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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, Tuple, Union, cast, overload
try:
import pytz
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.job import Job as APSJob
from apscheduler.schedulers.asyncio import AsyncIOScheduler
APS_AVAILABLE = True
except ImportError:
APS_AVAILABLE = False
from telegram._utils.types import JSONDict
from telegram._utils.warnings import warn
from telegram.ext._extbot import ExtBot
from telegram.ext._utils.types import CCT, JobCallback
if TYPE_CHECKING:
from telegram.ext import Application
_ALL_DAYS = tuple(range(7))
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-%E2%80%93-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.
.. versionchanged:: 20.0
Uses :class:`~apscheduler.schedulers.asyncio.AsyncIOScheduler` instead of
:class:`~apscheduler.schedulers.background.BackgroundScheduler`
"""
__slots__ = ("_application", "scheduler", "_executor")
_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(
timezone=pytz.utc, 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, int, datetime.timedelta, datetime.datetime, datetime.time],
shift_day: bool = False,
) -> datetime.datetime:
...
def _parse_time_input(
self,
time: Union[float, int, 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)
if isinstance(application.bot, ExtBot) and application.bot.defaults:
self.scheduler.configure(
timezone=application.bot.defaults.tzinfo or pytz.utc,
executors={"default": self._executor},
)
@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.")
def run_once(
self,
callback: JobCallback[CCT],
when: Union[float, datetime.timedelta, datetime.datetime, datetime.time],
data: object = None,
name: str = None,
chat_id: int = None,
user_id: int = None,
job_kwargs: 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(
job.run,
name=name,
trigger="date",
run_date=date_time,
args=(self.application,),
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: Union[float, datetime.timedelta, datetime.datetime, datetime.time] = None,
last: Union[float, datetime.timedelta, datetime.datetime, datetime.time] = None,
data: object = None,
name: str = None,
chat_id: int = None,
user_id: int = None,
job_kwargs: 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(
job.run,
trigger="interval",
args=(self.application,),
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: object = None,
name: str = None,
chat_id: int = None,
user_id: int = None,
job_kwargs: 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(
job.run,
trigger="cron",
args=(self.application,),
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: object = None,
name: str = None,
chat_id: int = None,
user_id: int = None,
job_kwargs: 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.
"""
# TODO: After v20.0, we should remove this warning.
if days != tuple(range(7)): # checks if user passed a custom value
warn(
"Prior to v20.0 the `days` parameter was not aligned to that of cron's weekday "
"scheme. We recommend double checking if the passed value is correct.",
stacklevel=2,
)
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(
job.run,
name=name,
args=(self.application,),
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: object = None,
name: str = None,
chat_id: int = None,
user_id: 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(job.run, args=(self.application,), 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) # pylint: disable=protected-access
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-%E2%80%93-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__ = (
"callback",
"data",
"name",
"_removed",
"_enabled",
"_job",
"chat_id",
"user_id",
)
def __init__(
self,
callback: JobCallback[CCT],
data: object = None,
name: str = None,
chat_id: int = None,
user_id: 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) # skipcq: PTC-W0052
@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
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:
context = application.context_types.context.from_job(self, application)
await context.refresh_data()
await self.callback(context)
except Exception as exc:
await application.create_task(application.process_error(None, exc, job=self))
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
@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, job: "APSJob") -> "Job[CCT]":
return job.func.__self__
def __getattr__(self, item: str) -> object:
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:
if isinstance(other, self.__class__):
return self.id == other.id
return False
def __hash__(self) -> int:
return hash(self.id)

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-2023
# 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, Dict, List, 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._handler 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]):
"""BaseHandler 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,
filters: 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, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,569 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 copyreg
import pickle
from copy import deepcopy
from pathlib import Path
from sys import version_info as py_ver
from typing import Any, Callable, Dict, Optional, Set, Tuple, Type, 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
if py_ver < (3, 8): # self.reducer_override is used above this version
# Here we define a private dispatch_table, because we want to preserve the bot
# attribute of objects so persistent_id works as intended. Otherwise, the bot attribute
# is deleted in __getstate__, which is used during regular pickling (via pickle.dumps)
self.dispatch_table = copyreg.dispatch_table.copy()
for obj in _all_subclasses(TelegramObject):
self.dispatch_table[obj] = _custom_reduction
super().__init__(*args, **kwargs)
def reducer_override( # skipcq: PYL-R0201
self, obj: TelegramObj
) -> Tuple[Callable, Tuple[Type[TelegramObj], dict]]:
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__ = (
"filepath",
"single_file",
"on_flush",
"user_data",
"chat_data",
"bot_data",
"callback_data",
"conversations",
"context_types",
)
@overload
def __init__(
self: "PicklePersistence[Dict[Any, Any], Dict[Any, Any], Dict[Any, Any]]",
filepath: FilePathInput,
store_data: 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: PersistenceInput = None,
single_file: bool = True,
on_flush: bool = False,
update_interval: float = 60,
context_types: ContextTypes[Any, UD, CD, BD] = None,
):
...
def __init__(
self,
filepath: FilePathInput,
store_data: PersistenceInput = None,
single_file: bool = True,
on_flush: bool = False,
update_interval: float = 60,
context_types: 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) # type: ignore[arg-type]
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) # type: ignore[arg-type]
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,71 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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._handler import BaseHandler
from telegram.ext._utils.types import CCT
class PollAnswerHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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-2023
# 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._handler import BaseHandler
from telegram.ext._utils.types import CCT
class PollHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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,70 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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."""
from telegram import Update
from telegram.ext._handler import BaseHandler
from telegram.ext._utils.types import CCT
class PreCheckoutQueryHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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`
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.pre_checkout_query)

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-2023
# 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, Dict, FrozenSet, List, Optional, Tuple, 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._handler 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]):
"""BaseHandler 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,
prefix: SCT[str],
command: SCT[str],
callback: HandlerCallback[Update, CCT, RT],
filters: 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, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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-2023
# 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._handler import BaseHandler
from telegram.ext._utils.types import CCT
class ShippingQueryHandler(BaseHandler[Update, CCT]):
"""BaseHandler 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-2023
# 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, List, Optional
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler import BaseHandler
from telegram.ext._utils.types import CCT, RT, HandlerCallback
if TYPE_CHECKING:
from telegram.ext import Application
class StringCommandHandler(BaseHandler[str, CCT]):
"""BaseHandler 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: 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:
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,
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, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,116 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import TYPE_CHECKING, Any, Match, Optional, Pattern, TypeVar, Union
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler 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]):
"""BaseHandler 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: 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:
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,
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):
match = re.match(self.pattern, update)
if match:
return match
return None
def collect_additional_context(
self,
context: CCT,
update: str, # skipcq: BAN-B301
application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301
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,95 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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, Type, TypeVar
from telegram._utils.defaultvalue import DEFAULT_TRUE
from telegram._utils.types import DVType
from telegram.ext._handler import BaseHandler
from telegram.ext._utils.types import CCT, HandlerCallback
RT = TypeVar("RT")
UT = TypeVar("UT")
class TypeHandler(BaseHandler[UT, CCT]):
"""BaseHandler 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: Update, 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__ = ("type", "strict")
def __init__(
self,
type: Type[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: Type[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,754 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 pathlib import Path
from types import TracebackType
from typing import (
TYPE_CHECKING,
AsyncContextManager,
Callable,
Coroutine,
List,
Optional,
Type,
TypeVar,
Union,
)
from telegram._utils.defaultvalue import DEFAULT_NONE
from telegram._utils.logging import get_logger
from telegram._utils.types import 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 telegram import Bot
_UpdaterType = TypeVar("_UpdaterType", bound="Updater") # pylint: disable=invalid-name
_LOGGER = get_logger(__name__)
class Updater(AsyncContextManager["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:: :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__ = (
"bot",
"update_queue",
"_last_update_id",
"_running",
"_initialized",
"_httpd",
"__lock",
"__polling_task",
)
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
@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 __aenter__(self: _UpdaterType) -> _UpdaterType:
"""Simple context manager which initializes the Updater."""
try:
await self.initialize()
return self
except Exception as exc:
await self.shutdown()
raise exc
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""Shutdown the Updater from the context manager."""
# 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()
async def start_polling(
self,
poll_interval: float = 0.0,
timeout: int = 10,
bootstrap_retries: int = -1,
read_timeout: float = 2,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
allowed_updates: List[str] = None,
drop_pending_updates: bool = None,
error_callback: 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:`float`, 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 ``2``.
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`.
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`.
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`.
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.
"""
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")
return self.update_queue
except Exception as exc:
self._running = False
raise exc
async def _start_polling(
self,
poll_interval: float,
timeout: int,
read_timeout: 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 asyncio.CancelledError as exc:
# TODO: in py3.8+, CancelledError is a subclass of BaseException, so we can drop
# this clause when we drop py3.7
raise exc
except TelegramError as exc:
# TelegramErrors should be processed by the network retry loop
raise exc
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,
)
)
if ready is not None:
ready.set()
async def start_webhook(
self,
listen: str = "127.0.0.1",
port: int = 80,
url_path: str = "",
cert: Union[str, Path] = None,
key: Union[str, Path] = None,
bootstrap_retries: int = 0,
webhook_url: str = None,
allowed_updates: List[str] = None,
drop_pending_updates: bool = None,
ip_address: str = None,
max_connections: int = 40,
secret_token: str = 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
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]`."
)
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=listen,
port=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,
)
_LOGGER.debug("Waiting for webhook server to start")
await webhook_ready.wait()
_LOGGER.debug("Webhook server started")
except Exception as exc:
self._running = False
raise exc
# 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: Union[str, Path] = None,
key: Union[str, Path] = None,
drop_pending_updates: bool = None,
webhook_url: str = None,
ready: asyncio.Event = None,
ip_address: str = None,
max_connections: int = 40,
secret_token: str = 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)
if not webhook_url:
webhook_url = self._gen_webhook_url(
protocol="https" if ssl_ctx else "http",
listen=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,
) -> 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`.
"""
_LOGGER.debug("Start network loop retry %s", description)
cur_interval = interval
while self.running:
try:
try:
if not await action_cb():
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 as pex:
_LOGGER.error("Invalid token; aborting")
raise pex
except TelegramError as telegram_exc:
_LOGGER.error("Error while %s: %s", description, 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)
except asyncio.CancelledError:
_LOGGER.debug("Network loop retry %s was cancelled", description)
break
async def _bootstrap(
self,
max_retries: int,
webhook_url: Optional[str],
allowed_updates: Optional[List[str]],
drop_pending_updates: bool = None,
cert: Optional[bytes] = None,
bootstrap_interval: float = 1,
ip_address: str = None,
max_connections: int = 40,
secret_token: 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,
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,
)
# 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,
)
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.cancel()
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

View File

@@ -0,0 +1,17 @@
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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,76 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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,122 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import ClassVar, Generic, List, Mapping, Set, Tuple, 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: ClassVar = 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 __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[key] if key in self else 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 __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 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: _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,116 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 typing import (
TYPE_CHECKING,
Any,
Callable,
Coroutine,
Dict,
List,
MutableMapping,
Tuple,
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,190 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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 ssl import SSLContext
from types import TracebackType
from typing import TYPE_CHECKING, Optional, Type
# 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
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",
"listen",
"port",
"is_running",
"_server_lock",
"_shutdown_lock",
)
def __init__(
self, listen: str, port: int, webhook_app: "WebhookAppClass", ssl_ctx: Optional[SSLContext]
):
self._http_server = HTTPServer(webhook_app, ssl_options=ssl_ctx)
self.listen = listen
self.port = port
self.is_running = False
self._server_lock = asyncio.Lock()
self._shutdown_lock = asyncio.Lock()
async def serve_forever(self, ready: asyncio.Event = None) -> None:
async with self._server_lock:
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: 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", "update_queue", "secret_token")
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 # skipcq: PYL-W0201
self.secret_token = secret_token # skipcq: PYL-W0201
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!",
exc_info=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