From c6eff42a2952ecc74e24e97f857ea0e6f752ca52 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Thu, 25 Jun 2020 14:12:51 +0200 Subject: [PATCH 01/53] filter_rewrite: Streamlined it a bit. --- teleflask/server/filters.py | 103 ++++++++++++++---------------------- 1 file changed, 41 insertions(+), 62 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 8ba9128..80a649e 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -105,21 +105,30 @@ def _prepare_required_keywords(required_keywords: Union[str, List[str], Tuple[st return required_keywords # end def - def match(self, update: Update, required_keywords: Union[Type[DEFAULT], None, List[str]] = DEFAULT) -> MATCH_RESULT_TYPE: - if required_keywords == DEFAULT: - required_keywords = self.required_update_keywords - # end if + @staticmethod + def _has_required_keywords(obj: Any, required_keywords: Union[Tuple[str, ...], List[str]]) -> bool: + """ + Check that ALL the given `required_keywords` are existent in the `obj`ect, and are not `None`. + :param required_keywords: List of required non-None element attributes. + :return: Boolean if that's the case. + """ if required_keywords is None: # no filter -> allow all the differnt type of updates - return + return True # end if - if all(getattr(update, required_keyword, None) != None for required_keyword in required_keywords): + if all(getattr(obj, required_keyword, None) is not None for required_keyword in required_keywords): # we have one of the required fields - return + return True + # end if + return False + # end def + + def match(self, update: Update) -> MATCH_RESULT_TYPE: + if not self._has_required_keywords(update, self.required_update_keywords): + raise NoMatch('update not matching the required keywords') # end if - raise NoMatch('update not matching the required keywords') # end def def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIONAL_SENDABLE_MESSAGE_TYPES: @@ -160,31 +169,20 @@ class MessageFilter(UpdateFilter): :params required_update_keywords: Optionally: Specify attribute the message needs to have. """ + TYPE = 'update' MATCH_RESULT_TYPE = None func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]] - def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], required_update_keywords: Union[List[str], None] = None): + def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], required_message_keywords: Union[List[str], None] = None): super().__init__(func=func, required_update_keywords=['message']) - self.required_message_keywords = self._prepare_required_keywords(required_update_keywords) + self.required_message_keywords = self._prepare_required_keywords(required_message_keywords) # end def - def match(self, update: Update, required_keywords: Union[Type[DEFAULT], None, List[str]] = DEFAULT) -> MATCH_RESULT_TYPE: - super().match(update=update, required_keywords=['message']) - - if required_keywords == DEFAULT: - required_keywords = self.required_message_keywords - # end if - - if required_keywords is None: - # no filter -> allow all the differnt type of updates - return - # end if - - if all(getattr(update.message, required_keyword, None) != None for required_keyword in required_keywords): - # we have one of the required fields - return + def match(self, update: Update) -> MATCH_RESULT_TYPE: + super().match(update=update) + if not self._has_required_keywords(update.message, self.required_message_keywords): + raise NoMatch('message not matching the required keywords') # end if - raise NoMatch('message not matching the required keywords') # end def def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIONAL_SENDABLE_MESSAGE_TYPES: @@ -206,6 +204,7 @@ class CommandFilter(MessageFilter): >>> def foobar(update, text): >>> ... # like above """ + TYPE = "command" TEXT_PARAM_TYPE = Union[None, str] MATCH_RESULT_TYPE = TEXT_PARAM_TYPE @@ -220,8 +219,8 @@ class CommandFilter(MessageFilter): command_strings: Tuple[str, ...] _command_strings: Union[Tuple[str, ...], None] - def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], command: str, username: str): - super().__init__(func=func, required_update_keywords=['message']) + def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], command: str, username: str = 'bot'): + super().__init__(func=func, required_message_keywords=['text']) self._command = command self._username = username self._command_strings = tuple(self._yield_commands(command=command, username=username)) @@ -233,7 +232,11 @@ def command(self) -> str: # end def @command.setter - def command(self, value: str): + def command(self, value: str) -> None: + if self._command == value: + # no need to waste resources here. + return + # end if self._command = value self._command_strings = tuple(self._yield_commands(command=value, username=self._username)) # end def @@ -244,7 +247,11 @@ def username(self) -> str: # end def @username.setter - def username(self, value: str): + def username(self, value: str) -> None: + if self._username == value: + # no need to waste resources here. + return + # end if self._username = value self._command_strings = tuple(self._yield_commands(command=self._command, username=value)) # end def @@ -276,9 +283,10 @@ def _yield_commands(command, username): # end for # end def _yield_commands - def match(self, update: Update, required_keywords: Union[Type[DEFAULT], None, List[str]] = DEFAULT) -> MATCH_RESULT_TYPE: - if not super().match(update=update, required_keywords=['text']): - return None + def match(self, update: Update) -> MATCH_RESULT_TYPE: + super().match(update=update) + if not self._has_required_keywords(update.message, self.required_message_keywords): + raise NoMatch('message not matching the required keywords') # end if txt = update.message.text.strip() @@ -302,32 +310,3 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def # end class - -class FilterHolder(object): - def on_update(self, *required_keywords): - """ - Decorator to register a function to receive updates. - - Usage: - >>> @app.on_update - >>> def foo(update): - >>> assert isinstance(update, Update) - >>> # do stuff with the update - >>> # you can use app.bot to access the bot's messages functions - """ - def on_update_inner(function): - return self.add_update_listener(function, required_keywords=required_keywords) - # end def - if ( - len(required_keywords) == 1 and # given could be the function, or a single required_keyword. - not isinstance(required_keywords[0], str) # not string -> must be function - ): - # @on_update - function = required_keywords[0] - required_keywords = None - return on_update_inner(function) # not string -> must be function - # end if - # -> else: *required_update_keywords are the strings - # @on_update("update_id", "message", "whatever") - return on_update_inner # let that function be called again with the function. - # end def From 884ea0ee0fb9c7c5ed17e3808ff952dea6f31f90 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Thu, 25 Jun 2020 15:45:42 +0200 Subject: [PATCH 02/53] filter_rewrite: Allow empty username for now. --- teleflask/server/filters.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 80a649e..a5a8a26 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -219,7 +219,7 @@ class CommandFilter(MessageFilter): command_strings: Tuple[str, ...] _command_strings: Union[Tuple[str, ...], None] - def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], command: str, username: str = 'bot'): + def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], command: str, username: Union[str, None]): super().__init__(func=func, required_message_keywords=['text']) self._command = command self._username = username @@ -273,14 +273,16 @@ def _yield_commands(command, username): :param command: The command to construct. :return: """ - for syntax in ( - "/{command}", # without username - "/{command}@{username}", # with username - "command:///{command}", # iOS represents commands like this - "command:///{command}@{username}" # iOS represents commands like this - ): - yield syntax.format(command=command, username=username) - # end for + yield from ( + f"/{command}", # without username + f"command:///{command}", # iOS represents commands like this + ) + if username: + yield from ( + f"/{command}@{username}", # with username + f"command:///{command}@{username}" # iOS represents commands like this + ) + # end if # end def _yield_commands def match(self, update: Update) -> MATCH_RESULT_TYPE: From 82a36fc3374a1530ee16ac2eaf9e4a32281113ec Mon Sep 17 00:00:00 2001 From: luckydonald Date: Thu, 25 Jun 2020 16:11:50 +0200 Subject: [PATCH 03/53] filter_rewrite: added __str__ and __repr__ to the filters. --- teleflask/server/filters.py | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index a5a8a26..94255b6 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -57,6 +57,14 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO """ return self.func(update) # end def + + def __str__(self): + return "Parent Filter class allowing everything, but actually you should subclass this." + # end def + + def __repr__(self): + return f"{self.__class__.__name__}(type={self.type!r}, func={self.func!r})" + # end def # end class @@ -137,6 +145,21 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO """ return self.func(update) # end def + + # noinspection SqlNoDataSourceInspection + def __str__(self): + if not self.required_update_keywords: + return "Update Filter matching every update." + elif len(self.required_update_keywords) == 1: + return f"Update Filter matching only updates with the attribute {self.required_update_keywords[0]!r} set and not None" + else: + return f"Update Filter matching only updates with all the attributes {self.required_update_keywords!r} set and not None" + # end if + # end def + + def __repr__(self): + return f"{self.__class__.__name__}(type={self.type!r}, func={self.func!r}, required_update_keywords={self.required_update_keywords!r})" + # end def # end def @@ -192,6 +215,21 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO message = update.message return self.func(update, message) # end def + + # noinspection SqlNoDataSourceInspection + def __str__(self): + if not self.required_message_keywords: + return "Message Filter matching every message." + elif len(self.required_message_keywords) == 1: + return f"Message Filter matching only messages with the attribute {self.required_message_keywords[0]!r} set and not None" + else: + return f"Message Filter matching only messages with all the attributes {self.required_message_keywords!r} set and not None" + # end if + # end def + + def __repr__(self): + return f"{self.__class__.__name__}(type={self.type!r}, func={self.func!r}, required_message_keywords={self.required_message_keywords!r})" + # end def # end class @@ -310,5 +348,18 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO """ return self.func(update, text=match_result) # end def + + # noinspection SqlNoDataSourceInspection + def __str__(self): + if not self._username: + return f"Command Filter matching the command {self._command} but no username suffixed commands." + else: + return f"Command Filter matching the command {self._command} including the ones with @{self._username}." + # end if + # end def + + def __repr__(self): + return f"{self.__class__.__name__}(type={self.type!r}, func={self.func!r}, command={self._command!r}, username={self._username!r})" + # end def # end class From 147c347e2afad8e3f927e16675649be1dc9bda69 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Thu, 25 Jun 2020 16:18:03 +0200 Subject: [PATCH 04/53] filter_rewrite: Migrated the `@on_update` to be using `Filter`s now. --- teleflask/server/filters.py | 52 +++++++++++++++ teleflask/server/mixins.py | 125 +++++++++++------------------------- 2 files changed, 91 insertions(+), 86 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 94255b6..61a5fc8 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -8,6 +8,8 @@ __author__ = 'luckydonald' from pytgbot.api_types.receivable.updates import Update, Message + +from teleflask import Teleflask, TBlueprint from ..messages import Message as OldSendableMessage from ..new_messages import SendableMessageBase @@ -21,6 +23,9 @@ class DEFAULT: pass # end if +_HANDLERS_ATTRIBUTE = '__teleflask.__handlers' + + class NoMatch(Exception): """ Raised by a filter if it denies to process the update. @@ -146,6 +151,53 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO return self.func(update) # end def + @classmethod + def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + """ + Decorator to register a function to receive updates. + + Usage: + >>> app = Teleflask(API_KEY) + + >>> @app.on_update + >>> @app.on_update("update_id", "message", "whatever") + >>> def foo(update): + ... assert isinstance(update, Update) + ... # do stuff with the update + ... # you can use app.bot to access the bot's messages functions + Or, if you wanna go do it directly for some strange reason: + >>> @UpdateFilter.decorator(app) + >>> @UpdateFilter.decorator(app)("update_id", "message", "whatever") + >>> def foo(update): + ... pass + """ + + def decorator_inner(function): + if teleflask_or_tblueprint: + filter = cls(func=function, required_update_keywords=required_keywords) + teleflask_or_tblueprint.register_handler(filter) + # end if + handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) + filter = cls(func=function, required_update_keywords=required_keywords) + handlers.append(filter) + setattr(function, _HANDLERS_ATTRIBUTE, handlers) + return function + # end def + + if ( + len(required_keywords) == 1 and # given could be the function, or a single required_keyword. + not isinstance(required_keywords[0], str) # not string -> must be function + ): + # @on_update + function = required_keywords[0] + required_keywords = None + return decorator_inner(function) # not string -> must be function + # end if + # -> else: all `*required_keywords` are the strings + # @on_update("update_id", "message", "whatever") + return decorator_inner # let that function be called again with the function. + # end def + # noinspection SqlNoDataSourceInspection def __str__(self): if not self.required_update_keywords: diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py index 6c91120..33b81fb 100644 --- a/teleflask/server/mixins.py +++ b/teleflask/server/mixins.py @@ -2,9 +2,11 @@ import logging from abc import abstractmethod from collections import OrderedDict +from typing import List from pytgbot.api_types.receivable.updates import Update +from .filters import UpdateFilter, Filter, NoMatch from ..exceptions import AbortProcessingPlease from .abstact import AbstractUpdates, AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup from .base import TeleflaskMixinBase @@ -42,16 +44,13 @@ class UpdatesMixin(TeleflaskMixinBase, AbstractUpdates): """ def __init__(self, *args, **kwargs): - self.update_listeners = OrderedDict() # Python3.6, dicts are sorted # Schema: - # Schema: {func: [ ["message", "key", "..."] ]} or {func: None} for wildcard. - # [ ['A', 'B'], ['C'] ] == 'A' and 'B' or 'C' - # [ ]  means 'allow all'. + self.update_listeners: List[Filter] = [] super(UpdatesMixin, self).__init__(*args, **kwargs) # end def - def on_update(self, *required_keywords): - """ + on_update = UpdateFilter.decorator + on_update.__doc__ = """ Decorator to register a function to receive updates. Usage: @@ -61,38 +60,24 @@ def on_update(self, *required_keywords): >>> # do stuff with the update >>> # you can use app.bot to access the bot's messages functions """ - def on_update_inner(function): - return self.add_update_listener(function, required_keywords=required_keywords) - # end def - if ( - len(required_keywords) == 1 and # given could be the function, or a single required_keyword. - not isinstance(required_keywords[0], str) # not string -> must be function - ): - # @on_update - function = required_keywords[0] - required_keywords = None - return on_update_inner(function) # not string -> must be function - # end if - # -> else: *required_keywords are the strings - # @on_update("update_id", "message", "whatever") - return on_update_inner # let that function be called again with the function. # end def - def add_update_listener(self, function, required_keywords=None): + def register_handler(self, event_handler: Filter): """ - Adds an listener for updates. - You can filter them if you supply a list of names of attributes which all need to be present. + Adds an listener for any update type. + You provide a Filter for them as parameter, it also contains the function. No error will be raised if it is already registered. In that case a warning will be logged, but nothing else will happen, and the function is not added. Examples: - >>> add_update_listener(func, required_keywords=["update_id", "message"]) + >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. - >>> add_update_listener(func, required_keywords=["inline_query"]) + >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) # calls func(msg) for all updates which are inline queries (have the inline_query attribute) - >>> add_update_listener(func) + >>> register_handler(UpdateFilter(func, required_keywords=None)) + >>> register_handler(UpdateFilter(func)) # allows all messages. :param function: The function to call. Will be called with the update and the message as arguments @@ -101,45 +86,13 @@ def add_update_listener(self, function, required_keywords=None): :return: the function, unmodified """ - if required_keywords is None: - self.update_listeners[function] = [None] - logging.debug("listener required keywords set to allow all.") - return function - # end def - - # check input, make a list out of what we might get. - if isinstance(required_keywords, str): - required_keywords = [required_keywords] # str => [str] - elif isinstance(required_keywords, tuple): - required_keywords = list(required_keywords) # (str,str) => [str,str] - # end if - assert isinstance(required_keywords, list) - for keyword in required_keywords: - assert isinstance(keyword, str) # required_keywords must all be type str - # end if - if function not in self.update_listeners: - # function does not exists, create the keywords. - logging.debug("adding function to listeners") - self.update_listeners[function] = [required_keywords] # list of lists. Outer list = OR, inner = AND - else: - # function already exists, add/merge the keywords. - if None in self.update_listeners[function]: - # None => allow all, so we don't need to add a filter - logger.debug('listener not updated, as it is already wildcard') - elif required_keywords in self.update_listeners[function]: - # the keywords already are required, we don't need to add a filter - logger.debug("listener required keywords already in {!r}".format(self.update_listeners[function])) - else: - # add another case - self.update_listeners[function].append(required_keywords) # Outer list = OR, required_keywords = AND - logger.debug("listener required keywords updated to {!r}".format(self.update_listeners[function])) - # end if - # end if - return function + logging.debug("adding handler to listeners") + self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND + return event_handler # end def add_update_listener - def remove_update_listener(self, func): + def remove_update_listener(self, event_handler): """ Removes an function from the update listener list. No error will be raised if it is already registered. In that case a warning will be logged, @@ -149,12 +102,11 @@ def remove_update_listener(self, func): :param function: The function to remove :return: the function, unmodified """ - if func in self.update_listeners: - del self.update_listeners[func] - else: + try: + self.update_listeners.remove(event_handler) + except ValueError: logger.warning("listener already removed.") # end if - return func # end def def process_update(self, update): @@ -167,25 +119,26 @@ def process_update(self, update): :return: nothing. """ assert isinstance(update, Update) # Todo: non python objects - for listener, required_fields_array in self.update_listeners.items(): - for required_fields in required_fields_array: - try: - if not required_fields or all([hasattr(update, f) and getattr(update, f) for f in required_fields]): - # either filters evaluates to False, (None, empty list etc) which means it should not filter - # or it has filters, than we need to check if that attributes really exist. - self.process_result(update, listener(update)) # this will be TeleflaskMixinBase.process_result() - break # stop processing other required_fields combinations - # end if - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception("Error executing the update listener {func}.".format(func=listener)) - # end try - # end for + filter: Filter + for filter in self.update_listeners: + try: + # check if the Filter matches + match_result = filter.match(update) + # call the handler + result = filter.call_handler(update=update, match_result=match_result) + # send the message + self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() + except NoMatch as e: + logger.debug(f'not matching filter {filter!s}.') + except AbortProcessingPlease as e: + logger.debug('Asked to stop processing updates.') + if e.return_value: + self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() + # end if + return # not calling super().process_update(update) + except Exception: + logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") + # end try # end for super().process_update(update) # end def process_update From 3cb6511709c112ed5efd4a6bb9f44c22bd668a93 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 19:01:47 +0200 Subject: [PATCH 05/53] filter_rewrite: Trying to provide the decorator in the class. Maybe I can superclass that somehow? --- teleflask/server/filters.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 61a5fc8..beb2278 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -268,6 +268,53 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO return self.func(update, message) # end def + @classmethod + def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + """ + Decorator to register a function to receive updates. + + Usage: + >>> app = Teleflask(API_KEY) + + >>> @app.on_update + >>> @app.on_update("update_id", "message", "whatever") + >>> def foo(update): + ... assert isinstance(update, Update) + ... # do stuff with the update + ... # you can use app.bot to access the bot's messages functions + Or, if you wanna go do it directly for some strange reason: + >>> @UpdateFilter.decorator(app) + >>> @UpdateFilter.decorator(app)("update_id", "message", "whatever") + >>> def foo(update): + ... pass + """ + + def decorator_inner(function): + if teleflask_or_tblueprint: + filter = cls(func=function, required_update_keywords=required_keywords) + teleflask_or_tblueprint.register_handler(filter) + # end if + handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) + filter = cls(func=function, required_update_keywords=required_keywords) + handlers.append(filter) + setattr(function, _HANDLERS_ATTRIBUTE, handlers) + return function + # end def + + if ( + len(required_keywords) == 1 and # given could be the function, or a single required_keyword. + not isinstance(required_keywords[0], str) # not string -> must be function + ): + # @on_update + function = required_keywords[0] + required_keywords = None + return decorator_inner(function) # not string -> must be function + # end if + # -> else: all `*required_keywords` are the strings + # @on_update("update_id", "message", "whatever") + return decorator_inner # let that function be called again with the function. + # end def + # noinspection SqlNoDataSourceInspection def __str__(self): if not self.required_message_keywords: From 15a631d0460b543c6da981580c53837b6bb88b25 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 19:12:23 +0200 Subject: [PATCH 06/53] filter_rewrite: Adapted the message decorator. --- teleflask/server/filters.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index beb2278..396319b 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -291,11 +291,12 @@ def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], def decorator_inner(function): if teleflask_or_tblueprint: - filter = cls(func=function, required_update_keywords=required_keywords) + # we don't want to register later + filter = cls(func=function, required_message_keywords=required_keywords) teleflask_or_tblueprint.register_handler(filter) # end if handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) - filter = cls(func=function, required_update_keywords=required_keywords) + filter = cls(func=function, required_message_keywords=required_keywords) handlers.append(filter) setattr(function, _HANDLERS_ATTRIBUTE, handlers) return function From 4ef55bb8e85073d369329b0cffc5f06b76ed3ca0 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 19:16:30 +0200 Subject: [PATCH 07/53] filter_rewrite: With that we can integrate MessagesMixin into the UpdateMixin. --- teleflask/server/extras.py | 2 +- teleflask/server/mixins.py | 243 ++++++------------------------------- 2 files changed, 38 insertions(+), 207 deletions(-) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 75084b9..434f4ae 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -class Teleflask(StartupMixin, BotCommandsMixin, MessagesMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): +class Teleflask(StartupMixin, BotCommandsMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): """ This is the full package, including all provided mixins. diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py index 33b81fb..db66006 100644 --- a/teleflask/server/mixins.py +++ b/teleflask/server/mixins.py @@ -6,13 +6,13 @@ from pytgbot.api_types.receivable.updates import Update -from .filters import UpdateFilter, Filter, NoMatch +from .filters import UpdateFilter, Filter, NoMatch, MessageFilter from ..exceptions import AbortProcessingPlease from .abstact import AbstractUpdates, AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup from .base import TeleflaskMixinBase __author__ = 'luckydonald' -__all__ = ['BotCommandsMixin', 'MessagesMixin', 'RegisterBlueprintsMixin', 'StartupMixin', 'UpdatesMixin'] +__all__ = ['BotCommandsMixin', 'RegisterBlueprintsMixin', 'StartupMixin', 'UpdatesMixin'] logger = logging.getLogger(__name__) @@ -59,8 +59,41 @@ def __init__(self, *args, **kwargs): >>> assert isinstance(update, Update) >>> # do stuff with the update >>> # you can use app.bot to access the bot's messages functions - """ - # end def + + :params required_keywords: Optionally: Specify attribute the message needs to have. + """ + + on_message = MessageFilter.decorator + on_message.__doc__ = """ + Decorator to register a listener for a message event. + You can give optionally give one or multiple strings. The message will need to have all this elements. + If you leave them out, you'll get all messages, unfiltered. + + Usage: + >>> @app.on_message + >>> def foo(update, msg): + >>> # all messages + >>> assert isinstance(update, Update) + >>> assert isinstance(msg, Message) + >>> app.bot.send_message(msg.chat.id, "you sent any message!") + + >>> @app.on_message("text") + >>> def foo(update, msg): + >>> # all messages which are text messages (have the text attribute) + >>> assert isinstance(update, Update) + >>> assert isinstance(msg, Message) + >>> app.bot.send_message(msg.chat.id, "you sent text!") + + >>> @app.on_message("photo", "sticker") + >>> def foo(update, msg): + >>> # all messages which are photos (have the photo attribute) and have a caption + >>> assert isinstance(update, Update) + >>> assert isinstance(msg, Message) + >>> app.bot.send_message(msg.chat.id, "you sent a photo with caption!") + + + :params required_keywords: Optionally: Specify attribute the message needs to have. + """ def register_handler(self, event_handler: Filter): """ @@ -149,208 +182,6 @@ def do_startup(self): # pragma: no cover # end class -class MessagesMixin(TeleflaskMixinBase, AbstractMessages): - """ - Add this to get messages. - - After adding this mixin to the TeleflaskBase you will get: - - `add_message_listener` to add functions - `remove_message_listener` to remove them again. - `@on_message` decorator as alias to `add_message_listener` - - Example: - This is the function we got: - - >>> def foobar(update, msg): - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - - Now we can add it like this: - - >>> app.add_message_listener(foobar) - - And remove it again: - - >>> app.remove_message_listener() - - You can also use the handy decorator: - - >>> @app.on_message("text") # all messages where msg.text is existent. - >>> def foobar(update, msg): - >>> ... # like above - Would be equal to: - >>> app.add_message_listener(foobar, ["text"]) - """ - - def __init__(self, *args, **kwargs): - self.message_listeners = dict() # key: func, value: [ ["arg", "arg2"], ["arg2"] ] - super(MessagesMixin, self).__init__(*args, **kwargs) - # end def - - def on_message(self, *required_keywords): - """ - Decorator to register a listener for a message event. - You can give optionally give one or multiple strings. The message will need to have all this elements. - If you leave them out, you'll get all messages, unfiltered. - - Usage: - >>> @app.on_message - >>> def foo(update, msg): - >>> # all messages - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent any message!") - - >>> @app.on_message("text") - >>> def foo(update, msg): - >>> # all messages which are text messages (have the text attribute) - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent text!") - - >>> @app.on_message("photo", "sticker") - >>> def foo(update, msg): - >>> # all messages which are photos (have the photo attribute) and have a caption - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent a photo with caption!") - - - :params required_keywords: Optionally: Specify attribute the message needs to have. - """ - def on_message_inner(function): - return self.add_message_listener(function, required_keywords=required_keywords) - # end def - - if (len(required_keywords) == 1 and # given could be the function, or a single required_keyword. - not isinstance(required_keywords[0], str) # not string -> must be function - ): - # @on_message - function = required_keywords[0] - required_keywords = None - return on_message_inner(function=function) # not string -> must be function - # end if - # -> else: *required_keywords are the strings - # @on_message("text", "sticker", "whatever") - return on_message_inner # let that function be called again with the function. - # end def - - def add_message_listener(self, function, required_keywords=None): - """ - Adds an listener for updates with messages. - You can filter them if you supply a list of names of attributes which all need to be present. - No error will be raised if it is already registered. In that case a warning will be logged, - but nothing else will happen, and the function is not added. - - Examples: - >>> add_message_listener(func, required_keywords=["sticker", "caption"]) - # will call func(msg) for all messages which are stickers (have the sticker attribute) and have a caption. - - >>> add_message_listener(func) - # allows all messages. - - :param function: The function to call. Will be called with the update and the message as arguments - :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. - :return: the function, unmodified - """ - if required_keywords is None: - self.message_listeners[function] = [None] - logging.debug("listener required keywords set to allow all.") - return function - # end def - - # check input, make a list out of what we might get. - if isinstance(required_keywords, str): - required_keywords = [required_keywords] # str => [str] - elif isinstance(required_keywords, tuple): - required_keywords = list(required_keywords) # (str,str) => [str,str] - # end if - assert isinstance(required_keywords, list) - for keyword in required_keywords: - assert isinstance(keyword, str) # required_keywords must all be type str - # end if - - if function not in self.message_listeners: - # function does not exists, create the keywords. - logging.debug("adding function to listeners") - self.message_listeners[function] = [required_keywords] # list of lists. Outer list = OR, inner = AND - else: - # function already exists, add/merge the keywords. - if None in self.message_listeners[function]: - # None => allow all, so we don't need to add a filter - logger.debug('listener not updated, as it is already wildcard') - elif required_keywords in self.message_listeners[function]: - # the keywords already are required, we don't need to add a filter - logger.debug("listener required keywords already in {!r}".format(self.message_listeners[function])) - else: - self.message_listeners[function].append(required_keywords) - logger.debug("listener required keywords updated to {!r}".format(self.message_listeners[function])) - # end if - # end if - return function - # end def add_message_listener - - def remove_message_listeners(self, func): - """ - Removes an function from the message listener list. - No error will be raised if it is already registered. In that case a warning will be logged, - but noting else will happen. - - - :param function: The function to remove - :return: the function, unmodified - """ - if func in self.message_listeners: - del self.message_listeners[func] - else: - logger.warning("listener already removed.") - # end if - return func - # end def - - def process_update(self, update): - """ - Iterates through self.message_listeners, and calls them with (update, app). - - No try catch stuff is done, will fail instantly, and not process any remaining listeners. - - :param update: incoming telegram update. - :return: nothing. - """ - assert isinstance(update, Update) - if update.message: - msg = update.message - for listener, required_fields_array in self.message_listeners.items(): - for required_fields in required_fields_array: - try: - if not required_fields or all([hasattr(msg, f) and getattr(msg, f) for f in required_fields]): - # either filters evaluates to False, (None, empty list etc) which means it should not filter - # or it has filters, than we need to check if that attributes really exist. - self.process_result(update, listener(update, update.message)) - break # stop processing other required_fields combinations - # end if - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception("Error executing the update listener {func}.".format(func=listener)) - # end try - # end for - # end for - # end if - super().process_update(update) - # end def process_update - - def do_startup(self): # pragma: no cover - super().do_startup() - # end def -# end class - - class BotCommandsMixin(TeleflaskMixinBase, AbstractBotCommands): """ Add this to get commands. From 370262aa6a60cd88b61655bcb189772d7bb387f5 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 19:17:00 +0200 Subject: [PATCH 08/53] filter_rewrite: Copy paste message decorator to the CommandFilter. --- teleflask/server/filters.py | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 396319b..7127c79 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -449,6 +449,55 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO return self.func(update, text=match_result) # end def + @classmethod + def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + """ + Decorator to register a function to receive updates. + + Usage: + >>> app = Teleflask(API_KEY) + + >>> @app.on_update + >>> @app.on_update("update_id", "message", "whatever") + >>> def foo(update): + ... assert isinstance(update, Update) + ... # do stuff with the update + ... # you can use app.bot to access the bot's messages functions + Or, if you wanna go do it directly for some strange reason: + >>> @UpdateFilter.decorator(app) + >>> @UpdateFilter.decorator(app)("update_id", "message", "whatever") + >>> def foo(update): + ... pass + """ + + def decorator_inner(function): + if teleflask_or_tblueprint: + # we don't want to register later + filter = cls(func=function, command=command) + teleflask_or_tblueprint.register_handler(filter) + # end if + handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) + filter = cls(func=function, required_message_keywords=required_keywords) + handlers.append(filter) + setattr(function, _HANDLERS_ATTRIBUTE, handlers) + return function + + # end def + + if ( + len(required_keywords) == 1 and # given could be the function, or a single required_keyword. + not isinstance(required_keywords[0], str) # not string -> must be function + ): + # @on_update + function = required_keywords[0] + required_keywords = None + return decorator_inner(function) # not string -> must be function + # end if + # -> else: all `*required_keywords` are the strings + # @on_update("update_id", "message", "whatever") + return decorator_inner # let that function be called again with the function. + # end def + # noinspection SqlNoDataSourceInspection def __str__(self): if not self._username: From 87c8d2bd009c01937eb4378edb2be2353c3615e3 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 19:23:25 +0200 Subject: [PATCH 09/53] filter_rewrite: Updated CommandFilter's decorator. --- teleflask/server/filters.py | 39 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 7127c79..586bf24 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -450,51 +450,34 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + def decorator(cls, command, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None] = None): """ - Decorator to register a function to receive updates. + Decorator to register a command. Usage: - >>> app = Teleflask(API_KEY) + >>> @app.command("foo") + >>> def foo(update, text): + >>> assert isinstance(update, Update) + >>> app.bot.send_message(update.message.chat.id, "bar:" + text) - >>> @app.on_update - >>> @app.on_update("update_id", "message", "whatever") - >>> def foo(update): - ... assert isinstance(update, Update) - ... # do stuff with the update - ... # you can use app.bot to access the bot's messages functions - Or, if you wanna go do it directly for some strange reason: - >>> @UpdateFilter.decorator(app) - >>> @UpdateFilter.decorator(app)("update_id", "message", "whatever") - >>> def foo(update): - ... pass + If you now write "/foo hey" to the bot, it will reply with "bar:hey" + + :param command: the string of a command, without the slash. """ def decorator_inner(function): if teleflask_or_tblueprint: # we don't want to register later - filter = cls(func=function, command=command) + filter = cls(func=function, command=command, username=teleflask_or_tblueprint.username, teleflask_or_tblueprint=teleflask_or_tblueprint) teleflask_or_tblueprint.register_handler(filter) # end if handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) - filter = cls(func=function, required_message_keywords=required_keywords) + filter = cls(func=function, command=command, username=None) handlers.append(filter) setattr(function, _HANDLERS_ATTRIBUTE, handlers) return function - # end def - if ( - len(required_keywords) == 1 and # given could be the function, or a single required_keyword. - not isinstance(required_keywords[0], str) # not string -> must be function - ): - # @on_update - function = required_keywords[0] - required_keywords = None - return decorator_inner(function) # not string -> must be function - # end if - # -> else: all `*required_keywords` are the strings - # @on_update("update_id", "message", "whatever") return decorator_inner # let that function be called again with the function. # end def From 2ef5136a2171253c6b56dd4e273bb3c5b7281c39 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 20:02:06 +0200 Subject: [PATCH 10/53] filter_rewrite: Multiline __init__ arguments for readability. --- teleflask/server/filters.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 586bf24..78b35d6 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -41,7 +41,11 @@ class Filter(object): type: str func: Union[Callable, DEFAULT_CALLABLE] - def __init__(self, type: str, func: Union[Callable, DEFAULT_CALLABLE]): + def __init__( + self, + type: str, + func: Union[Callable, DEFAULT_CALLABLE], + ): """ :param type: The type of this class. :param func: The function registered. @@ -98,7 +102,10 @@ class UpdateFilter(Filter): required_update_keywords: Union[List[str], None] - def __init__(self, func: Callable, required_update_keywords: Union[List[str], None] = None): + def __init__( + self, + func: Callable, required_update_keywords: Union[List[str], None] = None, + ): super().__init__(self.TYPE, func=func) self.required_update_keywords = self._prepare_required_keywords(required_update_keywords) # end def @@ -248,7 +255,11 @@ class MessageFilter(UpdateFilter): MATCH_RESULT_TYPE = None func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]] - def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], required_message_keywords: Union[List[str], None] = None): + def __init__( + self, + func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], + required_message_keywords: Union[List[str], None] = None, + ): super().__init__(func=func, required_update_keywords=['message']) self.required_message_keywords = self._prepare_required_keywords(required_message_keywords) # end def @@ -357,7 +368,12 @@ class CommandFilter(MessageFilter): command_strings: Tuple[str, ...] _command_strings: Union[Tuple[str, ...], None] - def __init__(self, func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], command: str, username: Union[str, None]): + def __init__( + self, + func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], + command: str, + username: Union[str, None], + ): super().__init__(func=func, required_message_keywords=['text']) self._command = command self._username = username From 8510a220511a7be378379f59ff8a4e7d6050f711 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 20:10:06 +0200 Subject: [PATCH 11/53] filter_rewrite: Killed old BotCommandsMixin; migrated classes into UpdatesMixin. --- teleflask/server/extras.py | 2 +- teleflask/server/mixins.py | 253 +++------------------------- tests/mixins/test_commands_mixin.py | 4 +- 3 files changed, 22 insertions(+), 237 deletions(-) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 434f4ae..1856815 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -class Teleflask(StartupMixin, BotCommandsMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): +class Teleflask(StartupMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): """ This is the full package, including all provided mixins. diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py index db66006..30e9805 100644 --- a/teleflask/server/mixins.py +++ b/teleflask/server/mixins.py @@ -6,13 +6,13 @@ from pytgbot.api_types.receivable.updates import Update -from .filters import UpdateFilter, Filter, NoMatch, MessageFilter +from .filters import UpdateFilter, Filter, NoMatch, MessageFilter, CommandFilter from ..exceptions import AbortProcessingPlease from .abstact import AbstractUpdates, AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup from .base import TeleflaskMixinBase __author__ = 'luckydonald' -__all__ = ['BotCommandsMixin', 'RegisterBlueprintsMixin', 'StartupMixin', 'UpdatesMixin'] +__all__ = ['RegisterBlueprintsMixin', 'StartupMixin', 'UpdatesMixin'] logger = logging.getLogger(__name__) @@ -95,6 +95,23 @@ def __init__(self, *args, **kwargs): :params required_keywords: Optionally: Specify attribute the message needs to have. """ + on_command = CommandFilter.decorator + on_command.__doc__ = """ + Decorator to register a command. + + Usage: + >>> @app.command("foo") + >>> def foo(update, text): + >>> assert isinstance(update, Update) + >>> app.bot.send_message(update.message.chat.id, "bar:" + text) + + If you now write "/foo hey" to the bot, it will reply with "bar:hey" + + :param command: the string of a command + """ + command = on_command + command.__doc__ = "Alias of @on_command:\n\n" + on_command.__doc__ + def register_handler(self, event_handler: Filter): """ Adds an listener for any update type. @@ -119,7 +136,6 @@ def register_handler(self, event_handler: Filter): :return: the function, unmodified """ - logging.debug("adding handler to listeners") self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND return event_handler @@ -182,237 +198,6 @@ def do_startup(self): # pragma: no cover # end class -class BotCommandsMixin(TeleflaskMixinBase, AbstractBotCommands): - """ - Add this to get commands. - - After adding this mixin to the TeleflaskBase you will get: - - `add_command` to add functions - `remove_command` to remove them again. - `@command` decorator as alias to `add_command` - `@on_command` decorator as alias to `@command` - - Example: - This is the function we got: - - >>> def foobar(update, text): - >>> assert isinstance(update, Update) - >>> text_to_send = "Your command has" - >>> text_to_send += "no argument." if text is None else ("the following args: " + text) - >>> app.bot.send_message(update.message.chat.id, text=text_to_send) - - Now we can add it like this: - - >>> app.add_command("command", foobar) - - And remove it again: - - >>> app.remove_command(command="command") - or - >>> app.remove_command(function=foobar) - - You can also use the handy decorator: - - >>> @app.command("command") - >>> def foobar(update, text): - >>> ... # like above - """ - def __init__(self, *args, **kwargs): - self.commands = dict() # 'cmd': (fuction, exclusive:bool) - super(BotCommandsMixin, self).__init__(*args, **kwargs) - # end def - - def on_command(self, command, exclusive=False): - """ - Decorator to register a command. - - :param command: The command to be registered. Omit the slash. - :param exclusive: Stop processing the update further, so no other listenere will be called if this command triggered. - - Usage: - >>> @app.on_command("foo") - >>> def foo(update, text): - >>> assert isinstance(update, Update) - >>> app.bot.send_message(update.message.chat.id, "bar:" + text) - - If you now write "/foo hey" to the bot, it will reply with "bar:hey" - - You can set to ignore other registered listeners to trigger. - - >>> @app.on_command("bar", exclusive=True) - >>> def bar(update, text) - >>> return "Bar command happened." - - >>> @app.on_command("bar") - >>> def bar2(update, text) - >>> return "This function will never be called." - - @on_command decorator. Actually is an alias to @command. - :param command: the string of a command - """ - return self.command(command, exclusive=exclusive) - # end if - - def command(self, command, exclusive=False): - """ - Decorator to register a command. - - Usage: - >>> @app.command("foo") - >>> def foo(update, text): - >>> assert isinstance(update, Update) - >>> app.bot.send_message(update.message.chat.id, "bar:" + text) - - If you now write "/foo hey" to the bot, it will reply with "bar:hey" - - :param command: the string of a command - """ - def register_command(func): - self.add_command(command, func, exclusive=exclusive) - return func - return register_command - # end def - - def add_command(self, command, function, exclusive=False): - """ - Adds `/command` and `/command@bot` - (also the iOS urls `command:///command` and `command:///command@bot`) - - Will overwrite existing commands. - - Arguments to the functions decorated will be (update, text) - - update: The update from telegram. :class:`pytgbot.api_types.receivable.updates.Update` - - text: The text after the command (:class:`str`), or None if there was no text. - Also see :def:`BotCommandsMixin._execute_command()` - - :param command: The command - :param function: The function to call. Will be called with the update and the text after the /command as args. - :return: Nothing - """ - for cmd in self._yield_commands(command): - if cmd in self.commands: - raise AssertionError( - 'Command function mapping is overwriting an existing command: {!r} would overwrite {}.'.format( - command, cmd - ) - ) - self.commands[cmd] = (function, exclusive) - # end for - # end def add_command - - def remove_command(self, command=None, function=None): - """ - :param command: remove them by command, e.g. `test`. - :param function: remove them by function - :return: - """ - if command: - for cmd in self._yield_commands(command): - if cmd not in self.commands: - continue - # end if - logger.debug("Deleting command {cmd!r}: {func}".format(cmd=cmd, func=self.commands[cmd])) - del self.commands[cmd] - # end for - # end if - if function: - for key, value in list(self.commands.items()): # list to allow deletion - func, exclusive = value - if func == function: - del self.commands[key] - # end if - # end for - # end if - if not command and not function: - raise ValueError("You have to specify a command or a function to remove. Or both.") - # end if - # end def remove_command - - def process_update(self, update): - """ - If the message is a registered command it will be called. - Arguments to the functions will be (update, text) - - update: The :class:`pytgbot.api_types.receivable.updates.Update` - - text: The text after the command, or None if there was no text. - Also see ._execute_command() - - :param update: incoming telegram update. - :return: nothing. - """ - assert isinstance(update, Update) - if update.message and update.message.text: - txt = update.message.text.strip() - func = None - if txt in self.commands: - logger.debug("Running command {input} (no text).".format(input=txt)) - func, exclusive = self.commands[txt] - try: - self.process_result(update, func(update, None)) - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception("Failed calling command {cmd!r} ({func}):".format(cmd=txt, func=func)) - # end try - elif " " in txt and txt.split(" ")[0] in self.commands: - cmd, text = tuple(txt.split(" ", maxsplit=1)) - logger.debug("Running command {cmd} (text={input!r}).".format(cmd=cmd, input=txt)) - func, exclusive = self.commands[cmd] - try: - self.process_result(update, func(update, text.strip())) - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception("Failed calling command {cmd!r} ({func}):".format(cmd=txt, func=func)) - # end try - else: - logging.debug("No fitting registered command function found.") - exclusive = False # so It won't abort. - # end if - if exclusive: - logger.debug( - "Command function {func!r} ({cmd}) marked exclusive, stopping further processing.".format( - func=func, cmd=cmd - ) - ) - return # not calling super().process_update(update) - # end if - # end if - super().process_update(update) - # end def process_update - - def do_startup(self): # pragma: no cover - super().do_startup() - # end if - - def _yield_commands(self, command): - """ - Yields possible strings with the given commands. - Like `/command` and `/command@bot`. - - :param command: The command to construct. - :return: - """ - for syntax in ( - "/{command}", # without username - "/{command}@{username}", # with username - "command:///{command}", # iOS represents commands like this - "command:///{command}@{username}" # iOS represents commands like this - ): - yield syntax.format(command=command, username=self.username) - # end for - # end def _yield_commands -# end class - - class StartupMixin(TeleflaskMixinBase, AbstractStartup): """ This mixin allows you to register functions to be run on bot/server start. diff --git a/tests/mixins/test_commands_mixin.py b/tests/mixins/test_commands_mixin.py index ca3e21b..1f2cb27 100644 --- a/tests/mixins/test_commands_mixin.py +++ b/tests/mixins/test_commands_mixin.py @@ -6,13 +6,13 @@ from luckydonaldUtils.logger import logging from pytgbot.api_types.receivable.updates import Update -from teleflask.server.mixins import BotCommandsMixin +from teleflask.server.mixins import UpdatesMixin __author__ = 'luckydonald' logger = logging.getLogger(__name__) -class BotCommandsMixinMockup(BotCommandsMixin): +class BotCommandsMixinMockup(UpdatesMixin): def __init__(self, callback_status, *args, **kwargs): self.callback_status = callback_status # extra dict for callbacks storage, to be checked by tests super().__init__(*args, **kwargs) From 899f68ed33b889613f8e02ae643c30aefad9cdcd Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 20:16:01 +0200 Subject: [PATCH 12/53] filter_rewrite: Trying to get the unittest working, by fixing the imports. --- teleflask/server/blueprints.py | 2 +- teleflask/server/extras.py | 2 +- teleflask/server/filters.py | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index 471bf57..e2a59c3 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -5,7 +5,7 @@ from .abstact import AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup, AbstractUpdates from .base import TeleflaskBase -# from .mixins import UpdatesMixin, MessagesMixin, BotCommandsMixin, StartupMixin +# from .mixins import UpdatesMixin, StartupMixin __author__ = 'luckydonald' logger = logging.getLogger(__name__) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 1856815..5919ce4 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -2,7 +2,7 @@ import os from .base import TeleflaskBase -from .mixins import StartupMixin, BotCommandsMixin, UpdatesMixin, MessagesMixin, RegisterBlueprintsMixin +from .mixins import StartupMixin, UpdatesMixin, RegisterBlueprintsMixin from luckydonaldUtils.logger import logging __author__ = 'luckydonald' diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 78b35d6..b93ce53 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -9,7 +9,6 @@ from pytgbot.api_types.receivable.updates import Update, Message -from teleflask import Teleflask, TBlueprint from ..messages import Message as OldSendableMessage from ..new_messages import SendableMessageBase @@ -159,11 +158,12 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords): """ Decorator to register a function to receive updates. Usage: + >>> from teleflask import Teleflask, TBlueprint >>> app = Teleflask(API_KEY) >>> @app.on_update @@ -280,11 +280,12 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None], *required_keywords): + def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords): """ Decorator to register a function to receive updates. Usage: + >>> from teleflask import Teleflask, TBlueprint >>> app = Teleflask(API_KEY) >>> @app.on_update @@ -466,7 +467,7 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, command, teleflask_or_tblueprint: Union[Teleflask, TBlueprint, None] = None): + def decorator(cls, command, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None] = None): """ Decorator to register a command. From ff41a227494605ced7018389c91a488861241c05 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 20:16:59 +0200 Subject: [PATCH 13/53] filter_rewrite: Trying to get the unittest working, by removing the docstrings value writes, those are readonly. --- teleflask/server/mixins.py | 57 -------------------------------------- 1 file changed, 57 deletions(-) diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py index 30e9805..5309743 100644 --- a/teleflask/server/mixins.py +++ b/teleflask/server/mixins.py @@ -50,67 +50,10 @@ def __init__(self, *args, **kwargs): # end def on_update = UpdateFilter.decorator - on_update.__doc__ = """ - Decorator to register a function to receive updates. - - Usage: - >>> @app.on_update - >>> def foo(update): - >>> assert isinstance(update, Update) - >>> # do stuff with the update - >>> # you can use app.bot to access the bot's messages functions - - :params required_keywords: Optionally: Specify attribute the message needs to have. - """ - on_message = MessageFilter.decorator - on_message.__doc__ = """ - Decorator to register a listener for a message event. - You can give optionally give one or multiple strings. The message will need to have all this elements. - If you leave them out, you'll get all messages, unfiltered. - - Usage: - >>> @app.on_message - >>> def foo(update, msg): - >>> # all messages - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent any message!") - - >>> @app.on_message("text") - >>> def foo(update, msg): - >>> # all messages which are text messages (have the text attribute) - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent text!") - - >>> @app.on_message("photo", "sticker") - >>> def foo(update, msg): - >>> # all messages which are photos (have the photo attribute) and have a caption - >>> assert isinstance(update, Update) - >>> assert isinstance(msg, Message) - >>> app.bot.send_message(msg.chat.id, "you sent a photo with caption!") - - - :params required_keywords: Optionally: Specify attribute the message needs to have. - """ - on_command = CommandFilter.decorator - on_command.__doc__ = """ - Decorator to register a command. - Usage: - >>> @app.command("foo") - >>> def foo(update, text): - >>> assert isinstance(update, Update) - >>> app.bot.send_message(update.message.chat.id, "bar:" + text) - - If you now write "/foo hey" to the bot, it will reply with "bar:hey" - - :param command: the string of a command - """ command = on_command - command.__doc__ = "Alias of @on_command:\n\n" + on_command.__doc__ def register_handler(self, event_handler: Filter): """ From 09d94e8c871037a73b80ff83b851f88a7d6d21f0 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sun, 28 Jun 2020 20:28:26 +0200 Subject: [PATCH 14/53] filter_rewrite: Make sure the abstracts are imported. --- teleflask/server/abstact.py | 9 +++++++-- teleflask/server/mixins.py | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/teleflask/server/abstact.py b/teleflask/server/abstact.py index 4ffdbcb..69e4393 100644 --- a/teleflask/server/abstact.py +++ b/teleflask/server/abstact.py @@ -19,12 +19,17 @@ def on_update(self, *required_keywords): # end def @abstractmethod - def add_update_listener(self, function, required_keywords=None): + def register_handler(self, handler): pass # end def @abstractmethod - def remove_update_listener(self, func): + def remove_handler(self, handler): + pass + # end def + + @abstractmethod + def remove_handled_func(self, func): pass # end def # end class diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py index 5309743..97115ad 100644 --- a/teleflask/server/mixins.py +++ b/teleflask/server/mixins.py @@ -82,11 +82,11 @@ def register_handler(self, event_handler: Filter): logging.debug("adding handler to listeners") self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND return event_handler - # end def add_update_listener + # end def - def remove_update_listener(self, event_handler): + def remove_handler(self, event_handler): """ - Removes an function from the update listener list. + Removes an handler from the update listener list. No error will be raised if it is already registered. In that case a warning will be logged, but noting else will happen. @@ -101,6 +101,18 @@ def remove_update_listener(self, event_handler): # end if # end def + def remove_handled_func(self, func): + """ + Removes an function from the update listener list. + No error will be raised if it is no longer registered. In that case noting else will happen. + + :param function: The function to remove + :return: the function, unmodified + """ + listerner: Filter + self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] + # end def + def process_update(self, update): """ Iterates through self.update_listeners, and calls them with (update, app). From a156f05de1c35cb83b7e4210916c68762c867de7 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 01:26:14 +0200 Subject: [PATCH 15/53] filter_rewrite: Trying to get the unittest working (2) --- tests/mixins/test_commands_mixin.py | 29 +++++++++++++++-------------- tests/mixins/test_updates_mixin.py | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/mixins/test_commands_mixin.py b/tests/mixins/test_commands_mixin.py index 1f2cb27..de8f8f6 100644 --- a/tests/mixins/test_commands_mixin.py +++ b/tests/mixins/test_commands_mixin.py @@ -7,6 +7,7 @@ from pytgbot.api_types.receivable.updates import Update from teleflask.server.mixins import UpdatesMixin +from teleflask.server.filters import Filter, CommandFilter __author__ = 'luckydonald' logger = logging.getLogger(__name__) @@ -16,7 +17,6 @@ class BotCommandsMixinMockup(UpdatesMixin): def __init__(self, callback_status, *args, **kwargs): self.callback_status = callback_status # extra dict for callbacks storage, to be checked by tests super().__init__(*args, **kwargs) - # end def def process_result(self, update, result): @@ -39,6 +39,7 @@ def username(self): # end class +# noinspection DuplicatedCode class SomeUpdatesMixinTestCase(unittest.TestCase): """ `@app.on_update` decorator @@ -57,7 +58,7 @@ def setUp(self): def tearDown(self): print("tearDown") del self.callbacks_status - del self.mixin.commands + del self.mixin.update_listeners del self.mixin # end def @@ -148,8 +149,8 @@ def tearDown(self): }) def test__on_command__command(self): - self.assertNotIn("on_command", self.callbacks_status, "no data => not executed yet") - self.assertDictEqual(self.mixin.commands, {}, "empty listener list => not added yet") + self.assertListEqual(self.mixin.update_listeners, [], "empty listener list => not added yet") + self.assertEqual(0, len(self.mixin.update_listeners), 'has update_listerner now') @self.mixin.command('test') def on_command__callback(update, text): @@ -158,20 +159,21 @@ def on_command__callback(update, text): # end def self.assertIsNotNone(on_command__callback, "function is not None => decorator returned something") - self.assertIn('/test', self.mixin.commands.keys(), 'command /test in dict keys => listener added') - self.assertIn('/test@UnitTest', self.mixin.commands, 'command /test@{bot} in dict keys => listener added') - self.assertEqual(self.mixin.commands['/test'], (on_command__callback, False), 'command /test has correct function') - self.assertEqual(self.mixin.commands['/test@UnitTest'], (on_command__callback, False), 'command /test has correct function') + self.assertEqual(1, len(self.mixin.update_listeners), 'has update_listerner now') + listener = self.mixin.update_listeners[0] + self.assertIsInstance(listener, Filter) + self.assertIsInstance(listener, CommandFilter) + self.assertEqual('test', listener.command) + self.assertIn('/test', listener.command_strings, 'command /test in dict keys => listener added') + self.assertIn('/test@UnitTest', listener.command_strings, 'command /test@{bot} in dict keys => listener added') self.assertNotIn("on_command", self.callbacks_status, "no data => not executed yet") self.mixin.process_update(self.command_test) self.assertIn("on_command", self.callbacks_status, "has data => did execute") - self.assertEqual(self.callbacks_status["on_command"], self.command_test, - "has update => successfully executed given function") + self.assertEqual(self.callbacks_status["on_command"], self.command_test, "has update => successfully executed given function") self.assertIn("processed_update", self.callbacks_status, "executed result collection") - self.assertEqual(self.callbacks_status["processed_update"], - (self.command_test, self.command_test)) # update, result + self.assertEqual(self.callbacks_status["processed_update"], self.command_test, self.command_test) # update, result # end def def test__on_command__command_reply(self): @@ -188,8 +190,7 @@ def on_command__callback(update, text): self.assertIsNotNone(on_command__callback, "function is not None => decorator returned something") self.assertIn('/test', self.mixin.commands.keys(), 'command /test in dict keys => listener added') self.assertIn('/test@UnitTest', self.mixin.commands, 'command /test@{bot} in dict keys => listener added') - self.assertEqual(self.mixin.commands['/test'], (on_command__callback, False), - 'command /test has correct function') + self.assertEqual(self.mixin.commands['/test'], (on_command__callback, False), 'command /test has correct function') self.assertEqual(self.mixin.commands['/test@UnitTest'], (on_command__callback, False), 'command /test has correct function') self.assertNotIn("on_command", self.callbacks_status, "no data => not executed yet") diff --git a/tests/mixins/test_updates_mixin.py b/tests/mixins/test_updates_mixin.py index a07f17e..b2dbbdf 100644 --- a/tests/mixins/test_updates_mixin.py +++ b/tests/mixins/test_updates_mixin.py @@ -260,7 +260,7 @@ def add_update_listener__callback(update): self.assertFalse(self.mixin.update_listeners, "empty listener list => still not added") - self.mixin.add_update_listener(add_update_listener__callback) + self.mixin.on_update(add_update_listener__callback) self.assertIn(add_update_listener__callback, self.mixin.update_listeners, "function in list => adding worked") From 51fa751ace5668603bff1f10b4b8bec54f68691c Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 01:35:29 +0200 Subject: [PATCH 16/53] filter_rewrite: Migrated RegisterBlueprintsMixin, StartupMixin and UpdatesMixin into Teleflask. --- teleflask/server/extras.py | 181 +++++++++++++++++++++- teleflask/server/mixins.py | 297 ------------------------------------- 2 files changed, 179 insertions(+), 299 deletions(-) delete mode 100644 teleflask/server/mixins.py diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 5919ce4..8803b08 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- import os +from pytgbot.api_types.receivable.updates import Update + +from exceptions import AbortProcessingPlease from .base import TeleflaskBase -from .mixins import StartupMixin, UpdatesMixin, RegisterBlueprintsMixin +from .filters import MessageFilter, UpdateFilter, CommandFilter, NoMatch, Filter from luckydonaldUtils.logger import logging __author__ = 'luckydonald' @@ -10,7 +13,7 @@ logger = logging.getLogger(__name__) -class Teleflask(StartupMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): +class BotServer(TeleflaskBase): """ This is the full package, including all provided mixins. @@ -97,12 +100,184 @@ def __init__( :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot """ + + self.startup_listeners = list() + self.startup_already_run = False + + self.blueprints = {} + self._blueprint_order = [] + super().__init__( api_key=api_key, app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, disable_setting_webhook_route=disable_setting_webhook_route, return_python_objects=return_python_objects, ) + def register_handler(self, event_handler: Filter): + """ + Adds an listener for any update type. + You provide a Filter for them as parameter, it also contains the function. + No error will be raised if it is already registered. In that case a warning will be logged, + but nothing else will happen, and the function is not added. + + Examples: + >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) + # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. + + >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) + # calls func(msg) for all updates which are inline queries (have the inline_query attribute) + + >>> register_handler(UpdateFilter(func, required_keywords=None)) + >>> register_handler(UpdateFilter(func)) + # allows all messages. + + :param function: The function to call. Will be called with the update and the message as arguments + :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. + Must be a list. + :return: the function, unmodified + """ + + logging.debug("adding handler to listeners") + self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND + return event_handler + # end def + + def remove_handler(self, event_handler): + """ + Removes an handler from the update listener list. + No error will be raised if it is already registered. In that case a warning will be logged, + but noting else will happen. + + + :param function: The function to remove + :return: the function, unmodified + """ + try: + self.update_listeners.remove(event_handler) + except ValueError: + logger.warning("listener already removed.") + # end if + # end def + + def remove_handled_func(self, func): + """ + Removes an function from the update listener list. + No error will be raised if it is no longer registered. In that case noting else will happen. + + :param function: The function to remove + :return: the function, unmodified + """ + listerner: Filter + self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] + # end def + + def process_update(self, update): + """ + Iterates through self.update_listeners, and calls them with (update, app). + + No try catch stuff is done, will fail instantly, and not process any remaining listeners. + + :param update: incoming telegram update. + :return: nothing. + """ + assert isinstance(update, Update) # Todo: non python objects + filter: Filter + for filter in self.update_listeners: + try: + # check if the Filter matches + match_result = filter.match(update) + # call the handler + result = filter.call_handler(update=update, match_result=match_result) + # send the message + self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() + except NoMatch as e: + logger.debug(f'not matching filter {filter!s}.') + except AbortProcessingPlease as e: + logger.debug('Asked to stop processing updates.') + if e.return_value: + self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() + # end if + return # not calling super().process_update(update) + except Exception: + logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") + # end try + # end for + super().process_update(update) + # end def + + def on_startup(self, func): + """ + Decorator to register a function to receive updates. + + Usage: + >>> @app.on_startup + >>> def foo(): + >>> print("doing stuff on boot") + + """ + return self.add_startup_listener(func) + # end def + + def add_startup_listener(self, func): + """ + Usage: + >>> def foo(): + >>> print("doing stuff on boot") + >>> app.add_startup_listener(foo) + + :param func: + :return: + """ + if func not in self.startup_listeners: + self.startup_listeners.append(func) + if self.startup_already_run: + func() + # end if + else: + logger.warning("listener already added.") + # end if + return func + # end def + + def remove_startup_listener(self, func): + if func in self.startup_listeners: + self.startup_listeners.remove(func) + else: + logger.warning("listener already removed.") + # end if + return func + # end def + + def register_tblueprint(self, tblueprint, **options): + """ + Registers a `TBlueprint` on the application. + """ + first_registration = False + if tblueprint.name in self.blueprints: + assert self.blueprints[tblueprint.name] is tblueprint, \ + 'A teleflask blueprint\'s name collision occurred between %r and ' \ + '%r. Both share the same name "%s". TBlueprints that ' \ + 'are created on the fly need unique names.' % \ + (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) + else: + self.blueprints[tblueprint.name] = tblueprint + self._blueprint_order.append(tblueprint) + first_registration = True + tblueprint.register(self, options, first_registration) + # end def + + def iter_blueprints(self): + """ + Iterates over all blueprints by the order they were registered. + """ + return iter(self._blueprint_order) + # end def + + on_update = UpdateFilter.decorator + on_message = MessageFilter.decorator + on_command = CommandFilter.decorator + + command = on_command # end def # end class @@ -158,3 +333,5 @@ def _start_proxy_process(self): telegram_proxy_process.start() # end def # end class + +Teleflask = BotServer diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py deleted file mode 100644 index 97115ad..0000000 --- a/teleflask/server/mixins.py +++ /dev/null @@ -1,297 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -from abc import abstractmethod -from collections import OrderedDict -from typing import List - -from pytgbot.api_types.receivable.updates import Update - -from .filters import UpdateFilter, Filter, NoMatch, MessageFilter, CommandFilter -from ..exceptions import AbortProcessingPlease -from .abstact import AbstractUpdates, AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup -from .base import TeleflaskMixinBase - -__author__ = 'luckydonald' -__all__ = ['RegisterBlueprintsMixin', 'StartupMixin', 'UpdatesMixin'] -logger = logging.getLogger(__name__) - - -class UpdatesMixin(TeleflaskMixinBase, AbstractUpdates): - """ - This mixin allows you to register functions to listen on updates. - - Functions added to your app: - - `@app.on_update` decorator - `app.add_update_listener(func)` - `app.remove_update_listener(func)` - - The registered function will be called with an `pytgbot.api_types.receivable.updates.Update` update parameter. - - So you could use it like this: - - >>> @app.on_update - >>> def foobar(update): - >>> assert isinstance(update, pytgbot.api_types.receivable.updates.Update) - >>> pass - - Also you can filter out Updates by specifying which attributes must be non-empty, like this: - - >>> @app.on_update("inline_query") - >>> def foobar2(update): - >>> assert update.inline_query - >>> # only get inline queries. - - """ - def __init__(self, *args, **kwargs): - self.update_listeners: List[Filter] = [] - - super(UpdatesMixin, self).__init__(*args, **kwargs) - # end def - - on_update = UpdateFilter.decorator - on_message = MessageFilter.decorator - on_command = CommandFilter.decorator - - command = on_command - - def register_handler(self, event_handler: Filter): - """ - Adds an listener for any update type. - You provide a Filter for them as parameter, it also contains the function. - No error will be raised if it is already registered. In that case a warning will be logged, - but nothing else will happen, and the function is not added. - - Examples: - >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) - # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. - - >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) - # calls func(msg) for all updates which are inline queries (have the inline_query attribute) - - >>> register_handler(UpdateFilter(func, required_keywords=None)) - >>> register_handler(UpdateFilter(func)) - # allows all messages. - - :param function: The function to call. Will be called with the update and the message as arguments - :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. - Must be a list. - :return: the function, unmodified - """ - - logging.debug("adding handler to listeners") - self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND - return event_handler - # end def - - def remove_handler(self, event_handler): - """ - Removes an handler from the update listener list. - No error will be raised if it is already registered. In that case a warning will be logged, - but noting else will happen. - - - :param function: The function to remove - :return: the function, unmodified - """ - try: - self.update_listeners.remove(event_handler) - except ValueError: - logger.warning("listener already removed.") - # end if - # end def - - def remove_handled_func(self, func): - """ - Removes an function from the update listener list. - No error will be raised if it is no longer registered. In that case noting else will happen. - - :param function: The function to remove - :return: the function, unmodified - """ - listerner: Filter - self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] - # end def - - def process_update(self, update): - """ - Iterates through self.update_listeners, and calls them with (update, app). - - No try catch stuff is done, will fail instantly, and not process any remaining listeners. - - :param update: incoming telegram update. - :return: nothing. - """ - assert isinstance(update, Update) # Todo: non python objects - filter: Filter - for filter in self.update_listeners: - try: - # check if the Filter matches - match_result = filter.match(update) - # call the handler - result = filter.call_handler(update=update, match_result=match_result) - # send the message - self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() - except NoMatch as e: - logger.debug(f'not matching filter {filter!s}.') - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") - # end try - # end for - super().process_update(update) - # end def process_update - - def do_startup(self): # pragma: no cover - super().do_startup() - # end def -# end class - - -class StartupMixin(TeleflaskMixinBase, AbstractStartup): - """ - This mixin allows you to register functions to be run on bot/server start. - - Functions added to your app: - - `@app.on_startup` decorator - `app.add_startup_listener(func)` - `app.remove_startup_listener(func)` - - The registered function will be called on either the server start, or as soon as registered. - - So you could use it like this: - - >>> @app.on_startup - >>> def foobar(): - >>> print("doing stuff on boot") - """ - def __init__(self, *args, **kwargs): - self.startup_listeners = list() - self.startup_already_run = False - super(StartupMixin, self).__init__(*args, **kwargs) - # end def - - def on_startup(self, func): - """ - Decorator to register a function to receive updates. - - Usage: - >>> @app.on_startup - >>> def foo(): - >>> print("doing stuff on boot") - - """ - return self.add_startup_listener(func) - # end def - - def add_startup_listener(self, func): - """ - Usage: - >>> def foo(): - >>> print("doing stuff on boot") - >>> app.add_startup_listener(foo) - - :param func: - :return: - """ - if func not in self.startup_listeners: - self.startup_listeners.append(func) - if self.startup_already_run: - func() - # end if - else: - logger.warning("listener already added.") - # end if - return func - # end def - - def remove_startup_listener(self, func): - if func in self.startup_listeners: - self.startup_listeners.remove(func) - else: - logger.warning("listener already removed.") - # end if - return func - # end def - - def do_startup(self): - """ - Iterates through self.startup_listeners, and calls them. - - No try catch stuff is done, will fail instantly, and not process any remaining listeners. - - :param update: - :return: the last non-None result any listener returned. - """ - for listener in self.startup_listeners: - try: - listener() - except Exception: - logger.exception("Error executing the startup listener {func}.".format(func=listener)) - raise - # end if - # end for - self.startup_already_run = True - super().do_startup() - # end def - - def process_update(self, update): # pragma: no cover - super().process_update(update) - # end if -# end class - - -class RegisterBlueprintsMixin(TeleflaskMixinBase, AbstractRegisterBlueprints): - def __init__(self, *args, **kwargs) -> None: - #: all the attached blueprints in a dictionary by name. Blueprints - #: can be attached multiple times so this dictionary does not tell - #: you how often they got attached. - #: - #: .. versionadded:: 2.0.0 - self.blueprints = {} - self._blueprint_order = [] - super().__init__(*args, **kwargs) - # end def - - def register_tblueprint(self, tblueprint, **options): - """Registers a `TBlueprint` on the application. - - .. versionadded:: 2.0.0 - """ - first_registration = False - if tblueprint.name in self.blueprints: - assert self.blueprints[tblueprint.name] is tblueprint, \ - 'A teleflask blueprint\'s name collision occurred between %r and ' \ - '%r. Both share the same name "%s". TBlueprints that ' \ - 'are created on the fly need unique names.' % \ - (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) - else: - self.blueprints[tblueprint.name] = tblueprint - self._blueprint_order.append(tblueprint) - first_registration = True - tblueprint.register(self, options, first_registration) - - def iter_blueprints(self): - """Iterates over all blueprints by the order they were registered. - - .. versionadded:: 0.11 - """ - return iter(self._blueprint_order) - # end def - - @abstractmethod - def process_update(self, update): - return super().process_update(update) - # end def - - @abstractmethod - def do_startup(self): - return super().do_startup() - # end def -# end class From 3a15568f4c7c0d25a17c29b60a732dc71a6f27d9 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 01:37:56 +0200 Subject: [PATCH 17/53] filter_rewrite: Removed TeleflaskMixinBase, we don't need those any longer. --- teleflask/server/base.py | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/teleflask/server/base.py b/teleflask/server/base.py index 6201602..1583ed7 100644 --- a/teleflask/server/base.py +++ b/teleflask/server/base.py @@ -17,38 +17,7 @@ _self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. -class TeleflaskMixinBase(metaclass=abc.ABCMeta): - @abc.abstractmethod - def process_update(self, update): - """ - This method is called from the flask webserver. - - Any Mixin implementing must call super().process_update(update). - So catch exceptions in your mixin's code. - - :param update: The Telegram update - :type update: pytgbot.api_types.receivable.updates.Update - :return: - """ - return - # end def - - @abc.abstractmethod - def do_startup(self): - """ - This method is called on bot/server startup. - To be precise, `TeleflaskBase.init_app()` will call it when done. - - Any Mixin implementing **must** call `super().do_startup(update)`. - So catch any and all exceptions in your mixin's own code. - :return: - """ - return - # end def -# end class - - -class TeleflaskBase(TeleflaskMixinBase): +class TeleflaskBase(object): VERSION = VERSION __version__ = VERSION From 6552dc06282663fa8db8a856d4a2aba93cd0d38f Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 01:55:42 +0200 Subject: [PATCH 18/53] filter_rewrite: Changed Teleflask to BotServer, and TeleflaskBase to be now Teleflask --- teleflask/server/base.py | 736 ------------------------------------ teleflask/server/extras.py | 747 ++++++++++++++++++++++++++++++++++++- 2 files changed, 743 insertions(+), 740 deletions(-) diff --git a/teleflask/server/base.py b/teleflask/server/base.py index 1583ed7..a6ffe29 100644 --- a/teleflask/server/base.py +++ b/teleflask/server/base.py @@ -8,742 +8,6 @@ from luckydonaldUtils.logger import logging from luckydonaldUtils.exceptions import assert_type_or_raise -from .. import VERSION -from .utilities import _class_self_decorate __author__ = 'luckydonald' logger = logging.getLogger(__name__) - -_self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. - - -class TeleflaskBase(object): - VERSION = VERSION - __version__ = VERSION - - def __init__(self, api_key, app=None, blueprint=None, - # FlaskTgBot kwargs: - hostname=None, hostpath=None, hookpath="/income/{API_KEY}", - debug_routes=False, disable_setting_webhook_route=None, disable_setting_webhook_telegram=None, - # pytgbot kwargs: - return_python_objects=True): - """ - A new Teleflask(Base) object. - - :param api_key: The key for the telegram bot api. - :type api_key: str - - :param app: The flask app if you don't like to call :meth:`init_app` yourself. - :type app: flask.Flask | None - - :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. - Use if you don't like to call :meth:`init_app` yourself. - If not set, but `app` is, it will register any routes to the `app` itself. - :type blueprint: flask.Blueprint | None - - :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. - Specify the path with `hostpath` - Used to calculate the webhook url. - Also configurable via environment variables. See calculate_webhook_url() - :type hostname: None|str - - :param hostpath: The host url the base of where this bot is reachable. - Examples: None (for root of server) or "/bot2" - Note: The webhook will only be set on initialisation. - Also configurable via environment variables. See calculate_webhook_url() - :type hostpath: None|str - - :param hookpath: The endpoint of the telegram webhook. - Defaults to "/income/" - Note: The webhook will only be set on initialisation. - Also configurable via environment variables. See calculate_webhook_url() - :type hookpath: str - - :param debug_routes: Add extra url endpoints usefull for debugging. See setup_routes(...) - :type debug_routes: bool - - :param disable_setting_webhook_telegram: Disable updating the telegram webhook when starting. - Useful for unit tests. Defaults to the app's config - DISABLE_SETTING_ROUTE_WEBHOOK or False. - :type disable_setting_webhook_telegram: None|bool - - :param disable_setting_webhook_route: Disable creation of the webhook route. - Usefull if you don't need to listen for incomming events. - :type disable_setting_webhook_route: None|bool - - :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot - """ - self.__api_key = api_key - self._bot = None # will be set in self.init_bot() - self.app = None # will be filled out by self.init_app(...) - self.blueprint = None # will be filled out by self.init_app(...) - self._return_python_objects = return_python_objects - self.__webhook_url = None # will be filled out by self.calculate_webhook_url() in self.init_app(...) - self.hostname = hostname # e.g. "example.com:443" - self.hostpath = hostpath - self.hookpath = hookpath - - if disable_setting_webhook_route is None: - try: - self.disable_setting_webhook_route = self.app.config["DISABLE_SETTING_WEBHOOK_ROUTE"] - except (AttributeError, KeyError): - logger.debug( - 'disable_setting_webhook_route is None and app is None or app has no DISABLE_SETTING_WEBHOOK_ROUTE' - ' config. Assuming False.' - ) - self.disable_setting_webhook_route = False - # end try - else: - self.disable_setting_webhook_route = disable_setting_webhook_route - # end if - - if disable_setting_webhook_telegram is None: - try: - self.disable_setting_webhook_telegram = self.app.config["DISABLE_SETTING_WEBHOOK_TELEGRAM"] - except (AttributeError, KeyError): - logger.debug( - 'disable_setting_webhook_telegram is None and app is None or app has no DISABLE_SETTING_WEBHOOK_TELEGRAM' - ' config. Assuming False.' - ) - self.disable_setting_webhook_telegram = False - # end try - else: - self.disable_setting_webhook_telegram = disable_setting_webhook_telegram - # end if - - if app or blueprint: # if we have an app or flask blueprint call init_app for adding the routes, which calls init_bot as well. - self.init_app(app, blueprint=blueprint, debug_routes=debug_routes) - elif api_key: # otherwise if we have at least an api key, call init_bot. - self.init_bot() - # end if - - self.update_listener = list() - self.commands = dict() - # end def - - def init_bot(self): - """ - Creates the bot, and retrieves information about the bot itself (username, user_id) from telegram. - - :return: - """ - if not self._bot: # so you can manually set it before calling `init_app(...)`, - # e.g. a mocking bot class for unit tests - self._bot = Bot(self._api_key, return_python_objects=self._return_python_objects) - elif self._bot.return_python_objects != self._return_python_objects: - # we don't have the same setting as the given one - raise ValueError("The already set bot has return_python_objects {given}, but we have {our}".format( - given=self._bot.return_python_objects, our=self._return_python_objects - )) - # end def - myself = self._bot.get_me() - if self._bot.return_python_objects: - self._user_id = myself.id - self._username = myself.username - else: - self._user_id = myself["result"]["id"] - self._username = myself["result"]["username"] - # end if - # end def - - def init_app(self, app, blueprint=None, debug_routes=False): - """ - Gives us access to the flask app (and optionally provide a Blueprint), - where we will add a routing endpoint for the telegram webhook. - - Calls `self.init_bot()`, calculates and sets webhook routes, and finally runs `self.do_startup()`. - - :param app: the :class:`flask.Flask` app - :type app: flask.Flask - - :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. - If `None` was provided, it will register any routes to the `app` itself. - Note: this is NOT a `TBlueprint`, but a regular `flask` one! - :type blueprint: flask.Blueprint | None - - :param debug_routes: Add extra url endpoints, useful for debugging. See setup_routes(...) - :type debug_routes: bool - - :return: None - :rtype: None - """ - self.app = app - self.blueprint = blueprint - self.init_bot() - hookpath, self.__webhook_url = self.calculate_webhook_url(hostname=self.hostname, hostpath=self.hostpath, hookpath=self.hookpath) - self.setup_routes(hookpath=hookpath, debug_routes=debug_routes) - self.set_webhook_telegram() # this will set the webhook in the bot api. - self.do_startup() # this calls the startup listeners of extending classes. - # end def - - def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/{API_KEY}"): - """ - Calculates the webhook url. - Please note, this doesn't change any registered view function! - Returns a tuple of the hook path (the url endpoint for your flask app) and the full webhook url (for telegram) - Note: Both can include the full API key, as replacement for ``{API_KEY}`` in the hookpath. - - :Example: - - Your bot is at ``https://example.com:443/bot2/``, - you want your flask to get the updates at ``/tg-webhook/{API_KEY}``. - This means Telegram will have to send the updates to ``https://example.com:443/bot2/tg-webhook/{API_KEY}``. - - You now would set - hostname = "example.com:443", - hostpath = "/bot2", - hookpath = "/tg-webhook/{API_KEY}" - - Note: Set ``hostpath`` if you are behind a reverse proxy, and/or your flask app root is *not* at the web server root. - - - :param hostname: A hostname. Without the protocol. - Examples: "localhost", "example.com", "example.com:443" - If None (default), the hostname comes from the URL_HOSTNAME environment variable, or from http://ipinfo.io if that fails. - :param hostpath: The path after the hostname. It must start with a slash. - Use this if you aren't at the root at the server, i.e. use url_rewrite. - Example: "/bot2" - If None (default), the path will be read from the URL_PATH environment variable, or "" if that fails. - :param hookpath: Template for the route of incoming telegram webhook events. Must start with a slash. - The placeholder {API_KEY} will replaced with the telegram api key. - Note: This doesn't change any routing. You need to update any registered @app.route manually! - :return: the tuple of calculated (hookpath, webhook_url). - :rtype: tuple - """ - import os, requests - # # - # # try to fill out empty arguments - # # - if not hostname: - hostname = os.getenv('URL_HOSTNAME', None) - # end if - if hostpath is None: - hostpath = os.getenv('URL_PATH', "") - # end if - if not hookpath: - hookpath = "/income/{API_KEY}" - # end if - # # - # # check if the path looks at least a bit valid - # # - logger.debug("hostname={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}".format( - hostn=hostname, hostp=hostpath, hookp=hookpath - )) - if hostname: - if hostname.endswith("/"): - raise ValueError("hostname can't end with a slash: {value}".format(value=hostname)) - # end if - if hostname.startswith("https://"): - hostname = hostname[len("https://"):] - logger.warning("Automatically removed \"https://\" from hostname. Don't include it.") - # end if - if hostname.startswith("http://"): - raise ValueError("Don't include the protocol ('http://') in the hostname. " - "Also telegram doesn't support http, only https.") - # end if - else: # no hostname - info = requests.get('http://ipinfo.io').json() - hostname = str(info["ip"]) - logger.warning("URL_HOSTNAME env not set, falling back to ip address: {ip!r}".format(ip=hostname)) - # end if - if not hostpath == "" and not hostpath.startswith("/"): - logger.info("hostpath didn't start with a slash: {value!r} Will be added automatically".format(value=hostpath)) - hostpath = "/" + hostpath - # end def - if not hookpath.startswith("/"): - raise ValueError("hookpath must start with a slash: {value!r}".format(value=hostpath)) - # end def - hookpath = hookpath.format(API_KEY=self._api_key) - if not hostpath: - logger.info("URL_PATH is not set.") - webhook_url = "https://{hostname}{hostpath}{hookpath}".format(hostname=hostname, hostpath=hostpath, hookpath=hookpath) - logger.debug("host={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}, hookurl={url!r}".format( - hostn=hostname, hostp=hostpath, hookp=hookpath, url=webhook_url - )) - return hookpath, webhook_url - # end def - - @property - def bot(self): - """ - :return: Returns the bot - :rtype: Bot - """ - return self._bot - # end def - @property - def username(self): - """ - Returns the name of the registerd bot - :return: - """ - return self._username - # end def - - @property - def user_id(self): - return self._user_id - # end def - - @property - def _webhook_url(self): - return self.__webhook_url - # end def - - @property - def _api_key(self): - return self.__api_key - # end def - - def set_webhook_telegram(self): - """ - Sets the telegram webhook. - Checks Telegram if there is a webhook set, and if it needs to be changed. - - :return: - """ - assert isinstance(self.bot, Bot) - existing_webhook = self.bot.get_webhook_info() - - if self._return_python_objects: - from pytgbot.api_types.receivable import WebhookInfo - assert isinstance(existing_webhook, WebhookInfo) - webhook_url = existing_webhook.url - webhook_meta = existing_webhook.to_array() - else: - webhook_url = existing_webhook["result"]["url"] - webhook_meta = existing_webhook["result"] - # end def - del existing_webhook - logger.info("Last webhook pointed to {url!r}.\nMetadata: {hook}".format( - url=self.hide_api_key(webhook_url), hook=self.hide_api_key("{!r}".format(webhook_meta)) - )) - if webhook_url == self._webhook_url: - logger.info("Webhook set correctly. No need to change.") - else: - if not self.disable_setting_webhook_telegram: - logger.info("Setting webhook to {url}".format(url=self.hide_api_key(self._webhook_url))) - logger.debug(self.bot.set_webhook(url=self._webhook_url)) - else: - logger.info( - "Would set webhook to {url!r}, but action is disabled by DISABLE_SETTING_TELEGRAM_WEBHOOK config " - "or disable_setting_webhook_telegram argument.".format(url=self.hide_api_key(self._webhook_url)) - ) - # end if - # end if - # end def - - def do_startup(self): - """ - This code is executed after server boot. - - Sets the telegram webhook (see :meth:`set_webhook_telegram(self)`) - and calls `super().do_setup()` for the superclass (e.g. other mixins) - - :return: - """ - super().do_startup() # do more registered startup actions. - # end def - - def hide_api_key(self, string): - """ - Replaces the api key with "" in a given string. - - Note: if the given object is no string, :meth:`str(object)` is called first. - - :param string: The str which can contain the api key. - :return: string with the key replaced - """ - if not isinstance(string, str): - string = str(string) - # end if - return string.replace(self._api_key, "") - # end def - - def jsonify(self, func): - """ - Decorator. - Converts the returned value of the function to json, and sets mimetype to "text/json". - It will also automatically replace the api key where found in the output with "". - - Usage: - @app.route("/foobar") - @app.jsonify - def foobar(): - return {"foo": "bar"} - # end def - # app is a instance of this class - - - There are some special cases to note: - - - :class:`tuple` is interpreted as (data, status). - E.g. - return {"error": "not found"}, 404 - would result in a 404 page, with json content {"error": "not found"} - - - :class:`flask.Response` will be returned directly, except it is in a :class:`tuple` - In that case the status code of the returned response will be overwritten by the second tuple element. - - - :class:`TgBotApiObject` will be converted to json too. Status code 200. - - - An exception will be returned as `{"error": "exception raised"}` with status code 503. - - - :param func: the function to wrap - :return: the wrapped function returning json responses. - """ - from functools import wraps - from flask import Response - import json - logger.debug("func: {}".format(func)) - - @wraps(func) - def jsonify_inner(*args, **kwargs): - try: - result = func(*args, **kwargs) - except: - logger.exception("failed executing {name}.".format(name=func.__name__), exc_info=True) - result = {"error": "exception raised"}, 503 - # end def - status = None # will be 200 if not otherwise changed - if isinstance(result, tuple): - response, status = result - else: - response = result - # end if - if isinstance(response, Response): - if status: - response.status_code = status - # end if - return response - # end if - if isinstance(response, TgBotApiObject): - response = response.to_array() - # end if - response = json.dumps(response) - # end if - assert isinstance(response, str) - response_kwargs = {} - response_kwargs.setdefault("mimetype", "text/json") - if status: - response_kwargs["status"] = status - # end if - res = Response(self.hide_api_key(response), **response_kwargs) - logger.debug("returning: {}".format(res)) - return res - # end def inner - return jsonify_inner - # end def - - @_self_jsonify - def view_exec(self, api_key, command): - """ - Issue commands. E.g. /exec/TELEGRAM_API_KEY/getMe - - :param api_key: gets checked, so you can't just execute commands. - :param command: the actual command - :return: - """ - if api_key != self._api_key: - error_msg = "Wrong API key: {wrong_key!r}".format(wrong_key=api_key) - logger.warning(error_msg) - return {"status": "error", "message": error_msg, "error_code": 403}, 403 - # end if - from flask import request - from pytgbot.exceptions import TgApiServerException - logger.debug("COMMAND: {cmd}, ARGS: {args}".format(cmd=command, args=request.args)) - try: - res = self.bot.do(command, **request.args) - if self._return_python_objects: - return res.to_array() - else: - return res - # end if - except TgApiServerException as e: - return {"status": "error", "message": e.description, "error_code": e.error_code}, e.error_code - # end try - # end def - - @_self_jsonify - def view_status(self): - """ - Returns the status about the bot's webhook. - - :return: webhook info - """ - try: - res = self.bot.get_webhook_info() # TODO: fix to work with return_python_objects==False - return res.to_array() - except TgApiServerException as e: - return {"status": "error", "message": e.description, "error_code": e.error_code}, e.error_code - # end try - - @_self_jsonify - def view_updates(self): - """ - This processes incoming telegram updates. - - :return: - """ - from pprint import pformat - from flask import request - - logger.debug("INCOME:\n{}\n\nHEADER:\n{}".format( - pformat(request.get_json()), - request.headers if hasattr(request, "headers") else None - )) - update = TGUpdate.from_array(request.get_json()) - try: - result = self.process_update(update) - except Exception as e: - logger.exception("process_update()") - result = {"status": "error", "message": str(e)} - result = result if result else {"status": "probably ok"} - logger.info("returning result: {}".format(result)) - return result - # end def - - @_self_jsonify - def view_host_info(self): - """ - Get infos about your host, like IP etc. - :return: - """ - import socket - import requests - info = requests.get('http://ipinfo.io').json() - info["host"] = socket.gethostname() - info["version"] = self.VERSION - return info - # end def - - @_self_jsonify - def view_routes_info(self): - """ - Get infos about your host, like IP etc. - :return: - """ - from werkzeug.routing import Rule - routes = [] - for rule in self.app.url_map.iter_rules(): - assert isinstance(rule, Rule) - routes.append({ - 'methods': list(rule.methods), - 'rule': rule.rule, - 'endpoint': rule.endpoint, - 'subdomain': rule.subdomain, - 'redirect_to': rule.redirect_to, - 'alias': rule.alias, - 'host': rule.host, - 'build_only': rule.build_only - }) - # end for - return routes - # end def - - @_self_jsonify - def view_request(self): - """ - Get infos about your host, like IP etc. - :return: - """ - import json - from flask import session - j = json.loads(json.dumps(session)), - # end for - return j - # end def - - def get_router(self): - """ - Where to call `add_url_rule` (aka. `@route`) on. - Returns either the blueprint if there is any, or the app. - - :raises ValueError: if neither blueprint nor app is set. - - :returns: either the blueprint if it is set, or the app. - :rtype: flask.Blueprint | flask.Flask - """ - if self.blueprint: - return self.blueprint - # end if - if not self.app: - raise ValueError("The app (self.app) is not set.") - # end if - return self.app - - def setup_routes(self, hookpath, debug_routes=False): - """ - Sets the pathes to the registered blueprint/app: - - "webhook" (self.view_updates) at hookpath - Also, if `debug_routes` is `True`: - - "exec" (self.view_exec) at "/teleflask_debug/exec/API_KEY/" (`API_KEY` is replaced, `` is any Telegram API command.) - - "status" (self.view_status) at "/teleflask_debug/status" - - "hostinfo" (self.view_host_info) at "/teleflask_debug/hostinfo" - - "routes" (self.view_routes_info) at "/teleflask_debug/routes" - - :param hookpath: The path where it expects telegram updates to hit the flask app/blueprint. - :type hookpath: str - - :param debug_routes: Add several debug paths. - :type debug_routes: bool - """ - # Todo: Find out how to handle blueprints - if not self.app and not self.blueprint: - raise ValueError("No app (self.app) or Blueprint (self.blueprint) was set.") - # end if - router = self.get_router() - if not self.disable_setting_webhook_route: - logger.info("Adding webhook route: {url!r}".format(url=hookpath)) - assert hookpath - router.add_url_rule(hookpath, endpoint="webhook", view_func=self.view_updates, methods=['POST']) - else: - logger.info("Not adding webhook route, because disable_setting_webhook=True") - # end if - if debug_routes: - logger.info("Adding debug routes.".format(url=hookpath)) - router.add_url_rule("/teleflask_debug/exec/{api_key}/".format(api_key=self._api_key), endpoint="exec", view_func=self.view_exec) - router.add_url_rule("/teleflask_debug/status", endpoint="status", view_func=self.view_status) - router.add_url_rule("/teleflask_debug/routes", endpoint="routes", view_func=self.view_routes_info) - # end if - # end def - - @abc.abstractmethod - def process_update(self, update): - return - # end def - - def process_result(self, update, result): - """ - Send the result. - It may be a :class:`Message` or a list of :class:`Message`s - Strings will be send as :class:`TextMessage`, encoded as raw text. - - :param update: A telegram incoming update - :type update: TGUpdate - - :param result: Something to send. - :type result: Union[List[Union[Message, str]], Message, str] - - :return: List of telegram responses. - :rtype: list - """ - from ..messages import Message - from ..new_messages import SendableMessageBase - reply_chat, reply_msg = self.msg_get_reply_params(update) - if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): - return list(self.send_messages(result, reply_chat, reply_msg)) - elif result is False or result is None: - logger.debug("Ignored result {res!r}".format(res=result)) - # ignore it - else: - logger.warning("Unexpected plugin result: {type}".format(type=type(result))) - # end if - # end def - - @staticmethod - def msg_get_reply_params(update): - """ - Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. - - :param update: pytgbot.api_types.receivable.updates.Update - :return: reply_chat, reply_msg - :rtype: tuple(int,int) - """ - assert_type_or_raise(update, TGUpdate, parameter_name="update") - assert isinstance(update, TGUpdate) - - if update.message and update.message.chat.id and update.message.message_id: - return update.message.chat.id, update.message.message_id - # end if - if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: - return update.channel_post.chat.id, update.channel_post.message_id - # end if - if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: - return update.edited_message.chat.id, update.edited_message.message_id - # end if - if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: - return update.edited_channel_post.chat.id, update.edited_channel_post.message_id - # end if - if update.callback_query and update.callback_query.message: - message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None - if update.callback_query.message.chat and update.callback_query.message.chat.id: - return update.callback_query.message.chat.id, message_id - # end if - if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: - return update.callback_query.message.from_peer.id, message_id - # end if - # end if - if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: - return update.inline_query.from_peer.id, None - # end if - return None, None - # end def - - def send_messages(self, messages, reply_chat, reply_msg): - """ - Sends a Message. - Plain strings will become an unformatted TextMessage. - Supports to mass send lists, tuples, Iterable. - - :param messages: A Message object. - :type messages: Message | str | list | tuple | - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. - False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. - :type instant: bool or None - """ - from pytgbot.exceptions import TgApiException - from ..messages import Message, TextMessage - from ..new_messages import SendableMessageBase - - logger.debug("Got {}".format(messages)) - if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): - raise TypeError("Is not a Message type (or str or tuple/list).") - # end if - if isinstance(messages, tuple): - messages = [x for x in messages] - # end if - if not isinstance(messages, list): - messages = [messages] - # end if - assert isinstance(messages, list) - for msg in messages: - if isinstance(msg, str): - assert not isinstance(messages, str) # because we would split a string to pieces. - msg = TextMessage(msg, parse_mode="text") - # end if - if not isinstance(msg, (Message, SendableMessageBase)): - raise TypeError("Is not a Message/SendableMessageBase type.") - # end if - # if msg._next_msg: # TODO: Reply message? - # message.insert(message.index(msg) + 1, msg._next_msg) - # msg._next_msg = None - from requests.exceptions import RequestException - msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) - try: - yield msg.send(self.bot) - except (TgApiException, RequestException): - logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) - # end try - # end for - # end def - - def send_message(self, messages, reply_chat, reply_msg): - """ - Backwards compatible version of send_messages. - - :param messages: - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :return: None - """ - list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) - return None -# end class diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 8803b08..596cee8 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -1,19 +1,21 @@ # -*- coding: utf-8 -*- import os +from luckydonaldUtils.exceptions import assert_type_or_raise +from luckydonaldUtils.logger import logging from pytgbot.api_types.receivable.updates import Update from exceptions import AbortProcessingPlease -from .base import TeleflaskBase from .filters import MessageFilter, UpdateFilter, CommandFilter, NoMatch, Filter -from luckydonaldUtils.logger import logging +from .. import VERSION +from .utilities import _class_self_decorate __author__ = 'luckydonald' __all__ = ["Teleflask"] logger = logging.getLogger(__name__) -class BotServer(TeleflaskBase): +class BotServer(object): """ This is the full package, including all provided mixins. @@ -282,6 +284,744 @@ def iter_blueprints(self): # end class +_self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. + + +class Teleflask(BotServer): + VERSION = VERSION + __version__ = VERSION + + def __init__( + self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", + debug_routes=False, disable_setting_webhook_route=None, disable_setting_webhook_telegram=None, + return_python_objects=True + ): + """ + A new Teleflask object. + + :param api_key: The key for the telegram bot api. + :type api_key: str + + :param app: The flask app if you don't like to call :meth:`init_app` yourself. + :type app: flask.Flask | None + + :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. + Use if you don't like to call :meth:`init_app` yourself. + If not set, but `app` is, it will register any routes to the `app` itself. + :type blueprint: flask.Blueprint | None + + :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. + Specify the path with `hostpath` + Used to calculate the webhook url. + Also configurable via environment variables. See calculate_webhook_url() + :type hostname: None|str + + :param hostpath: The host url the base of where this bot is reachable. + Examples: None (for root of server) or "/bot2" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :type hostpath: None|str + + :param hookpath: The endpoint of the telegram webhook. + Defaults to "/income/" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :type hookpath: str + + :param debug_routes: Add extra url endpoints usefull for debugging. See setup_routes(...) + :type debug_routes: bool + + :param disable_setting_webhook_telegram: Disable updating the telegram webhook when starting. + Useful for unit tests. Defaults to the app's config + DISABLE_SETTING_ROUTE_WEBHOOK or False. + :type disable_setting_webhook_telegram: None|bool + + :param disable_setting_webhook_route: Disable creation of the webhook route. + Usefull if you don't need to listen for incomming events. + :type disable_setting_webhook_route: None|bool + + :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot + """ + super().__init__(api_key, app, blueprint, hostname, hostpath, hookpath, debug_routes, + disable_setting_webhook_telegram, disable_setting_webhook_route, return_python_objects) + self.__api_key = api_key + self._bot = None # will be set in self.init_bot() + self.app = None # will be filled out by self.init_app(...) + self.blueprint = None # will be filled out by self.init_app(...) + self._return_python_objects = return_python_objects + self.__webhook_url = None # will be filled out by self.calculate_webhook_url() in self.init_app(...) + self.hostname = hostname # e.g. "example.com:443" + self.hostpath = hostpath + self.hookpath = hookpath + + if disable_setting_webhook_route is None: + try: + self.disable_setting_webhook_route = self.app.config["DISABLE_SETTING_WEBHOOK_ROUTE"] + except (AttributeError, KeyError): + logger.debug( + 'disable_setting_webhook_route is None and app is None or app has no DISABLE_SETTING_WEBHOOK_ROUTE' + ' config. Assuming False.' + ) + self.disable_setting_webhook_route = False + # end try + else: + self.disable_setting_webhook_route = disable_setting_webhook_route + # end if + + if disable_setting_webhook_telegram is None: + try: + self.disable_setting_webhook_telegram = self.app.config["DISABLE_SETTING_WEBHOOK_TELEGRAM"] + except (AttributeError, KeyError): + logger.debug( + 'disable_setting_webhook_telegram is None and app is None or app has no DISABLE_SETTING_WEBHOOK_TELEGRAM' + ' config. Assuming False.' + ) + self.disable_setting_webhook_telegram = False + # end try + else: + self.disable_setting_webhook_telegram = disable_setting_webhook_telegram + # end if + + if app or blueprint: # if we have an app or flask blueprint call init_app for adding the routes, which calls init_bot as well. + self.init_app(app, blueprint=blueprint, debug_routes=debug_routes) + elif api_key: # otherwise if we have at least an api key, call init_bot. + self.init_bot() + # end if + + self.update_listener = list() + self.commands = dict() + # end def + + def init_bot(self): + """ + Creates the bot, and retrieves information about the bot itself (username, user_id) from telegram. + + :return: + """ + if not self._bot: # so you can manually set it before calling `init_app(...)`, + # e.g. a mocking bot class for unit tests + self._bot = Bot(self._api_key, return_python_objects=self._return_python_objects) + elif self._bot.return_python_objects != self._return_python_objects: + # we don't have the same setting as the given one + raise ValueError("The already set bot has return_python_objects {given}, but we have {our}".format( + given=self._bot.return_python_objects, our=self._return_python_objects + )) + # end def + myself = self._bot.get_me() + if self._bot.return_python_objects: + self._user_id = myself.id + self._username = myself.username + else: + self._user_id = myself["result"]["id"] + self._username = myself["result"]["username"] + # end if + # end def + + def init_app(self, app, blueprint=None, debug_routes=False): + """ + Gives us access to the flask app (and optionally provide a Blueprint), + where we will add a routing endpoint for the telegram webhook. + + Calls `self.init_bot()`, calculates and sets webhook routes, and finally runs `self.do_startup()`. + + :param app: the :class:`flask.Flask` app + :type app: flask.Flask + + :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. + If `None` was provided, it will register any routes to the `app` itself. + Note: this is NOT a `TBlueprint`, but a regular `flask` one! + :type blueprint: flask.Blueprint | None + + :param debug_routes: Add extra url endpoints, useful for debugging. See setup_routes(...) + :type debug_routes: bool + + :return: None + :rtype: None + """ + self.app = app + self.blueprint = blueprint + self.init_bot() + hookpath, self.__webhook_url = self.calculate_webhook_url(hostname=self.hostname, hostpath=self.hostpath, hookpath=self.hookpath) + self.setup_routes(hookpath=hookpath, debug_routes=debug_routes) + self.set_webhook_telegram() # this will set the webhook in the bot api. + self.do_startup() # this calls the startup listeners of extending classes. + # end def + + def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/{API_KEY}"): + """ + Calculates the webhook url. + Please note, this doesn't change any registered view function! + Returns a tuple of the hook path (the url endpoint for your flask app) and the full webhook url (for telegram) + Note: Both can include the full API key, as replacement for ``{API_KEY}`` in the hookpath. + + :Example: + + Your bot is at ``https://example.com:443/bot2/``, + you want your flask to get the updates at ``/tg-webhook/{API_KEY}``. + This means Telegram will have to send the updates to ``https://example.com:443/bot2/tg-webhook/{API_KEY}``. + + You now would set + hostname = "example.com:443", + hostpath = "/bot2", + hookpath = "/tg-webhook/{API_KEY}" + + Note: Set ``hostpath`` if you are behind a reverse proxy, and/or your flask app root is *not* at the web server root. + + + :param hostname: A hostname. Without the protocol. + Examples: "localhost", "example.com", "example.com:443" + If None (default), the hostname comes from the URL_HOSTNAME environment variable, or from http://ipinfo.io if that fails. + :param hostpath: The path after the hostname. It must start with a slash. + Use this if you aren't at the root at the server, i.e. use url_rewrite. + Example: "/bot2" + If None (default), the path will be read from the URL_PATH environment variable, or "" if that fails. + :param hookpath: Template for the route of incoming telegram webhook events. Must start with a slash. + The placeholder {API_KEY} will replaced with the telegram api key. + Note: This doesn't change any routing. You need to update any registered @app.route manually! + :return: the tuple of calculated (hookpath, webhook_url). + :rtype: tuple + """ + import os, requests + # # + # # try to fill out empty arguments + # # + if not hostname: + hostname = os.getenv('URL_HOSTNAME', None) + # end if + if hostpath is None: + hostpath = os.getenv('URL_PATH', "") + # end if + if not hookpath: + hookpath = "/income/{API_KEY}" + # end if + # # + # # check if the path looks at least a bit valid + # # + logger.debug("hostname={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}".format( + hostn=hostname, hostp=hostpath, hookp=hookpath + )) + if hostname: + if hostname.endswith("/"): + raise ValueError("hostname can't end with a slash: {value}".format(value=hostname)) + # end if + if hostname.startswith("https://"): + hostname = hostname[len("https://"):] + logger.warning("Automatically removed \"https://\" from hostname. Don't include it.") + # end if + if hostname.startswith("http://"): + raise ValueError("Don't include the protocol ('http://') in the hostname. " + "Also telegram doesn't support http, only https.") + # end if + else: # no hostname + info = requests.get('http://ipinfo.io').json() + hostname = str(info["ip"]) + logger.warning("URL_HOSTNAME env not set, falling back to ip address: {ip!r}".format(ip=hostname)) + # end if + if not hostpath == "" and not hostpath.startswith("/"): + logger.info("hostpath didn't start with a slash: {value!r} Will be added automatically".format(value=hostpath)) + hostpath = "/" + hostpath + # end def + if not hookpath.startswith("/"): + raise ValueError("hookpath must start with a slash: {value!r}".format(value=hostpath)) + # end def + hookpath = hookpath.format(API_KEY=self._api_key) + if not hostpath: + logger.info("URL_PATH is not set.") + webhook_url = "https://{hostname}{hostpath}{hookpath}".format(hostname=hostname, hostpath=hostpath, hookpath=hookpath) + logger.debug("host={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}, hookurl={url!r}".format( + hostn=hostname, hostp=hostpath, hookp=hookpath, url=webhook_url + )) + return hookpath, webhook_url + # end def + + @property + def bot(self): + """ + :return: Returns the bot + :rtype: Bot + """ + return self._bot + # end def + + @property + def username(self): + """ + Returns the name of the registerd bot + :return: + """ + return self._username + # end def + + @property + def user_id(self): + return self._user_id + # end def + + @property + def _webhook_url(self): + return self.__webhook_url + # end def + + @property + def _api_key(self): + return self.__api_key + # end def + + def set_webhook_telegram(self): + """ + Sets the telegram webhook. + Checks Telegram if there is a webhook set, and if it needs to be changed. + + :return: + """ + assert isinstance(self.bot, Bot) + existing_webhook = self.bot.get_webhook_info() + + if self._return_python_objects: + from pytgbot.api_types.receivable import WebhookInfo + assert isinstance(existing_webhook, WebhookInfo) + webhook_url = existing_webhook.url + webhook_meta = existing_webhook.to_array() + else: + webhook_url = existing_webhook["result"]["url"] + webhook_meta = existing_webhook["result"] + # end def + del existing_webhook + logger.info("Last webhook pointed to {url!r}.\nMetadata: {hook}".format( + url=self.hide_api_key(webhook_url), hook=self.hide_api_key("{!r}".format(webhook_meta)) + )) + if webhook_url == self._webhook_url: + logger.info("Webhook set correctly. No need to change.") + else: + if not self.disable_setting_webhook_telegram: + logger.info("Setting webhook to {url}".format(url=self.hide_api_key(self._webhook_url))) + logger.debug(self.bot.set_webhook(url=self._webhook_url)) + else: + logger.info( + "Would set webhook to {url!r}, but action is disabled by DISABLE_SETTING_TELEGRAM_WEBHOOK config " + "or disable_setting_webhook_telegram argument.".format(url=self.hide_api_key(self._webhook_url)) + ) + # end if + # end if + # end def + + def do_startup(self): + """ + This code is executed after server boot. + + Sets the telegram webhook (see :meth:`set_webhook_telegram(self)`) + and calls `super().do_setup()` for the superclass (e.g. other mixins) + + :return: + """ + super().do_startup() # do more registered startup actions. + # end def + + def hide_api_key(self, string): + """ + Replaces the api key with "" in a given string. + + Note: if the given object is no string, :meth:`str(object)` is called first. + + :param string: The str which can contain the api key. + :return: string with the key replaced + """ + if not isinstance(string, str): + string = str(string) + # end if + return string.replace(self._api_key, "") + # end def + + def jsonify(self, func): + """ + Decorator. + Converts the returned value of the function to json, and sets mimetype to "text/json". + It will also automatically replace the api key where found in the output with "". + + Usage: + @app.route("/foobar") + @app.jsonify + def foobar(): + return {"foo": "bar"} + # end def + # app is a instance of this class + + + There are some special cases to note: + + - :class:`tuple` is interpreted as (data, status). + E.g. + return {"error": "not found"}, 404 + would result in a 404 page, with json content {"error": "not found"} + + - :class:`flask.Response` will be returned directly, except it is in a :class:`tuple` + In that case the status code of the returned response will be overwritten by the second tuple element. + + - :class:`TgBotApiObject` will be converted to json too. Status code 200. + + - An exception will be returned as `{"error": "exception raised"}` with status code 503. + + + :param func: the function to wrap + :return: the wrapped function returning json responses. + """ + from functools import wraps + from flask import Response + import json + logger.debug("func: {}".format(func)) + + @wraps(func) + def jsonify_inner(*args, **kwargs): + try: + result = func(*args, **kwargs) + except: + logger.exception("failed executing {name}.".format(name=func.__name__), exc_info=True) + result = {"error": "exception raised"}, 503 + # end def + status = None # will be 200 if not otherwise changed + if isinstance(result, tuple): + response, status = result + else: + response = result + # end if + if isinstance(response, Response): + if status: + response.status_code = status + # end if + return response + # end if + if isinstance(response, TgBotApiObject): + response = response.to_array() + # end if + response = json.dumps(response) + # end if + assert isinstance(response, str) + response_kwargs = {} + response_kwargs.setdefault("mimetype", "text/json") + if status: + response_kwargs["status"] = status + # end if + res = Response(self.hide_api_key(response), **response_kwargs) + logger.debug("returning: {}".format(res)) + return res + # end def inner + return jsonify_inner + # end def + + @_self_jsonify + def view_exec(self, api_key, command): + """ + Issue commands. E.g. /exec/TELEGRAM_API_KEY/getMe + + :param api_key: gets checked, so you can't just execute commands. + :param command: the actual command + :return: + """ + if api_key != self._api_key: + error_msg = "Wrong API key: {wrong_key!r}".format(wrong_key=api_key) + logger.warning(error_msg) + return {"status": "error", "message": error_msg, "error_code": 403}, 403 + # end if + from flask import request + from pytgbot.exceptions import TgApiServerException + logger.debug("COMMAND: {cmd}, ARGS: {args}".format(cmd=command, args=request.args)) + try: + res = self.bot.do(command, **request.args) + if self._return_python_objects: + return res.to_array() + else: + return res + # end if + except TgApiServerException as e: + return {"status": "error", "message": e.description, "error_code": e.error_code}, e.error_code + # end try + # end def + + @_self_jsonify + def view_status(self): + """ + Returns the status about the bot's webhook. + + :return: webhook info + """ + try: + res = self.bot.get_webhook_info() # TODO: fix to work with return_python_objects==False + return res.to_array() + except TgApiServerException as e: + return {"status": "error", "message": e.description, "error_code": e.error_code}, e.error_code + # end try + + @_self_jsonify + def view_updates(self): + """ + This processes incoming telegram updates. + + :return: + """ + from pprint import pformat + from flask import request + + logger.debug("INCOME:\n{}\n\nHEADER:\n{}".format( + pformat(request.get_json()), + request.headers if hasattr(request, "headers") else None + )) + update = Update.from_array(request.get_json()) + try: + result = self.process_update(update) + except Exception as e: + logger.exception("process_update()") + result = {"status": "error", "message": str(e)} + result = result if result else {"status": "probably ok"} + logger.info("returning result: {}".format(result)) + return result + # end def + + @_self_jsonify + def view_host_info(self): + """ + Get infos about your host, like IP etc. + :return: + """ + import socket + import requests + info = requests.get('http://ipinfo.io').json() + info["host"] = socket.gethostname() + info["version"] = self.VERSION + return info + # end def + + @_self_jsonify + def view_routes_info(self): + """ + Get infos about your host, like IP etc. + :return: + """ + from werkzeug.routing import Rule + routes = [] + for rule in self.app.url_map.iter_rules(): + assert isinstance(rule, Rule) + routes.append({ + 'methods': list(rule.methods), + 'rule': rule.rule, + 'endpoint': rule.endpoint, + 'subdomain': rule.subdomain, + 'redirect_to': rule.redirect_to, + 'alias': rule.alias, + 'host': rule.host, + 'build_only': rule.build_only + }) + # end for + return routes + # end def + + @_self_jsonify + def view_request(self): + """ + Get infos about your host, like IP etc. + :return: + """ + import json + from flask import session + j = json.loads(json.dumps(session)), + # end for + return j + # end def + + def get_router(self): + """ + Where to call `add_url_rule` (aka. `@route`) on. + Returns either the blueprint if there is any, or the app. + + :raises ValueError: if neither blueprint nor app is set. + + :returns: either the blueprint if it is set, or the app. + :rtype: flask.Blueprint | flask.Flask + """ + if self.blueprint: + return self.blueprint + # end if + if not self.app: + raise ValueError("The app (self.app) is not set.") + # end if + return self.app + + def setup_routes(self, hookpath, debug_routes=False): + """ + Sets the pathes to the registered blueprint/app: + - "webhook" (self.view_updates) at hookpath + Also, if `debug_routes` is `True`: + - "exec" (self.view_exec) at "/teleflask_debug/exec/API_KEY/" (`API_KEY` is replaced, `` is any Telegram API command.) + - "status" (self.view_status) at "/teleflask_debug/status" + - "hostinfo" (self.view_host_info) at "/teleflask_debug/hostinfo" + - "routes" (self.view_routes_info) at "/teleflask_debug/routes" + + :param hookpath: The path where it expects telegram updates to hit the flask app/blueprint. + :type hookpath: str + + :param debug_routes: Add several debug paths. + :type debug_routes: bool + """ + # Todo: Find out how to handle blueprints + if not self.app and not self.blueprint: + raise ValueError("No app (self.app) or Blueprint (self.blueprint) was set.") + # end if + router = self.get_router() + if not self.disable_setting_webhook_route: + logger.info("Adding webhook route: {url!r}".format(url=hookpath)) + assert hookpath + router.add_url_rule(hookpath, endpoint="webhook", view_func=self.view_updates, methods=['POST']) + else: + logger.info("Not adding webhook route, because disable_setting_webhook=True") + # end if + if debug_routes: + logger.info("Adding debug routes.".format(url=hookpath)) + router.add_url_rule("/teleflask_debug/exec/{api_key}/".format(api_key=self._api_key), endpoint="exec", view_func=self.view_exec) + router.add_url_rule("/teleflask_debug/status", endpoint="status", view_func=self.view_status) + router.add_url_rule("/teleflask_debug/routes", endpoint="routes", view_func=self.view_routes_info) + # end if + # end def + + @abc.abstractmethod + def process_update(self, update): + return + # end def + + def process_result(self, update, result): + """ + Send the result. + It may be a :class:`Message` or a list of :class:`Message`s + Strings will be send as :class:`TextMessage`, encoded as raw text. + + :param update: A telegram incoming update + :type update: Update + + :param result: Something to send. + :type result: Union[List[Union[Message, str]], Message, str] + + :return: List of telegram responses. + :rtype: list + """ + from ..messages import Message + from ..new_messages import SendableMessageBase + reply_chat, reply_msg = self.msg_get_reply_params(update) + if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): + return list(self.send_messages(result, reply_chat, reply_msg)) + elif result is False or result is None: + logger.debug("Ignored result {res!r}".format(res=result)) + # ignore it + else: + logger.warning("Unexpected plugin result: {type}".format(type=type(result))) + # end if + # end def + + @staticmethod + def msg_get_reply_params(update): + """ + Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. + + :param update: pytgbot.api_types.receivable.updates.Update + :return: reply_chat, reply_msg + :rtype: tuple(int,int) + """ + assert_type_or_raise(update, Update, parameter_name="update") + assert isinstance(update, Update) + + if update.message and update.message.chat.id and update.message.message_id: + return update.message.chat.id, update.message.message_id + # end if + if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: + return update.channel_post.chat.id, update.channel_post.message_id + # end if + if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: + return update.edited_message.chat.id, update.edited_message.message_id + # end if + if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: + return update.edited_channel_post.chat.id, update.edited_channel_post.message_id + # end if + if update.callback_query and update.callback_query.message: + message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None + if update.callback_query.message.chat and update.callback_query.message.chat.id: + return update.callback_query.message.chat.id, message_id + # end if + if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: + return update.callback_query.message.from_peer.id, message_id + # end if + # end if + if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: + return update.inline_query.from_peer.id, None + # end if + return None, None + # end def + + def send_messages(self, messages, reply_chat, reply_msg): + """ + Sends a Message. + Plain strings will become an unformatted TextMessage. + Supports to mass send lists, tuples, Iterable. + + :param messages: A Message object. + :type messages: Message | str | list | tuple | + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. + False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. + :type instant: bool or None + """ + from pytgbot.exceptions import TgApiException + from ..messages import Message, TextMessage + from ..new_messages import SendableMessageBase + + logger.debug("Got {}".format(messages)) + if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): + raise TypeError("Is not a Message type (or str or tuple/list).") + # end if + if isinstance(messages, tuple): + messages = [x for x in messages] + # end if + if not isinstance(messages, list): + messages = [messages] + # end if + assert isinstance(messages, list) + for msg in messages: + if isinstance(msg, str): + assert not isinstance(messages, str) # because we would split a string to pieces. + msg = TextMessage(msg, parse_mode="text") + # end if + if not isinstance(msg, (Message, SendableMessageBase)): + raise TypeError("Is not a Message/SendableMessageBase type.") + # end if + # if msg._next_msg: # TODO: Reply message? + # message.insert(message.index(msg) + 1, msg._next_msg) + # msg._next_msg = None + from requests.exceptions import RequestException + msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) + try: + yield msg.send(self.bot) + except (TgApiException, RequestException): + logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) + # end try + # end for + # end def + + def send_message(self, messages, reply_chat, reply_msg): + """ + Backwards compatible version of send_messages. + + :param messages: + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :return: None + """ + list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) + return None + # end def +# end class + + class PollingTeleflask(Teleflask): def __init__(self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", debug_routes=False, disable_setting_webhook=True, return_python_objects=True, https=True, start_process=True): @@ -334,4 +1074,3 @@ def _start_proxy_process(self): # end def # end class -Teleflask = BotServer From 9e8f6512a9b44f644458500e494e813da0c3f98e Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:14:59 +0200 Subject: [PATCH 19/53] filter_rewrite: Missing imports. --- teleflask/server/extras.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 596cee8..8d5da99 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -1,11 +1,18 @@ # -*- coding: utf-8 -*- +import abc import os +from typing import Union, List, Callable, Dict from luckydonaldUtils.exceptions import assert_type_or_raise from luckydonaldUtils.logger import logging +from pytgbot import Bot +from pytgbot.api_types import TgBotApiObject +from pytgbot.api_types.receivable.peer import User from pytgbot.api_types.receivable.updates import Update +from pytgbot.exceptions import TgApiServerException from exceptions import AbortProcessingPlease +from teleflask import TBlueprint from .filters import MessageFilter, UpdateFilter, CommandFilter, NoMatch, Filter from .. import VERSION from .utilities import _class_self_decorate From 19c0e82a4b6940032ea562ce6155d2b513860d6b Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:15:57 +0200 Subject: [PATCH 20/53] filter_rewrite: Moved some bot stuff up to the new parent. --- teleflask/server/extras.py | 672 +++++++++++++++++++------------------ 1 file changed, 345 insertions(+), 327 deletions(-) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 8d5da99..6bc9b1f 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -64,6 +64,17 @@ class BotServer(object): :class:`teleflask.extras.TeleflaskUpdates` and :class:`teleflask.extras.TeleflaskStartup`. """ + __api_key: str + _bot = Union[Bot, None] + _me: Union[User, None] + _return_python_objects: bool + + startup_listeners: List[Callable] + startup_already_run: bool + + blueprints: Dict[str, TBlueprint] + _blueprint_order: List[TBlueprint] + def __init__( self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", debug_routes=False, disable_setting_webhook_telegram=None, disable_setting_webhook_route=None, @@ -110,184 +121,362 @@ def __init__( :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot """ - self.startup_listeners = list() - self.startup_already_run = False + self.startup_listeners: List[Callable] = list() + self.startup_already_run: bool = False + + self.blueprints: Dict[str, TBlueprint] = {} + self._blueprint_order: List[TBlueprint] = [] - self.blueprints = {} - self._blueprint_order = [] + self.__api_key: str = api_key + self._bot = Union[Bot, None] = None # will be set in self.init_bot() + self._me: Union[User, None] = None # will be set in self.init_bot() + self._return_python_objects: bool = return_python_objects super().__init__( api_key=api_key, app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, disable_setting_webhook_route=disable_setting_webhook_route, return_python_objects=return_python_objects, ) - - def register_handler(self, event_handler: Filter): - """ - Adds an listener for any update type. - You provide a Filter for them as parameter, it also contains the function. - No error will be raised if it is already registered. In that case a warning will be logged, - but nothing else will happen, and the function is not added. - - Examples: - >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) - # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. - - >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) - # calls func(msg) for all updates which are inline queries (have the inline_query attribute) - - >>> register_handler(UpdateFilter(func, required_keywords=None)) - >>> register_handler(UpdateFilter(func)) - # allows all messages. - - :param function: The function to call. Will be called with the update and the message as arguments - :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. - Must be a list. - :return: the function, unmodified - """ - - logging.debug("adding handler to listeners") - self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND - return event_handler # end def - def remove_handler(self, event_handler): - """ - Removes an handler from the update listener list. - No error will be raised if it is already registered. In that case a warning will be logged, - but noting else will happen. + def register_handler(self, event_handler: Filter): + """ + Adds an listener for any update type. + You provide a Filter for them as parameter, it also contains the function. + No error will be raised if it is already registered. In that case a warning will be logged, + but nothing else will happen, and the function is not added. + + Examples: + >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) + # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. + + >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) + # calls func(msg) for all updates which are inline queries (have the inline_query attribute) + + >>> register_handler(UpdateFilter(func, required_keywords=None)) + >>> register_handler(UpdateFilter(func)) + # allows all messages. + + :param function: The function to call. Will be called with the update and the message as arguments + :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. + Must be a list. + :return: the function, unmodified + """ + + logging.debug("adding handler to listeners") + self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND + return event_handler + # end def + + def remove_handler(self, event_handler): + """ + Removes an handler from the update listener list. + No error will be raised if it is already registered. In that case a warning will be logged, + but noting else will happen. + + + :param function: The function to remove + :return: the function, unmodified + """ + try: + self.update_listeners.remove(event_handler) + except ValueError: + logger.warning("listener already removed.") + # end if + # end def + + def remove_handled_func(self, func): + """ + Removes an function from the update listener list. + No error will be raised if it is no longer registered. In that case noting else will happen. + + :param function: The function to remove + :return: the function, unmodified + """ + listerner: Filter + self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] + # end def + + def process_update(self, update): + """ + Iterates through self.update_listeners, and calls them with (update, app). + No try catch stuff is done, will fail instantly, and not process any remaining listeners. - :param function: The function to remove - :return: the function, unmodified - """ + :param update: incoming telegram update. + :return: nothing. + """ + assert isinstance(update, Update) # Todo: non python objects + filter: Filter + for filter in self.update_listeners: try: - self.update_listeners.remove(event_handler) - except ValueError: - logger.warning("listener already removed.") + # check if the Filter matches + match_result = filter.match(update) + # call the handler + result = filter.call_handler(update=update, match_result=match_result) + # send the message + self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() + except NoMatch as e: + logger.debug(f'not matching filter {filter!s}.') + except AbortProcessingPlease as e: + logger.debug('Asked to stop processing updates.') + if e.return_value: + self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() + # end if + return # not calling super().process_update(update) + except Exception: + logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") + # end try + # end for + super().process_update(update) + # end def + + def on_startup(self, func): + """ + Decorator to register a function to receive updates. + + Usage: + >>> @app.on_startup + >>> def foo(): + >>> print("doing stuff on boot") + + """ + return self.add_startup_listener(func) + # end def + + def add_startup_listener(self, func): + """ + Usage: + >>> def foo(): + >>> print("doing stuff on boot") + >>> app.add_startup_listener(foo) + + :param func: + :return: + """ + if func not in self.startup_listeners: + self.startup_listeners.append(func) + if self.startup_already_run: + func() # end if - # end def + else: + logger.warning("listener already added.") + # end if + return func + # end def + + def remove_startup_listener(self, func): + if func in self.startup_listeners: + self.startup_listeners.remove(func) + else: + logger.warning("listener already removed.") + # end if + return func + # end def - def remove_handled_func(self, func): - """ - Removes an function from the update listener list. - No error will be raised if it is no longer registered. In that case noting else will happen. + def register_tblueprint(self, tblueprint: TBlueprint, **options): + """ + Registers a `TBlueprint` on the application. + """ + first_registration = False + if tblueprint.name in self.blueprints: + assert self.blueprints[tblueprint.name] is tblueprint, \ + 'A teleflask blueprint\'s name collision occurred between %r and ' \ + '%r. Both share the same name "%s". TBlueprints that ' \ + 'are created on the fly need unique names.' % \ + (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) + else: + self.blueprints[tblueprint.name] = tblueprint + self._blueprint_order.append(tblueprint) + first_registration = True + tblueprint.register(self, options, first_registration) + # end def - :param function: The function to remove - :return: the function, unmodified - """ - listerner: Filter - self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] - # end def + def iter_blueprints(self): + """ + Iterates over all blueprints by the order they were registered. + """ + return iter(self._blueprint_order) + # end def - def process_update(self, update): - """ - Iterates through self.update_listeners, and calls them with (update, app). - - No try catch stuff is done, will fail instantly, and not process any remaining listeners. - - :param update: incoming telegram update. - :return: nothing. - """ - assert isinstance(update, Update) # Todo: non python objects - filter: Filter - for filter in self.update_listeners: - try: - # check if the Filter matches - match_result = filter.match(update) - # call the handler - result = filter.call_handler(update=update, match_result=match_result) - # send the message - self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() - except NoMatch as e: - logger.debug(f'not matching filter {filter!s}.') - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") - # end try - # end for - super().process_update(update) - # end def - def on_startup(self, func): - """ - Decorator to register a function to receive updates. - Usage: - >>> @app.on_startup - >>> def foo(): - >>> print("doing stuff on boot") + def process_result(self, update, result): + """ + Send the result. + It may be a :class:`Message` or a list of :class:`Message`s + Strings will be send as :class:`TextMessage`, encoded as raw text. - """ - return self.add_startup_listener(func) - # end def + :param update: A telegram incoming update + :type update: Update - def add_startup_listener(self, func): - """ - Usage: - >>> def foo(): - >>> print("doing stuff on boot") - >>> app.add_startup_listener(foo) - - :param func: - :return: - """ - if func not in self.startup_listeners: - self.startup_listeners.append(func) - if self.startup_already_run: - func() - # end if - else: - logger.warning("listener already added.") + :param result: Something to send. + :type result: Union[List[Union[Message, str]], Message, str] + + :return: List of telegram responses. + :rtype: list + """ + from ..messages import Message + from ..new_messages import SendableMessageBase + reply_chat, reply_msg = self.msg_get_reply_params(update) + if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): + return list(self.send_messages(result, reply_chat, reply_msg)) + elif result is False or result is None: + logger.debug("Ignored result {res!r}".format(res=result)) + # ignore it + else: + logger.warning("Unexpected plugin result: {type}".format(type=type(result))) + # end if + # end def + + @staticmethod + def msg_get_reply_params(update): + """ + Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. + + :param update: pytgbot.api_types.receivable.updates.Update + :return: reply_chat, reply_msg + :rtype: tuple(int,int) + """ + assert_type_or_raise(update, Update, parameter_name="update") + assert isinstance(update, Update) + + if update.message and update.message.chat.id and update.message.message_id: + return update.message.chat.id, update.message.message_id + # end if + if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: + return update.channel_post.chat.id, update.channel_post.message_id + # end if + if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: + return update.edited_message.chat.id, update.edited_message.message_id + # end if + if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: + return update.edited_channel_post.chat.id, update.edited_channel_post.message_id + # end if + if update.callback_query and update.callback_query.message: + message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None + if update.callback_query.message.chat and update.callback_query.message.chat.id: + return update.callback_query.message.chat.id, message_id # end if - return func - # end def + if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: + return update.callback_query.message.from_peer.id, message_id + # end if + # end if + if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: + return update.inline_query.from_peer.id, None + # end if + return None, None + # end def - def remove_startup_listener(self, func): - if func in self.startup_listeners: - self.startup_listeners.remove(func) - else: - logger.warning("listener already removed.") + def send_messages(self, messages, reply_chat, reply_msg): + """ + Sends a Message. + Plain strings will become an unformatted TextMessage. + Supports to mass send lists, tuples, Iterable. + + :param messages: A Message object. + :type messages: Message | str | list | tuple | + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. + False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. + :type instant: bool or None + """ + from pytgbot.exceptions import TgApiException + from ..messages import Message, TextMessage + from ..new_messages import SendableMessageBase + + logger.debug("Got {}".format(messages)) + if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): + raise TypeError("Is not a Message type (or str or tuple/list).") + # end if + if isinstance(messages, tuple): + messages = [x for x in messages] + # end if + if not isinstance(messages, list): + messages = [messages] + # end if + assert isinstance(messages, list) + for msg in messages: + if isinstance(msg, str): + assert not isinstance(messages, str) # because we would split a string to pieces. + msg = TextMessage(msg, parse_mode="text") # end if - return func - # end def + if not isinstance(msg, (Message, SendableMessageBase)): + raise TypeError("Is not a Message/SendableMessageBase type.") + # end if + # if msg._next_msg: # TODO: Reply message? + # message.insert(message.index(msg) + 1, msg._next_msg) + # msg._next_msg = None + from requests.exceptions import RequestException + msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) + try: + yield msg.send(self.bot) + except (TgApiException, RequestException): + logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) + # end try + # end for + # end def - def register_tblueprint(self, tblueprint, **options): - """ - Registers a `TBlueprint` on the application. - """ - first_registration = False - if tblueprint.name in self.blueprints: - assert self.blueprints[tblueprint.name] is tblueprint, \ - 'A teleflask blueprint\'s name collision occurred between %r and ' \ - '%r. Both share the same name "%s". TBlueprints that ' \ - 'are created on the fly need unique names.' % \ - (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) - else: - self.blueprints[tblueprint.name] = tblueprint - self._blueprint_order.append(tblueprint) - first_registration = True - tblueprint.register(self, options, first_registration) - # end def + def send_message(self, messages, reply_chat, reply_msg): + """ + Backwards compatible version of send_messages. - def iter_blueprints(self): - """ - Iterates over all blueprints by the order they were registered. - """ - return iter(self._blueprint_order) - # end def + :param messages: + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :return: None + """ + list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) + return None + # end def - on_update = UpdateFilter.decorator - on_message = MessageFilter.decorator - on_command = CommandFilter.decorator + @property + def bot(self): + """ + :return: Returns the bot + :rtype: Bot + """ + return self._bot + # end def + + @property + def me(self) -> User: + """ + Returns the info about the registered bot + :return: info about the registered bot user + """ + return self._me + # end def - command = on_command + @property + def username(self) -> str: + """ + Returns the name of the registered bot + :return: the name + """ + return self.me.username + # end def + + @property + def user_id(self): + return self.me + # end def + + @property + def _api_key(self): + return self.__api_key # end def + + on_update = UpdateFilter.decorator + on_message = MessageFilter.decorator + on_command = CommandFilter.decorator + + command = on_command # end class @@ -351,11 +540,8 @@ def __init__( """ super().__init__(api_key, app, blueprint, hostname, hostpath, hookpath, debug_routes, disable_setting_webhook_telegram, disable_setting_webhook_route, return_python_objects) - self.__api_key = api_key - self._bot = None # will be set in self.init_bot() self.app = None # will be filled out by self.init_app(...) self.blueprint = None # will be filled out by self.init_app(...) - self._return_python_objects = return_python_objects self.__webhook_url = None # will be filled out by self.calculate_webhook_url() in self.init_app(...) self.hostname = hostname # e.g. "example.com:443" self.hostpath = hostpath @@ -416,11 +602,10 @@ def init_bot(self): # end def myself = self._bot.get_me() if self._bot.return_python_objects: - self._user_id = myself.id - self._username = myself.username + self._me = myself else: - self._user_id = myself["result"]["id"] - self._username = myself["result"]["username"] + assert isinstance(myself, dict) + self._me = User.from_array(myself["result"]) # end if # end def @@ -541,39 +726,6 @@ def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/ return hookpath, webhook_url # end def - @property - def bot(self): - """ - :return: Returns the bot - :rtype: Bot - """ - return self._bot - # end def - - @property - def username(self): - """ - Returns the name of the registerd bot - :return: - """ - return self._username - # end def - - @property - def user_id(self): - return self._user_id - # end def - - @property - def _webhook_url(self): - return self.__webhook_url - # end def - - @property - def _api_key(self): - return self.__api_key - # end def - def set_webhook_telegram(self): """ Sets the telegram webhook. @@ -888,143 +1040,9 @@ def setup_routes(self, hookpath, debug_routes=False): # end if # end def - @abc.abstractmethod - def process_update(self, update): - return - # end def - - def process_result(self, update, result): - """ - Send the result. - It may be a :class:`Message` or a list of :class:`Message`s - Strings will be send as :class:`TextMessage`, encoded as raw text. - - :param update: A telegram incoming update - :type update: Update - - :param result: Something to send. - :type result: Union[List[Union[Message, str]], Message, str] - - :return: List of telegram responses. - :rtype: list - """ - from ..messages import Message - from ..new_messages import SendableMessageBase - reply_chat, reply_msg = self.msg_get_reply_params(update) - if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): - return list(self.send_messages(result, reply_chat, reply_msg)) - elif result is False or result is None: - logger.debug("Ignored result {res!r}".format(res=result)) - # ignore it - else: - logger.warning("Unexpected plugin result: {type}".format(type=type(result))) - # end if - # end def - - @staticmethod - def msg_get_reply_params(update): - """ - Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. - - :param update: pytgbot.api_types.receivable.updates.Update - :return: reply_chat, reply_msg - :rtype: tuple(int,int) - """ - assert_type_or_raise(update, Update, parameter_name="update") - assert isinstance(update, Update) - - if update.message and update.message.chat.id and update.message.message_id: - return update.message.chat.id, update.message.message_id - # end if - if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: - return update.channel_post.chat.id, update.channel_post.message_id - # end if - if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: - return update.edited_message.chat.id, update.edited_message.message_id - # end if - if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: - return update.edited_channel_post.chat.id, update.edited_channel_post.message_id - # end if - if update.callback_query and update.callback_query.message: - message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None - if update.callback_query.message.chat and update.callback_query.message.chat.id: - return update.callback_query.message.chat.id, message_id - # end if - if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: - return update.callback_query.message.from_peer.id, message_id - # end if - # end if - if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: - return update.inline_query.from_peer.id, None - # end if - return None, None - # end def - - def send_messages(self, messages, reply_chat, reply_msg): - """ - Sends a Message. - Plain strings will become an unformatted TextMessage. - Supports to mass send lists, tuples, Iterable. - - :param messages: A Message object. - :type messages: Message | str | list | tuple | - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. - False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. - :type instant: bool or None - """ - from pytgbot.exceptions import TgApiException - from ..messages import Message, TextMessage - from ..new_messages import SendableMessageBase - - logger.debug("Got {}".format(messages)) - if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): - raise TypeError("Is not a Message type (or str or tuple/list).") - # end if - if isinstance(messages, tuple): - messages = [x for x in messages] - # end if - if not isinstance(messages, list): - messages = [messages] - # end if - assert isinstance(messages, list) - for msg in messages: - if isinstance(msg, str): - assert not isinstance(messages, str) # because we would split a string to pieces. - msg = TextMessage(msg, parse_mode="text") - # end if - if not isinstance(msg, (Message, SendableMessageBase)): - raise TypeError("Is not a Message/SendableMessageBase type.") - # end if - # if msg._next_msg: # TODO: Reply message? - # message.insert(message.index(msg) + 1, msg._next_msg) - # msg._next_msg = None - from requests.exceptions import RequestException - msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) - try: - yield msg.send(self.bot) - except (TgApiException, RequestException): - logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) - # end try - # end for - # end def - - def send_message(self, messages, reply_chat, reply_msg): - """ - Backwards compatible version of send_messages. - - :param messages: - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :return: None - """ - list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) - return None + @property + def _webhook_url(self): + return self.__webhook_url # end def # end class From 2883df09a798b67f43d777c29f450ed4e6b521a2 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:32:01 +0200 Subject: [PATCH 21/53] filter_rewrite: Changed TBlueprint.teleflask to be TBlueprint.server now. Still, keeping backwards compatibility for now. --- teleflask/server/blueprints.py | 39 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index e2a59c3..b334421 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- from functools import update_wrapper # should be installed by flask +from typing import Union from luckydonaldUtils.logger import logging from .abstact import AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup, AbstractUpdates -from .base import TeleflaskBase -# from .mixins import UpdatesMixin, StartupMixin __author__ = 'luckydonald' + +from .extras import BotServer, Teleflask + logger = logging.getLogger(__name__) @@ -136,12 +138,12 @@ def on_startup(self, func): return self.add_startup_listener(func) # end def - def add_command(self, command, function, exclusive=False): + def add_command(self, command, function): """ Like `BotCommandsMixin.add_command`, but for this `Blueprint`. """ self.record( - lambda state: state.teleflask.add_command(command, function, exclusive) + lambda state: state.teleflask.add_command(command, function) ) # end def @@ -153,19 +155,19 @@ def remove_command(self, command=None, function=None): lambda state: state.teleflask.remove_command(command, function) ) - def on_command(self, command, exclusive=False): + def on_command(self, command): """ Like `BotCommandsMixin.on_command`, but for this `Blueprint`. """ - return self.command(command, exclusive=exclusive) + return self.command(command) # end def - def command(self, command, exclusive=False): + def command(self, command): """ Like `BotCommandsMixin.command`, but for this `Blueprint`. """ def register_command(func): - self.add_command(command, func, exclusive=exclusive) + self.add_command(command, func) return func return register_command # end def @@ -243,20 +245,32 @@ def on_update_inner(function): # end def @property - def teleflask(self): + def server(self) -> Union[BotServer, Teleflask]: if not self._got_registered_once: raise AssertionError('Not registered to an Teleflask instance yet.') # end if - if not self._teleflask: + if not self._server: raise AssertionError('No Teleflask instance yet. Did you register it?') # end if - return self._teleflask + return self._server + # end def + + @property + def teleflask(self): + logger.warning('Please use the TBlueprint.server instead of TBlueprint.teleflask. This function is only kept for compatibility.') + return self.server + # end def @property def bot(self): return self.teleflask.bot # end def + @property + def me(self): + return self.teleflask.me + # end def + @property def username(self): return self.teleflask.username @@ -269,8 +283,7 @@ def user_id(self): @staticmethod def msg_get_reply_params(update): - return TeleflaskBase.msg_get_reply_params(update) - + return Teleflask.msg_get_reply_params(update) # end def def send_messages(self, messages, reply_chat, reply_msg): From 1266c218e3693d9e5c108b2c24e9d7e3e167e41b Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:38:17 +0200 Subject: [PATCH 22/53] filter_rewrite: Fixed do_startup getting lost somehow. --- teleflask/server/extras.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 6bc9b1f..310322d 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -24,7 +24,8 @@ class BotServer(object): """ - This is the full package, including all provided mixins. + This is the core logic. + You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and You can use: @@ -299,8 +300,6 @@ def iter_blueprints(self): return iter(self._blueprint_order) # end def - - def process_result(self, update, result): """ Send the result. @@ -742,6 +741,7 @@ def set_webhook_telegram(self): webhook_url = existing_webhook.url webhook_meta = existing_webhook.to_array() else: + assert isinstance(existing_webhook, dict) webhook_url = existing_webhook["result"]["url"] webhook_meta = existing_webhook["result"] # end def @@ -766,14 +766,22 @@ def set_webhook_telegram(self): def do_startup(self): """ - This code is executed after server boot. + Iterates through self.startup_listeners, and calls them. - Sets the telegram webhook (see :meth:`set_webhook_telegram(self)`) - and calls `super().do_setup()` for the superclass (e.g. other mixins) + No try catch stuff is done, will fail instantly, and not process any remaining listeners. - :return: + :param update: + :return: the last non-None result any listener returned. """ - super().do_startup() # do more registered startup actions. + for listener in self.startup_listeners: + try: + listener() + except Exception: + logger.exception("Error executing the startup listener {func}.".format(func=listener)) + raise + # end if + # end for + self.startup_already_run = True # end def def hide_api_key(self, string): @@ -1082,10 +1090,10 @@ def do_startup(self): :return: """ + super().do_startup() if self.start_process: self._start_proxy_process() # end def - super().do_startup() # end def def _start_proxy_process(self): From c5f813d52f7fc0256972f45add16c0836fb07d94 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:43:33 +0200 Subject: [PATCH 23/53] filter_rewrite: Renamed BotServer to TeleServe. --- teleflask/server/blueprints.py | 6 ++++-- teleflask/server/extras.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index b334421..57252a8 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -8,7 +8,7 @@ __author__ = 'luckydonald' -from .extras import BotServer, Teleflask +from .extras import TeleServe, Teleflask logger = logging.getLogger(__name__) @@ -97,6 +97,8 @@ def record_once(self, func): def wrapper(state): if state.first_registration: func(state) + # end if + # end def return self.record(update_wrapper(wrapper, func)) # end def @@ -245,7 +247,7 @@ def on_update_inner(function): # end def @property - def server(self) -> Union[BotServer, Teleflask]: + def server(self) -> Union[TeleServe, Teleflask]: if not self._got_registered_once: raise AssertionError('Not registered to an Teleflask instance yet.') # end if diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 310322d..1500bd9 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -class BotServer(object): +class TeleServe(object): """ This is the core logic. You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and @@ -482,7 +482,7 @@ def _api_key(self): _self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. -class Teleflask(BotServer): +class Teleflask(TeleServe): VERSION = VERSION __version__ = VERSION From d63ed5cb45993d1b13a54d2540a8743585613279 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:52:53 +0200 Subject: [PATCH 24/53] filter_rewrite: Renamed TeleServe to Teleserver. --- teleflask/server/extras.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index 1500bd9..a6a5a16 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -class TeleServe(object): +class Teleserver(object): """ This is the core logic. You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and @@ -482,7 +482,7 @@ def _api_key(self): _self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. -class Teleflask(TeleServe): +class Teleflask(Teleserver): VERSION = VERSION __version__ = VERSION From 3cacda2fbe4897fb59ae3fd3448260e600d50566 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:53:24 +0200 Subject: [PATCH 25/53] filter_rewrite: Fixed imports. --- teleflask/server/blueprints.py | 4 +--- teleflask/server/extras.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index 57252a8..fa51833 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -8,8 +8,6 @@ __author__ = 'luckydonald' -from .extras import TeleServe, Teleflask - logger = logging.getLogger(__name__) @@ -247,7 +245,7 @@ def on_update_inner(function): # end def @property - def server(self) -> Union[TeleServe, Teleflask]: + def server(self) -> Union['teleflask.Teleserver', 'teleflask.Teleflask']: if not self._got_registered_once: raise AssertionError('Not registered to an Teleflask instance yet.') # end if diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index a6a5a16..af54f89 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -12,7 +12,7 @@ from pytgbot.exceptions import TgApiServerException from exceptions import AbortProcessingPlease -from teleflask import TBlueprint +from .blueprints import TBlueprint from .filters import MessageFilter, UpdateFilter, CommandFilter, NoMatch, Filter from .. import VERSION from .utilities import _class_self_decorate From 50292e906b12b048b86e83e9b852e00e9728cf95 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:54:45 +0200 Subject: [PATCH 26/53] filter_rewrite: Import that one in the main file. --- teleflask/server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teleflask/server/__init__.py b/teleflask/server/__init__.py index fbcb3d8..6b8f984 100644 --- a/teleflask/server/__init__.py +++ b/teleflask/server/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from .extras import Teleflask +from .extras import Teleflask, Teleserver __author__ = 'luckydonald' From 89a498d09b47338098bfc91d06b0b255d4eac357 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 02:57:53 +0200 Subject: [PATCH 27/53] filter_rewrite: Moved `Teleserver` to `.core`. --- teleflask/server/__init__.py | 3 +- teleflask/server/blueprints.py | 2 +- teleflask/server/core.py | 477 +++++++++++++++++++++++++++++++++ teleflask/server/extras.py | 467 +------------------------------- 4 files changed, 482 insertions(+), 467 deletions(-) create mode 100644 teleflask/server/core.py diff --git a/teleflask/server/__init__.py b/teleflask/server/__init__.py index 6b8f984..204dc49 100644 --- a/teleflask/server/__init__.py +++ b/teleflask/server/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from .extras import Teleflask, Teleserver +from .core import Teleserver +from .extras import Teleflask __author__ = 'luckydonald' diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index fa51833..2924e29 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -245,7 +245,7 @@ def on_update_inner(function): # end def @property - def server(self) -> Union['teleflask.Teleserver', 'teleflask.Teleflask']: + def server(self) -> Union['', 'teleflask.Teleflask']: if not self._got_registered_once: raise AssertionError('Not registered to an Teleflask instance yet.') # end if diff --git a/teleflask/server/core.py b/teleflask/server/core.py new file mode 100644 index 0000000..0b93592 --- /dev/null +++ b/teleflask/server/core.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Union, List, Callable, Dict + +from luckydonaldUtils.exceptions import assert_type_or_raise +from luckydonaldUtils.logger import logging + +__author__ = 'luckydonald' + +from pytgbot import Bot +from pytgbot.api_types.receivable.peer import User +from pytgbot.api_types.receivable.updates import Update + +from exceptions import AbortProcessingPlease +from server.extras import logger +from server.filters import Filter, NoMatch, UpdateFilter, MessageFilter, CommandFilter +from teleflask import TBlueprint + +logger = logging.getLogger(__name__) +if __name__ == '__main__': + logging.add_colored_handler(level=logging.DEBUG) +# end if + +class Teleserver(object): + """ + This is the core logic. + You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and + + You can use: + + Startup: + - `app.add_startup_listener` to let the given function be called on server/bot startup + - `app.remove_startup_listener` to remove the given function again + - `@app.on_startup` decorator which does the same as add_startup_listener. + See :class:`teleflask.mixins.StartupMixin` for complete information. + + Commands: + - `app.add_command` to add command functions + - `app.remove_command` to remove them again. + - `@app.command("command")` decorator as alias to `add_command` + - `@app.on_command("command")` decorator as alias to `add_command` + See :class:`teleflask.mixins.BotCommandsMixin` for complete information. + + Messages: + - `app.add_message_listener` to add functions + - `app.remove_message_listener` to remove them again. + - `@app.on_message` decorator as alias to `add_message_listener` + See :class:`teleflask.mixins.MessagesMixin` for complete information. + + Updates: + - `app.add_update_listener` to add functions to be called on incoming telegram updates. + - `app.remove_update_listener` to remove them again. + - `@app.on_update` decorator doing the same as `add_update_listener` + See :class:`teleflask.mixins.UpdatesMixin` for complete information. + + Execution order: + + It will first check for commands (`@command`), then for messages (`@on_message`) and + finally for update listeners (`@on_update`) + + Functionality is separated into mixin classes. This means you can plug together a class with just the functions you need. + But we also provide some ready-build cases: + :class:`teleflask.extras.TeleflaskCommands`, :class:`teleflask.extras.TeleflaskMessages`, + :class:`teleflask.extras.TeleflaskUpdates` and :class:`teleflask.extras.TeleflaskStartup`. + """ + + __api_key: str + _bot = Union[Bot, None] + _me: Union[User, None] + _return_python_objects: bool + + startup_listeners: List[Callable] + startup_already_run: bool + + blueprints: Dict[str, TBlueprint] + _blueprint_order: List[TBlueprint] + + def __init__( + self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", + debug_routes=False, disable_setting_webhook_telegram=None, disable_setting_webhook_route=None, + return_python_objects=True + ): + """ + A new Teleflask object. + + :param api_key: The key for the telegram bot api. + :type api_key: str + + :param app: The flask app if you don't like to call :meth:`init_app` yourself. + :type app: flask.Flask | None + + :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. + Use if you don't like to call :meth:`init_app` yourself. + If not set, but `app` is, it will register any routes to the `app` itself. + Note: This is NOT a `TBlueprint` but a regular `flask` one! + :type blueprint: flask.Blueprint | None + + :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. + Specify the path with :param hostpath: + Used to calculate the webhook url. + Also configurable via environment variables. See calculate_webhook_url() + :param hostpath: The host url the base of where this bot is reachable. + Examples: None (for root of server) or "/bot2" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :param hookpath: The endpoint of the telegram webhook. + Defaults to "/income/" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :param debug_routes: Add extra url endpoints usefull for debugging. See setup_routes(...) + + :param disable_setting_webhook_telegram: Disable updating the telegram webhook when starting. + Useful for unit tests. Defaults to the app's config + DISABLE_SETTING_ROUTE_WEBHOOK or False. + :type disable_setting_webhook_telegram: None|bool + + :param disable_setting_webhook_route: Disable creation of the webhook route. + Usefull if you don't need to listen for incomming events. + :type disable_setting_webhook_route: None|bool + + :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot + """ + + self.startup_listeners: List[Callable] = list() + self.startup_already_run: bool = False + + self.blueprints: Dict[str, TBlueprint] = {} + self._blueprint_order: List[TBlueprint] = [] + + self.__api_key: str = api_key + self._bot = Union[Bot, None] = None # will be set in self.init_bot() + self._me: Union[User, None] = None # will be set in self.init_bot() + self._return_python_objects: bool = return_python_objects + + super().__init__( + api_key=api_key, app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, + debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, + disable_setting_webhook_route=disable_setting_webhook_route, return_python_objects=return_python_objects, + ) + # end def + + def register_handler(self, event_handler: Filter): + """ + Adds an listener for any update type. + You provide a Filter for them as parameter, it also contains the function. + No error will be raised if it is already registered. In that case a warning will be logged, + but nothing else will happen, and the function is not added. + + Examples: + >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) + # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. + + >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) + # calls func(msg) for all updates which are inline queries (have the inline_query attribute) + + >>> register_handler(UpdateFilter(func, required_keywords=None)) + >>> register_handler(UpdateFilter(func)) + # allows all messages. + + :param function: The function to call. Will be called with the update and the message as arguments + :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. + Must be a list. + :return: the function, unmodified + """ + + logging.debug("adding handler to listeners") + self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND + return event_handler + # end def + + def remove_handler(self, event_handler): + """ + Removes an handler from the update listener list. + No error will be raised if it is already registered. In that case a warning will be logged, + but noting else will happen. + + + :param function: The function to remove + :return: the function, unmodified + """ + try: + self.update_listeners.remove(event_handler) + except ValueError: + logger.warning("listener already removed.") + # end if + # end def + + def remove_handled_func(self, func): + """ + Removes an function from the update listener list. + No error will be raised if it is no longer registered. In that case noting else will happen. + + :param function: The function to remove + :return: the function, unmodified + """ + listerner: Filter + self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] + # end def + + def process_update(self, update): + """ + Iterates through self.update_listeners, and calls them with (update, app). + + No try catch stuff is done, will fail instantly, and not process any remaining listeners. + + :param update: incoming telegram update. + :return: nothing. + """ + assert isinstance(update, Update) # Todo: non python objects + filter: Filter + for filter in self.update_listeners: + try: + # check if the Filter matches + match_result = filter.match(update) + # call the handler + result = filter.call_handler(update=update, match_result=match_result) + # send the message + self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() + except NoMatch as e: + logger.debug(f'not matching filter {filter!s}.') + except AbortProcessingPlease as e: + logger.debug('Asked to stop processing updates.') + if e.return_value: + self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() + # end if + return # not calling super().process_update(update) + except Exception: + logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") + # end try + # end for + super().process_update(update) + # end def + + def on_startup(self, func): + """ + Decorator to register a function to receive updates. + + Usage: + >>> @app.on_startup + >>> def foo(): + >>> print("doing stuff on boot") + + """ + return self.add_startup_listener(func) + # end def + + def add_startup_listener(self, func): + """ + Usage: + >>> def foo(): + >>> print("doing stuff on boot") + >>> app.add_startup_listener(foo) + + :param func: + :return: + """ + if func not in self.startup_listeners: + self.startup_listeners.append(func) + if self.startup_already_run: + func() + # end if + else: + logger.warning("listener already added.") + # end if + return func + # end def + + def remove_startup_listener(self, func): + if func in self.startup_listeners: + self.startup_listeners.remove(func) + else: + logger.warning("listener already removed.") + # end if + return func + # end def + + def register_tblueprint(self, tblueprint: TBlueprint, **options): + """ + Registers a `TBlueprint` on the application. + """ + first_registration = False + if tblueprint.name in self.blueprints: + assert self.blueprints[tblueprint.name] is tblueprint, \ + 'A teleflask blueprint\'s name collision occurred between %r and ' \ + '%r. Both share the same name "%s". TBlueprints that ' \ + 'are created on the fly need unique names.' % \ + (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) + else: + self.blueprints[tblueprint.name] = tblueprint + self._blueprint_order.append(tblueprint) + first_registration = True + tblueprint.register(self, options, first_registration) + # end def + + def iter_blueprints(self): + """ + Iterates over all blueprints by the order they were registered. + """ + return iter(self._blueprint_order) + # end def + + def process_result(self, update, result): + """ + Send the result. + It may be a :class:`Message` or a list of :class:`Message`s + Strings will be send as :class:`TextMessage`, encoded as raw text. + + :param update: A telegram incoming update + :type update: Update + + :param result: Something to send. + :type result: Union[List[Union[Message, str]], Message, str] + + :return: List of telegram responses. + :rtype: list + """ + from ..messages import Message + from ..new_messages import SendableMessageBase + reply_chat, reply_msg = self.msg_get_reply_params(update) + if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): + return list(self.send_messages(result, reply_chat, reply_msg)) + elif result is False or result is None: + logger.debug("Ignored result {res!r}".format(res=result)) + # ignore it + else: + logger.warning("Unexpected plugin result: {type}".format(type=type(result))) + # end if + # end def + + @staticmethod + def msg_get_reply_params(update): + """ + Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. + + :param update: pytgbot.api_types.receivable.updates.Update + :return: reply_chat, reply_msg + :rtype: tuple(int,int) + """ + assert_type_or_raise(update, Update, parameter_name="update") + assert isinstance(update, Update) + + if update.message and update.message.chat.id and update.message.message_id: + return update.message.chat.id, update.message.message_id + # end if + if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: + return update.channel_post.chat.id, update.channel_post.message_id + # end if + if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: + return update.edited_message.chat.id, update.edited_message.message_id + # end if + if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: + return update.edited_channel_post.chat.id, update.edited_channel_post.message_id + # end if + if update.callback_query and update.callback_query.message: + message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None + if update.callback_query.message.chat and update.callback_query.message.chat.id: + return update.callback_query.message.chat.id, message_id + # end if + if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: + return update.callback_query.message.from_peer.id, message_id + # end if + # end if + if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: + return update.inline_query.from_peer.id, None + # end if + return None, None + # end def + + def send_messages(self, messages, reply_chat, reply_msg): + """ + Sends a Message. + Plain strings will become an unformatted TextMessage. + Supports to mass send lists, tuples, Iterable. + + :param messages: A Message object. + :type messages: Message | str | list | tuple | + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. + False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. + :type instant: bool or None + """ + from pytgbot.exceptions import TgApiException + from ..messages import Message, TextMessage + from ..new_messages import SendableMessageBase + + logger.debug("Got {}".format(messages)) + if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): + raise TypeError("Is not a Message type (or str or tuple/list).") + # end if + if isinstance(messages, tuple): + messages = [x for x in messages] + # end if + if not isinstance(messages, list): + messages = [messages] + # end if + assert isinstance(messages, list) + for msg in messages: + if isinstance(msg, str): + assert not isinstance(messages, str) # because we would split a string to pieces. + msg = TextMessage(msg, parse_mode="text") + # end if + if not isinstance(msg, (Message, SendableMessageBase)): + raise TypeError("Is not a Message/SendableMessageBase type.") + # end if + # if msg._next_msg: # TODO: Reply message? + # message.insert(message.index(msg) + 1, msg._next_msg) + # msg._next_msg = None + from requests.exceptions import RequestException + msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) + try: + yield msg.send(self.bot) + except (TgApiException, RequestException): + logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) + # end try + # end for + # end def + + def send_message(self, messages, reply_chat, reply_msg): + """ + Backwards compatible version of send_messages. + + :param messages: + :param reply_chat: chat id + :type reply_chat: int + :param reply_msg: message id + :type reply_msg: int + :return: None + """ + list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) + return None + # end def + + @property + def bot(self): + """ + :return: Returns the bot + :rtype: Bot + """ + return self._bot + # end def + + @property + def me(self) -> User: + """ + Returns the info about the registered bot + :return: info about the registered bot user + """ + return self._me + # end def + + @property + def username(self) -> str: + """ + Returns the name of the registered bot + :return: the name + """ + return self.me.username + # end def + + @property + def user_id(self): + return self.me + # end def + + @property + def _api_key(self): + return self.__api_key + # end def + + on_update = UpdateFilter.decorator + on_message = MessageFilter.decorator + on_command = CommandFilter.decorator + + command = on_command diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py index af54f89..f508286 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- -import abc import os -from typing import Union, List, Callable, Dict -from luckydonaldUtils.exceptions import assert_type_or_raise from luckydonaldUtils.logger import logging from pytgbot import Bot from pytgbot.api_types import TgBotApiObject @@ -11,474 +8,14 @@ from pytgbot.api_types.receivable.updates import Update from pytgbot.exceptions import TgApiServerException -from exceptions import AbortProcessingPlease -from .blueprints import TBlueprint -from .filters import MessageFilter, UpdateFilter, CommandFilter, NoMatch, Filter +from . import Teleserver from .. import VERSION from .utilities import _class_self_decorate __author__ = 'luckydonald' -__all__ = ["Teleflask"] +__all__ = ["Teleflask", "PollingTeleflask"] logger = logging.getLogger(__name__) - -class Teleserver(object): - """ - This is the core logic. - You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and - - You can use: - - Startup: - - `app.add_startup_listener` to let the given function be called on server/bot startup - - `app.remove_startup_listener` to remove the given function again - - `@app.on_startup` decorator which does the same as add_startup_listener. - See :class:`teleflask.mixins.StartupMixin` for complete information. - - Commands: - - `app.add_command` to add command functions - - `app.remove_command` to remove them again. - - `@app.command("command")` decorator as alias to `add_command` - - `@app.on_command("command")` decorator as alias to `add_command` - See :class:`teleflask.mixins.BotCommandsMixin` for complete information. - - Messages: - - `app.add_message_listener` to add functions - - `app.remove_message_listener` to remove them again. - - `@app.on_message` decorator as alias to `add_message_listener` - See :class:`teleflask.mixins.MessagesMixin` for complete information. - - Updates: - - `app.add_update_listener` to add functions to be called on incoming telegram updates. - - `app.remove_update_listener` to remove them again. - - `@app.on_update` decorator doing the same as `add_update_listener` - See :class:`teleflask.mixins.UpdatesMixin` for complete information. - - Execution order: - - It will first check for commands (`@command`), then for messages (`@on_message`) and - finally for update listeners (`@on_update`) - - Functionality is separated into mixin classes. This means you can plug together a class with just the functions you need. - But we also provide some ready-build cases: - :class:`teleflask.extras.TeleflaskCommands`, :class:`teleflask.extras.TeleflaskMessages`, - :class:`teleflask.extras.TeleflaskUpdates` and :class:`teleflask.extras.TeleflaskStartup`. - """ - - __api_key: str - _bot = Union[Bot, None] - _me: Union[User, None] - _return_python_objects: bool - - startup_listeners: List[Callable] - startup_already_run: bool - - blueprints: Dict[str, TBlueprint] - _blueprint_order: List[TBlueprint] - - def __init__( - self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", - debug_routes=False, disable_setting_webhook_telegram=None, disable_setting_webhook_route=None, - return_python_objects=True - ): - """ - A new Teleflask object. - - :param api_key: The key for the telegram bot api. - :type api_key: str - - :param app: The flask app if you don't like to call :meth:`init_app` yourself. - :type app: flask.Flask | None - - :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. - Use if you don't like to call :meth:`init_app` yourself. - If not set, but `app` is, it will register any routes to the `app` itself. - Note: This is NOT a `TBlueprint` but a regular `flask` one! - :type blueprint: flask.Blueprint | None - - :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. - Specify the path with :param hostpath: - Used to calculate the webhook url. - Also configurable via environment variables. See calculate_webhook_url() - :param hostpath: The host url the base of where this bot is reachable. - Examples: None (for root of server) or "/bot2" - Note: The webhook will only be set on initialisation. - Also configurable via environment variables. See calculate_webhook_url() - :param hookpath: The endpoint of the telegram webhook. - Defaults to "/income/" - Note: The webhook will only be set on initialisation. - Also configurable via environment variables. See calculate_webhook_url() - :param debug_routes: Add extra url endpoints usefull for debugging. See setup_routes(...) - - :param disable_setting_webhook_telegram: Disable updating the telegram webhook when starting. - Useful for unit tests. Defaults to the app's config - DISABLE_SETTING_ROUTE_WEBHOOK or False. - :type disable_setting_webhook_telegram: None|bool - - :param disable_setting_webhook_route: Disable creation of the webhook route. - Usefull if you don't need to listen for incomming events. - :type disable_setting_webhook_route: None|bool - - :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot - """ - - self.startup_listeners: List[Callable] = list() - self.startup_already_run: bool = False - - self.blueprints: Dict[str, TBlueprint] = {} - self._blueprint_order: List[TBlueprint] = [] - - self.__api_key: str = api_key - self._bot = Union[Bot, None] = None # will be set in self.init_bot() - self._me: Union[User, None] = None # will be set in self.init_bot() - self._return_python_objects: bool = return_python_objects - - super().__init__( - api_key=api_key, app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, - debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, - disable_setting_webhook_route=disable_setting_webhook_route, return_python_objects=return_python_objects, - ) - # end def - - def register_handler(self, event_handler: Filter): - """ - Adds an listener for any update type. - You provide a Filter for them as parameter, it also contains the function. - No error will be raised if it is already registered. In that case a warning will be logged, - but nothing else will happen, and the function is not added. - - Examples: - >>> register_handler(UpdateFilter(func, required_keywords=["update_id", "message"])) - # will call func(msg) for all updates which are message (have the message attribute) and have a update_id. - - >>> register_handler(UpdateFilter(func, required_keywords=["inline_query"])) - # calls func(msg) for all updates which are inline queries (have the inline_query attribute) - - >>> register_handler(UpdateFilter(func, required_keywords=None)) - >>> register_handler(UpdateFilter(func)) - # allows all messages. - - :param function: The function to call. Will be called with the update and the message as arguments - :param required_keywords: If that evaluates to False (None, empty list, etc...) the filter is not applied, all messages are accepted. - Must be a list. - :return: the function, unmodified - """ - - logging.debug("adding handler to listeners") - self.update_listeners.append(event_handler) # list of lists. Outer list = OR, inner = AND - return event_handler - # end def - - def remove_handler(self, event_handler): - """ - Removes an handler from the update listener list. - No error will be raised if it is already registered. In that case a warning will be logged, - but noting else will happen. - - - :param function: The function to remove - :return: the function, unmodified - """ - try: - self.update_listeners.remove(event_handler) - except ValueError: - logger.warning("listener already removed.") - # end if - # end def - - def remove_handled_func(self, func): - """ - Removes an function from the update listener list. - No error will be raised if it is no longer registered. In that case noting else will happen. - - :param function: The function to remove - :return: the function, unmodified - """ - listerner: Filter - self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] - # end def - - def process_update(self, update): - """ - Iterates through self.update_listeners, and calls them with (update, app). - - No try catch stuff is done, will fail instantly, and not process any remaining listeners. - - :param update: incoming telegram update. - :return: nothing. - """ - assert isinstance(update, Update) # Todo: non python objects - filter: Filter - for filter in self.update_listeners: - try: - # check if the Filter matches - match_result = filter.match(update) - # call the handler - result = filter.call_handler(update=update, match_result=match_result) - # send the message - self.process_result(update, result) # this will be TeleflaskMixinBase.process_result() - except NoMatch as e: - logger.debug(f'not matching filter {filter!s}.') - except AbortProcessingPlease as e: - logger.debug('Asked to stop processing updates.') - if e.return_value: - self.process_result(update, e.return_value) # this will be TeleflaskMixinBase.process_result() - # end if - return # not calling super().process_update(update) - except Exception: - logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") - # end try - # end for - super().process_update(update) - # end def - - def on_startup(self, func): - """ - Decorator to register a function to receive updates. - - Usage: - >>> @app.on_startup - >>> def foo(): - >>> print("doing stuff on boot") - - """ - return self.add_startup_listener(func) - # end def - - def add_startup_listener(self, func): - """ - Usage: - >>> def foo(): - >>> print("doing stuff on boot") - >>> app.add_startup_listener(foo) - - :param func: - :return: - """ - if func not in self.startup_listeners: - self.startup_listeners.append(func) - if self.startup_already_run: - func() - # end if - else: - logger.warning("listener already added.") - # end if - return func - # end def - - def remove_startup_listener(self, func): - if func in self.startup_listeners: - self.startup_listeners.remove(func) - else: - logger.warning("listener already removed.") - # end if - return func - # end def - - def register_tblueprint(self, tblueprint: TBlueprint, **options): - """ - Registers a `TBlueprint` on the application. - """ - first_registration = False - if tblueprint.name in self.blueprints: - assert self.blueprints[tblueprint.name] is tblueprint, \ - 'A teleflask blueprint\'s name collision occurred between %r and ' \ - '%r. Both share the same name "%s". TBlueprints that ' \ - 'are created on the fly need unique names.' % \ - (tblueprint, self.blueprints[tblueprint.name], tblueprint.name) - else: - self.blueprints[tblueprint.name] = tblueprint - self._blueprint_order.append(tblueprint) - first_registration = True - tblueprint.register(self, options, first_registration) - # end def - - def iter_blueprints(self): - """ - Iterates over all blueprints by the order they were registered. - """ - return iter(self._blueprint_order) - # end def - - def process_result(self, update, result): - """ - Send the result. - It may be a :class:`Message` or a list of :class:`Message`s - Strings will be send as :class:`TextMessage`, encoded as raw text. - - :param update: A telegram incoming update - :type update: Update - - :param result: Something to send. - :type result: Union[List[Union[Message, str]], Message, str] - - :return: List of telegram responses. - :rtype: list - """ - from ..messages import Message - from ..new_messages import SendableMessageBase - reply_chat, reply_msg = self.msg_get_reply_params(update) - if isinstance(result, (SendableMessageBase, Message, str, list, tuple)): - return list(self.send_messages(result, reply_chat, reply_msg)) - elif result is False or result is None: - logger.debug("Ignored result {res!r}".format(res=result)) - # ignore it - else: - logger.warning("Unexpected plugin result: {type}".format(type=type(result))) - # end if - # end def - - @staticmethod - def msg_get_reply_params(update): - """ - Builds the `reply_chat` (chat id) and `reply_msg` (message id) values needed for `Message.send(...)` from an telegram `pytgbot` `Update` instance. - - :param update: pytgbot.api_types.receivable.updates.Update - :return: reply_chat, reply_msg - :rtype: tuple(int,int) - """ - assert_type_or_raise(update, Update, parameter_name="update") - assert isinstance(update, Update) - - if update.message and update.message.chat.id and update.message.message_id: - return update.message.chat.id, update.message.message_id - # end if - if update.channel_post and update.channel_post.chat.id and update.channel_post.message_id: - return update.channel_post.chat.id, update.channel_post.message_id - # end if - if update.edited_message and update.edited_message.chat.id and update.edited_message.message_id: - return update.edited_message.chat.id, update.edited_message.message_id - # end if - if update.edited_channel_post and update.edited_channel_post.chat.id and update.edited_channel_post.message_id: - return update.edited_channel_post.chat.id, update.edited_channel_post.message_id - # end if - if update.callback_query and update.callback_query.message: - message_id = update.callback_query.message.message_id if update.callback_query.message.message_id else None - if update.callback_query.message.chat and update.callback_query.message.chat.id: - return update.callback_query.message.chat.id, message_id - # end if - if update.callback_query.message.from_peer and update.callback_query.message.from_peer.id: - return update.callback_query.message.from_peer.id, message_id - # end if - # end if - if update.inline_query and update.inline_query.from_peer and update.inline_query.from_peer.id: - return update.inline_query.from_peer.id, None - # end if - return None, None - # end def - - def send_messages(self, messages, reply_chat, reply_msg): - """ - Sends a Message. - Plain strings will become an unformatted TextMessage. - Supports to mass send lists, tuples, Iterable. - - :param messages: A Message object. - :type messages: Message | str | list | tuple | - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :param instant: Send without waiting for the plugin's function to be done. True to send as soon as possible. - False or None to wait until the plugin's function is done and has returned, messages the answers in a bulk. - :type instant: bool or None - """ - from pytgbot.exceptions import TgApiException - from ..messages import Message, TextMessage - from ..new_messages import SendableMessageBase - - logger.debug("Got {}".format(messages)) - if not isinstance(messages, (SendableMessageBase, Message, str, list, tuple)): - raise TypeError("Is not a Message type (or str or tuple/list).") - # end if - if isinstance(messages, tuple): - messages = [x for x in messages] - # end if - if not isinstance(messages, list): - messages = [messages] - # end if - assert isinstance(messages, list) - for msg in messages: - if isinstance(msg, str): - assert not isinstance(messages, str) # because we would split a string to pieces. - msg = TextMessage(msg, parse_mode="text") - # end if - if not isinstance(msg, (Message, SendableMessageBase)): - raise TypeError("Is not a Message/SendableMessageBase type.") - # end if - # if msg._next_msg: # TODO: Reply message? - # message.insert(message.index(msg) + 1, msg._next_msg) - # msg._next_msg = None - from requests.exceptions import RequestException - msg._apply_update_receiver(receiver=reply_chat, reply_id=reply_msg) - try: - yield msg.send(self.bot) - except (TgApiException, RequestException): - logger.exception("Manager failed messages. Message was {msg!s}".format(msg=msg)) - # end try - # end for - # end def - - def send_message(self, messages, reply_chat, reply_msg): - """ - Backwards compatible version of send_messages. - - :param messages: - :param reply_chat: chat id - :type reply_chat: int - :param reply_msg: message id - :type reply_msg: int - :return: None - """ - list(self.send_messages(messages, reply_chat=reply_chat, reply_msg=reply_msg)) - return None - # end def - - @property - def bot(self): - """ - :return: Returns the bot - :rtype: Bot - """ - return self._bot - # end def - - @property - def me(self) -> User: - """ - Returns the info about the registered bot - :return: info about the registered bot user - """ - return self._me - # end def - - @property - def username(self) -> str: - """ - Returns the name of the registered bot - :return: the name - """ - return self.me.username - # end def - - @property - def user_id(self): - return self.me - # end def - - @property - def _api_key(self): - return self.__api_key - # end def - - on_update = UpdateFilter.decorator - on_message = MessageFilter.decorator - on_command = CommandFilter.decorator - - command = on_command -# end class - - _self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. From 2d2192af31fb2ab4803d61074ad4f8a93959c814 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 03:00:08 +0200 Subject: [PATCH 28/53] filter_rewrite: Woops. --- teleflask/server/blueprints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teleflask/server/blueprints.py b/teleflask/server/blueprints.py index 2924e29..379df9b 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -245,7 +245,7 @@ def on_update_inner(function): # end def @property - def server(self) -> Union['', 'teleflask.Teleflask']: + def server(self) -> Union['Teleserver', 'Teleflask']: if not self._got_registered_once: raise AssertionError('Not registered to an Teleflask instance yet.') # end if From 3d1166a9a3552a58eb2540eb88132faa21d6b2f1 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 03:00:31 +0200 Subject: [PATCH 29/53] filter_rewrite: Make the main package import the server one too. --- teleflask/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teleflask/__init__.py b/teleflask/__init__.py index 0420c6a..4c8c4c3 100644 --- a/teleflask/__init__.py +++ b/teleflask/__init__.py @@ -4,6 +4,6 @@ VERSION = "2.0.0.dev21" __version__ = VERSION -from .server import Teleflask +from .server import Teleserver, Teleflask from .server.blueprints import TBlueprint from .server.utilities import abort_processing From 0eb0ebf63a6901d13c27db46b053e5e92f9cda8b Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 03:07:02 +0200 Subject: [PATCH 30/53] filter_rewrite: Moved `Teleflask` to `.extras.flask`. Maybe I could look into something like https://snarky.ca/lazy-importing-in-python-3-7/ ? --- teleflask/server/__init__.py | 3 +-- teleflask/server/{extras.py => extras/flask.py} | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) rename teleflask/server/{extras.py => extras/flask.py} (99%) diff --git a/teleflask/server/__init__.py b/teleflask/server/__init__.py index 204dc49..62ed0c6 100644 --- a/teleflask/server/__init__.py +++ b/teleflask/server/__init__.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- from .core import Teleserver -from .extras import Teleflask +from .extras.flask import Teleflask __author__ = 'luckydonald' - diff --git a/teleflask/server/extras.py b/teleflask/server/extras/flask.py similarity index 99% rename from teleflask/server/extras.py rename to teleflask/server/extras/flask.py index f508286..479010b 100644 --- a/teleflask/server/extras.py +++ b/teleflask/server/extras/flask.py @@ -8,9 +8,9 @@ from pytgbot.api_types.receivable.updates import Update from pytgbot.exceptions import TgApiServerException -from . import Teleserver -from .. import VERSION -from .utilities import _class_self_decorate +from .. import Teleserver +from ... import VERSION +from ..utilities import _class_self_decorate __author__ = 'luckydonald' __all__ = ["Teleflask", "PollingTeleflask"] From 95ec3f288797fb47351c4c534f9f58451b35c2d7 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 03:26:45 +0200 Subject: [PATCH 31/53] filter_rewrite: Tried a lazy import for Teleserver, Teleflask as with different engines it could be funny in the feature. https://snarky.ca/lazy-importing-in-python-3-7/ --- teleflask/__init__.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/teleflask/__init__.py b/teleflask/__init__.py index 4c8c4c3..8a44a43 100644 --- a/teleflask/__init__.py +++ b/teleflask/__init__.py @@ -1,9 +1,43 @@ # -*- coding: utf-8 -*- __author__ = 'luckydonald' +import sys as _sys + VERSION = "2.0.0.dev21" __version__ = VERSION -from .server import Teleserver, Teleflask +__all__ = [ + 'VERSION', '__version__', + # the ones we provide for easy toplevel access: + 'Teleserver', 'Teleflask', 'TBlueprint', 'abort_processing', + # submodules: + 'server', 'exceptions', 'messages', 'new_messages', 'proxy', +] + +from .server.core import Teleserver + +if _sys.version_info.major >= 3 and _sys.version_info.minor >= 6: + try: + # we try to serve them as lazy imports + import importlib as _importlib + + _module = _importlib.import_module('.sever', __name__) + + def __getattr__(name): + if name not in ('Teleserver', 'Teleflask'): + raise AttributeError(f'module {_module.__spec__.parent!r} has no attribute {name!r}') + # end if + imported = _importlib.import_module(name, _module.__spec__.parent) + return imported + # end def + except Exception: + # for some reason it failed, go back to importing it directly + from .server.extras.flask import Teleflask + # end if +else: + # older python, go back to importing it directly + from .server.extras.flask import Teleflask +# end if + from .server.blueprints import TBlueprint from .server.utilities import abort_processing From c277ad3da1910c8a5c967b1f09a3a008e76d7508 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 03:29:15 +0200 Subject: [PATCH 32/53] filter_rewrite: Fixed a few import bugs. --- teleflask/server/core.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 0b93592..c3c365b 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -5,16 +5,17 @@ from luckydonaldUtils.exceptions import assert_type_or_raise from luckydonaldUtils.logger import logging -__author__ = 'luckydonald' - from pytgbot import Bot from pytgbot.api_types.receivable.peer import User from pytgbot.api_types.receivable.updates import Update -from exceptions import AbortProcessingPlease -from server.extras import logger -from server.filters import Filter, NoMatch, UpdateFilter, MessageFilter, CommandFilter -from teleflask import TBlueprint +from .blueprints import TBlueprint +from .filters import Filter, NoMatch, UpdateFilter, MessageFilter, CommandFilter + +from ..exceptions import AbortProcessingPlease + +__author__ = 'luckydonald' + logger = logging.getLogger(__name__) if __name__ == '__main__': @@ -128,7 +129,7 @@ def __init__( self._blueprint_order: List[TBlueprint] = [] self.__api_key: str = api_key - self._bot = Union[Bot, None] = None # will be set in self.init_bot() + self._bot: Union[Bot, None] = None # will be set in self.init_bot() self._me: Union[User, None] = None # will be set in self.init_bot() self._return_python_objects: bool = return_python_objects From 3717653d419c4ca43fe3c5e969b47236a1b7fb02 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 22:54:52 +0200 Subject: [PATCH 33/53] filter_rewrite: Moved the VERSION and __version__ object to the master parent. --- teleflask/server/core.py | 5 +++++ teleflask/server/extras/flask.py | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index c3c365b..82c0fe9 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -13,6 +13,7 @@ from .filters import Filter, NoMatch, UpdateFilter, MessageFilter, CommandFilter from ..exceptions import AbortProcessingPlease +from .. import VERSION __author__ = 'luckydonald' @@ -22,7 +23,11 @@ logging.add_colored_handler(level=logging.DEBUG) # end if + class Teleserver(object): + VERSION = VERSION + __version__ = VERSION + """ This is the core logic. You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py index 479010b..791ebc4 100644 --- a/teleflask/server/extras/flask.py +++ b/teleflask/server/extras/flask.py @@ -8,8 +8,7 @@ from pytgbot.api_types.receivable.updates import Update from pytgbot.exceptions import TgApiServerException -from .. import Teleserver -from ... import VERSION +from ... import Teleserver from ..utilities import _class_self_decorate __author__ = 'luckydonald' @@ -20,9 +19,6 @@ class Teleflask(Teleserver): - VERSION = VERSION - __version__ = VERSION - def __init__( self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", debug_routes=False, disable_setting_webhook_route=None, disable_setting_webhook_telegram=None, From e6a1f59d9e90f69347ae4da5a3743d2e8daf8dc0 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:02:09 +0200 Subject: [PATCH 34/53] filter_rewrite: Renamed Teleserver to Teleprocessor, to have the new Teleserver handle webhook pathes and such. --- teleflask/server/core.py | 140 +++++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 12 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 82c0fe9..864d465 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -9,6 +9,7 @@ from pytgbot.api_types.receivable.peer import User from pytgbot.api_types.receivable.updates import Update +from .utilities import calculate_webhook_url from .blueprints import TBlueprint from .filters import Filter, NoMatch, UpdateFilter, MessageFilter, CommandFilter @@ -24,7 +25,7 @@ # end if -class Teleserver(object): +class Teleprocessor(object): VERSION = VERSION __version__ = VERSION @@ -82,12 +83,20 @@ class Teleserver(object): _blueprint_order: List[TBlueprint] def __init__( - self, api_key, app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", - debug_routes=False, disable_setting_webhook_telegram=None, disable_setting_webhook_route=None, - return_python_objects=True + self, + api_key, + *, + return_python_objects: bool = True ): """ - A new Teleflask object. + This is the very core of the server. + + Handles registration and update processing. + Does not handle any webserver kind of things, so you can use this very flexable. + + You can register a bunch of listeners. + Then you have to call `do_startup` once and `process_update` with every update and you're good to go. + :param api_key: The key for the telegram bot api. :type api_key: str @@ -137,13 +146,7 @@ def __init__( self._bot: Union[Bot, None] = None # will be set in self.init_bot() self._me: Union[User, None] = None # will be set in self.init_bot() self._return_python_objects: bool = return_python_objects - - super().__init__( - api_key=api_key, app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, - debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, - disable_setting_webhook_route=disable_setting_webhook_route, return_python_objects=return_python_objects, - ) - # end def + # end def def register_handler(self, event_handler: Filter): """ @@ -481,3 +484,116 @@ def _api_key(self): on_command = CommandFilter.decorator command = on_command +# end def + + +class Teleserver(Teleprocessor): + def __init__( + self, + api_key, + hostname=None, hostpath=None, hookpath="/income/{API_KEY}", + return_python_objects=True + ): + """ + This is the very core of the server. + + Handles registration and update processing. + Does not handle any webserver kind of things, so you can use this very flexable. + + You can register a bunch of listeners. + Then you have to call `do_startup` once and `process_update` with every update and you're good to go. + + + :param api_key: The key for the telegram bot api. + :type api_key: str + + :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. + Specify the path with :param hostpath: + Used to calculate the webhook url. + Also configurable via environment variables. See calculate_webhook_url() + :param hostpath: The host url the base of where this bot is reachable. + Examples: None (for root of server) or "/bot2" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :param hookpath: The endpoint of the telegram webhook. + Defaults to "/income/" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + + :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot + """ + super().__init__( + api_key=api_key, + return_python_objects=return_python_objects, + ) + self.hostname = hostname # e.g. "example.com:443" + self.hostpath = hostpath # e.g. /foo + self.hookpath = hookpath # e.g. /income/{API_KEY} + # end def +# end def + + +class Gnerf(Teleserver): + def __init__( + self, + api_key, + app=None, blueprint=None, hostname=None, hostpath=None, hookpath="/income/{API_KEY}", + debug_routes=False, disable_setting_webhook_telegram=None, disable_setting_webhook_route=None, + return_python_objects=True + ): + """ + This is the very core of the server. + + Handles registration and update processing. + Does not handle any webserver kind of things, so you can use this very flexable. + + You can register a bunch of listeners. + Then you have to call `do_startup` once and `process_update` with every update and you're good to go. + + + :param api_key: The key for the telegram bot api. + :type api_key: str + + :param app: The flask app if you don't like to call :meth:`init_app` yourself. + :type app: flask.Flask | None + + :param blueprint: A blueprint, where the telegram webhook (and the debug endpoints, see `debug_routes`) will be registered in. + Use if you don't like to call :meth:`init_app` yourself. + If not set, but `app` is, it will register any routes to the `app` itself. + Note: This is NOT a `TBlueprint` but a regular `flask` one! + :type blueprint: flask.Blueprint | None + + :param hostname: The hostname or IP (and maybe a port) where this server is reachable in the internet. + Specify the path with :param hostpath: + Used to calculate the webhook url. + Also configurable via environment variables. See calculate_webhook_url() + :param hostpath: The host url the base of where this bot is reachable. + Examples: None (for root of server) or "/bot2" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :param hookpath: The endpoint of the telegram webhook. + Defaults to "/income/" + Note: The webhook will only be set on initialisation. + Also configurable via environment variables. See calculate_webhook_url() + :param debug_routes: Add extra url endpoints usefull for debugging. See setup_routes(...) + + :param disable_setting_webhook_telegram: Disable updating the telegram webhook when starting. + Useful for unit tests. Defaults to the app's config + DISABLE_SETTING_ROUTE_WEBHOOK or False. + :type disable_setting_webhook_telegram: None|bool + + :param disable_setting_webhook_route: Disable creation of the webhook route. + Usefull if you don't need to listen for incomming events. + :type disable_setting_webhook_route: None|bool + + :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot + """ + super().__init__( + api_key=api_key, + app=app, blueprint=blueprint, hostname=hostname, hookpath=hookpath, + debug_routes=debug_routes, disable_setting_webhook_telegram=disable_setting_webhook_telegram, + disable_setting_webhook_route=disable_setting_webhook_route, + return_python_objects=return_python_objects, + ) + pass +# end def From b25f3b80512eb12abe77b0c317ce7e13c471db44 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:03:00 +0200 Subject: [PATCH 35/53] filter_rewrite: Extracted calculate_webhook_url to be an helper function. --- teleflask/server/core.py | 1 + teleflask/server/extras/flask.py | 91 ------------------------------ teleflask/server/utilities.py | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 91 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 864d465..739a6fd 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -529,6 +529,7 @@ def __init__( self.hostname = hostname # e.g. "example.com:443" self.hostpath = hostpath # e.g. /foo self.hookpath = hookpath # e.g. /income/{API_KEY} + self.webhook_url = calculate_webhook_url(api_key=api_key, hostname=hostname, hostpath=hostpath, hookpath=hookpath) # will be filled out by self.calculate_webhook_url() in self.init_app(...) # end def # end def diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py index 791ebc4..a2a5707 100644 --- a/teleflask/server/extras/flask.py +++ b/teleflask/server/extras/flask.py @@ -74,10 +74,6 @@ def __init__( disable_setting_webhook_telegram, disable_setting_webhook_route, return_python_objects) self.app = None # will be filled out by self.init_app(...) self.blueprint = None # will be filled out by self.init_app(...) - self.__webhook_url = None # will be filled out by self.calculate_webhook_url() in self.init_app(...) - self.hostname = hostname # e.g. "example.com:443" - self.hostpath = hostpath - self.hookpath = hookpath if disable_setting_webhook_route is None: try: @@ -171,93 +167,6 @@ def init_app(self, app, blueprint=None, debug_routes=False): self.do_startup() # this calls the startup listeners of extending classes. # end def - def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/{API_KEY}"): - """ - Calculates the webhook url. - Please note, this doesn't change any registered view function! - Returns a tuple of the hook path (the url endpoint for your flask app) and the full webhook url (for telegram) - Note: Both can include the full API key, as replacement for ``{API_KEY}`` in the hookpath. - - :Example: - - Your bot is at ``https://example.com:443/bot2/``, - you want your flask to get the updates at ``/tg-webhook/{API_KEY}``. - This means Telegram will have to send the updates to ``https://example.com:443/bot2/tg-webhook/{API_KEY}``. - - You now would set - hostname = "example.com:443", - hostpath = "/bot2", - hookpath = "/tg-webhook/{API_KEY}" - - Note: Set ``hostpath`` if you are behind a reverse proxy, and/or your flask app root is *not* at the web server root. - - - :param hostname: A hostname. Without the protocol. - Examples: "localhost", "example.com", "example.com:443" - If None (default), the hostname comes from the URL_HOSTNAME environment variable, or from http://ipinfo.io if that fails. - :param hostpath: The path after the hostname. It must start with a slash. - Use this if you aren't at the root at the server, i.e. use url_rewrite. - Example: "/bot2" - If None (default), the path will be read from the URL_PATH environment variable, or "" if that fails. - :param hookpath: Template for the route of incoming telegram webhook events. Must start with a slash. - The placeholder {API_KEY} will replaced with the telegram api key. - Note: This doesn't change any routing. You need to update any registered @app.route manually! - :return: the tuple of calculated (hookpath, webhook_url). - :rtype: tuple - """ - import os, requests - # # - # # try to fill out empty arguments - # # - if not hostname: - hostname = os.getenv('URL_HOSTNAME', None) - # end if - if hostpath is None: - hostpath = os.getenv('URL_PATH', "") - # end if - if not hookpath: - hookpath = "/income/{API_KEY}" - # end if - # # - # # check if the path looks at least a bit valid - # # - logger.debug("hostname={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}".format( - hostn=hostname, hostp=hostpath, hookp=hookpath - )) - if hostname: - if hostname.endswith("/"): - raise ValueError("hostname can't end with a slash: {value}".format(value=hostname)) - # end if - if hostname.startswith("https://"): - hostname = hostname[len("https://"):] - logger.warning("Automatically removed \"https://\" from hostname. Don't include it.") - # end if - if hostname.startswith("http://"): - raise ValueError("Don't include the protocol ('http://') in the hostname. " - "Also telegram doesn't support http, only https.") - # end if - else: # no hostname - info = requests.get('http://ipinfo.io').json() - hostname = str(info["ip"]) - logger.warning("URL_HOSTNAME env not set, falling back to ip address: {ip!r}".format(ip=hostname)) - # end if - if not hostpath == "" and not hostpath.startswith("/"): - logger.info("hostpath didn't start with a slash: {value!r} Will be added automatically".format(value=hostpath)) - hostpath = "/" + hostpath - # end def - if not hookpath.startswith("/"): - raise ValueError("hookpath must start with a slash: {value!r}".format(value=hostpath)) - # end def - hookpath = hookpath.format(API_KEY=self._api_key) - if not hostpath: - logger.info("URL_PATH is not set.") - webhook_url = "https://{hostname}{hostpath}{hookpath}".format(hostname=hostname, hostpath=hostpath, hookpath=hookpath) - logger.debug("host={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}, hookurl={url!r}".format( - hostn=hostname, hostp=hostpath, hookp=hookpath, url=webhook_url - )) - return hookpath, webhook_url - # end def - def set_webhook_telegram(self): """ Sets the telegram webhook. diff --git a/teleflask/server/utilities.py b/teleflask/server/utilities.py index 4921713..3f20e3d 100644 --- a/teleflask/server/utilities.py +++ b/teleflask/server/utilities.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +from typing import Union, Tuple from ..exceptions import AbortProcessingPlease @@ -51,6 +52,100 @@ def self_extractor(self, *args): # end def +def calculate_webhook_url( + api_key: str, + hostname: Union[str, None] = None, + hostpath: Union[str, None] = None, + hookpath: str = "/income/{API_KEY}" +) -> Tuple[str, str]: + """ + Calculates the webhook url. + Returns a tuple of the hook path (the url endpoint for your flask app) and the full webhook url (for telegram) + Note: Both can include the full API key, as replacement for ``{API_KEY}`` in the hookpath. + + :Example: + + Your bot is at ``https://example.com:443/bot2/``, + you want your flask to get the updates at ``/tg-webhook/{API_KEY}``. + This means Telegram will have to send the updates to ``https://example.com:443/bot2/tg-webhook/{API_KEY}``. + + You now would set + hostname = "example.com:443", + hostpath = "/bot2", + hookpath = "/tg-webhook/{API_KEY}" + + Note: Set ``hostpath`` if you are behind a reverse proxy, and/or your flask app root is *not* at the web server root. + + + :param hostname: A hostname. Without the protocol. + Examples: "localhost", "example.com", "example.com:443" + If None (default), the hostname comes from the URL_HOSTNAME environment variable, or from http://ipinfo.io if that fails. + :param hostpath: The path after the hostname. It must start with a slash. + Use this if you aren't at the root at the server, i.e. use url_rewrite. + Example: "/bot2" + If None (default), the path will be read from the URL_PATH environment variable, or "" if that fails. + :param hookpath: Template for the route of incoming telegram webhook events. Must start with a slash. + The placeholder {API_KEY} will replaced with the telegram api key. + Note: This doesn't change any routing. You need to update any registered @app.route manually! + :return: the tuple of calculated (hookpath, webhook_url). + :rtype: tuple + """ + import os, requests + + # # + # # try to fill out empty arguments + # # + if not hostname: + hostname = os.getenv('URL_HOSTNAME', None) + # end if + if hostpath is None: + hostpath = os.getenv('URL_PATH', "") + # end if + if not hookpath: + hookpath = "/income/{API_KEY}" + # end if + + # # + # # check if the path looks at least a bit valid + # # + logger.debug("hostname={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}".format( + hostn=hostname, hostp=hostpath, hookp=hookpath + )) + if hostname: + if hostname.endswith("/"): + raise ValueError("hostname can't end with a slash: {value}".format(value=hostname)) + # end if + if hostname.startswith("https://"): + hostname = hostname[len("https://"):] + logger.warning("Automatically removed \"https://\" from hostname. Don't include it.") + # end if + if hostname.startswith("http://"): + raise ValueError("Don't include the protocol ('http://') in the hostname. " + "Also telegram doesn't support http, only https.") + # end if + else: + raise ValueError("hostname can't be None.") + # end if + + if not hostpath == "" and not hostpath.startswith("/"): + logger.info("hostpath didn't start with a slash: {value!r} Will be added automatically".format(value=hostpath)) + hostpath = "/" + hostpath + # end def + if not hookpath.startswith("/"): + raise ValueError("hookpath must start with a slash: {value!r}".format(value=hostpath)) + # end def + hookpath = hookpath.format(API_KEY=api_key) + if not hostpath: + logger.info("URL_PATH is not set.") + # end if + webhook_url = "https://{hostname}{hostpath}{hookpath}".format(hostname=hostname, hostpath=hostpath, hookpath=hookpath) + logger.debug("host={hostn!r}, hostpath={hostp!r}, hookpath={hookp!r}, hookurl={url!r}".format( + hostn=hostname, hostp=hostpath, hookp=hookpath, url=webhook_url + )) + return hookpath, webhook_url +# end def + + def abort_processing(func): """ Wraps a function to automatically raise a `AbortProcessingPlease` exception after execution, From c5e5188b5edd9b98f53f184838093366c79793ce Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:06:16 +0200 Subject: [PATCH 36/53] filter_rewrite: Fixed/imporved some imports. --- teleflask/server/extras/flask.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py index a2a5707..3fe41a3 100644 --- a/teleflask/server/extras/flask.py +++ b/teleflask/server/extras/flask.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- import os +import requests +from flask import request +from pprint import pformat from luckydonaldUtils.logger import logging from pytgbot import Bot @@ -539,7 +542,7 @@ def do_startup(self): # end def def _start_proxy_process(self): - from ..proxy import proxy_telegram + from ...proxy import proxy_telegram from multiprocessing import Process global telegram_proxy_process telegram_proxy_process = Process(target=proxy_telegram, args=(), kwargs=dict( From 876acb866fe463abbfbd49526ee0f0ac05a49feb Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:07:42 +0200 Subject: [PATCH 37/53] filter_rewrite: Removed some imports, you'll need to go the full path yourself, or use the top level import. --- teleflask/server/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/teleflask/server/__init__.py b/teleflask/server/__init__.py index 62ed0c6..6984253 100644 --- a/teleflask/server/__init__.py +++ b/teleflask/server/__init__.py @@ -1,5 +1,2 @@ # -*- coding: utf-8 -*- -from .core import Teleserver -from .extras.flask import Teleflask - __author__ = 'luckydonald' From 73d6bdb9fcf0693642d2f33b15c8bc7988b97588 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:08:22 +0200 Subject: [PATCH 38/53] filter_rewrite: Speaking of top level import. --- teleflask/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/teleflask/__init__.py b/teleflask/__init__.py index 8a44a43..9c4e463 100644 --- a/teleflask/__init__.py +++ b/teleflask/__init__.py @@ -9,14 +9,18 @@ __all__ = [ 'VERSION', '__version__', # the ones we provide for easy toplevel access: - 'Teleserver', 'Teleflask', 'TBlueprint', 'abort_processing', + 'Teleprocessor', 'Teleserver', + 'TBlueprint', 'abort_processing', + # special modules: + 'Teleflask', # submodules: 'server', 'exceptions', 'messages', 'new_messages', 'proxy', ] -from .server.core import Teleserver +from .server.core import Teleprocessor, Teleserver if _sys.version_info.major >= 3 and _sys.version_info.minor >= 6: + IMPORTS = {'Teleflask': '.server.extras.flask',} try: # we try to serve them as lazy imports import importlib as _importlib @@ -24,10 +28,10 @@ _module = _importlib.import_module('.sever', __name__) def __getattr__(name): - if name not in ('Teleserver', 'Teleflask'): - raise AttributeError(f'module {_module.__spec__.parent!r} has no attribute {name!r}') + if name not in IMPORTS: + raise AttributeError(f'module {_module.__spec__.parent!r} has no attribute {name!r} ({IMPORTS[name]!r}') # end if - imported = _importlib.import_module(name, _module.__spec__.parent) + imported = _importlib.import_module(IMPORTS[name], _module.__spec__.parent) return imported # end def except Exception: From cfecae458330f09bfff301ba0118be5d11f682bd Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:12:32 +0200 Subject: [PATCH 39/53] filter_rewrite: Don't import inside of the functions. --- teleflask/server/extras/flask.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py index 3fe41a3..6afddfa 100644 --- a/teleflask/server/extras/flask.py +++ b/teleflask/server/extras/flask.py @@ -5,6 +5,7 @@ from pprint import pformat from luckydonaldUtils.logger import logging + from pytgbot import Bot from pytgbot.api_types import TgBotApiObject from pytgbot.api_types.receivable.peer import User @@ -334,8 +335,6 @@ def view_exec(self, api_key, command): logger.warning(error_msg) return {"status": "error", "message": error_msg, "error_code": 403}, 403 # end if - from flask import request - from pytgbot.exceptions import TgApiServerException logger.debug("COMMAND: {cmd}, ARGS: {args}".format(cmd=command, args=request.args)) try: res = self.bot.do(command, **request.args) @@ -370,9 +369,6 @@ def view_updates(self): :return: """ - from pprint import pformat - from flask import request - logger.debug("INCOME:\n{}\n\nHEADER:\n{}".format( pformat(request.get_json()), request.headers if hasattr(request, "headers") else None From 05b4a8df564a98cc336363d2aeb61b3c258311cc Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:13:44 +0200 Subject: [PATCH 40/53] filter_rewrite: Bring back the http://ipinfo.io check within Teleflask. That is because we'll not always be sync, but may be async in the future. --- teleflask/server/extras/flask.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py index 6afddfa..f0eec78 100644 --- a/teleflask/server/extras/flask.py +++ b/teleflask/server/extras/flask.py @@ -493,6 +493,22 @@ def setup_routes(self, hookpath, debug_routes=False): def _webhook_url(self): return self.__webhook_url # end def + + def calculate_webhook_url(self, hostname, hostpath, hookpath): + if not hostname: + hostname = os.getenv('URL_HOSTNAME', None) + # end if + if not hostname: + info = requests.get('http://ipinfo.io').json() + hostname = str(info["ip"]) + logger.warning("URL_HOSTNAME env not set, falling back to ip address: {ip!r}".format(ip=hostname)) + # end if + if hostname is None: # no hostname + info = requests.get('http://ipinfo.io').json() + hostname = str(info["ip"]) + logger.warning("URL_HOSTNAME env not set, falling back to ip address: {ip!r}".format(ip=hostname)) + # end if + # end def # end class From 3440293d9e2a75f3db8c33430b7a5f7ea6daf27d Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:16:48 +0200 Subject: [PATCH 41/53] filter_rewrite: Fixed import path of PollingTeleflask in one example. --- examples/example2/bot_stuff2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/example2/bot_stuff2.py b/examples/example2/bot_stuff2.py index c248eac..e477fc0 100644 --- a/examples/example2/bot_stuff2.py +++ b/examples/example2/bot_stuff2.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from flask import Flask -from teleflask.server.extras import PollingTeleflask +from teleflask.server.extras.flask import PollingTeleflask from somewhere import API_KEY @@ -33,4 +33,4 @@ def test2(update): # end def -app.run(HOST, PORT, debug=True) \ No newline at end of file +app.run(HOST, PORT, debug=True) From 6a4101d78aef3bbc77f5e35d9e073142d77c9b56 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:42:58 +0200 Subject: [PATCH 42/53] filter_rewrite: Improved docstrings. --- teleflask/server/core.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 739a6fd..0d91f88 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -30,8 +30,12 @@ class Teleprocessor(object): __version__ = VERSION """ - This is the core logic. - You can register a bunch of listeners. Then you have to call `do_startup` and `process_update` and + This is the core logic. Handles registration and update processing. + Does not handle any webserver kind of things, so you can use this very flexable. + + You can register a bunch of listeners. + Then you have to call `do_startup` once and `process_update` with every update and you're good to go. + You can use: From 2194cb295286c203048396c4737f687ba76e1f29 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:47:30 +0200 Subject: [PATCH 43/53] filter_rewrite: Trying to get a sync polling version set up. --- teleflask/__init__.py | 2 +- teleflask/server/extras/polling/sync.py | 103 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 teleflask/server/extras/polling/sync.py diff --git a/teleflask/__init__.py b/teleflask/__init__.py index 9c4e463..7b3eae7 100644 --- a/teleflask/__init__.py +++ b/teleflask/__init__.py @@ -20,7 +20,7 @@ from .server.core import Teleprocessor, Teleserver if _sys.version_info.major >= 3 and _sys.version_info.minor >= 6: - IMPORTS = {'Teleflask': '.server.extras.flask',} + IMPORTS = {'Teleflask': '.server.extras.flask', 'SyncTelepoll': '.server.extras.polling.sync'} try: # we try to serve them as lazy imports import importlib as _importlib diff --git a/teleflask/server/extras/polling/sync.py b/teleflask/server/extras/polling/sync.py new file mode 100644 index 0000000..93f03d3 --- /dev/null +++ b/teleflask/server/extras/polling/sync.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +from pprint import pformat +from flask import request + +from luckydonaldUtils.logger import logging +from pytgbot.exceptions import TgApiServerException + +from pytgbot import Bot +from pytgbot.api_types import TgBotApiObject +from pytgbot.api_types.receivable.peer import User +from pytgbot.api_types.receivable.updates import Update +from pytgbot.exceptions import TgApiServerException + +from ... import Teleserver + +__author__ = 'luckydonald' +__all__ = ["Telepoll"] +logger = logging.getLogger(__name__) + +class Telepoll(Teleserver): + please_do_stop: bool + + def __init__( + self, + api_key: str, + return_python_objects: bool = True, + ): + """ + A simple bot interface polling the telegram servers repeatedly and using the Teleserver api to process the updates. + This allows to use this system without the need for servers and webhooks. + + Just initialize it and call `.run_forever()` + + :param api_key: The key for the telegram bot api. + :type api_key: str + + :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot + """ + super().__init__(api_key, return_python_objects) + self.please_do_stop = False + self.init_bot() + self._offset = None + # end def + + def init_bot(self): + """ + Creates the bot, and retrieves information about the bot itself (username, user_id) from telegram. + + :return: + """ + if not self._bot: # so you can manually set it before calling `init_app(...)`, + # e.g. a mocking bot class for unit tests + self._bot = Bot(self._api_key, return_python_objects=self._return_python_objects) + elif self._bot.return_python_objects != self._return_python_objects: + # we don't have the same setting as the given one + raise ValueError("The already set bot has return_python_objects {given}, but we have {our}".format( + given=self._bot.return_python_objects, our=self._return_python_objects + )) + # end def + myself = self._bot.get_me() + if self._bot.return_python_objects: + self._me = myself + else: + assert isinstance(myself, dict) + self._me = User.from_array(myself["result"]) + # end if + # end def + + def _foreach_update(self): + """ + Waits for the next update by active polling the telegram servers. + As soon as we got an update, it'll call all the registered @decorators, and will yield the results of those, + so you can process them. If a single update triggers multiple results, all of those will be yielded. + So you'd get sendable stuff, or None. + + :return: + """ + + def run_forever(self): + while not self.please_do_stop: + updates = self.bot.get_updates( + offset=self._offset, + limit=100, + error_as_empty = True, + ) + for update in updates: + logger.debug(f'processing update: {update!r}') + try: + result = self.process_update(update) + except: + logger.exception('processing update failed') + continue + # end try + try: + messages = self.process_result(update, result) + except: + logger.exception('processing result failed') + continue + # end try + logger.debug(f'sent {len(messages)} messages') + # end for + # end while +# end class From 6127e739adc227110c80918d3f332869641ae414 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Mon, 29 Jun 2020 23:49:13 +0200 Subject: [PATCH 44/53] filter_rewrite: Example for the sync polling version. --- examples/example2/sync_poll_bot_example.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 examples/example2/sync_poll_bot_example.py diff --git a/examples/example2/sync_poll_bot_example.py b/examples/example2/sync_poll_bot_example.py new file mode 100644 index 0000000..0149655 --- /dev/null +++ b/examples/example2/sync_poll_bot_example.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from luckydonaldUtils.logger import logging +from teleflask.server.extras.polling.sync import Telepoll + +from somewhere import API_KEY + +__author__ = 'luckydonald' + +logger = logging.getLogger(__name__) +if __name__ == '__main__': + logging.add_colored_handler(level=logging.DEBUG) +# end if + + +bot = Telepoll(api_key=API_KEY) + + +@bot.command("test") +def test(update, text): + return "You tested with {arg!r}".format(arg=text) +# end def + + +@bot.on_update() +def test2(update): + pass +# end def + + +bot.run_forever() From 3c2ac6fae0a6c1adc4cf27d1d024abe5b423dd3f Mon Sep 17 00:00:00 2001 From: luckydonald Date: Tue, 30 Jun 2020 03:16:03 +0200 Subject: [PATCH 45/53] filter_rewrite: Fixed subclassing the server instead of just the processor here. --- teleflask/server/extras/polling/sync.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/teleflask/server/extras/polling/sync.py b/teleflask/server/extras/polling/sync.py index 93f03d3..29eabf4 100644 --- a/teleflask/server/extras/polling/sync.py +++ b/teleflask/server/extras/polling/sync.py @@ -11,13 +11,13 @@ from pytgbot.api_types.receivable.updates import Update from pytgbot.exceptions import TgApiServerException -from ... import Teleserver +from ...core import Teleprocessor __author__ = 'luckydonald' __all__ = ["Telepoll"] logger = logging.getLogger(__name__) -class Telepoll(Teleserver): +class Telepoll(Teleprocessor): please_do_stop: bool def __init__( @@ -36,7 +36,7 @@ def __init__( :param return_python_objects: Enable return_python_objects in pytgbot. See pytgbot.bot.Bot """ - super().__init__(api_key, return_python_objects) + super().__init__(api_key, return_python_objects=return_python_objects) self.please_do_stop = False self.init_bot() self._offset = None From b5843f1690b8b41574dcc8cccb42bdce0d2aa412 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Tue, 30 Jun 2020 03:16:35 +0200 Subject: [PATCH 46/53] filter_rewrite: Be verbose with the process_update type assertion. --- teleflask/server/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 0d91f88..0db4155 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -210,7 +210,7 @@ def remove_handled_func(self, func): self.update_listeners = [listerner for listerner in self.update_listeners if listerner.func != func] # end def - def process_update(self, update): + def process_update(self, update: Update): """ Iterates through self.update_listeners, and calls them with (update, app). @@ -219,7 +219,7 @@ def process_update(self, update): :param update: incoming telegram update. :return: nothing. """ - assert isinstance(update, Update) # Todo: non python objects + assert_type_or_raise(update, Update, parameter_name='update') # Todo: non python objects filter: Filter for filter in self.update_listeners: try: From f067dcca6fdb032ff8e96665fc3c7ed99d5192fe Mon Sep 17 00:00:00 2001 From: luckydonald Date: Tue, 30 Jun 2020 03:17:35 +0200 Subject: [PATCH 47/53] filter_rewrite: Streamlined the requirements to now have a [sync], [async], [flask] and [quart] flavor. --- setup.py | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index b5ac2e1..aed7a2d 100644 --- a/setup.py +++ b/setup.py @@ -7,20 +7,31 @@ long_description = """A Python module that connects to the Telegram bot api, allowing to interact with Telegram users or groups.""" + +SYNC_REQUIREMENTS = [ + 'pytgbot[sync]', + 'requests', "requests[security]", # connect with the internet in general +] +ASYNC_REQUIREMENTS = [ + 'pytgbot[async]', + 'httpx', # connect with the internet in general +] + + setup( - name='teleflask', version="2.0.0.dev21", - description='Easily create Telegram bots with pytgbot and flask. Webhooks made easy.', + name='teleflask', version="3.0.0.dev1", + description='Easily create Telegram bots with decorators functions, running a webserver of your choice. Webhooks made easy, but you don\'t even have to use \'em.', long_description=long_description, # The project's main homepage. url='https://github.com/luckydonald/teleflask', # Author details author='luckydonald', - author_email='code@luckydonald.de', + author_email='teleflask+code@luckydonald.de', # Choose your license license='GPLv3+', # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ - 'Development Status :: 2 - Pre-Alpha', # 2 - Pre-Alpha, 3 - Alpha, 4 - Beta, 5 - Production/Stable + 'Development Status :: 4 - Beta', # 2 - Pre-Alpha, 3 - Alpha, 4 - Beta, 5 - Production/Stable # Indicate who your project is intended for 'Intended Audience :: Developers', 'Topic :: Communications', @@ -36,16 +47,18 @@ # that you indicate whether you support Python 2, Python 3 or both. # 'Programming Language :: Python :: 2', # 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', + # 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', # 'Programming Language :: Python :: 3.2', # 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.7', 'Operating System :: MacOS :: MacOS X', 'Operating System :: Unix', ], # What does your project relate to? - keywords='pytgbot flask webhook telegram bot api python message send receive python secure fast answer reply image voice picture location contacts typing multi messanger inline quick reply gif image video mp4 mpeg4', + keywords='pytgbot flask webhook telegram bot api python message send receive python secure fast answer reply image voice picture location contacts typing multi messanger inline quick reply gif image video mp4 mpeg4 webserver decorators', # You can just specify the packages manually here if your project is # simple. Or you can use find_packages(). packages=['teleflask', 'teleflask.server'], @@ -55,20 +68,27 @@ # requirements files see: # https://packaging.python.org/en/latest/requirements.html install_requires=[ - "flask", # have flask - "pytgbot>=4.0", # connect to telegram + 'pprint', "DictObject", "luckydonald-utils>=0.70", # general utils "python-magic", "backoff>=1.4.1", # messages messages # backoff >=1.4.1 because of a bug with the flask development server # see https://github.com/litl/backoff/issues/30 - "requests", "requests[security]", # connect with the internet in general + 'pytgbot>=4.0" # connect to telegram' ], # List additional groups of dependencies here (e.g. development dependencies). # You can install these using the following syntax, for example: # $ pip install -e .[dev,test] extras_require = { - 'dev': ['bump2version'], - # 'test': ['coverage'], + 'dev': ['bump2version'], + 'sync': SYNC_REQUIREMENTS, + 'async': ASYNC_REQUIREMENTS, + 'flask': [ + 'flask', + ] + SYNC_REQUIREMENTS, + 'quart': [ + 'quart', + ] + ASYNC_REQUIREMENTS, + # 'test': ['coverage'], }, # If there are data files included in your packages that need to be # installed, specify them here. If using Python 2.6 or less, then these From 5ce8b3da930acfe9430dd6059bd945fb8f642555 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Tue, 30 Jun 2020 03:28:27 +0200 Subject: [PATCH 48/53] filter_rewrite: Fixed some errors. --- teleflask/server/core.py | 19 +++++++++++++++---- teleflask/server/extras/polling/sync.py | 8 ++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/teleflask/server/core.py b/teleflask/server/core.py index 0db4155..3c65f93 100644 --- a/teleflask/server/core.py +++ b/teleflask/server/core.py @@ -83,6 +83,8 @@ class Teleprocessor(object): startup_listeners: List[Callable] startup_already_run: bool + update_listeners: List[Filter] + blueprints: Dict[str, TBlueprint] _blueprint_order: List[TBlueprint] @@ -143,6 +145,8 @@ def __init__( self.startup_listeners: List[Callable] = list() self.startup_already_run: bool = False + self.update_listeners: List[Filter] = list() + self.blueprints: Dict[str, TBlueprint] = {} self._blueprint_order: List[TBlueprint] = [] @@ -241,7 +245,6 @@ def process_update(self, update: Update): logger.exception(f"Error executing the update listener with {filter!s}: {filter!r}") # end try # end for - super().process_update(update) # end def def on_startup(self, func): @@ -483,9 +486,17 @@ def _api_key(self): return self.__api_key # end def - on_update = UpdateFilter.decorator - on_message = MessageFilter.decorator - on_command = CommandFilter.decorator + def on_update(self, *required_keywords: str): + return UpdateFilter.decorator(self, *required_keywords) + # end def + + def on_message(self, *required_keywords: str): + return MessageFilter.decorator(self, *required_keywords) + # end def + + def on_command(self, command: str): + return CommandFilter.decorator(command, teleflask_or_tblueprint=self) + # end def command = on_command # end def diff --git a/teleflask/server/extras/polling/sync.py b/teleflask/server/extras/polling/sync.py index 29eabf4..47a0fdb 100644 --- a/teleflask/server/extras/polling/sync.py +++ b/teleflask/server/extras/polling/sync.py @@ -76,7 +76,10 @@ def _foreach_update(self): :return: """ - def run_forever(self): + def run_forever(self, remove_webhook: bool = True): + if remove_webhook: + self.bot.set_webhook('') + # end if while not self.please_do_stop: updates = self.bot.get_updates( offset=self._offset, @@ -85,6 +88,7 @@ def run_forever(self): ) for update in updates: logger.debug(f'processing update: {update!r}') + self._offset = update.update_id try: result = self.process_update(update) except: @@ -97,7 +101,7 @@ def run_forever(self): logger.exception('processing result failed') continue # end try - logger.debug(f'sent {len(messages)} messages') + logger.debug(f'sent {"no" if messages is None else len(messages)} messages') # end for # end while # end class From 25661120c6c2d040b551edde4444eefe39d3b359 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sat, 4 Jul 2020 22:19:13 +0200 Subject: [PATCH 49/53] filter_rewrite: Some type hints. --- teleflask/server/filters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index b93ce53..42f5f68 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -158,7 +158,7 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords): + def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords: str): """ Decorator to register a function to receive updates. @@ -280,7 +280,7 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords): + def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords: str): """ Decorator to register a function to receive updates. @@ -467,7 +467,7 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO # end def @classmethod - def decorator(cls, command, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None] = None): + def decorator(cls, command: str, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None] = None): """ Decorator to register a command. From 660d68c52c0d9f96aca8fbb4256801f241ac865a Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sat, 4 Jul 2020 22:19:43 +0200 Subject: [PATCH 50/53] filter_rewrite: Don't provide the teleflask object. --- teleflask/server/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 42f5f68..0b22a6f 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -485,7 +485,7 @@ def decorator(cls, command: str, teleflask_or_tblueprint: Union['Teleflask', 'TB def decorator_inner(function): if teleflask_or_tblueprint: # we don't want to register later - filter = cls(func=function, command=command, username=teleflask_or_tblueprint.username, teleflask_or_tblueprint=teleflask_or_tblueprint) + filter = cls(func=function, command=command, username=teleflask_or_tblueprint.username) teleflask_or_tblueprint.register_handler(filter) # end if handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) From b858c4351e400ee6e156acbb061016edef176872 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sat, 4 Jul 2020 22:21:03 +0200 Subject: [PATCH 51/53] filter_rewrite: Added on message example. --- examples/example2/sync_poll_bot_example.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/example2/sync_poll_bot_example.py b/examples/example2/sync_poll_bot_example.py index 0149655..2225739 100644 --- a/examples/example2/sync_poll_bot_example.py +++ b/examples/example2/sync_poll_bot_example.py @@ -22,6 +22,11 @@ def test(update, text): # end def +@bot.on_message() +def test32(update): + bot.bot.forward_message(10717954) + + @bot.on_update() def test2(update): pass From 74c1d3ae7c2aacc655132647edff98e6322557a2 Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sat, 4 Jul 2020 22:22:37 +0200 Subject: [PATCH 52/53] filter_rewrite: Added missing parameters. --- examples/example2/sync_poll_bot_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example2/sync_poll_bot_example.py b/examples/example2/sync_poll_bot_example.py index 2225739..281b2ef 100644 --- a/examples/example2/sync_poll_bot_example.py +++ b/examples/example2/sync_poll_bot_example.py @@ -24,7 +24,7 @@ def test(update, text): @bot.on_message() def test32(update): - bot.bot.forward_message(10717954) + bot.bot.forward_message(10717954, update.message.chat.id, update.message.message_id) @bot.on_update() From 2bbef1b3d6c656f6d36a28f0e5205cb21267061f Mon Sep 17 00:00:00 2001 From: luckydonald Date: Sat, 4 Jul 2020 22:39:37 +0200 Subject: [PATCH 53/53] filter_rewrite: Added HelpfulCommandFilter, to later automatically generate /help commands and commands. --- teleflask/server/filters.py | 89 ++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 0b22a6f..52ac66a 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -499,7 +499,7 @@ def decorator_inner(function): # end def # noinspection SqlNoDataSourceInspection - def __str__(self): + def __str__(self) -> str: if not self._username: return f"Command Filter matching the command {self._command} but no username suffixed commands." else: @@ -507,8 +507,93 @@ def __str__(self): # end if # end def - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(type={self.type!r}, func={self.func!r}, command={self._command!r}, username={self._username!r})" # end def # end class + +class HelpfulCommandFilter(CommandFilter): + """ + Same as CommandFilter, but has a few fields usefull for generating command descriptions and help texts automatically. + """ + short_description: Union[str, None] + long_description: Union[str, None] + + def __init__(self, + func: Union[Callable, Callable[[Update, Message], OPTIONAL_SENDABLE_MESSAGE_TYPES]], + command: str, + *, + short_description: Union[str, None], + long_description: Union[str, None], + username: Union[str, None] + ): + super().__init__(func, command, username) + self.short_description = short_description + self.long_description = long_description + # end if + + @classmethod + def decorator(cls, command: str, *, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None] = None): + """ + Decorator to register a command. + + Usage: + >>> @app.command("foo") + >>> def foo(update, text): + >>> assert isinstance(update, Update) + >>> app.bot.send_message(update.message.chat.id, "bar:" + text) + + If you now write "/foo hey" to the bot, it will reply with "bar:hey" + + :param command: the string of a command, without the slash. + """ + + def decorator_inner(function): + docs: Union[str, None] = function.__doc__ + short_description: Union[str, None] = None + long_description: Union[str, None] = None + if docs: + docs = docs.strip() + docs: List[str] = docs.splitlines() + short_description = docs[0] + long_description = "" + for line in docs[1::]: + line = line.strip() + if line.startswith(":"): + break + # end if + long_description += line + "\n" + # end if + long_description = long_description.strip() + if not long_description: + long_description = short_description + # end if + # end if + + if teleflask_or_tblueprint: + # we don't want to register later + filter = cls( + func=function, command=command, username=teleflask_or_tblueprint.username, + short_description=short_description, long_description=long_description, + ) + teleflask_or_tblueprint.register_handler(filter) + # end if + handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) + filter = cls(func=function, command=command, username=None, short_description=None, long_description=None) + handlers.append(filter) + setattr(function, _HANDLERS_ATTRIBUTE, handlers) + return function + # end def + + return decorator_inner # let that function be called again with the function. + # end def + + def __str__(self): + return super().__str__().rstrip('.') + f": {self.short_description!s} (long description: {self.long_description!r})." + # end def + + def __repr__(self): + return super().__repr__().rstrip('.') + f": {self.short_description!s} (long description: {self.long_description!r})." + # end def +# end class