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) diff --git a/examples/example2/sync_poll_bot_example.py b/examples/example2/sync_poll_bot_example.py new file mode 100644 index 0000000..281b2ef --- /dev/null +++ b/examples/example2/sync_poll_bot_example.py @@ -0,0 +1,36 @@ +#!/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_message() +def test32(update): + bot.bot.forward_message(10717954, update.message.chat.id, update.message.message_id) + + +@bot.on_update() +def test2(update): + pass +# end def + + +bot.run_forever() 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 diff --git a/teleflask/__init__.py b/teleflask/__init__.py index 0420c6a..7b3eae7 100644 --- a/teleflask/__init__.py +++ b/teleflask/__init__.py @@ -1,9 +1,47 @@ # -*- coding: utf-8 -*- __author__ = 'luckydonald' +import sys as _sys + VERSION = "2.0.0.dev21" __version__ = VERSION -from .server import Teleflask +__all__ = [ + 'VERSION', '__version__', + # the ones we provide for easy toplevel access: + 'Teleprocessor', 'Teleserver', + 'TBlueprint', 'abort_processing', + # special modules: + 'Teleflask', + # submodules: + 'server', 'exceptions', 'messages', 'new_messages', 'proxy', +] + +from .server.core import Teleprocessor, Teleserver + +if _sys.version_info.major >= 3 and _sys.version_info.minor >= 6: + IMPORTS = {'Teleflask': '.server.extras.flask', 'SyncTelepoll': '.server.extras.polling.sync'} + 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 IMPORTS: + raise AttributeError(f'module {_module.__spec__.parent!r} has no attribute {name!r} ({IMPORTS[name]!r}') + # end if + imported = _importlib.import_module(IMPORTS[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 diff --git a/teleflask/server/__init__.py b/teleflask/server/__init__.py index fbcb3d8..6984253 100644 --- a/teleflask/server/__init__.py +++ b/teleflask/server/__init__.py @@ -1,5 +1,2 @@ # -*- coding: utf-8 -*- -from .extras import Teleflask - __author__ = 'luckydonald' - 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/base.py b/teleflask/server/base.py index 6201602..a6ffe29 100644 --- a/teleflask/server/base.py +++ b/teleflask/server/base.py @@ -8,773 +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 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): - 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/blueprints.py b/teleflask/server/blueprints.py index 471bf57..379df9b 100644 --- a/teleflask/server/blueprints.py +++ b/teleflask/server/blueprints.py @@ -1,13 +1,13 @@ # -*- 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, MessagesMixin, BotCommandsMixin, StartupMixin __author__ = 'luckydonald' + logger = logging.getLogger(__name__) @@ -95,6 +95,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 @@ -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['Teleserver', '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): diff --git a/teleflask/server/core.py b/teleflask/server/core.py new file mode 100644 index 0000000..3c65f93 --- /dev/null +++ b/teleflask/server/core.py @@ -0,0 +1,615 @@ +#!/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 + +from pytgbot import Bot +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 + +from ..exceptions import AbortProcessingPlease +from .. import VERSION + +__author__ = 'luckydonald' + + +logger = logging.getLogger(__name__) +if __name__ == '__main__': + logging.add_colored_handler(level=logging.DEBUG) +# end if + + +class Teleprocessor(object): + VERSION = VERSION + __version__ = VERSION + + """ + 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: + + 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 + + update_listeners: List[Filter] + + blueprints: Dict[str, TBlueprint] + _blueprint_order: List[TBlueprint] + + def __init__( + self, + api_key, + *, + return_python_objects: bool = 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 + """ + + 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] = [] + + 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 + # 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: 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_type_or_raise(update, Update, parameter_name='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 + # 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 + + 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 + + +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} + 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 + + +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 diff --git a/teleflask/server/extras.py b/teleflask/server/extras.py deleted file mode 100644 index 75084b9..0000000 --- a/teleflask/server/extras.py +++ /dev/null @@ -1,160 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -from .base import TeleflaskBase -from .mixins import StartupMixin, BotCommandsMixin, UpdatesMixin, MessagesMixin, RegisterBlueprintsMixin -from luckydonaldUtils.logger import logging - -__author__ = 'luckydonald' -__all__ = ["Teleflask"] -logger = logging.getLogger(__name__) - - -class Teleflask(StartupMixin, BotCommandsMixin, MessagesMixin, UpdatesMixin, RegisterBlueprintsMixin, TeleflaskBase): - """ - This is the full package, including all provided mixins. - - 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`. - """ - - 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 - """ - 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 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): - # https: if we should use https for our host. - # start_process: If the proxy process should be started. - if not disable_setting_webhook: - logger.warn( - 'You are using the {clazz} class to use poll based updates for debugging, but requested creating a ' - 'webhook route (disable_setting_webhook is set to False).'.format( - clazz=self.__class__.__name__ - )) - # end if - self.https = https - self.start_process = start_process - super().__init__(api_key, app, blueprint, hostname, hostpath, hookpath, debug_routes, disable_setting_webhook, - return_python_objects) - - def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/{API_KEY}"): - return super().calculate_webhook_url(hostname if hostname else os.getenv('URL_HOSTNAME', 'localhost'), hostpath, hookpath) - # end def - - def set_webhook_telegram(self): - """ - We need to unset a telegram webhook if any. - """ - pass - # end def - - def do_startup(self): - """ - Uses the get updates method to run. - Checks Telegram if there is a webhook set, and if it needs to be changed. - - :return: - """ - if self.start_process: - self._start_proxy_process() - # end def - super().do_startup() - # end def - - def _start_proxy_process(self): - from ..proxy import proxy_telegram - from multiprocessing import Process - global telegram_proxy_process - telegram_proxy_process = Process(target=proxy_telegram, args=(), kwargs=dict( - api_key=self._api_key, https=self.https, host=self.hostname, hookpath=self.hookpath - )) - telegram_proxy_process.start() - # end def -# end class diff --git a/teleflask/server/extras/flask.py b/teleflask/server/extras/flask.py new file mode 100644 index 0000000..f0eec78 --- /dev/null +++ b/teleflask/server/extras/flask.py @@ -0,0 +1,566 @@ +# -*- coding: utf-8 -*- +import os +import requests +from flask import request +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 +from pytgbot.api_types.receivable.updates import Update +from pytgbot.exceptions import TgApiServerException + +from ... import Teleserver +from ..utilities import _class_self_decorate + +__author__ = 'luckydonald' +__all__ = ["Teleflask", "PollingTeleflask"] +logger = logging.getLogger(__name__) + +_self_jsonify = _class_self_decorate("jsonify") # calls self.jsonify(...) with the result of the decorated function. + + +class Teleflask(Teleserver): + 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.app = None # will be filled out by self.init_app(...) + self.blueprint = None # will be filled out by self.init_app(...) + + 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._me = myself + else: + assert isinstance(myself, dict) + self._me = User.from_array(myself["result"]) + # 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 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: + assert isinstance(existing_webhook, dict) + 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): + """ + 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 + # 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 + 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: + """ + 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 + + @property + 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 + + +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): + # https: if we should use https for our host. + # start_process: If the proxy process should be started. + if not disable_setting_webhook: + logger.warn( + 'You are using the {clazz} class to use poll based updates for debugging, but requested creating a ' + 'webhook route (disable_setting_webhook is set to False).'.format( + clazz=self.__class__.__name__ + )) + # end if + self.https = https + self.start_process = start_process + super().__init__(api_key, app, blueprint, hostname, hostpath, hookpath, debug_routes, disable_setting_webhook, + return_python_objects) + + def calculate_webhook_url(self, hostname=None, hostpath=None, hookpath="/income/{API_KEY}"): + return super().calculate_webhook_url(hostname if hostname else os.getenv('URL_HOSTNAME', 'localhost'), hostpath, hookpath) + # end def + + def set_webhook_telegram(self): + """ + We need to unset a telegram webhook if any. + """ + pass + # end def + + def do_startup(self): + """ + Uses the get updates method to run. + Checks Telegram if there is a webhook set, and if it needs to be changed. + + :return: + """ + super().do_startup() + if self.start_process: + self._start_proxy_process() + # end def + # end def + + def _start_proxy_process(self): + from ...proxy import proxy_telegram + from multiprocessing import Process + global telegram_proxy_process + telegram_proxy_process = Process(target=proxy_telegram, args=(), kwargs=dict( + api_key=self._api_key, https=self.https, host=self.hostname, hookpath=self.hookpath + )) + telegram_proxy_process.start() + # end def +# end class + diff --git a/teleflask/server/extras/polling/sync.py b/teleflask/server/extras/polling/sync.py new file mode 100644 index 0000000..47a0fdb --- /dev/null +++ b/teleflask/server/extras/polling/sync.py @@ -0,0 +1,107 @@ +# -*- 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 ...core import Teleprocessor + +__author__ = 'luckydonald' +__all__ = ["Telepoll"] +logger = logging.getLogger(__name__) + +class Telepoll(Teleprocessor): + 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=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, 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, + limit=100, + error_as_empty = True, + ) + for update in updates: + logger.debug(f'processing update: {update!r}') + self._offset = update.update_id + 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 {"no" if messages is None else len(messages)} messages') + # end for + # end while +# end class diff --git a/teleflask/server/filters.py b/teleflask/server/filters.py index 8ba9128..52ac66a 100644 --- a/teleflask/server/filters.py +++ b/teleflask/server/filters.py @@ -8,6 +8,7 @@ __author__ = 'luckydonald' from pytgbot.api_types.receivable.updates import Update, Message + from ..messages import Message as OldSendableMessage from ..new_messages import SendableMessageBase @@ -21,6 +22,9 @@ class DEFAULT: pass # end if +_HANDLERS_ATTRIBUTE = '__teleflask.__handlers' + + class NoMatch(Exception): """ Raised by a filter if it denies to process the update. @@ -36,7 +40,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. @@ -57,6 +65,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 @@ -85,7 +101,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 @@ -105,21 +124,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: @@ -128,6 +156,69 @@ 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: str): + """ + Decorator to register a function to receive updates. + + Usage: + >>> from teleflask import Teleflask, TBlueprint + >>> 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: + 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 @@ -160,31 +251,24 @@ 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: @@ -194,6 +278,70 @@ def call_handler(self, update: Update, match_result: MATCH_RESULT_TYPE) -> OPTIO message = update.message return self.func(update, message) # end def + + @classmethod + def decorator(cls, teleflask_or_tblueprint: Union['Teleflask', 'TBlueprint', None], *required_keywords: str): + """ + Decorator to register a function to receive updates. + + Usage: + >>> from teleflask import Teleflask, TBlueprint + >>> 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, required_message_keywords=required_keywords) + 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.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 @@ -206,6 +354,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 +369,13 @@ 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: Union[str, None], + ): + 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 +387,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 +402,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 @@ -266,19 +428,22 @@ 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, 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() @@ -300,34 +465,135 @@ 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, 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): + 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.register_handler(filter) + # end if + handlers = getattr(function, _HANDLERS_ATTRIBUTE, []) + filter = cls(func=function, command=command, username=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 + + # noinspection SqlNoDataSourceInspection + def __str__(self) -> str: + 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) -> 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 FilterHolder(object): - def on_update(self, *required_keywords): +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 function to receive updates. + Decorator to register a command. Usage: - >>> @app.on_update - >>> def foo(update): + >>> @app.command("foo") + >>> def foo(update, text): >>> assert isinstance(update, Update) - >>> # do stuff with the update - >>> # you can use app.bot to access the bot's messages functions + >>> 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 on_update_inner(function): - return self.add_update_listener(function, required_keywords=required_keywords) + + 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 - 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. + + 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 diff --git a/teleflask/server/mixins.py b/teleflask/server/mixins.py deleted file mode 100644 index 6c91120..0000000 --- a/teleflask/server/mixins.py +++ /dev/null @@ -1,773 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -from abc import abstractmethod -from collections import OrderedDict - -from pytgbot.api_types.receivable.updates import Update - -from ..exceptions import AbortProcessingPlease -from .abstact import AbstractUpdates, AbstractBotCommands, AbstractMessages, AbstractRegisterBlueprints, AbstractStartup -from .base import TeleflaskMixinBase - -__author__ = 'luckydonald' -__all__ = ['BotCommandsMixin', 'MessagesMixin', '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 = 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'. - - super(UpdatesMixin, self).__init__(*args, **kwargs) - # end def - - 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_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): - """ - Adds an listener for updates. - 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_update_listener(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"]) - # calls func(msg) for all updates which are inline queries (have the inline_query attribute) - - >>> add_update_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. - Must be a list. - :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 - # end def add_update_listener - - def remove_update_listener(self, func): - """ - 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, - but noting else will happen. - - - :param function: The function to remove - :return: the function, unmodified - """ - if func in self.update_listeners: - del self.update_listeners[func] - else: - logger.warning("listener already removed.") - # end if - return 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 - 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 - # 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 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. - - 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. - - 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 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, diff --git a/tests/mixins/test_commands_mixin.py b/tests/mixins/test_commands_mixin.py index ca3e21b..de8f8f6 100644 --- a/tests/mixins/test_commands_mixin.py +++ b/tests/mixins/test_commands_mixin.py @@ -6,17 +6,17 @@ 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 +from teleflask.server.filters import Filter, CommandFilter __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) - # 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")