diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2025-07-19 10:42:15 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2025-07-19 10:42:15 +0300 |
| commit | 5a636abf7e98a5687811167917d0e07103599bc8 (patch) | |
| tree | 682f8d70d194c6bf02b750780f05689fcb83ba5a /telepot/helper.py | |
| parent | e1ae18d1bc9e26f8ab817ca1c1e4e271badf02ad (diff) | |
Vendor the unmaintained telepot dependency
Diffstat (limited to 'telepot/helper.py')
| -rw-r--r-- | telepot/helper.py | 1170 |
1 files changed, 1170 insertions, 0 deletions
diff --git a/telepot/helper.py b/telepot/helper.py new file mode 100644 index 0000000..1b57b84 --- /dev/null +++ b/telepot/helper.py @@ -0,0 +1,1170 @@ +import time +import traceback +import threading +import logging +import collections +import re +import inspect +from functools import partial +from . import filtering, exception +from . import ( + flavor, chat_flavors, inline_flavors, is_event, + message_identifier, origin_identifier) + +try: + import Queue as queue +except ImportError: + import queue + + +class Microphone(object): + def __init__(self): + self._queues = set() + self._lock = threading.Lock() + + def _locked(func): + def k(self, *args, **kwargs): + with self._lock: + return func(self, *args, **kwargs) + return k + + @_locked + def add(self, q): + self._queues.add(q) + + @_locked + def remove(self, q): + self._queues.remove(q) + + @_locked + def send(self, msg): + for q in self._queues: + try: + q.put_nowait(msg) + except queue.Full: + traceback.print_exc() + + +class Listener(object): + def __init__(self, mic, q): + self._mic = mic + self._queue = q + self._patterns = [] + + def __del__(self): + self._mic.remove(self._queue) + + def capture(self, pattern): + """ + Add a pattern to capture. + + :param pattern: a list of templates. + + A template may be a function that: + - takes one argument - a message + - returns ``True`` to indicate a match + + A template may also be a dictionary whose: + - **keys** are used to *select* parts of message. Can be strings or + regular expressions (as obtained by ``re.compile()``) + - **values** are used to match against the selected parts. Can be + typical data or a function. + + All templates must produce a match for a message to be considered a match. + """ + self._patterns.append(pattern) + + def wait(self): + """ + Block until a matched message appears. + """ + if not self._patterns: + raise RuntimeError('Listener has nothing to capture') + + while 1: + msg = self._queue.get(block=True) + + if any(map(lambda p: filtering.match_all(msg, p), self._patterns)): + return msg + + +class Sender(object): + """ + When you are dealing with a particular chat, it is tedious to have to supply + the same ``chat_id`` every time to send a message, or to send anything. + + This object is a proxy to a bot's ``send*`` and ``forwardMessage`` methods, + automatically fills in a fixed chat id for you. Available methods have + identical signatures as those of the underlying bot, **except there is no need + to supply the aforementioned** ``chat_id``: + + - :meth:`.Bot.sendMessage` + - :meth:`.Bot.forwardMessage` + - :meth:`.Bot.sendPhoto` + - :meth:`.Bot.sendAudio` + - :meth:`.Bot.sendDocument` + - :meth:`.Bot.sendSticker` + - :meth:`.Bot.sendVideo` + - :meth:`.Bot.sendVoice` + - :meth:`.Bot.sendVideoNote` + - :meth:`.Bot.sendMediaGroup` + - :meth:`.Bot.sendLocation` + - :meth:`.Bot.sendVenue` + - :meth:`.Bot.sendContact` + - :meth:`.Bot.sendGame` + - :meth:`.Bot.sendChatAction` + """ + + def __init__(self, bot, chat_id): + for method in ['sendMessage', + 'forwardMessage', + 'sendPhoto', + 'sendAudio', + 'sendDocument', + 'sendSticker', + 'sendVideo', + 'sendVoice', + 'sendVideoNote', + 'sendMediaGroup', + 'sendLocation', + 'sendVenue', + 'sendContact', + 'sendGame', + 'sendChatAction',]: + setattr(self, method, partial(getattr(bot, method), chat_id)) + # Essentially doing: + # self.sendMessage = partial(bot.sendMessage, chat_id) + + +class Administrator(object): + """ + When you are dealing with a particular chat, it is tedious to have to supply + the same ``chat_id`` every time to get a chat's info or to perform administrative + tasks. + + This object is a proxy to a bot's chat administration methods, + automatically fills in a fixed chat id for you. Available methods have + identical signatures as those of the underlying bot, **except there is no need + to supply the aforementioned** ``chat_id``: + + - :meth:`.Bot.kickChatMember` + - :meth:`.Bot.unbanChatMember` + - :meth:`.Bot.restrictChatMember` + - :meth:`.Bot.promoteChatMember` + - :meth:`.Bot.exportChatInviteLink` + - :meth:`.Bot.setChatPhoto` + - :meth:`.Bot.deleteChatPhoto` + - :meth:`.Bot.setChatTitle` + - :meth:`.Bot.setChatDescription` + - :meth:`.Bot.pinChatMessage` + - :meth:`.Bot.unpinChatMessage` + - :meth:`.Bot.leaveChat` + - :meth:`.Bot.getChat` + - :meth:`.Bot.getChatAdministrators` + - :meth:`.Bot.getChatMembersCount` + - :meth:`.Bot.getChatMember` + - :meth:`.Bot.setChatStickerSet` + - :meth:`.Bot.deleteChatStickerSet` + """ + + def __init__(self, bot, chat_id): + for method in ['kickChatMember', + 'unbanChatMember', + 'restrictChatMember', + 'promoteChatMember', + 'exportChatInviteLink', + 'setChatPhoto', + 'deleteChatPhoto', + 'setChatTitle', + 'setChatDescription', + 'pinChatMessage', + 'unpinChatMessage', + 'leaveChat', + 'getChat', + 'getChatAdministrators', + 'getChatMembersCount', + 'getChatMember', + 'setChatStickerSet', + 'deleteChatStickerSet']: + setattr(self, method, partial(getattr(bot, method), chat_id)) + + +class Editor(object): + """ + If you want to edit a message over and over, it is tedious to have to supply + the same ``msg_identifier`` every time. + + This object is a proxy to a bot's message-editing methods, automatically fills + in a fixed message identifier for you. Available methods have + identical signatures as those of the underlying bot, **except there is no need + to supply the aforementioned** ``msg_identifier``: + + - :meth:`.Bot.editMessageText` + - :meth:`.Bot.editMessageCaption` + - :meth:`.Bot.editMessageReplyMarkup` + - :meth:`.Bot.deleteMessage` + - :meth:`.Bot.editMessageLiveLocation` + - :meth:`.Bot.stopMessageLiveLocation` + + A message's identifier can be easily extracted with :func:`telepot.message_identifier`. + """ + + def __init__(self, bot, msg_identifier): + """ + :param msg_identifier: + a message identifier as mentioned above, or a message (whose + identifier will be automatically extracted). + """ + # Accept dict as argument. Maybe expand this convenience to other cases in future. + if isinstance(msg_identifier, dict): + msg_identifier = message_identifier(msg_identifier) + + for method in ['editMessageText', + 'editMessageCaption', + 'editMessageReplyMarkup', + 'deleteMessage', + 'editMessageLiveLocation', + 'stopMessageLiveLocation']: + setattr(self, method, partial(getattr(bot, method), msg_identifier)) + + +class Answerer(object): + """ + When processing inline queries, ensure **at most one active thread** per user id. + """ + + def __init__(self, bot): + self._bot = bot + self._workers = {} # map: user id --> worker thread + self._lock = threading.Lock() # control access to `self._workers` + + def answer(outerself, inline_query, compute_fn, *compute_args, **compute_kwargs): + """ + Spawns a thread that calls ``compute fn`` (along with additional arguments + ``*compute_args`` and ``**compute_kwargs``), then applies the returned value to + :meth:`.Bot.answerInlineQuery` to answer the inline query. + If a preceding thread is already working for a user, that thread is cancelled, + thus ensuring at most one active thread per user id. + + :param inline_query: + The inline query to be processed. The originating user is inferred from ``msg['from']['id']``. + + :param compute_fn: + A **thread-safe** function whose returned value is given to :meth:`.Bot.answerInlineQuery` to send. + May return: + + - a *list* of `InlineQueryResult <https://core.telegram.org/bots/api#inlinequeryresult>`_ + - a *tuple* whose first element is a list of `InlineQueryResult <https://core.telegram.org/bots/api#inlinequeryresult>`_, + followed by positional arguments to be supplied to :meth:`.Bot.answerInlineQuery` + - a *dictionary* representing keyword arguments to be supplied to :meth:`.Bot.answerInlineQuery` + + :param \*compute_args: positional arguments to ``compute_fn`` + :param \*\*compute_kwargs: keyword arguments to ``compute_fn`` + """ + + from_id = inline_query['from']['id'] + + class Worker(threading.Thread): + def __init__(innerself): + super(Worker, innerself).__init__() + innerself._cancelled = False + + def cancel(innerself): + innerself._cancelled = True + + def run(innerself): + try: + query_id = inline_query['id'] + + if innerself._cancelled: + return + + # Important: compute function must be thread-safe. + ans = compute_fn(*compute_args, **compute_kwargs) + + if innerself._cancelled: + return + + if isinstance(ans, list): + outerself._bot.answerInlineQuery(query_id, ans) + elif isinstance(ans, tuple): + outerself._bot.answerInlineQuery(query_id, *ans) + elif isinstance(ans, dict): + outerself._bot.answerInlineQuery(query_id, **ans) + else: + raise ValueError('Invalid answer format') + finally: + with outerself._lock: + # Delete only if I have NOT been cancelled. + if not innerself._cancelled: + del outerself._workers[from_id] + + # If I have been cancelled, that position in `outerself._workers` + # no longer belongs to me. I should not delete that key. + + # Several threads may access `outerself._workers`. Use `outerself._lock` to protect. + with outerself._lock: + if from_id in outerself._workers: + outerself._workers[from_id].cancel() + + outerself._workers[from_id] = Worker() + outerself._workers[from_id].start() + + +class AnswererMixin(object): + """ + Install an :class:`.Answerer` to handle inline query. + """ + Answerer = Answerer # let subclass customize Answerer class + + def __init__(self, *args, **kwargs): + self._answerer = self.Answerer(self.bot) + super(AnswererMixin, self).__init__(*args, **kwargs) + + @property + def answerer(self): + return self._answerer + + +class CallbackQueryCoordinator(object): + def __init__(self, id, origin_set, enable_chat, enable_inline): + """ + :param origin_set: + Callback query whose origin belongs to this set will be captured + + :param enable_chat: + - ``False``: Do not intercept *chat-originated* callback query + - ``True``: Do intercept + - Notifier function: Do intercept and call the notifier function + on adding or removing an origin + + :param enable_inline: + Same meaning as ``enable_chat``, but apply to *inline-originated* + callback query + + Notifier functions should have the signature ``notifier(origin, id, adding)``: + + - On adding an origin, ``notifier(origin, my_id, True)`` will be called. + - On removing an origin, ``notifier(origin, my_id, False)`` will be called. + """ + self._id = id + self._origin_set = origin_set + + def dissolve(enable): + if not enable: + return False, None + elif enable is True: + return True, None + elif callable(enable): + return True, enable + else: + raise ValueError() + + self._enable_chat, self._chat_notify = dissolve(enable_chat) + self._enable_inline, self._inline_notify = dissolve(enable_inline) + + def configure(self, listener): + """ + Configure a :class:`.Listener` to capture callback query + """ + listener.capture([ + lambda msg: flavor(msg) == 'callback_query', + {'message': self._chat_origin_included} + ]) + + listener.capture([ + lambda msg: flavor(msg) == 'callback_query', + {'inline_message_id': self._inline_origin_included} + ]) + + def _chat_origin_included(self, msg): + try: + return (msg['chat']['id'], msg['message_id']) in self._origin_set + except KeyError: + return False + + def _inline_origin_included(self, inline_message_id): + return (inline_message_id,) in self._origin_set + + def _rectify(self, msg_identifier): + if isinstance(msg_identifier, tuple): + if len(msg_identifier) == 2: + return msg_identifier, self._chat_notify + elif len(msg_identifier) == 1: + return msg_identifier, self._inline_notify + else: + raise ValueError() + else: + return (msg_identifier,), self._inline_notify + + def capture_origin(self, msg_identifier, notify=True): + msg_identifier, notifier = self._rectify(msg_identifier) + self._origin_set.add(msg_identifier) + notify and notifier and notifier(msg_identifier, self._id, True) + + def uncapture_origin(self, msg_identifier, notify=True): + msg_identifier, notifier = self._rectify(msg_identifier) + self._origin_set.discard(msg_identifier) + notify and notifier and notifier(msg_identifier, self._id, False) + + def _contains_callback_data(self, message_kw): + def contains(obj, key): + if isinstance(obj, dict): + return key in obj + else: + return hasattr(obj, key) + + if contains(message_kw, 'reply_markup'): + reply_markup = filtering.pick(message_kw, 'reply_markup') + if contains(reply_markup, 'inline_keyboard'): + inline_keyboard = filtering.pick(reply_markup, 'inline_keyboard') + for array in inline_keyboard: + if any(filter(lambda button: contains(button, 'callback_data'), array)): + return True + return False + + def augment_send(self, send_func): + """ + :param send_func: + a function that sends messages, such as :meth:`.Bot.send\*` + + :return: + a function that wraps around ``send_func`` and examines whether the + sent message contains an inline keyboard with callback data. If so, + future callback query originating from the sent message will be captured. + """ + def augmented(*aa, **kw): + sent = send_func(*aa, **kw) + + if self._enable_chat and self._contains_callback_data(kw): + self.capture_origin(message_identifier(sent)) + + return sent + return augmented + + def augment_edit(self, edit_func): + """ + :param edit_func: + a function that edits messages, such as :meth:`.Bot.edit*` + + :return: + a function that wraps around ``edit_func`` and examines whether the + edited message contains an inline keyboard with callback data. If so, + future callback query originating from the edited message will be captured. + If not, such capturing will be stopped. + """ + def augmented(msg_identifier, *aa, **kw): + edited = edit_func(msg_identifier, *aa, **kw) + + if (edited is True and self._enable_inline) or (isinstance(edited, dict) and self._enable_chat): + if self._contains_callback_data(kw): + self.capture_origin(msg_identifier) + else: + self.uncapture_origin(msg_identifier) + + return edited + return augmented + + def augment_delete(self, delete_func): + """ + :param delete_func: + a function that deletes messages, such as :meth:`.Bot.deleteMessage` + + :return: + a function that wraps around ``delete_func`` and stops capturing + callback query originating from that deleted message. + """ + def augmented(msg_identifier, *aa, **kw): + deleted = delete_func(msg_identifier, *aa, **kw) + + if deleted is True: + self.uncapture_origin(msg_identifier) + + return deleted + return augmented + + def augment_on_message(self, handler): + """ + :param handler: + an ``on_message()`` handler function + + :return: + a function that wraps around ``handler`` and examines whether the + incoming message is a chosen inline result with an ``inline_message_id`` + field. If so, future callback query originating from this chosen + inline result will be captured. + """ + def augmented(msg): + if (self._enable_inline + and flavor(msg) == 'chosen_inline_result' + and 'inline_message_id' in msg): + inline_message_id = msg['inline_message_id'] + self.capture_origin(inline_message_id) + + return handler(msg) + return augmented + + def augment_bot(self, bot): + """ + :return: + a proxy to ``bot`` with these modifications: + + - all ``send*`` methods augmented by :meth:`augment_send` + - all ``edit*`` methods augmented by :meth:`augment_edit` + - ``deleteMessage()`` augmented by :meth:`augment_delete` + - all other public methods, including properties, copied unchanged + """ + # Because a plain object cannot be set attributes, we need a class. + class BotProxy(object): + pass + + proxy = BotProxy() + + send_methods = ['sendMessage', + 'forwardMessage', + 'sendPhoto', + 'sendAudio', + 'sendDocument', + 'sendSticker', + 'sendVideo', + 'sendVoice', + 'sendVideoNote', + 'sendLocation', + 'sendVenue', + 'sendContact', + 'sendGame', + 'sendInvoice', + 'sendChatAction',] + + for method in send_methods: + setattr(proxy, method, self.augment_send(getattr(bot, method))) + + edit_methods = ['editMessageText', + 'editMessageCaption', + 'editMessageReplyMarkup',] + + for method in edit_methods: + setattr(proxy, method, self.augment_edit(getattr(bot, method))) + + delete_methods = ['deleteMessage'] + + for method in delete_methods: + setattr(proxy, method, self.augment_delete(getattr(bot, method))) + + def public_untouched(nv): + name, value = nv + return (not name.startswith('_') + and name not in send_methods + edit_methods + delete_methods) + + for name, value in filter(public_untouched, inspect.getmembers(bot)): + setattr(proxy, name, value) + + return proxy + + +class SafeDict(dict): + """ + A subclass of ``dict``, thread-safety added:: + + d = SafeDict() # Thread-safe operations include: + d['a'] = 3 # key assignment + d['a'] # key retrieval + del d['a'] # key deletion + """ + + def __init__(self, *args, **kwargs): + super(SafeDict, self).__init__(*args, **kwargs) + self._lock = threading.Lock() + + def _locked(func): + def k(self, *args, **kwargs): + with self._lock: + return func(self, *args, **kwargs) + return k + + @_locked + def __getitem__(self, key): + return super(SafeDict, self).__getitem__(key) + + @_locked + def __setitem__(self, key, value): + return super(SafeDict, self).__setitem__(key, value) + + @_locked + def __delitem__(self, key): + return super(SafeDict, self).__delitem__(key) + + +_cqc_origins = SafeDict() + +class InterceptCallbackQueryMixin(object): + """ + Install a :class:`.CallbackQueryCoordinator` to capture callback query + dynamically. + + Using this mixin has one consequence. The :meth:`self.bot` property no longer + returns the original :class:`.Bot` object. Instead, it returns an augmented + version of the :class:`.Bot` (augmented by :class:`.CallbackQueryCoordinator`). + The original :class:`.Bot` can be accessed with ``self.__bot`` (double underscore). + """ + CallbackQueryCoordinator = CallbackQueryCoordinator + + def __init__(self, intercept_callback_query, *args, **kwargs): + """ + :param intercept_callback_query: + a 2-tuple (enable_chat, enable_inline) to pass to + :class:`.CallbackQueryCoordinator` + """ + global _cqc_origins + + # Restore origin set to CallbackQueryCoordinator + if self.id in _cqc_origins: + origin_set = _cqc_origins[self.id] + else: + origin_set = set() + _cqc_origins[self.id] = origin_set + + if isinstance(intercept_callback_query, tuple): + cqc_enable = intercept_callback_query + else: + cqc_enable = (intercept_callback_query,) * 2 + + self._callback_query_coordinator = self.CallbackQueryCoordinator(self.id, origin_set, *cqc_enable) + cqc = self._callback_query_coordinator + cqc.configure(self.listener) + + self.__bot = self._bot # keep original version of bot + self._bot = cqc.augment_bot(self._bot) # modify send* and edit* methods + self.on_message = cqc.augment_on_message(self.on_message) # modify on_message() + + super(InterceptCallbackQueryMixin, self).__init__(*args, **kwargs) + + def __del__(self): + global _cqc_origins + if self.id in _cqc_origins and not _cqc_origins[self.id]: + del _cqc_origins[self.id] + # Remove empty set from dictionary + + @property + def callback_query_coordinator(self): + return self._callback_query_coordinator + + +class IdleEventCoordinator(object): + def __init__(self, scheduler, timeout): + self._scheduler = scheduler + self._timeout_seconds = timeout + self._timeout_event = None + + def refresh(self): + """ Refresh timeout timer """ + try: + if self._timeout_event: + self._scheduler.cancel(self._timeout_event) + + # Timeout event has been popped from queue prematurely + except exception.EventNotFound: + pass + + # Ensure a new event is scheduled always + finally: + self._timeout_event = self._scheduler.event_later( + self._timeout_seconds, + ('_idle', {'seconds': self._timeout_seconds})) + + def augment_on_message(self, handler): + """ + :return: + a function wrapping ``handler`` to refresh timer for every + non-event message + """ + def augmented(msg): + # Reset timer if this is an external message + is_event(msg) or self.refresh() + + # Ignore timeout event that have been popped from queue prematurely + if flavor(msg) == '_idle' and msg is not self._timeout_event.data: + return + + return handler(msg) + return augmented + + def augment_on_close(self, handler): + """ + :return: + a function wrapping ``handler`` to cancel timeout event + """ + def augmented(ex): + try: + if self._timeout_event: + self._scheduler.cancel(self._timeout_event) + self._timeout_event = None + # This closing may have been caused by my own timeout, in which case + # the timeout event can no longer be found in the scheduler. + except exception.EventNotFound: + self._timeout_event = None + return handler(ex) + return augmented + + +class IdleTerminateMixin(object): + """ + Install an :class:`.IdleEventCoordinator` to manage idle timeout. Also define + instance method ``on__idle()`` to handle idle timeout events. + """ + IdleEventCoordinator = IdleEventCoordinator + + def __init__(self, timeout, *args, **kwargs): + self._idle_event_coordinator = self.IdleEventCoordinator(self.scheduler, timeout) + idlec = self._idle_event_coordinator + idlec.refresh() # start timer + self.on_message = idlec.augment_on_message(self.on_message) + self.on_close = idlec.augment_on_close(self.on_close) + super(IdleTerminateMixin, self).__init__(*args, **kwargs) + + @property + def idle_event_coordinator(self): + return self._idle_event_coordinator + + def on__idle(self, event): + """ + Raise an :class:`.IdleTerminate` to close the delegate. + """ + raise exception.IdleTerminate(event['_idle']['seconds']) + + +class StandardEventScheduler(object): + """ + A proxy to the underlying :class:`.Bot`\'s scheduler, this object implements + the *standard event format*. A standard event looks like this:: + + {'_flavor': { + 'source': { + 'space': event_space, 'id': source_id} + 'custom_key1': custom_value1, + 'custom_key2': custom_value2, + ... }} + + - There is a single top-level key indicating the flavor, starting with an _underscore. + - On the second level, there is a ``source`` key indicating the event source. + - An event source consists of an *event space* and a *source id*. + - An event space is shared by all delegates in a group. Source id simply refers + to a delegate's id. They combine to ensure a delegate is always able to capture + its own events, while its own events would not be mistakenly captured by others. + + Events scheduled through this object always have the second-level ``source`` key fixed, + while the flavor and other data may be customized. + """ + def __init__(self, scheduler, event_space, source_id): + self._base = scheduler + self._event_space = event_space + self._source_id = source_id + + @property + def event_space(self): + return self._event_space + + def configure(self, listener): + """ + Configure a :class:`.Listener` to capture events with this object's + event space and source id. + """ + listener.capture([{re.compile('^_.+'): {'source': {'space': self._event_space, 'id': self._source_id}}}]) + + def make_event_data(self, flavor, data): + """ + Marshall ``flavor`` and ``data`` into a standard event. + """ + if not flavor.startswith('_'): + raise ValueError('Event flavor must start with _underscore') + + d = {'source': {'space': self._event_space, 'id': self._source_id}} + d.update(data) + return {flavor: d} + + def event_at(self, when, data_tuple): + """ + Schedule an event to be emitted at a certain time. + + :param when: an absolute timestamp + :param data_tuple: a 2-tuple (flavor, data) + :return: an event object, useful for cancelling. + """ + return self._base.event_at(when, self.make_event_data(*data_tuple)) + + def event_later(self, delay, data_tuple): + """ + Schedule an event to be emitted after a delay. + + :param delay: number of seconds + :param data_tuple: a 2-tuple (flavor, data) + :return: an event object, useful for cancelling. + """ + return self._base.event_later(delay, self.make_event_data(*data_tuple)) + + def event_now(self, data_tuple): + """ + Schedule an event to be emitted now. + + :param data_tuple: a 2-tuple (flavor, data) + :return: an event object, useful for cancelling. + """ + return self._base.event_now(self.make_event_data(*data_tuple)) + + def cancel(self, event): + """ Cancel an event. """ + return self._base.cancel(event) + + +class StandardEventMixin(object): + """ + Install a :class:`.StandardEventScheduler`. + """ + StandardEventScheduler = StandardEventScheduler + + def __init__(self, event_space, *args, **kwargs): + self._scheduler = self.StandardEventScheduler(self.bot.scheduler, event_space, self.id) + self._scheduler.configure(self.listener) + super(StandardEventMixin, self).__init__(*args, **kwargs) + + @property + def scheduler(self): + return self._scheduler + + +class ListenerContext(object): + def __init__(self, bot, context_id, *args, **kwargs): + # Initialize members before super() so mixin could use them. + self._bot = bot + self._id = context_id + self._listener = bot.create_listener() + super(ListenerContext, self).__init__(*args, **kwargs) + + @property + def bot(self): + """ + The underlying :class:`.Bot` or an augmented version thereof + """ + return self._bot + + @property + def id(self): + return self._id + + @property + def listener(self): + """ See :class:`.Listener` """ + return self._listener + + +class ChatContext(ListenerContext): + def __init__(self, bot, context_id, *args, **kwargs): + super(ChatContext, self).__init__(bot, context_id, *args, **kwargs) + self._chat_id = context_id + self._sender = Sender(self.bot, self._chat_id) + self._administrator = Administrator(self.bot, self._chat_id) + + @property + def chat_id(self): + return self._chat_id + + @property + def sender(self): + """ A :class:`.Sender` for this chat """ + return self._sender + + @property + def administrator(self): + """ An :class:`.Administrator` for this chat """ + return self._administrator + + +class UserContext(ListenerContext): + def __init__(self, bot, context_id, *args, **kwargs): + super(UserContext, self).__init__(bot, context_id, *args, **kwargs) + self._user_id = context_id + self._sender = Sender(self.bot, self._user_id) + + @property + def user_id(self): + return self._user_id + + @property + def sender(self): + """ A :class:`.Sender` for this user """ + return self._sender + + +class CallbackQueryOriginContext(ListenerContext): + def __init__(self, bot, context_id, *args, **kwargs): + super(CallbackQueryOriginContext, self).__init__(bot, context_id, *args, **kwargs) + self._origin = context_id + self._editor = Editor(self.bot, self._origin) + + @property + def origin(self): + """ Mesasge identifier of callback query's origin """ + return self._origin + + @property + def editor(self): + """ An :class:`.Editor` to the originating message """ + return self._editor + + +class InvoiceContext(ListenerContext): + def __init__(self, bot, context_id, *args, **kwargs): + super(InvoiceContext, self).__init__(bot, context_id, *args, **kwargs) + self._payload = context_id + + @property + def payload(self): + return self._payload + + +def openable(cls): + """ + A class decorator to fill in certain methods and properties to ensure + a class can be used by :func:`.create_open`. + + These instance methods and property will be added, if not defined + by the class: + + - ``open(self, initial_msg, seed)`` + - ``on_message(self, msg)`` + - ``on_close(self, ex)`` + - ``close(self, ex=None)`` + - property ``listener`` + """ + + def open(self, initial_msg, seed): + pass + + def on_message(self, msg): + raise NotImplementedError() + + def on_close(self, ex): + logging.error('on_close() called due to %s: %s', type(ex).__name__, ex) + + def close(self, ex=None): + raise ex if ex else exception.StopListening() + + @property + def listener(self): + raise NotImplementedError() + + def ensure_method(name, fn): + if getattr(cls, name, None) is None: + setattr(cls, name, fn) + + # set attribute if no such attribute + ensure_method('open', open) + ensure_method('on_message', on_message) + ensure_method('on_close', on_close) + ensure_method('close', close) + ensure_method('listener', listener) + + return cls + + +class Router(object): + """ + Map a message to a handler function, using a **key function** and + a **routing table** (dictionary). + + A *key function* digests a message down to a value. This value is treated + as a key to the *routing table* to look up a corresponding handler function. + """ + + def __init__(self, key_function, routing_table): + """ + :param key_function: + A function that takes one argument (the message) and returns + one of the following: + + - a key to the routing table + - a 1-tuple (key,) + - a 2-tuple (key, (positional, arguments, ...)) + - a 3-tuple (key, (positional, arguments, ...), {keyword: arguments, ...}) + + Extra arguments, if returned, will be applied to the handler function + after using the key to look up the routing table. + + :param routing_table: + A dictionary of ``{key: handler}``. A ``None`` key acts as a default + catch-all. If the key being looked up does not exist in the routing + table, the ``None`` key and its corresponding handler is used. + """ + super(Router, self).__init__() + self.key_function = key_function + self.routing_table = routing_table + + def map(self, msg): + """ + Apply key function to ``msg`` to obtain a key. Return the routing table entry. + """ + k = self.key_function(msg) + key = k[0] if isinstance(k, (tuple, list)) else k + return self.routing_table[key] + + def route(self, msg, *aa, **kw): + """ + Apply key function to ``msg`` to obtain a key, look up routing table + to obtain a handler function, then call the handler function with + positional and keyword arguments, if any is returned by the key function. + + ``*aa`` and ``**kw`` are dummy placeholders for easy chaining. + Regardless of any number of arguments returned by the key function, + multi-level routing may be achieved like this:: + + top_router.routing_table['key1'] = sub_router1.route + top_router.routing_table['key2'] = sub_router2.route + """ + k = self.key_function(msg) + + if isinstance(k, (tuple, list)): + key, args, kwargs = {1: tuple(k) + ((),{}), + 2: tuple(k) + ({},), + 3: tuple(k),}[len(k)] + else: + key, args, kwargs = k, (), {} + + try: + fn = self.routing_table[key] + except KeyError as e: + # Check for default handler, key=None + if None in self.routing_table: + fn = self.routing_table[None] + else: + raise RuntimeError('No handler for key: %s, and default handler not defined' % str(e.args)) + + return fn(msg, *args, **kwargs) + + +class DefaultRouterMixin(object): + """ + Install a default :class:`.Router` and the instance method ``on_message()``. + """ + def __init__(self, *args, **kwargs): + self._router = Router(flavor, {'chat': lambda msg: self.on_chat_message(msg), + 'callback_query': lambda msg: self.on_callback_query(msg), + 'inline_query': lambda msg: self.on_inline_query(msg), + 'chosen_inline_result': lambda msg: self.on_chosen_inline_result(msg), + 'shipping_query': lambda msg: self.on_shipping_query(msg), + 'pre_checkout_query': lambda msg: self.on_pre_checkout_query(msg), + '_idle': lambda event: self.on__idle(event)}) + # use lambda to delay evaluation of self.on_ZZZ to runtime because + # I don't want to require defining all methods right here. + + super(DefaultRouterMixin, self).__init__(*args, **kwargs) + + @property + def router(self): + return self._router + + def on_message(self, msg): + """ Call :meth:`.Router.route` to handle the message. """ + self._router.route(msg) + + +@openable +class Monitor(ListenerContext, DefaultRouterMixin): + def __init__(self, seed_tuple, capture, **kwargs): + """ + A delegate that never times-out, probably doing some kind of background monitoring + in the application. Most naturally paired with :func:`.per_application`. + + :param capture: a list of patterns for :class:`.Listener` to capture + """ + bot, initial_msg, seed = seed_tuple + super(Monitor, self).__init__(bot, seed, **kwargs) + + for pattern in capture: + self.listener.capture(pattern) + + +@openable +class ChatHandler(ChatContext, + DefaultRouterMixin, + StandardEventMixin, + IdleTerminateMixin): + def __init__(self, seed_tuple, + include_callback_query=False, **kwargs): + """ + A delegate to handle a chat. + """ + bot, initial_msg, seed = seed_tuple + super(ChatHandler, self).__init__(bot, seed, **kwargs) + + self.listener.capture([{'chat': {'id': self.chat_id}}]) + + if include_callback_query: + self.listener.capture([{'message': {'chat': {'id': self.chat_id}}}]) + + +@openable +class UserHandler(UserContext, + DefaultRouterMixin, + StandardEventMixin, + IdleTerminateMixin): + def __init__(self, seed_tuple, + include_callback_query=False, + flavors=chat_flavors+inline_flavors, **kwargs): + """ + A delegate to handle a user's actions. + + :param flavors: + A list of flavors to capture. ``all`` covers all flavors. + """ + bot, initial_msg, seed = seed_tuple + super(UserHandler, self).__init__(bot, seed, **kwargs) + + if flavors == 'all': + self.listener.capture([{'from': {'id': self.user_id}}]) + else: + self.listener.capture([lambda msg: flavor(msg) in flavors, {'from': {'id': self.user_id}}]) + + if include_callback_query: + self.listener.capture([{'message': {'chat': {'id': self.user_id}}}]) + + +class InlineUserHandler(UserHandler): + def __init__(self, seed_tuple, **kwargs): + """ + A delegate to handle a user's inline-related actions. + """ + super(InlineUserHandler, self).__init__(seed_tuple, flavors=inline_flavors, **kwargs) + + +@openable +class CallbackQueryOriginHandler(CallbackQueryOriginContext, + DefaultRouterMixin, + StandardEventMixin, + IdleTerminateMixin): + def __init__(self, seed_tuple, **kwargs): + """ + A delegate to handle callback query from one origin. + """ + bot, initial_msg, seed = seed_tuple + super(CallbackQueryOriginHandler, self).__init__(bot, seed, **kwargs) + + self.listener.capture([ + lambda msg: + flavor(msg) == 'callback_query' and origin_identifier(msg) == self.origin + ]) + + +@openable +class InvoiceHandler(InvoiceContext, + DefaultRouterMixin, + StandardEventMixin, + IdleTerminateMixin): + def __init__(self, seed_tuple, **kwargs): + """ + A delegate to handle messages related to an invoice. + """ + bot, initial_msg, seed = seed_tuple + super(InvoiceHandler, self).__init__(bot, seed, **kwargs) + + self.listener.capture([{'invoice_payload': self.payload}]) + self.listener.capture([{'successful_payment': {'invoice_payload': self.payload}}]) |
