python-telegram-bot/telegram/ext/dispatcher.py

759 lines
30 KiB
Python

#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2022
# 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 Dispatcher class."""
import inspect
import logging
import weakref
from collections import defaultdict
from pathlib import Path
from queue import Empty, Queue
from threading import BoundedSemaphore, Event, Lock, Thread, current_thread
from time import sleep
from typing import (
Callable,
DefaultDict,
Dict,
List,
Optional,
Set,
Union,
Generic,
TypeVar,
TYPE_CHECKING,
)
from uuid import uuid4
from telegram import Update
from telegram.error import TelegramError
from telegram.ext import BasePersistence, ContextTypes, ExtBot
from telegram.ext.handler import Handler
from telegram.ext.callbackdatacache import CallbackDataCache
from telegram.utils.defaultvalue import DefaultValue, DEFAULT_FALSE
from telegram.utils.warnings import warn
from telegram.ext.utils.promise import Promise
from telegram.ext.utils.types import CCT, UD, CD, BD, BT, JQ, PT
from .utils.stack import was_called_by
if TYPE_CHECKING:
from .jobqueue import Job
from .builders import InitDispatcherBuilder
DEFAULT_GROUP: int = 0
UT = TypeVar('UT')
class DispatcherHandlerStop(Exception):
"""
Raise this in a handler or an error handler to prevent execution of any other handler (even in
different group).
In order to use this exception in a :class:`telegram.ext.ConversationHandler`, pass the
optional ``state`` parameter instead of returning the next state:
.. code-block:: python
def callback(update, context):
...
raise DispatcherHandlerStop(next_state)
Note:
Has no effect, if the handler or error handler is run asynchronously.
Attributes:
state (:obj:`object`): Optional. The next state of the conversation.
Args:
state (:obj:`object`, optional): The next state of the conversation.
"""
__slots__ = ('state',)
def __init__(self, state: object = None) -> None:
super().__init__()
self.state = state
class Dispatcher(Generic[BT, CCT, UD, CD, BD, JQ, PT]):
"""This class dispatches all kinds of updates to its registered handlers.
Note:
This class may not be initialized directly. Use :class:`telegram.ext.DispatcherBuilder` or
:meth:`builder` (for convenience).
.. versionchanged:: 14.0
Initialization is now done through the :class:`telegram.ext.DispatcherBuilder`.
Attributes:
bot (:class:`telegram.Bot`): The bot object that should be passed to the handlers.
update_queue (:obj:`Queue`): The synchronized queue that will contain the updates.
job_queue (:class:`telegram.ext.JobQueue`): Optional. The :class:`telegram.ext.JobQueue`
instance to pass onto handler callbacks.
workers (:obj:`int`, optional): Number of maximum concurrent worker threads for the
``@run_async`` decorator and :meth:`run_async`.
user_data (:obj:`defaultdict`): A dictionary handlers can use to store data for the user.
chat_data (:obj:`defaultdict`): A dictionary handlers can use to store data for the chat.
bot_data (:obj:`dict`): A dictionary handlers can use to store data for the bot.
persistence (:class:`telegram.ext.BasePersistence`): Optional. The persistence class to
store data that should be persistent over restarts.
exception_event (:class:`threading.Event`): When this event is set, the dispatcher will
stop processing updates. If this dispatcher is used together with an
:class:`telegram.ext.Updater`, then this event will be the same object as
:attr:`telegram.ext.Updater.exception_event`.
handlers (Dict[:obj:`int`, List[:class:`telegram.ext.Handler`]]): A dictionary mapping each
handler group to the list of handlers registered to that group.
.. seealso::
:meth:`add_handler`
groups (List[:obj:`int`]): A list of all handler groups that have handlers registered.
.. seealso::
:meth:`add_handler`
error_handlers (Dict[:obj:`callable`, :obj:`bool`]): A dict, where the keys are error
handlers and the values indicate whether they are to be run asynchronously via
:meth:`run_async`.
.. seealso::
:meth:`add_error_handler`
running (:obj:`bool`): Indicates if this dispatcher is running.
.. seealso::
:meth:`start`, :meth:`stop`
"""
# Allowing '__weakref__' creation here since we need it for the singleton
__slots__ = (
'workers',
'persistence',
'update_queue',
'job_queue',
'user_data',
'chat_data',
'bot_data',
'_update_persistence_lock',
'handlers',
'groups',
'error_handlers',
'running',
'__stop_event',
'exception_event',
'__async_queue',
'__async_threads',
'bot',
'__weakref__',
'context_types',
)
__singleton_lock = Lock()
__singleton_semaphore = BoundedSemaphore()
__singleton = None
logger = logging.getLogger(__name__)
def __init__(
self: 'Dispatcher[BT, CCT, UD, CD, BD, JQ, PT]',
*,
bot: BT,
update_queue: Queue,
job_queue: JQ,
workers: int,
persistence: PT,
context_types: ContextTypes[CCT, UD, CD, BD],
exception_event: Event,
stack_level: int = 4,
):
if not was_called_by(
inspect.currentframe(), Path(__file__).parent.resolve() / 'builders.py'
):
warn(
'`Dispatcher` instances should be built via the `DispatcherBuilder`.',
stacklevel=2,
)
self.bot = bot
self.update_queue = update_queue
self.job_queue = job_queue
self.workers = workers
self.context_types = context_types
self.exception_event = exception_event
if self.workers < 1:
warn(
'Asynchronous callbacks can not be processed without at least one worker thread.',
stacklevel=stack_level,
)
self.user_data: DefaultDict[int, UD] = defaultdict(self.context_types.user_data)
self.chat_data: DefaultDict[int, CD] = defaultdict(self.context_types.chat_data)
self.bot_data = self.context_types.bot_data()
self.persistence: Optional[BasePersistence] = None
self._update_persistence_lock = Lock()
if persistence:
if not isinstance(persistence, BasePersistence):
raise TypeError("persistence must be based on telegram.ext.BasePersistence")
self.persistence = persistence
# This raises an exception if persistence.store_data.callback_data is True
# but self.bot is not an instance of ExtBot - so no need to check that later on
self.persistence.set_bot(self.bot)
if self.persistence.store_data.user_data:
self.user_data = self.persistence.get_user_data()
if not isinstance(self.user_data, defaultdict):
raise ValueError("user_data must be of type defaultdict")
if self.persistence.store_data.chat_data:
self.chat_data = self.persistence.get_chat_data()
if not isinstance(self.chat_data, defaultdict):
raise ValueError("chat_data must be of type defaultdict")
if self.persistence.store_data.bot_data:
self.bot_data = self.persistence.get_bot_data()
if not isinstance(self.bot_data, self.context_types.bot_data):
raise ValueError(
f"bot_data must be of type {self.context_types.bot_data.__name__}"
)
if self.persistence.store_data.callback_data:
persistent_data = self.persistence.get_callback_data()
if persistent_data is not None:
if not isinstance(persistent_data, tuple) and len(persistent_data) != 2:
raise ValueError('callback_data must be a 2-tuple')
# Mypy doesn't know that persistence.set_bot (see above) already checks that
# self.bot is an instance of ExtBot if callback_data should be stored ...
self.bot.callback_data_cache = CallbackDataCache( # type: ignore[attr-defined]
self.bot, # type: ignore[arg-type]
self.bot.callback_data_cache.maxsize, # type: ignore[attr-defined]
persistent_data=persistent_data,
)
else:
self.persistence = None
self.handlers: Dict[int, List[Handler]] = {}
self.groups: List[int] = []
self.error_handlers: Dict[Callable, Union[bool, DefaultValue]] = {}
self.running = False
self.__stop_event = Event()
self.__async_queue: Queue = Queue()
self.__async_threads: Set[Thread] = set()
# For backward compatibility, we allow a "singleton" mode for the dispatcher. When there's
# only one instance of Dispatcher, it will be possible to use the `run_async` decorator.
with self.__singleton_lock:
# pylint: disable=consider-using-with
if self.__singleton_semaphore.acquire(blocking=False):
self._set_singleton(self)
else:
self._set_singleton(None)
@staticmethod
def builder() -> 'InitDispatcherBuilder':
"""Convenience method. Returns a new :class:`telegram.ext.DispatcherBuilder`.
.. versionadded:: 14.0
"""
# Unfortunately this needs to be here due to cyclical imports
from telegram.ext import DispatcherBuilder # pylint: disable=import-outside-toplevel
return DispatcherBuilder()
def _init_async_threads(self, base_name: str, workers: int) -> None:
base_name = f'{base_name}_' if base_name else ''
for i in range(workers):
thread = Thread(target=self._pooled, name=f'Bot:{self.bot.id}:worker:{base_name}{i}')
self.__async_threads.add(thread)
thread.start()
@classmethod
def _set_singleton(cls, val: Optional['Dispatcher']) -> None:
cls.logger.debug('Setting singleton dispatcher as %s', val)
cls.__singleton = weakref.ref(val) if val else None
@classmethod
def get_instance(cls) -> 'Dispatcher':
"""Get the singleton instance of this class.
Returns:
:class:`telegram.ext.Dispatcher`
Raises:
RuntimeError
"""
if cls.__singleton is not None:
return cls.__singleton() # type: ignore[return-value] # pylint: disable=not-callable
raise RuntimeError(f'{cls.__name__} not initialized or multiple instances exist')
def _pooled(self) -> None:
thr_name = current_thread().name
while 1:
promise = self.__async_queue.get()
# If unpacking fails, the thread pool is being closed from Updater._join_async_threads
if not isinstance(promise, Promise):
self.logger.debug(
"Closing run_async thread %s/%d", thr_name, len(self.__async_threads)
)
break
promise.run()
if not promise.exception:
self.update_persistence(update=promise.update)
continue
if isinstance(promise.exception, DispatcherHandlerStop):
warn(
'DispatcherHandlerStop is not supported with async functions; '
f'func: {promise.pooled_function.__name__}',
)
continue
# Avoid infinite recursion of error handlers.
if promise.pooled_function in self.error_handlers:
self.logger.exception(
'An error was raised and an uncaught error was raised while '
'handling the error with an error_handler.',
exc_info=promise.exception,
)
continue
# If we arrive here, an exception happened in the promise and was neither
# DispatcherHandlerStop nor raised by an error handler. So we can and must handle it
self.dispatch_error(promise.update, promise.exception, promise=promise)
def run_async(
self, func: Callable[..., object], *args: object, update: object = None, **kwargs: object
) -> Promise:
"""
Queue a function (with given args/kwargs) to be run asynchronously. Exceptions raised
by the function will be handled by the error handlers registered with
:meth:`add_error_handler`.
Warning:
* If you're using ``@run_async``/:meth:`run_async` you cannot rely on adding custom
attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info.
* Calling a function through :meth:`run_async` from within an error handler can lead to
an infinite error handling loop.
Args:
func (:obj:`callable`): The function to run in the thread.
*args (:obj:`tuple`, optional): Arguments to ``func``.
update (:class:`telegram.Update` | :obj:`object`, optional): The update associated with
the functions call. If passed, it will be available in the error handlers, in case
an exception is raised by :attr:`func`.
**kwargs (:obj:`dict`, optional): Keyword arguments to ``func``.
Returns:
Promise
"""
promise = Promise(func, args, kwargs, update=update)
self.__async_queue.put(promise)
return promise
def start(self, ready: Event = None) -> None:
"""Thread target of thread 'dispatcher'.
Runs in background and processes the update queue. Also starts :attr:`job_queue`, if set.
Args:
ready (:obj:`threading.Event`, optional): If specified, the event will be set once the
dispatcher is ready.
"""
if self.running:
self.logger.warning('already running')
if ready is not None:
ready.set()
return
if self.exception_event.is_set():
msg = 'reusing dispatcher after exception event is forbidden'
self.logger.error(msg)
raise TelegramError(msg)
if self.job_queue:
self.job_queue.start()
self._init_async_threads(str(uuid4()), self.workers)
self.running = True
self.logger.debug('Dispatcher started')
if ready is not None:
ready.set()
while 1:
try:
# Pop update from update queue.
update = self.update_queue.get(True, 1)
except Empty:
if self.__stop_event.is_set():
self.logger.debug('orderly stopping')
break
if self.exception_event.is_set():
self.logger.critical('stopping due to exception in another thread')
break
continue
self.logger.debug('Processing Update: %s', update)
self.process_update(update)
self.update_queue.task_done()
self.running = False
self.logger.debug('Dispatcher thread stopped')
def stop(self) -> None:
"""Stops the thread and :attr:`job_queue`, if set.
Also calls :meth:`update_persistence` and :meth:`BasePersistence.flush` on
:attr:`persistence`, if set.
"""
if self.running:
self.__stop_event.set()
while self.running:
sleep(0.1)
self.__stop_event.clear()
# async threads must be join()ed only after the dispatcher thread was joined,
# otherwise we can still have new async threads dispatched
threads = list(self.__async_threads)
total = len(threads)
# Stop all threads in the thread pool by put()ting one non-tuple per thread
for i in range(total):
self.__async_queue.put(None)
for i, thr in enumerate(threads):
self.logger.debug('Waiting for async thread %s/%s to end', i + 1, total)
thr.join()
self.__async_threads.remove(thr)
self.logger.debug('async thread %s/%s has ended', i + 1, total)
if self.job_queue:
self.job_queue.stop()
self.logger.debug('JobQueue was shut down.')
self.update_persistence()
if self.persistence:
self.persistence.flush()
# Clear the connection pool
self.bot.request.stop()
@property
def has_running_threads(self) -> bool: # skipcq: PY-D0003
return self.running or bool(self.__async_threads)
def process_update(self, update: object) -> None:
"""Processes a single update and updates the persistence.
Note:
If the update is handled by least one synchronously running handlers (i.e.
``run_async=False``), :meth:`update_persistence` is called *once* after all handlers
synchronous handlers are done. Each asynchronously running handler will trigger
:meth:`update_persistence` on its own.
Args:
update (:class:`telegram.Update` | :obj:`object` | \
:class:`telegram.error.TelegramError`):
The update to process.
"""
# An error happened while polling
if isinstance(update, TelegramError):
self.dispatch_error(None, update)
return
context = None
handled = False
sync_modes = []
for group in self.groups:
try:
for handler in self.handlers[group]:
check = handler.check_update(update)
if check is not None and check is not False:
if not context:
context = self.context_types.context.from_update(update, self)
context.refresh_data()
handled = True
sync_modes.append(handler.run_async)
handler.handle_update(update, self, check, context)
break
# Stop processing with any other handler.
except DispatcherHandlerStop:
self.logger.debug('Stopping further handlers due to DispatcherHandlerStop')
self.update_persistence(update=update)
break
# Dispatch any error.
except Exception as exc:
if self.dispatch_error(update, exc):
self.logger.debug('Error handler stopped further handlers.')
break
# Update persistence, if handled
handled_only_async = all(sync_modes)
if handled:
# Respect default settings
if (
all(mode is DEFAULT_FALSE for mode in sync_modes)
and isinstance(self.bot, ExtBot)
and self.bot.defaults
):
handled_only_async = self.bot.defaults.run_async
# If update was only handled by async handlers, we don't need to update here
if not handled_only_async:
self.update_persistence(update=update)
def add_handler(self, handler: Handler[UT, CCT], group: int = DEFAULT_GROUP) -> None:
"""Register a handler.
TL;DR: Order and priority counts. 0 or 1 handlers per group will be used. End handling of
update with :class:`telegram.ext.DispatcherHandlerStop`.
A handler must be an instance of a subclass of :class:`telegram.ext.Handler`. All handlers
are organized in groups with a numeric value. The default group is 0. All groups will be
evaluated for handling an update, but only 0 or 1 handler per group will be used. If
:class:`telegram.ext.DispatcherHandlerStop` is raised from one of the handlers, no further
handlers (regardless of the group) will be called.
The priority/order of handlers is determined as follows:
* Priority of the group (lower group number == higher priority)
* The first handler in a group which should handle an update (see
:attr:`telegram.ext.Handler.check_update`) will be used. Other handlers from the
group will not be used. The order in which handlers were added to the group defines the
priority.
Args:
handler (:class:`telegram.ext.Handler`): A Handler instance.
group (:obj:`int`, optional): The group identifier. Default is 0.
"""
# Unfortunately due to circular imports this has to be here
# pylint: disable=import-outside-toplevel
from .conversationhandler import ConversationHandler
if not isinstance(handler, Handler):
raise TypeError(f'handler is not an instance of {Handler.__name__}')
if not isinstance(group, int):
raise TypeError('group is not int')
# For some reason MyPy infers the type of handler is <nothing> here,
# so for now we just ignore all the errors
if (
isinstance(handler, ConversationHandler)
and handler.persistent # type: ignore[attr-defined]
and handler.name # type: ignore[attr-defined]
):
if not self.persistence:
raise ValueError(
f"ConversationHandler {handler.name} " # type: ignore[attr-defined]
f"can not be persistent if dispatcher has no persistence"
)
handler.persistence = self.persistence # type: ignore[attr-defined]
handler.conversations = ( # type: ignore[attr-defined]
self.persistence.get_conversations(handler.name) # type: ignore[attr-defined]
)
if group not in self.handlers:
self.handlers[group] = []
self.groups.append(group)
self.groups = sorted(self.groups)
self.handlers[group].append(handler)
def remove_handler(self, handler: Handler, group: int = DEFAULT_GROUP) -> None:
"""Remove a handler from the specified group.
Args:
handler (:class:`telegram.ext.Handler`): A Handler instance.
group (:obj:`object`, optional): The group identifier. Default is 0.
"""
if handler in self.handlers[group]:
self.handlers[group].remove(handler)
if not self.handlers[group]:
del self.handlers[group]
self.groups.remove(group)
def update_persistence(self, update: object = None) -> None:
"""Update :attr:`user_data`, :attr:`chat_data` and :attr:`bot_data` in :attr:`persistence`.
Args:
update (:class:`telegram.Update`, optional): The update to process. If passed, only the
corresponding ``user_data`` and ``chat_data`` will be updated.
"""
with self._update_persistence_lock:
self.__update_persistence(update)
def __update_persistence(self, update: object = None) -> None:
if self.persistence:
# We use list() here in order to decouple chat_ids from self.chat_data, as dict view
# objects will change, when the dict does and we want to loop over chat_ids
chat_ids = list(self.chat_data.keys())
user_ids = list(self.user_data.keys())
if isinstance(update, Update):
if update.effective_chat:
chat_ids = [update.effective_chat.id]
else:
chat_ids = []
if update.effective_user:
user_ids = [update.effective_user.id]
else:
user_ids = []
if self.persistence.store_data.callback_data:
try:
# Mypy doesn't know that persistence.set_bot (see above) already checks that
# self.bot is an instance of ExtBot if callback_data should be stored ...
self.persistence.update_callback_data(
self.bot.callback_data_cache.persistence_data # type: ignore[attr-defined]
)
except Exception as exc:
self.dispatch_error(update, exc)
if self.persistence.store_data.bot_data:
try:
self.persistence.update_bot_data(self.bot_data)
except Exception as exc:
self.dispatch_error(update, exc)
if self.persistence.store_data.chat_data:
for chat_id in chat_ids:
try:
self.persistence.update_chat_data(chat_id, self.chat_data[chat_id])
except Exception as exc:
self.dispatch_error(update, exc)
if self.persistence.store_data.user_data:
for user_id in user_ids:
try:
self.persistence.update_user_data(user_id, self.user_data[user_id])
except Exception as exc:
self.dispatch_error(update, exc)
def add_error_handler(
self,
callback: Callable[[object, CCT], None],
run_async: Union[bool, DefaultValue] = DEFAULT_FALSE,
) -> None:
"""Registers an error handler in the Dispatcher. This handler will receive every error
which happens in your bot. See the docs of :meth:`dispatch_error` for more details on how
errors are handled.
Note:
Attempts to add the same callback multiple times will be ignored.
Args:
callback (:obj:`callable`): The callback function for this error handler. Will be
called when an error is raised. Callback signature:
``def callback(update: Update, context: CallbackContext)``
The error that happened will be present in context.error.
run_async (:obj:`bool`, optional): Whether this handlers callback should be run
asynchronously using :meth:`run_async`. Defaults to :obj:`False`.
"""
if callback in self.error_handlers:
self.logger.debug('The callback is already registered as an error handler. Ignoring.')
return
if (
run_async is DEFAULT_FALSE
and isinstance(self.bot, ExtBot)
and self.bot.defaults
and self.bot.defaults.run_async
):
run_async = True
self.error_handlers[callback] = run_async
def remove_error_handler(self, callback: Callable[[object, CCT], None]) -> None:
"""Removes an error handler.
Args:
callback (:obj:`callable`): The error handler to remove.
"""
self.error_handlers.pop(callback, None)
def dispatch_error(
self,
update: Optional[object],
error: Exception,
promise: Promise = None,
job: 'Job' = None,
) -> bool:
"""Dispatches an error by passing it to all error handlers registered with
:meth:`add_error_handler`. If one of the error handlers raises
:class:`telegram.ext.DispatcherHandlerStop`, the update will not be handled by other error
handlers or handlers (even in other groups). All other exceptions raised by an error
handler will just be logged.
.. versionchanged:: 14.0
* Exceptions raised by error handlers are now properly logged.
* :class:`telegram.ext.DispatcherHandlerStop` is no longer reraised but converted into
the return value.
Args:
update (:obj:`object` | :class:`telegram.Update`): The update that caused the error.
error (:obj:`Exception`): The error that was raised.
promise (:class:`telegram.utils.Promise`, optional): The promise whose pooled function
raised the error.
job (:class:`telegram.ext.Job`, optional): The job that caused the error.
.. versionadded:: 14.0
Returns:
:obj:`bool`: :obj:`True` if one of the error handlers raised
:class:`telegram.ext.DispatcherHandlerStop`. :obj:`False`, otherwise.
"""
async_args = None if not promise else promise.args
async_kwargs = None if not promise else promise.kwargs
if self.error_handlers:
for (
callback,
run_async,
) in self.error_handlers.items(): # pylint: disable=redefined-outer-name
context = self.context_types.context.from_error(
update=update,
error=error,
dispatcher=self,
async_args=async_args,
async_kwargs=async_kwargs,
job=job,
)
if run_async:
self.run_async(callback, update, context, update=update)
else:
try:
callback(update, context)
except DispatcherHandlerStop:
return True
except Exception as exc:
self.logger.exception(
'An error was raised and an uncaught error was raised while '
'handling the error with an error_handler.',
exc_info=exc,
)
return False
self.logger.exception(
'No error handlers are registered, logging exception.', exc_info=error
)
return False