Queer European MD passionate about IT
Browse Source

First commit

Davte 5 years ago
commit
4b04d10f78
9 changed files with 2495 additions and 0 deletions
  1. 99 0
      .gitignore
  2. 1 0
      LICENSE
  3. 41 0
      README.md
  4. 0 0
      data/__init__.py
  5. 8 0
      datelepot/__init__.py
  6. 1272 0
      datelepot/custombot.py
  7. 986 0
      datelepot/utilities.py
  8. 35 0
      requirements.txt
  9. 53 0
      setup.py

+ 99 - 0
.gitignore

@@ -0,0 +1,99 @@
+# local_* files
+local_*
+
+# databases
+*.db
+*.json
+
+# Data
+davtebot/data/*
+!davtebot/data/__init__.py
+!davtebot/data/davtebot_helper.json
+
+# OLD directory (backup files)
+Old/
+
+MiniScript utili/
+*.txt
+!requirements.txt
+
+# Bash files (to run the code) and nohup output file (nohup lets a script run after terminal closes)
+*.sh
+!RunMe.sh
+!RunOnEuler.sh
+!*push.sh
+!nautilus_VPN.sh
+*nohup.out
+
+#Pictures
+davtebot/img/*
+!davtebot/img/balance.png
+!davtebot/img/gear.png
+!davtebot/img/money.png
+!davtebot/img/ProfilePic.png
+!davtebot/img/ProfilePic.xcf
+!davtebot/img/robot.png
+!davtebot/img/search.png
+!davtebot/img/key.png
+!davtebot/img/malus.png
+
+# ---> Python
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+.ipynb_checkpoints/
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/

+ 1 - 0
LICENSE

@@ -0,0 +1 @@
+ This project is licensed under the GNU General Public License v3.0

+ 41 - 0
README.md

@@ -0,0 +1,41 @@
+# datelepot
+This project conveniently subclasses third party telepot.aio.Bot, providing many interesting features.
+
+Please note that you need Python3.5+ to run async code
+
+Check requirements.txt for third party dependencies.
+
+Check out `help(Bot)` for detailed information.
+
+## Project folders
+
+### data folder
+* `*.db`: databases used by bots
+* `*.log`: log files (store log_file_name and errors_file_name in `data/config.py` module)
+* `passwords.py`: contains secret information to be git-ignored (e.g. bot tokens)
+
+```
+my_token = 'token_of_bot1'
+my_other_token = 'token_of_bot2'
+...
+```
+
+## Usage
+```
+from datelepot import Bot
+from data.passwords import my_token, my_other_token
+
+my_bot = Bot(token=my_token, db_name='my_db')
+my_other_bot = Bot(token=my_other_token, db_name='my_other_db')
+
+@my_bot.command('/foo')
+async def foo_command(update):
+  return "Bar!"
+
+@my_other_bot.command('/bar')
+async def bar_command(update):
+  return "Foo!"
+
+Bot.run()
+```
+Check out `help(Bot)` for detailed information.

+ 0 - 0
data/__init__.py


+ 8 - 0
datelepot/__init__.py

@@ -0,0 +1,8 @@
+__author__ = "Davide Testa"
+__credits__ = "Marco Origlia"
+__license__ = "GNU General Public License v3.0"
+__version__ = "1.0"
+__maintainer__ = "Davide Testa"
+__contact__ = "t.me/davte"
+
+from custombot import Bot

+ 1272 - 0
datelepot/custombot.py

@@ -0,0 +1,1272 @@
+"""This module conveniently subclasses third party telepot.aio.Bot, providing the following features.
+- It prevents hitting Telegram flood limits by waiting between text and photo messages.
+- It provides command, parser, button and other decorators to associate common Telegram actions with custom handlers.
+- It supports multiple bots running in the same script and allows communications between them as well as complete independency from each other.
+- Each bot is associated with a sqlite database (using dataset third party library).
+
+Please note that you need Python3.5+ to run async code
+Check requirements.txt for third party dependencies.
+"""
+
+# Standard library modules
+import asyncio
+import datetime
+import io
+import logging
+import os
+
+# Third party modules
+import dataset
+import telepot
+
+# Project modules
+from utilities import Gettable, MyOD
+from utilities import escape_html_chars, get_cleaned_text, make_lines_of_buttons, markdown_check, remove_html_tags, sleep_until
+
+def split_text_gracefully(text, limit, parse_mode):
+    """Split text if it hits telegram limits for text messages.
+    Split at `\n` if possible.
+    Add a `[...]` at the end and beginning of split messages, with proper code markdown.
+    """
+    text = text.split("\n")[::-1]
+    result = []
+    while len(text)>0:
+        temp=[]
+        while len(text)>0 and len("\n".join(temp + [text[-1]]))<limit:
+            temp.append(text.pop())
+        if len(temp) == 0:
+            temp.append(text[-1][:limit])
+            text[-1] = text[-1][limit:]
+        result.append("\n".join(temp))
+    if len(result)>1:
+        for i in range(1,len(result)):
+            result[i] = "{tag[0]}[...]{tag[1]}\n{text}".format(
+                tag=('`','`') if parse_mode=='Markdown'
+                    else ('<code>', '</code>') if parse_mode.lower() == 'html'
+                    else ('', ''),
+                text=result[i]
+            )
+            result[i-1] = "{text}\n{tag[0]}[...]{tag[1]}".format(
+                tag=('`','`') if parse_mode=='Markdown'
+                    else ('<code>', '</code>') if parse_mode.lower() == 'html'
+                    else ('', ''),
+                text=result[i-1]
+            )
+    return result
+
+def make_inline_query_answer(answer):
+    """Return an article-type answer to inline query.
+    Takes either a string or a dictionary and returns a list."""
+    if type(answer) is str:
+        answer = dict(
+            type='article',
+            id=0,
+            title=remove_html_tags(answer),
+            input_message_content=dict(
+                message_text=answer,
+                parse_mode='HTML'
+            )
+        )
+    if type(answer) is dict:
+        answer = [answer]
+    return answer
+
+class Bot(telepot.aio.Bot, Gettable):
+    """telepot.aio.Bot (async Telegram bot framework) convenient subclass.
+    === General functioning ===
+    - While Bot.run() coroutine is executed, HTTP get requests are made to Telegram servers asking for new messages for each Bot instance.
+    - Each message causes the proper Bot instance method coroutine to be awaited, according to its flavour (see routing_table)
+        -- For example, chat messages cause `Bot().on_chat_message(message)` to be awaited.
+    - This even-processing coroutine ensures the proper handling function a future and returns.
+        -- That means that simpler tasks are completed before slower ones, since handling functions are not awaited but scheduled by `asyncio.ensure_future(handling_function(...))`
+        -- For example, chat text messages are handled by `handle_text_message`, which looks for the proper function to elaborate the request (in bot's commands and parsers)
+    - The handling function evaluates an answer, depending on the message content, and eventually provides a reply
+        -- For example, `handle_text_message` sends its answer via `send_message`
+    - All Bot.instances run simultaneously and faster requests are completed earlier.
+    - All uncaught events are ignored.
+    """
+
+    instances = {}
+    stop = False
+    # Cooldown time between sent messages, to prevent hitting Telegram flood limits
+    # Current limits: 30 total messages sent per second, 1 message per second per chat, 20 messages per minute per group
+    COOLDOWN_TIME_ABSOLUTE = datetime.timedelta(seconds=1/30)
+    COOLDOWN_TIME_PER_CHAT = datetime.timedelta(seconds=1)
+    MAX_GROUP_MESSAGES_PER_MINUTE = 20
+    # Max length of text field for a Telegram message (UTF-8 text)
+    TELEGRAM_MESSAGES_MAX_LEN = 4096
+    _path = os.path.dirname(__file__)
+    _unauthorized_message = None
+    _unknown_command_message = None
+    _maintenance_message = None
+    _default_inline_query_answer = [
+        dict(
+            type='article',
+            id=0,
+            title="I cannot answer this query, sorry",
+            input_message_content=dict(
+                message_text="I'm sorry but I could not find an answer for your query."
+            )
+        )
+    ]
+
+    def __init__(self, token, db_name=None):
+        super().__init__(token)
+        self.routing_table = {
+            'chat' : self.on_chat_message,
+            'inline_query' : self.on_inline_query,
+            'chosen_inline_result' : self.on_chosen_inline_result,
+            'callback_query' : self.on_callback_query
+        }
+        self.chat_message_handlers = {
+            'text': self.handle_text_message,
+            'pinned_message': self.handle_pinned_message,
+            'photo': self.handle_photo_message
+        }
+        if db_name:
+            self.db_url = 'sqlite:///{name}{ext}'.format(
+                name=db_name,
+                ext='.db' if not db_name.endswith('.db') else ''
+            )
+        self._unauthorized_message = None
+        self.authorization_function = lambda update, authorization_level: True
+        self.get_chat_id = lambda update: update['message']['chat']['id'] if 'message' in update else update['chat']['id']
+        self.commands = dict()
+        self.callback_handlers = dict()
+        self.inline_query_handlers = MyOD()
+        self._default_inline_query_answer = None
+        self.chosen_inline_result_handlers = dict()
+        self.aliases = MyOD()
+        self.parsers = MyOD()
+        self.custom_parsers = dict()
+        self.custom_photo_parsers = dict()
+        self.bot_name = None
+        self.default_reply_keyboard_elements = []
+        self._default_keyboard = dict()
+        self.run_before_loop = []
+        self.run_after_loop = []
+        self.to_be_obscured = []
+        self.to_be_destroyed = []
+        self.last_sending_time = dict(
+            absolute=datetime.datetime.now() - self.__class__.COOLDOWN_TIME_ABSOLUTE
+        )
+        self._maintenance = False
+        self._maintenance_message = None
+        self.chat_actions = dict(
+            pinned=MyOD()
+        )
+
+    @property
+    def name(self):
+        """Bot name"""
+        return self.bot_name
+
+    @property
+    def path(self):
+        """custombot.py file path"""
+        return self.__class__._path
+
+    @property
+    def db(self):
+        """Connection to bot's database
+        It must be used inside a with statement: `with bot.db as db`
+        """
+        if self.db_url:
+            return dataset.connect(self.db_url)
+
+    @property
+    def default_keyboard(self):
+        """Default keyboard which is sent when reply_markup is left blank and chat is private.
+        """
+        return self._default_keyboard
+
+    @property
+    def default_inline_query_answer(self):
+        """Answer to be returned if inline query returned None.
+        """
+        if self._default_inline_query_answer:
+            return self._default_inline_query_answer
+        return self.__class__._default_inline_query_answer
+
+    @property
+    def unauthorized_message(self):
+        """Return this if user is unauthorized to make a request.
+        If instance message is not set, class message is returned.
+        """
+        if self._unauthorized_message:
+            return self._unauthorized_message
+        return self.__class__._unauthorized_message
+
+    @property
+    def unknown_command_message(self):
+        """Message to be returned if user sends an unknown command in private chat.
+        If instance message is not set, class message is returned.
+        """
+        if self._unknown_command_message:
+            return self._unknown_command_message
+        return self.__class__._unknown_command_message
+
+    @property
+    def maintenance(self):
+        """True if bot is under maintenance, False otherwise.
+        While under maintenance, bot will reply with `self.maintenance_message` to any request, with few exceptions."""
+        return self._maintenance
+
+    @property
+    def maintenance_message(self):
+        """Message to be returned if bot is under maintenance.
+        If instance message is not set, class message is returned.
+        """
+        if self._maintenance_message:
+            return self._maintenance_message
+        if self.__class__.maintenance_message:
+            return self.__class__._maintenance_message
+        return "Bot is currently under maintenance! Retry later please."
+
+    @classmethod
+    def set_class_unauthorized_message(csl, unauthorized_message):
+        """Set class unauthorized message, to be returned if user is unauthorized to make a request.
+        """
+        csl._unauthorized_message = unauthorized_message
+
+    @classmethod
+    def set_class_unknown_command_message(cls, unknown_command_message):
+        """Set class unknown command message, to be returned if user sends an unknown command in private chat.
+        """
+        cls._unknown_command_message = unknown_command_message
+
+    @classmethod
+    def set_class_maintenance_message(cls, maintenance_message):
+        """Set class maintenance message, to be returned if bot is under maintenance.
+        """
+        cls._maintenance_message = maintenance_message
+
+    @classmethod
+    def set_class_default_inline_query_answer(cls, default_inline_query_answer):
+        """Set class default inline query answer, to be returned if an inline query returned no answer.
+        """
+        cls._default_inline_query_answer = default_inline_query_answer
+
+    def set_unauthorized_message(self, unauthorized_message):
+        """Set instance unauthorized message
+        If instance message is None, default class message is used.
+        """
+        self._unauthorized_message = unauthorized_message
+
+    def set_unknown_command_message(self, unknown_command_message):
+        """Set instance unknown command message, to be returned if user sends an unknown command in private chat.
+        If instance message is None, default class message is used.
+        """
+        self._unknown_command_message = unknown_command_message
+
+    def set_maintenance_message(self, maintenance_message):
+        """Set instance maintenance message, to be returned if bot is under maintenance.
+        If instance message is None, default class message is used.
+        """
+        self._maintenance_message = maintenance_message
+
+    def set_default_inline_query_answer(self, default_inline_query_answer):
+        """Set a custom default_inline_query_answer to be returned when no answer is found for an inline query.
+        If instance answer is None, default class answer is used.
+        """
+        if type(default_inline_query_answer) in (str, dict):
+            default_inline_query_answer = make_inline_query_answer(default_inline_query_answer)
+        if type(default_inline_query_answer) is not list:
+            return 1
+        self._default_inline_query_answer = default_inline_query_answer
+        return 0
+
+    def set_maintenance(self, maintenance_message):
+        """Puts the bot under maintenance or ends it.
+        While in maintenance, bot will reply to users with maintenance_message.
+        Bot will accept /coma, /stop and /restart commands from admins.
+        s"""
+        self._maintenance = not self.maintenance
+        if maintenance_message:
+            self.set_maintenance_message(maintenance_message)
+        if self.maintenance:
+            return "<i>Bot has just been put under maintenance!</i>\n\nUntil further notice, it will reply to users with the following message:\n\n{}".format(
+                self.maintenance_message
+            )
+        return "<i>Maintenance ended!</i>"
+
+    def set_authorization_function(self, authorization_function):
+        """Set a custom authorization_function, which evaluates True if user is authorized to perform a specific action and False otherwise.
+        It should take update and role and return a Boolean.
+        Default authorization_function always evaluates True.
+        """
+        self.authorization_function = authorization_function
+
+    def set_get_chat_id_function(self, get_chat_id_function):
+        """Set a custom get_chat_id function which takes and update and returns the chat in which a reply should be sent.
+        For instance, bots could reply in private to group messages as a default behaviour.
+        Default chat_id returned is current chat id.
+        """
+        self.get_chat_id = get_chat_id_function
+
+    async def avoid_flooding(self, chat_id):
+        """asyncio-sleep until COOLDOWN_TIME (per_chat and absolute) has passed.
+        To prevent hitting Telegram flood limits, send_message and send_photo await this function.
+        """
+        if type(chat_id) is int and chat_id > 0:
+            while (
+                datetime.datetime.now() < self.last_sending_time['absolute'] + self.__class__.COOLDOWN_TIME_ABSOLUTE
+            ) or (
+                chat_id in self.last_sending_time
+                and datetime.datetime.now() < self.last_sending_time[chat_id] + self.__class__.COOLDOWN_TIME_PER_CHAT
+                ):
+                    await asyncio.sleep(self.__class__.COOLDOWN_TIME_ABSOLUTE.seconds)
+            self.last_sending_time[chat_id] = datetime.datetime.now()
+        else:
+            while (
+                datetime.datetime.now() < self.last_sending_time['absolute'] + self.__class__.COOLDOWN_TIME_ABSOLUTE
+            ) or (
+                chat_id in self.last_sending_time
+                and len(
+                    [
+                        sending_datetime
+                        for sending_datetime in self.last_sending_time[chat_id]
+                        if sending_datetime >= datetime.datetime.now() - datetime.timedelta(minutes=1)
+                    ]
+                ) >= self.__class__.MAX_GROUP_MESSAGES_PER_MINUTE
+            ) or (
+                chat_id in self.last_sending_time
+                and len(self.last_sending_time[chat_id]) > 0
+                and datetime.datetime.now() < self.last_sending_time[chat_id][-1] + self.__class__.COOLDOWN_TIME_PER_CHAT
+            ):
+                await asyncio.sleep(0.5)
+            if chat_id not in self.last_sending_time:
+                self.last_sending_time[chat_id] = []
+            self.last_sending_time[chat_id].append(datetime.datetime.now())
+            self.last_sending_time[chat_id] = [
+                sending_datetime
+                for sending_datetime in self.last_sending_time[chat_id]
+                if sending_datetime >= datetime.datetime.now() - datetime.timedelta(minutes=1)
+            ]
+        self.last_sending_time['absolute'] = datetime.datetime.now()
+        return
+
+    async def on_inline_query(self, update):
+        """Schedule handling of received inline queries.
+        Notice that handling is only scheduled, not awaited.
+        This means that all Bot instances may now handle other requests before this one is completed.
+        """
+        asyncio.ensure_future(self.handle_inline_query(update))
+        return
+
+    async def on_chosen_inline_result(self, update):
+        """Schedule handling of received chosen inline result events.
+        Notice that handling is only scheduled, not awaited.
+        This means that all Bot instances may now handle other requests before this one is completed.
+        """
+        asyncio.ensure_future(self.handle_chosen_inline_result(update))
+        return
+
+    async def on_callback_query(self, update):
+        """Schedule handling of received callback queries.
+        A callback query is sent when users press inline keyboard buttons.
+        Bad clients may send malformed or deceiving callback queries: never use secret keys in buttons and always check request validity!
+        Notice that handling is only scheduled, not awaited.
+        This means that all Bot instances may now handle other requests before this one is completed.
+        """
+        # Reject malformed updates lacking of data field
+        if 'data' not in update:
+            return
+        asyncio.ensure_future(self.handle_callback_query(update))
+        return
+
+    async def on_chat_message(self, update):
+        """Schedule handling of received chat message.
+        Notice that handling is only scheduled, not awaited.
+        According to update type, the corresponding handler is scheduled (see self.chat_message_handlers).
+        This means that all Bot instances may now handle other requests before this one is completed.
+        """
+        answer = None
+        content_type, chat_type, chat_id = telepot.glance(
+            update,
+            flavor='chat',
+            long=False
+        )
+        if content_type in self.chat_message_handlers:
+            answer = asyncio.ensure_future(
+                self.chat_message_handlers[content_type](update)
+            )
+        else:
+            answer = None
+            logging.debug("Unhandled message")
+        return answer
+
+    async def handle_inline_query(self, update):
+        """Handle inline query and answer it with results, or log errors.
+        """
+        query = update['query']
+        answer = None
+        switch_pm_text, switch_pm_parameter = None, None
+        if self.maintenance:
+            answer = self.maintenance_message
+        else:
+            for condition, handler in self.inline_query_handlers.items():
+                answerer = handler['function']
+                if condition(update['query']):
+                    if asyncio.iscoroutinefunction(answerer):
+                        answer = await answerer(update)
+                    else:
+                        answer = answerer(update)
+                    break
+            if not answer:
+                answer = self.default_inline_query_answer
+        if type(answer) is dict:
+            if 'switch_pm_text' in answer:
+                switch_pm_text = answer['switch_pm_text']
+            if 'switch_pm_parameter' in answer:
+                switch_pm_parameter = answer['switch_pm_parameter']
+            answer = answer['answer']
+        if type(answer) is str:
+            answer = make_inline_query_answer(answer)
+        try:
+            await self.answerInlineQuery(
+                update['id'],
+                answer,
+                cache_time=10,
+                is_personal=True,
+                switch_pm_text=switch_pm_text,
+                switch_pm_parameter=switch_pm_parameter
+            )
+        except Exception as e:
+            logging.info("Error answering inline query\n{}".format(e))
+        return
+
+    async def handle_chosen_inline_result(self, update):
+        """If chosen inline result id is in self.chosen_inline_result_handlers, call the related function passing the update as argument."""
+        user_id = update['from']['id'] if 'from' in update else None
+        if self.maintenance:
+            return
+        if user_id in self.chosen_inline_result_handlers:
+            result_id = update['result_id']
+            handlers = self.chosen_inline_result_handlers[user_id]
+            if result_id in handlers:
+                func = handlers[result_id]
+                if asyncio.iscoroutinefunction(func):
+                    await func(update)
+                else:
+                    func(update)
+        return
+
+    def set_inline_result_handler(self, user_id, result_id, func):
+        """Associate a func to a result_id: when an inline result is chosen having that id, function will be passed the update as argument."""
+        if type(user_id) is dict:
+            user_id = user_id['from']['id']
+        assert type(user_id) is int, "user_id must be int!"
+        result_id = str(result_id) # Query result ids are parsed as str by telegram
+        assert callable(func), "func must be a callable"
+        if user_id not in self.chosen_inline_result_handlers:
+            self.chosen_inline_result_handlers[user_id] = {}
+        self.chosen_inline_result_handlers[user_id][result_id] = func
+        return
+
+    async def handle_callback_query(self, update):
+        """Get an answer from the callback handler associated to the query prefix.
+        The answer is used to edit the source message or send new ones if text is longer than single message limit.
+        Anyway, the query is answered, otherwise the client would hang and the bot would look like idle.
+        """
+        answer = None
+        if self.maintenance:
+            answer = remove_html_tags(self.maintenance_message[:45])
+        else:
+            data = update['data']
+            for start_text, handler in self.callback_handlers.items():
+                answerer = handler['function']
+                if data.startswith(start_text):
+                    if asyncio.iscoroutinefunction(answerer):
+                        answer = await answerer(update)
+                    else:
+                        answer = answerer(update)
+                    break
+        if answer:
+            if type(answer) is str:
+                answer = {'text': answer}
+            if type(answer) is not dict:
+                return
+            if 'edit' in answer:
+                if 'message' in update:
+                    message_identifier = telepot.message_identifier(update['message'])
+                else:
+                    message_identifier = telepot.message_identifier(update)
+                edit = answer['edit']
+                reply_markup = edit['reply_markup'] if 'reply_markup' in edit else None
+                text = edit['text'] if 'text' in edit else None
+                caption = edit['caption'] if 'caption' in edit else None
+                parse_mode = edit['parse_mode'] if 'parse_mode' in edit else None
+                disable_web_page_preview = edit['disable_web_page_preview'] if 'disable_web_page_preview' in edit else None
+                try:
+                    if 'text' in edit:
+                        if len(text) > self.__class__.TELEGRAM_MESSAGES_MAX_LEN - 200:
+                            if 'from' in update:
+                                await self.send_message(
+                                    chat_id=update['from']['id'],
+                                    text=text,
+                                    reply_markup=reply_markup,
+                                    parse_mode=parse_mode,
+                                    disable_web_page_preview=disable_web_page_preview
+                                )
+                        else:
+                            await self.editMessageText(
+                                msg_identifier=message_identifier,
+                                text=text,
+                                parse_mode=parse_mode,
+                                disable_web_page_preview=disable_web_page_preview,
+                                reply_markup=reply_markup
+                            )
+                    elif 'caption' in edit:
+                        await self.editMessageCaption(
+                            msg_identifier=message_identifier,
+                            caption=caption,
+                            reply_markup=reply_markup
+                        )
+                    elif 'reply_markup' in edit:
+                        await self.editMessageReplyMarkup(
+                            msg_identifier=message_identifier,
+                            reply_markup=reply_markup
+                        )
+                except Exception as e:
+                    logging.info("Message was not modified:\n{}".format(e))
+            text = answer['text'][:180] if 'text' in answer else None
+            show_alert = answer['show_alert'] if 'show_alert' in answer else None
+            cache_time = answer['cache_time'] if 'cache_time' in answer else None
+            try:
+                await self.answerCallbackQuery(
+                    callback_query_id=update['id'],
+                    text=text,
+                    show_alert=show_alert,
+                    cache_time=cache_time
+                )
+            except telepot.exception.TelegramError as e:
+                logging.error(e)
+        else:
+            try:
+                await self.answerCallbackQuery(callback_query_id=update['id'])
+            except telepot.exception.TelegramError as e:
+                logging.error(e)
+        return
+
+    async def handle_text_message(self, update):
+        """Answer to chat text messages.
+        1) Ignore bot name (case-insensitive) and search bot custom parsers, commands, aliases and parsers for an answerer.
+        2) Get an answer from answerer(update).
+        3) Send it to the user.
+        """
+        answerer, answer = None, None
+        # Lower text and replace only bot's tag, meaning that `/command@OtherBot` will be ignored.
+        text = update['text'].lower().replace('@{}'.format(self.name.lower()),'')
+        user_id = update['from']['id'] if 'from' in update else None
+        if self.maintenance and not any(
+            text.startswith(x)
+            for x in ('/coma', '/restart')
+        ):
+            if update['chat']['id']>0:
+                answer = self.maintenance_message
+        elif user_id in self.custom_parsers:
+            answerer = self.custom_parsers[user_id]
+            del self.custom_parsers[user_id]
+        elif text.startswith('/'):
+            command = text.split()[0].strip(' /@')
+            if command in self.commands:
+                answerer = self.commands[command]['function']
+            elif update['chat']['id']>0:
+                answer = self.unknown_command_message
+        else:
+            # If text starts with an alias (case insensitive: text and alias are both .lower()):
+            for alias, parser in self.aliases.items():
+                if text.startswith(alias.lower()):
+                    answerer = parser
+                    break
+            # If update matches any parser
+            for check_function, parser in self.parsers.items():
+                if (
+                    parser['argument'] == 'text'
+                    and check_function(text)
+                ) or (
+                    parser['argument'] == 'update'
+                    and check_function(update)
+                ):
+                    answerer = parser['function']
+                    break
+        if answerer:
+            if asyncio.iscoroutinefunction(answerer):
+                answer = await answerer(update)
+            else:
+                answer = answerer(update)
+        if answer:
+            try:
+                return await self.send_message(answer=answer, chat_id=update)
+            except Exception as e:
+                logging.error("Failed to process answer:\n{}".format(e), exc_info=True)
+
+    async def handle_pinned_message(self, update):
+        """Handle pinned message chat action."""
+        if self.maintenance:
+            return
+        answerer = None
+        for criteria, handler in self.chat_actions['pinned'].items():
+            if criteria(update):
+                answerer = handler['function']
+                break
+        if answerer is None:
+            return
+        elif asyncio.iscoroutinefunction(answerer):
+            answer = await answerer(update)
+        else:
+            answer = answerer(update)
+        if answer:
+            try:
+                return await self.send_message(answer=answer, chat_id=update['chat']['id'])
+            except Exception as e:
+                logging.error("Failed to process answer:\n{}".format(e), exc_info=True)
+        return
+
+    async def handle_photo_message(self, update):
+        """Handle photo chat message"""
+        user_id = update['from']['id'] if 'from' in update else None
+        answerer, answer = None, None
+        if self.maintenance:
+            if update['chat']['id']>0:
+                answer = self.maintenance_message
+        elif user_id in self.custom_photo_parsers:
+            answerer = self.custom_photo_parsers[user_id]
+            del self.custom_photo_parsers[user_id]
+        if answerer:
+            if asyncio.iscoroutinefunction(answerer):
+                answer = await answerer(update)
+            else:
+                answer = answerer(update)
+        if answer:
+            try:
+                return await self.send_message(answer=answer, chat_id=update)
+            except Exception as e:
+                logging.error("Failed to process answer:\n{}".format(e), exc_info=True)
+        return
+
+    def set_custom_parser(self, parser, update=None, user=None):
+        """Set a custom parser for the user.
+        Any chat message update coming from the user will be handled by this custom parser instead of default parsers (commands, aliases and text parsers).
+        Custom parsers last one single use, but their handler can call this function to provide multiple tries.
+        """
+        if user and type(user) is int:
+            pass
+        elif type(update) is int:
+            user = update
+        elif type(user) is dict:
+            user = user['from']['id'] if 'from' in user and 'id' in user['from'] else None
+        elif not user and type(update) is dict:
+            user = update['from']['id'] if 'from' in update and 'id' in update['from'] else None
+        else:
+            raise TypeError('Invalid user.\nuser: {}\nupdate: {}'.format(user, update))
+        if not type(user) is int:
+            raise TypeError('User {} is not an int id'.format(user))
+        if not callable(parser):
+            raise TypeError('Parser {} is not a callable'.format(parser.__name__))
+        self.custom_parsers[user] = parser
+        return
+
+    def set_custom_photo_parser(self, parser, update=None, user=None):
+        """Set a custom photo parser for the user.
+        Any photo chat update coming from the user will be handled by this custom parser instead of default parsers.
+        Custom photo parsers last one single use, but their handler can call this function to provide multiple tries.
+        """
+        if user and type(user) is int:
+            pass
+        elif type(update) is int:
+            user = update
+        elif type(user) is dict:
+            user = user['from']['id'] if 'from' in user and 'id' in user['from'] else None
+        elif not user and type(update) is dict:
+            user = update['from']['id'] if 'from' in update and 'id' in update['from'] else None
+        else:
+            raise TypeError('Invalid user.\nuser: {}\nupdate: {}'.format(user, update))
+        if not type(user) is int:
+            raise TypeError('User {} is not an int id'.format(user))
+        if not callable(parser):
+            raise TypeError('Parser {} is not a callable'.format(parser.__name__))
+        self.custom_photo_parsers[user] = parser
+        return
+
+    def command(self, command, aliases=None, show_in_keyboard=False, descr="", auth='admin'):
+        """
+        Decorator: `@bot.command(*args)`
+        When a message text starts with `/command[@bot_name]`, or with an alias, it gets passed to the decorated function.
+        `command` is the command name (with or without /)
+        `aliases` is a list of aliases
+        `show_in_keyboard`, if True, makes first alias appear in default_keyboard
+        `descr` is a description
+        `auth` is the lowest authorization level needed to run the command
+        """
+        command = command.replace('/','').lower()
+        if not isinstance(command, str):
+            raise TypeError('Command {} is not a string'.format(command))
+        if aliases:
+            if not isinstance(aliases, list):
+                raise TypeError('Aliases is not a list: {}'.format(aliases))
+            for alias in aliases:
+                if not isinstance(alias, str):
+                    raise TypeError('Alias {} is not a string'.format(alias))
+        def decorator(func):
+            if asyncio.iscoroutinefunction(func):
+                async def decorated(message):
+                    logging.info("COMMAND({}) @{} FROM({})".format(command, self.name, message['from'] if 'from' in message else message['chat']))
+                    if self.authorization_function(message, auth):
+                        return await func(message)
+                    return self.unauthorized_message
+            else:
+                def decorated(message):
+                    logging.info("COMMAND({}) @{} FROM({})".format(command, self.name, message['from'] if 'from' in message else message['chat']))
+                    if self.authorization_function(message, auth):
+                        return func(message)
+                    return self.unauthorized_message
+            self.commands[command] = dict(
+                function=decorated,
+                descr=descr,
+                auth=auth
+            )
+            if aliases:
+                for alias in aliases:
+                    self.aliases[alias] = decorated
+                if show_in_keyboard:
+                    self.default_reply_keyboard_elements.append(aliases[0])
+        return decorator
+
+    def parser(self, condition, descr='', auth='admin', argument='text'):
+        """
+        Decorator: `@bot.parser(condition)`
+        If condition evaluates True when run on a message text (not starting with '/'), such decorated function gets called on update.
+        Conditions of parsers are evaluated in order; when one is True, others will be skipped.
+        `descr` is a description
+        `auth` is the lowest authorization level needed to run the command
+        """
+        if not callable(condition):
+            raise TypeError('Condition {} is not a callable'.format(condition.__name__))
+        def decorator(func):
+            if asyncio.iscoroutinefunction(func):
+                async def decorated(message):
+                    logging.info("TEXT MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return await func(message)
+                    return self.unauthorized_message
+            else:
+                def decorated(message):
+                    logging.info("TEXT MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return func(message)
+                    return self.unauthorized_message
+            self.parsers[condition] = dict(
+                function=decorated,
+                descr=descr,
+                auth=auth,
+                argument=argument
+            )
+        return decorator
+
+    def pinned(self, condition, descr='', auth='admin'):
+        """
+        Decorator: `@bot.pinned(condition)`
+        If condition evaluates True when run on a pinned_message update, such decorated function gets called on update.
+        Conditions are evaluated in order; when one is True, others will be skipped.
+        `descr` is a description
+        `auth` is the lowest authorization level needed to run the command
+        """
+        if not callable(condition):
+            raise TypeError('Condition {} is not a callable'.format(condition.__name__))
+        def decorator(func):
+            if asyncio.iscoroutinefunction(func):
+                async def decorated(message):
+                    logging.info("CHAT ACTION MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return await func(message)
+                    return# self.unauthorized_message
+            else:
+                def decorated(message):
+                    logging.info("CHAT ACTION MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return func(message)
+                    return# self.unauthorized_message
+            self.chat_actions['pinned'][condition] = dict(
+                function=decorated,
+                descr=descr,
+                auth=auth
+            )
+        return decorator
+
+    def button(self, data, descr='', auth='admin'):
+        """
+        Decorator: `@bot.button('example:///')`
+        When a callback data text starts with <data>, it gets passed to the decorated function
+        `descr` is a description
+        `auth` is the lowest authorization level needed to run the command
+        """
+        if not isinstance(data, str):
+            raise TypeError('Inline button callback_data {} is not a string'.format(data))
+        def decorator(func):
+            if asyncio.iscoroutinefunction(func):
+                async def decorated(message):
+                    logging.info("INLINE BUTTON({}) @{} FROM({})".format(message['data'], self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return await func(message)
+                    return self.unauthorized_message
+            else:
+                def decorated(message):
+                    logging.info("INLINE BUTTON({}) @{} FROM({})".format(message['data'], self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return func(message)
+                    return self.unauthorized_message
+            self.callback_handlers[data] = dict(
+                function=decorated,
+                descr=descr,
+                auth=auth
+            )
+        return decorator
+
+    def query(self, condition, descr='', auth='admin'):
+        """
+        Decorator: `@bot.query(example)`
+        When an inline query matches the `condition` function, decorated function is called and passed the query update object as argument.
+        `descr` is a description
+        `auth` is the lowest authorization level needed to run the command
+        """
+        if not callable(condition):
+            raise TypeError('Condition {} is not a callable'.format(condition.__name__))
+        def decorator(func):
+            if asyncio.iscoroutinefunction(func):
+                async def decorated(message):
+                    logging.info("QUERY MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return await func(message)
+                    return self.unauthorized_message
+            else:
+                def decorated(message):
+                    logging.info("QUERY MATCHING CONDITION({}) @{} FROM({})".format(condition.__name__, self.name, message['from']))
+                    if self.authorization_function(message, auth):
+                        return func(message)
+                    return self.unauthorized_message
+            self.inline_query_handlers[condition] = dict(
+                function=decorated,
+                descr=descr,
+                auth=auth
+            )
+        return decorator
+
+    def additional_task(self, when='BEFORE'):
+        """Decorator: such decorated async functions get awaited BEFORE or AFTER messageloop"""
+        when = when[0].lower()
+        def decorator(func):
+            if when == 'b':
+                self.run_before_loop.append(func())
+            elif when == 'a':
+                self.run_after_loop.append(func())
+        return decorator
+
+    def set_default_keyboard(self, keyboard='set_default'):
+        """Set a default keyboard for the bot.
+           If a keyboard is not passed as argument, a default one is generated, based on aliases of commands.
+        """
+        if keyboard=='set_default':
+            btns = [
+                dict(
+                    text=x
+                )
+                for x in self.default_reply_keyboard_elements
+            ]
+            row_len = 2 if len(btns) < 4 else 3
+            self._default_keyboard = dict(
+                keyboard=make_lines_of_buttons(
+                    btns,
+                    row_len
+                ),
+                resize_keyboard=True
+            )
+        else:
+            self._default_keyboard = keyboard
+        return
+
+    async def edit_message(self, update, *args, **kwargs):
+        """Edit given update with given *args and **kwargs.
+        Please note, that it is currently only possible to edit messages without reply_markup or with inline keyboards.
+        """
+        try:
+            return await self.editMessageText(
+                telepot.message_identifier(update),
+                *args,
+                **kwargs
+            )
+        except Exception as e:
+            logging.error("{}".format(e))
+
+    async def delete_message(self, update, *args, **kwargs):
+        """Delete given update with given *args and **kwargs.
+        Please note, that a bot can delete only messages sent by itself or sent in a group which it is administrator of.
+        """
+        try:
+            return await self.deleteMessage(
+                telepot.message_identifier(update),
+                *args,
+                **kwargs
+            )
+        except Exception as e:
+            logging.error("{}".format(e))
+
+    async def send_message(self, answer=dict(), chat_id=None, text='', parse_mode="HTML", disable_web_page_preview=None, disable_notification=None, reply_to_message_id=None, reply_markup=None):
+        """Convenient method to call telepot.Bot(token).sendMessage
+        All sendMessage **kwargs can be either **kwargs of send_message or key:val of answer argument.
+        Messages longer than telegram limit will be split properly.
+        Telegram flood limits won't be reached thanks to `await avoid_flooding(chat_id)`
+        parse_mode will be checked and edited if necessary.
+        Arguments will be checked and adapted.
+        """
+        if type(answer) is dict and 'chat_id' in answer:
+            chat_id = answer['chat_id']
+        # chat_id may simply be the update to which the bot should repy: get_chat_id is called
+        if type(chat_id) is dict:
+            chat_id = self.get_chat_id(chat_id)
+        if type(answer) is str:
+            text = answer
+            if not reply_markup and chat_id > 0 and text!=self.unauthorized_message:
+                reply_markup = self.default_keyboard
+        elif type(answer) is dict:
+            if 'text' in answer:
+                text = answer['text']
+            if 'parse_mode' in answer:
+                parse_mode = answer['parse_mode']
+            if 'disable_web_page_preview' in answer:
+                disable_web_page_preview = answer['disable_web_page_preview']
+            if 'disable_notification' in answer:
+                disable_notification = answer['disable_notification']
+            if 'reply_to_message_id' in answer:
+                reply_to_message_id = answer['reply_to_message_id']
+            if 'reply_markup' in answer:
+                reply_markup = answer['reply_markup']
+            elif not reply_markup and type(chat_id) is int and chat_id > 0 and text!=self.unauthorized_message:
+                reply_markup = self.default_keyboard
+        assert type(text) is str, "Text is not a string!"
+        assert (
+            type(chat_id) is int
+            or (type(chat_id) is str and chat_id.startswith('@'))
+        ), "Invalid chat_id:\n\t\t{}".format(chat_id)
+        if not text:
+            return
+        parse_mode = str(parse_mode)
+        text_chunks = split_text_gracefully(
+            text=text,
+            limit=self.__class__.TELEGRAM_MESSAGES_MAX_LEN - 100,
+            parse_mode=parse_mode
+        )
+        n = len(text_chunks)
+        for text_chunk in text_chunks:
+            n-=1
+            if parse_mode.lower() == "html":
+                this_parse_mode = "HTML"
+                # Check that all tags are well-formed
+                if not markdown_check(
+                    text_chunk,
+                    [
+                        "<", ">",
+                        "code>", "bold>", "italic>",
+                        "b>", "i>", "a>", "pre>"
+                    ]
+                ):
+                    this_parse_mode = "None"
+                    text_chunk = "!!![invalid markdown syntax]!!!\n\n" + text_chunk
+            elif parse_mode != "None":
+                this_parse_mode = "Markdown"
+                # Check that all markdowns are well-formed
+                if not markdown_check(
+                    text_chunk,
+                    [
+                        "*", "_", "`"
+                    ]
+                ):
+                    this_parse_mode = "None"
+                    text_chunk = "!!![invalid markdown syntax]!!!\n\n" + text_chunk
+            else:
+                this_parse_mode = parse_mode
+            this_reply_markup = reply_markup if n==0 else None
+            try:
+                await self.avoid_flooding(chat_id)
+                result = await self.sendMessage(
+                    chat_id=chat_id,
+                    text=text_chunk,
+                    parse_mode=this_parse_mode,
+                    disable_web_page_preview=disable_web_page_preview,
+                    disable_notification=disable_notification,
+                    reply_to_message_id=reply_to_message_id,
+                    reply_markup=this_reply_markup
+                )
+            except Exception as e:
+                logging.debug(e, exc_info=False) # Set exc_info=True for more information
+                result = e
+        return result
+
+    async def send_photo(self, chat_id=None, answer={}, photo=None, caption='', parse_mode='HTML', disable_notification=None, reply_to_message_id=None,reply_markup=None, use_stored=True, second_chance=False):
+        """Convenient method to call telepot.Bot(token).sendPhoto
+        All sendPhoto **kwargs can be either **kwargs of send_message or key:val of answer argument.
+        Captions longer than telegram limit will be shortened gently.
+        Telegram flood limits won't be reached thanks to `await avoid_flooding(chat_id)`
+        Most arguments will be checked and adapted.
+        If use_stored is set to True, the bot will store sent photo telegram_id and use it for faster sending next times (unless future errors).
+        Sending photos by their file_id already stored on telegram servers is way faster: that's why bot stores and uses this info, if required.
+        A second_chance is given to send photo on error.
+        """
+        if 'chat_id' in answer:
+            chat_id = answer['chat_id']
+        # chat_id may simply be the update to which the bot should repy: get_chat_id is called
+        if type(chat_id) is dict:
+            chat_id = self.get_chat_id(chat_id)
+        assert (
+            type(chat_id) is int
+            or (type(chat_id) is str and chat_id.startswith('@'))
+        ), "Invalid chat_id:\n\t\t{}".format(chat_id)
+        if 'photo' in answer:
+            photo = answer['photo']
+        assert photo is not None, "Null photo!"
+        if 'caption' in answer:
+            caption = answer['caption']
+        if 'parse_mode' in answer:
+            parse_mode = answer['parse_mode']
+        if 'disable_notification' in answer:
+            disable_notification = answer['disable_notification']
+        if 'reply_to_message_id' in answer:
+            reply_to_message_id = answer['reply_to_message_id']
+        if 'reply_markup' in answer:
+            reply_markup = answer['reply_markup']
+        already_sent = False
+        if type(photo) is str:
+            photo_url = photo
+            with self.db as db:
+                already_sent = db['sent_pictures'].find_one(url=photo_url, errors=False)
+            if already_sent and use_stored:
+                photo = already_sent['file_id']
+                already_sent = True
+            else:
+                already_sent = False
+                if not any(photo_url.startswith(x) for x in ['http', 'www']):
+                    with io.BytesIO() as buffered_picture:
+                        with open("{}/{}".format(self.path, photo_url), 'rb') as photo_file:
+                            buffered_picture.write(photo_file.read())
+                        photo = buffered_picture.getvalue()
+        caption = escape_html_chars(caption)
+        if len(caption) > 199:
+            new_caption = ''
+            tag = False
+            tag_body = False
+            count = 0
+            temp = ''
+            for char in caption:
+                if tag and char == '>':
+                    tag = False
+                elif char == '<':
+                    tag = True
+                    tag_body = not tag_body
+                elif not tag:
+                    count += 1
+                if count == 199:
+                    break
+                temp += char
+                if not tag_body:
+                    new_caption += temp
+                    temp = ''
+            caption = new_caption
+        sent = None
+        try:
+            await self.avoid_flooding(chat_id)
+            sent = await self.sendPhoto(
+                chat_id=chat_id,
+                photo=photo,
+                caption=caption,
+                parse_mode=parse_mode,
+                disable_notification=disable_notification,
+                reply_to_message_id=reply_to_message_id,
+                reply_markup=reply_markup
+            )
+            if isinstance(sent, Exception):
+                raise Exception("SendingFailed")
+        except Exception as e:
+            logging.error("Error sending photo\n{}".format(e), exc_info=False) # Set exc_info=True for more information
+            if already_sent:
+                with self.db as db:
+                    db['sent_pictures'].update(
+                        dict(
+                            url=photo_url,
+                            errors=True
+                        ),
+                        ['url']
+                    )
+                if not second_chance:
+                    logging.info("Trying again (only once)...")
+                    sent = await self.send_photo(
+                        chat_id=chat_id,
+                        answer=answer,
+                        photo=photo,
+                        caption=caption,
+                        parse_mode=parse_mode,
+                        disable_notification=disable_notification,
+                        reply_to_message_id=reply_to_message_id,
+                        reply_markup=reply_markup,
+                        second_chance=True
+                    )
+        if (
+            sent is not None
+            and hasattr(sent, '__getitem__')
+            and 'photo' in sent
+            and len(sent['photo'])>0
+            and 'file_id' in sent['photo'][0]
+            and (not already_sent)
+            and use_stored
+        ):
+            with self.db as db:
+                db['sent_pictures'].insert(
+                    dict(
+                        url=photo_url,
+                        file_id=sent['photo'][0]['file_id'],
+                        errors=False
+                    )
+                )
+        return sent
+
+    async def send_and_destroy(self, chat_id, answer, timer=60, mode='text', **kwargs):
+        """Send a message or photo and delete it after `timer` seconds"""
+        if mode == 'text':
+            sent_message = await self.send_message(
+                chat_id=chat_id,
+                answer=answer,
+                **kwargs
+            )
+        elif mode == 'pic':
+            sent_message = await self.send_photo(
+                chat_id=chat_id,
+                answer=answer,
+                **kwargs
+            )
+        if sent_message is None:
+            return
+        self.to_be_destroyed.append(sent_message)
+        await asyncio.sleep(timer)
+        if await self.delete_message(sent_message):
+            self.to_be_destroyed.remove(sent_message)
+        return
+
+    async def wait_and_obscure(self, update, when, inline_message_id):
+        """Obscure an inline_message `timer` seconds after sending it, by editing its text or caption.
+        At the moment Telegram won't let bots delete sent inline query results."""
+        if type(when) is int:
+            when = datetime.datetime.now() + datetime.timedelta(seconds=when)
+        assert type(when) is datetime.datetime, "when must be a datetime instance or a number of seconds (int) to be awaited"
+        if 'inline_message_id' not in update:
+            logging.info("This inline query result owns no inline_keyboard, so it can't be modified")
+            return
+        inline_message_id = update['inline_message_id']
+        self.to_be_obscured.append(inline_message_id)
+        while datetime.datetime.now() < when:
+            await sleep_until(when)
+        try:
+            await self.editMessageCaption(inline_message_id, text="Time over")
+        except:
+            try:
+                await self.editMessageText(inline_message_id, text="Time over")
+            except Exception as e:
+                logging.error("Couldn't obscure message\n{}\n\n{}".format(inline_message_id,e))
+        self.to_be_obscured.remove(inline_message_id)
+        return
+
+    async def continue_running(self):
+        """If bot can be got, sets name and telegram_id, awaits preliminary tasks and starts getting updates from telegram.
+        If bot can't be got, restarts all bots in 5 minutes."""
+        try:
+            me = await self.getMe()
+            self.bot_name = me["username"]
+            self.telegram_id = me['id']
+        except:
+            logging.error("Could not get bot")
+            await asyncio.sleep(5*60)
+            self.restart_bots()
+            return
+        for task in self.run_before_loop:
+            await task
+        self.set_default_keyboard()
+        asyncio.ensure_future(
+            self.message_loop(handler=self.routing_table)
+        )
+        return
+
+    def stop_bots(self):
+        """Causes the script to exit"""
+        Bot.stop = True
+
+    def restart_bots(self):
+        """Causes the script to restart.
+        Actually, you need to catch Bot.stop state when Bot.run() returns and handle the situation yourself."""
+        Bot.stop = "Restart"
+
+    @classmethod
+    async def check_task(cls):
+        """Await until cls.stop, then end session and return"""
+        for bot in cls.instances.values():
+            asyncio.ensure_future(bot.continue_running())
+        while not cls.stop:
+            await asyncio.sleep(10)
+        return await cls.end_session()
+
+    @classmethod
+    async def end_session(cls):
+        """Run after stop, before the script exits.
+        Await final tasks, obscure and delete pending messages, log current operation (stop/restart)."""
+        for bot in cls.instances.values():
+            for task in bot.run_after_loop:
+                await task
+            for message in bot.to_be_destroyed:
+                try:
+                    await bot.delete_message(message)
+                except Exception as e:
+                    logging.error("Couldn't delete message\n{}\n\n{}".format(message,e))
+            for inline_message_id in bot.to_be_obscured:
+                try:
+                    await bot.editMessageCaption(inline_message_id, text="Time over")
+                except:
+                    try:
+                        await bot.editMessageText(inline_message_id, text="Time over")
+                    except Exception as e:
+                        logging.error("Couldn't obscure message\n{}\n\n{}".format(inline_message_id,e))
+        if cls.stop=="Restart":
+            logging.info("\n\t\t---Restart!---")
+        elif cls.stop=="KeyboardInterrupt":
+            logging.info("Stopped by KeyboardInterrupt.")
+        else:
+            logging.info("Stopped gracefully by user.")
+        return
+
+    @classmethod
+    def run(cls, loop=None):
+        """
+        Call this method to run the async bots.
+        """
+        if not loop:
+            loop = asyncio.get_event_loop()
+        logging.info(
+            "{sep}{subjvb} STARTED{sep}".format(
+                sep='-'*10,
+                subjvb='BOT HAS' if len(cls.instances)==1 else 'BOTS HAVE'
+            )
+        )
+        try:
+            loop.run_until_complete(cls.check_task())
+        except KeyboardInterrupt:
+            logging.info('\n\t\tYour script received a KeyboardInterrupt signal, your bot{} being stopped.'.format(
+                's are' if len(cls.instances)>1 else ' is'
+            ))
+            cls.stop = "KeyboardInterrupt"
+            loop.run_until_complete(cls.end_session())
+        except Exception as e:
+            logging.error('\nYour bot has been stopped. with error \'{}\''.format(e), exc_info=True)
+        logging.info(
+            "{sep}{subjvb} STOPPED{sep}".format(
+                sep='-'*10,
+                subjvb='BOT HAS' if len(cls.instances)==1 else 'BOTS HAVE'
+            )
+        )
+        return

+ 986 - 0
datelepot/utilities.py

@@ -0,0 +1,986 @@
+# Standard library modules
+import aiohttp
+import asyncio
+import collections
+import csv
+import datetime
+import io
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+
+# Third party modules
+from bs4 import BeautifulSoup
+import telepot, telepot.aio
+
+def sumif(it, cond):
+    return sum(filter(cond, it))
+
+def markdown_check(text, symbols):
+    #Dato un testo text e una lista symbols di simboli, restituisce vero solo se TUTTI i simboli della lista sono presenti in numero pari di volte nel testo.
+    for s in symbols:
+        if (len(text.replace(s,"")) - len(text))%2 != 0:
+            return False
+    return True
+
+def shorten_text(text, limit, symbol="[...]"):
+    """Return a given text truncated at limit if longer than limit. On truncation, add symbol.
+    """
+    assert type(text) is str and type(symbol) is str and type(limit) is int
+    if len(text) <= limit:
+        return text
+    return text[:limit-len(symbol)] + symbol
+
+def extract(text, starter=None, ender=None):
+    """Return string in text between starter and ender.
+    If starter is None, truncates at ender.
+    """
+    if starter and starter in text:
+        text = text.partition(starter)[2]
+    if ender:
+        return text.partition(ender)[0]
+    return text
+
+def mkbtn(x, y):
+    if len(y) > 60:#If callback_data exceeeds 60 characters (max is 64), it gets trunkated at the last comma
+        y = y[:61]
+        y = y[:- y[::-1].find(",")-1]
+    return {'text': x, 'callback_data': y}
+
+def make_lines_of_buttons(btns, row_len=1):
+    return [btns[i:i + row_len] for i in range(0, len(btns), row_len)]
+
+def make_inline_keyboard(btns, row_len=1):
+    return dict(inline_keyboard=make_lines_of_buttons(btns, row_len))
+
+async def async_get(url, mode='json', **kwargs):
+    if 'mode' in kwargs:
+        mode = kwargs['mode']
+        del kwargs['mode']
+    return await async_request(url, type='get', mode=mode, **kwargs)
+
+async def async_post(url, mode='html', **kwargs):
+    return await async_request(url, type='post', mode=mode, **kwargs)
+
+async def async_request(url, type='get', mode='json', **kwargs):
+    try:
+        async with aiohttp.ClientSession() as s:
+                async with (s.get(url, timeout=30) if type=='get' else s.post(url, timeout=30, data=kwargs)) as r:
+                    result = await r.read()
+    except Exception as e:
+        logging.error('Error making async request to {}:\n{}'.format(url, e), exc_info=False) # Set exc_info=True to debug
+        return e
+    if mode=='json':
+        if not result:
+            return {}
+        return json.loads(result.decode('utf-8'))
+    if mode=='html':
+        return BeautifulSoup(result.decode('utf-8'), "html.parser")
+    if mode=='string':
+        return result.decode('utf-8')
+    return result
+
+def json_read(file, default={}):
+    if not os.path.isfile(file):
+        return default
+    with open(file, "r", encoding='utf-8') as f:
+        return json.load(f)
+
+def json_write(what, file):
+    with open(file, "w") as f:
+        return json.dump(what, f, indent=4)
+
+def csv_read(file_, default=[]):
+    if not os.path.isfile(file_):
+        return default
+    result = []
+    keys = []
+    with open(file_, newline='', encoding='utf8') as csv_file:
+        csv_reader = csv.reader(csv_file, delimiter=',', quotechar='"')
+        for row in csv_reader:
+            if not keys:
+                keys = row
+                continue
+            item = collections.OrderedDict()
+            for key, val in zip(keys, row):
+                item[key] = val
+            result.append(item)
+    return result
+
+def csv_write(info=[], file_='output.csv'):
+    assert type(info) is list and len(info)>0, "info must be a non-empty list"
+    assert all(isinstance(row, dict) for row in info), "Rows must be dictionaries!"
+    with open(file_, 'w', newline='', encoding='utf8') as csv_file:
+        csv_writer = csv.writer(csv_file, delimiter=',', quotechar='"')
+        csv_writer.writerow(info[0].keys())
+        for row in info:
+            csv_writer.writerow(row.values())
+    return
+
+class MyOD(collections.OrderedDict):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._anti_list_casesensitive = None
+        self._anti_list_caseinsensitive = None
+
+    @property
+    def anti_list_casesensitive(self):
+        if not self._anti_list_casesensitive:
+            self._anti_list_casesensitive = {self[x]:x for x in self}
+        return self._anti_list_casesensitive
+
+    @property
+    def anti_list_caseinsensitive(self):
+        if not self._anti_list_caseinsensitive:
+            self._anti_list_caseinsensitive = {self[x].lower() if type(self[x]) is str else self[x] :x for x in self}
+        return self._anti_list_caseinsensitive
+
+    #MyOD[key] = val <-- MyOD.get(key) = val <--> MyOD.get_by_val(val) = key
+    def get_by_val(self, val, case_sensitive=True):
+        return (self.anti_list_casesensitive if case_sensitive else self.anti_list_caseinsensitive)[val]
+
+    def get_by_key_val(self, key, val, case_sensitive=True, return_value=False):
+        for x, y in self.items():
+            if (y[key] == val and case_sensitive) or (y[key].lower() == val.lower() and not case_sensitive):
+                return y if return_value else x
+        return None
+
+def line_drawing_unordered_list(l):
+    result = ""
+    if l:
+        for x in l[:-1]:
+            result += "├ {}\n".format(x)
+        result += "└ {}".format(l[-1])
+    return result
+
+def str_to_datetime(d):
+    if isinstance(d, datetime.datetime):
+        return d
+    return datetime.datetime.strptime(d, '%Y-%m-%d %H:%M:%S.%f')
+
+def datetime_to_str(d):
+    if not isinstance(d, datetime.datetime):
+        raise TypeError('Input of datetime_to_str function must be a datetime.datetime object. Output is a str')
+    return '{:%Y-%m-%d %H:%M:%S.%f}'.format(d)
+
+class MyCounter():
+    def __init__(self):
+        self.n = 0
+        return
+
+    def lvl(self):
+        self.n += 1
+        return self.n
+
+    def reset(self):
+        self.n = 0
+        return self.n
+
+def wrapper(func, *args, **kwargs):
+    def wrapped(update):
+        return func(update, *args, **kwargs)
+    return wrapped
+
+async def async_wrapper(func, *args, **kwargs):
+    async def wrapped(update):
+        return await func(update, *args, **kwargs)
+    return wrapped
+
+#Decorator: such decorated functions have effect only if update is forwarded from someone (you can specify *by* whom)
+def forwarded(by=None):
+    def isforwardedby(update, by):
+        if 'forward_from' not in update:
+            return False
+        if by:
+            if update['forward_from']['id']!=by:
+                return False
+        return True
+    def decorator(view_func):
+        if asyncio.iscoroutinefunction(view_func):
+            async def decorated(update):
+                if isforwardedby(update, by):
+                    return await view_func(update)
+        else:
+            def decorated(update):
+                if isforwardedby(update, by):
+                    return view_func(update)
+        return decorated
+    return decorator
+
+#Decorator: such decorated functions have effect only if update comes from specific chat.
+def chat_selective(chat_id=None):
+    def check_function(update, chat_id):
+        if 'chat' not in update:
+            return False
+        if chat_id:
+            if update['chat']['id']!=chat_id:
+                return False
+        return True
+    def decorator(view_func):
+        if asyncio.iscoroutinefunction(view_func):
+            async def decorated(update):
+                if check_function(update, chat_id):
+                    return await view_func(update)
+        else:
+            def decorated(update):
+                if check_function(update, chat_id):
+                    return view_func(update)
+        return decorated
+    return decorator
+
+async def sleep_until(when):
+    if not isinstance(when, datetime.datetime):
+        raise TypeError("sleep_until takes a datetime.datetime object as argument!")
+    delta = when - datetime.datetime.now()
+    if delta.days>=0:
+        await asyncio.sleep(max(1, delta.seconds//2))
+    return
+
+async def wait_and_do(when, what, *args, **kwargs):
+    while when >= datetime.datetime.now():
+        await sleep_until(when)
+    return await what(*args, **kwargs)
+
+def get_csv_string(l):
+    return ','.join(
+    str(x) if type(x) is not str
+    else '"{}"'.format(x)
+    for x in l
+    )
+
+def case_accent_insensitive_sql(field):
+    """Given a field, return a part of SQL string necessary to perform a case- and accent-insensitive query."""
+    replacements = [
+        (' ', ''),
+        ('à', 'a'),
+        ('è', 'e'),
+        ('é', 'e'),
+        ('ì', 'i'),
+        ('ò', 'o'),
+        ('ù', 'u'),
+    ]
+    return "{r}LOWER({f}){w}".format(
+        r="replace(".upper()*len(replacements),
+        f=field,
+        w=''.join(
+            ", '{w[0]}', '{w[1]}')".format(w=w)
+            for w in replacements
+        )
+    )
+
+ARTICOLI = MyOD()
+ARTICOLI[1] = {
+    'ind': 'un',
+    'dets': 'il',
+    'detp': 'i',
+    'dess': 'l',
+    'desp': 'i'
+}
+ARTICOLI[2] = {
+    'ind': 'una',
+    'dets': 'la',
+    'detp': 'le',
+    'dess': 'lla',
+    'desp': 'lle'
+}
+ARTICOLI[3] = {
+    'ind': 'uno',
+    'dets': 'lo',
+    'detp': 'gli',
+    'dess': 'llo',
+    'desp': 'gli'
+}
+ARTICOLI[4] = {
+    'ind': 'un',
+    'dets': 'l\'',
+    'detp': 'gli',
+    'dess': 'll\'',
+    'desp': 'gli'
+}
+
+class Gettable():
+    """Gettable objects can be retrieved from memory without being duplicated.
+    Key is the primary key.
+    Use classmethod get to instanciate (or retrieve) Gettable objects.
+    Assign SubClass.instances = {} or Gettable.instances will contain SubClass objects.
+    """
+    instances = {}
+
+    @classmethod
+    def get(cls, key, *args, **kwargs):
+        if key not in cls.instances:
+            cls.instances[key] = cls(key, *args, **kwargs)
+        return cls.instances[key]
+
+class Confirmable():
+    """Confirmable objects are provided with a confirm instance method.
+    It evaluates True if it was called within self._confirm_timedelta, False otherwise.
+    When it returns True, timer is reset.
+    """
+    CONFIRM_TIMEDELTA = datetime.timedelta(seconds=10)
+
+    def __init__(self, confirm_timedelta=None):
+        if confirm_timedelta is None:
+            confirm_timedelta = self.__class__.CONFIRM_TIMEDELTA
+        self.set_confirm_timedelta(confirm_timedelta)
+        self._confirm_datetimes = {}
+
+    @property
+    def confirm_timedelta(self):
+        return self._confirm_timedelta
+
+    def confirm_datetime(self, who='unique'):
+        if who not in self._confirm_datetimes:
+            self._confirm_datetimes[who] = datetime.datetime.now() - 2*self.confirm_timedelta
+        confirm_datetime = self._confirm_datetimes[who]
+        return confirm_datetime
+
+    def set_confirm_timedelta(self, confirm_timedelta):
+        if type(confirm_timedelta) is int:
+            confirm_timedelta = datetime.timedelta(seconds=confirm_timedelta)
+        assert isinstance(confirm_timedelta, datetime.timedelta), "confirm_timedelta must be a datetime.timedelta instance!"
+        self._confirm_timedelta = confirm_timedelta
+
+    def confirm(self, who='unique'):
+        now = datetime.datetime.now()
+        if now >= self.confirm_datetime(who) + self.confirm_timedelta:
+            self._confirm_datetimes[who] = now
+            return False
+        self._confirm_datetimes[who] = now - 2*self.confirm_timedelta
+        return True
+
+class HasBot():
+    """HasBot objects have a class method which sets the class attribute bot (set_bot)\
+    and an instance property which returns it (bot).
+    """
+    bot = None
+
+    @property
+    def bot(self):
+        return self.__class__.bot
+
+    @property
+    def db(self):
+        return self.bot.db
+
+    @classmethod
+    def set_bot(cls, bot):
+        cls.bot = bot
+
+class CachedPage(Gettable):
+    """Store a webpage in this object, return cached webpage during CACHE_TIME, otherwise refresh.
+
+    Usage:
+    cached_page = CachedPage.get('https://www.google.com', datetime.timedelta(seconds=30), **kwargs)
+    page = await cached_page.get_page()
+
+    __init__ arguments
+        url: the URL to be cached
+        cache_time: timedelta from last_update during which page is not refreshed
+        **kwargs will be passed to async_get function
+    """
+    CACHE_TIME = datetime.timedelta(minutes=5)
+    instances = {}
+
+    def __init__(self, url, cache_time=None, **async_get_kwargs):
+        self._url = url
+        if type(cache_time) is int:
+            cache_time = datetime.timedelta(seconds=cache_time)
+        if cache_time is None:
+            cache_time = self.__class__.CACHE_TIME
+        assert type(cache_time) is datetime.timedelta, "Cache time must be a datetime.timedelta object!"
+        self._cache_time = cache_time
+        self._page = None
+        self._last_update = datetime.datetime.now() - self.cache_time
+        self._async_get_kwargs = async_get_kwargs
+
+    @property
+    def url(self):
+        return self._url
+
+    @property
+    def cache_time(self):
+        return self._cache_time
+
+    @property
+    def page(self):
+        return self._page
+
+    @property
+    def last_update(self):
+        return self._last_update
+
+    @property
+    def async_get_kwargs(self):
+        return self._async_get_kwargs
+
+    @property
+    def is_old(self):
+        return datetime.datetime.now() > self.last_update + self.cache_time
+
+    async def refresh(self):
+        try:
+            self._page = await async_get(self.url, **self.async_get_kwargs)
+            self._last_update = datetime.datetime.now()
+            return 0
+        except Exception as e:
+            self._page = None
+            logging.error(''.format(e), exc_info=True)
+            return 1
+        return 1
+
+    async def get_page(self):
+        if self.is_old:
+            await self.refresh()
+        return self.page
+
+class Confirmator(Gettable, Confirmable):
+    instances = {}
+    def __init__(self, key, *args, confirm_timedelta=None):
+        Confirmable.__init__(self, confirm_timedelta)
+
+def get_cleaned_text(update, bot=None, replace=[], strip='/ @'):
+    if bot is not None:
+        replace.append(
+            '@{.name}'.format(
+                bot
+            )
+        )
+    text = update['text'].strip(strip)
+    for s in replace:
+        while s and text.lower().startswith(s.lower()):
+            text = text[len(s):]
+    return text.strip(strip)
+
+def get_user(record):
+    if not record:
+        return
+    from_ = {key: val for key, val in record.items()}
+    first_name, last_name, username, id_ = None, None, None, None
+    result = ''
+    if 'telegram_id' in from_:
+        from_['id'] = from_['telegram_id']
+    if 'id' in from_:
+        result = '<a href="tg://user?id={}">{{name}}</a>'.format(from_['id'])
+    if 'username' in from_ and from_['username']:
+        result = result.format(
+            name=from_['username']
+        )
+    elif 'first_name' in from_ and from_['first_name'] and 'last_name' in from_ and from_['last_name']:
+        result = result.format(
+            name='{} {}'.format(
+                from_['first_name'],
+                from_['last_name']
+            )
+        )
+    elif 'first_name' in from_ and from_['first_name']:
+        result = result.format(
+            name=from_['first_name']
+        )
+    elif 'last_name' in from_ and from_['last_name']:
+        result = result.format(
+            name=from_['last_name']
+        )
+    else:
+        result = result.format(
+            name="Utente anonimo"
+        )
+    return result
+
+def datetime_from_utc_to_local(utc_datetime):
+    now_timestamp = time.time()
+    offset = datetime.datetime.fromtimestamp(now_timestamp) - datetime.datetime.utcfromtimestamp(now_timestamp)
+    return utc_datetime + offset
+
+# TIME_SYMBOLS from more specific to less specific (avoid false positives!)
+TIME_SYMBOLS = MyOD()
+TIME_SYMBOLS["'"] = 'minutes'
+TIME_SYMBOLS["settimana"] = 'weeks'
+TIME_SYMBOLS["settimane"] = 'weeks'
+TIME_SYMBOLS["weeks"] = 'weeks'
+TIME_SYMBOLS["week"] = 'weeks'
+TIME_SYMBOLS["giorno"] = 'days'
+TIME_SYMBOLS["giorni"] = 'days'
+TIME_SYMBOLS["secondi"] = 'seconds'
+TIME_SYMBOLS["seconds"] = 'seconds'
+TIME_SYMBOLS["secondo"] = 'seconds'
+TIME_SYMBOLS["minuti"] = 'minutes'
+TIME_SYMBOLS["minuto"] = 'minutes'
+TIME_SYMBOLS["minute"] = 'minutes'
+TIME_SYMBOLS["minutes"] = 'minutes'
+TIME_SYMBOLS["day"] = 'days'
+TIME_SYMBOLS["days"] = 'days'
+TIME_SYMBOLS["ore"] = 'hours'
+TIME_SYMBOLS["ora"] = 'hours'
+TIME_SYMBOLS["sec"] = 'seconds'
+TIME_SYMBOLS["min"] = 'minutes'
+TIME_SYMBOLS["m"] = 'minutes'
+TIME_SYMBOLS["h"] = 'hours'
+TIME_SYMBOLS["d"] = 'days'
+TIME_SYMBOLS["s"] = 'seconds'
+
+def _interval_parser(text, result):
+    text = text.lower()
+    succeeded = False
+    if result is None:
+        result = []
+    if len(result)==0 or result[-1]['ok']:
+        text_part = ''
+        _text = text # I need to iterate through _text modifying text
+        for char in _text:
+            if not char.isnumeric():
+                break
+            else:
+                text_part += char
+                text = text[1:]
+        if text_part.isnumeric():
+            result.append(
+                dict(
+                    unit=None,
+                    value=int(text_part),
+                    ok=False
+                )
+            )
+            succeeded = True, True
+            if text:
+                dummy, result = _interval_parser(text, result)
+    elif len(result)>0 and not result[-1]['ok']:
+        text_part = ''
+        _text = text # I need to iterate through _text modifying text
+        for char in _text:
+            if char.isnumeric():
+                break
+            else:
+                text_part += char
+                text = text[1:]
+        for time_symbol, unit in TIME_SYMBOLS.items():
+            if time_symbol in text_part:
+                result[-1]['unit'] = unit
+                result[-1]['ok'] = True
+                succeeded = True, True
+                break
+        else:
+            result.pop()
+        if text:
+            dummy, result = _interval_parser(text, result)
+    return succeeded, result
+
+def _date_parser(text, result):
+    succeeded = False
+    if 3 <= len(text) <= 10 and text.count('/')>=1:
+        if 3 <= len(text) <= 5 and text.count('/')==1:
+            text += '/{:%y}'.format(datetime.datetime.now())
+        if 6 <= len(text) <= 10 and text.count('/')==2:
+            day, month, year = [
+                int(n) for n in [
+                    ''.join(char)
+                    for char in text.split('/')
+                    if char.isnumeric()
+                ]
+            ]
+            if year < 100: year += 2000
+            if result is None: result = []
+            result += [
+                dict(
+                    unit='day',
+                    value=day,
+                    ok=True
+                ),
+                dict(
+                    unit='month',
+                    value=month,
+                    ok=True
+                ),
+                dict(
+                    unit='year',
+                    value=year,
+                    ok=True
+                )
+            ]
+            succeeded = True, True
+    return succeeded, result
+
+def _time_parser(text, result):
+    succeeded = False
+    if (1 <= len(text) <= 8) and any(char.isnumeric() for char in text):
+        text = text.replace('.', ':')
+        if len(text) <= 2:
+            text = '{:02d}:00:00'.format(int(text))
+        elif len(text) == 4 and ':' not in text:
+            text = '{:02d}:{:02d}:00'.format(*[int(x) for x in (text[:2], text[2:])])
+        elif text.count(':')==1:
+            text = '{:02d}:{:02d}:00'.format(*[int(x) for x in text.split(':')])
+        if text.count(':')==2:
+            hour, minute, second = (int(x) for x in text.split(':'))
+            if (0 <= hour <= 24) and (0 <= minute <= 60) and (0 <= second <= 60):
+                if result is None: result = []
+                result += [
+                    dict(
+                        unit='hour',
+                        value=hour,
+                        ok=True
+                    ),
+                    dict(
+                        unit='minute',
+                        value=minute,
+                        ok=True
+                    ),
+                    dict(
+                        unit='second',
+                        value=second,
+                        ok=True
+                    )
+                ]
+                succeeded = True
+    return succeeded, result
+
+WEEKDAY_NAMES_ITA = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"]
+WEEKDAY_NAMES_ENG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+
+def _period_parser(text, result):
+    succeeded = False
+    if text in ('every', 'ogni',):
+        succeeded = True
+    if text.title() in WEEKDAY_NAMES_ITA + WEEKDAY_NAMES_ENG:
+        day_code = (WEEKDAY_NAMES_ITA + WEEKDAY_NAMES_ENG).index(text.title())
+        if day_code > 6: day_code -= 7
+        today = datetime.date.today()
+        days = 1
+        while (today + datetime.timedelta(days=days)).weekday() != day_code:
+            days += 1
+        if result is None:
+            result = []
+        result.append(
+            dict(
+                unit='days',
+                value=days,
+                ok=True,
+                weekly=True
+            )
+        )
+        succeeded = True
+    else:
+        succeeded, result = _interval_parser(text, result)
+    return succeeded, result
+
+TIME_WORDS = {
+    'tra': dict(
+        parser=_interval_parser,
+        recurring=False,
+        type_='delta'
+    ),
+    'in': dict(
+        parser=_interval_parser,
+        recurring=False,
+        type_='delta'
+    ),
+    'at': dict(
+        parser=_time_parser,
+        recurring=False,
+        type_='set'
+    ),
+    'on': dict(
+        parser=_date_parser,
+        recurring=False,
+        type_='set'
+    ),
+    'alle': dict(
+        parser=_time_parser,
+        recurring=False,
+        type_='set'
+    ),
+    'il': dict(
+        parser=_date_parser,
+        recurring=False,
+        type_='set'
+    ),
+    'every': dict(
+        parser=_period_parser,
+        recurring=True,
+        type_='delta'
+    ),
+    'ogni': dict(
+        parser=_period_parser,
+        recurring=True,
+        type_='delta'
+    ),
+}
+
+def parse_datetime_interval_string(text):
+    parsers = []
+    result_text, result_datetime, result_timedelta = [], None, None
+    is_quoted_text = False
+    for word in text.split(' '):
+        if word.count('"') % 2:
+            is_quoted_text = not is_quoted_text
+        if is_quoted_text or '"' in word:
+            result_text.append(
+                word.replace('"', '') if 'href=' not in word else word
+            )
+            continue
+        result_text.append(word)
+        word = word.lower()
+        succeeded = False
+        if len(parsers) > 0:
+            succeeded, result = parsers[-1]['parser'](word, parsers[-1]['result'])
+            if succeeded:
+                parsers[-1]['result'] = result
+        if not succeeded and word in TIME_WORDS:
+            parsers.append(
+                dict(
+                    result=None,
+                    parser=TIME_WORDS[word]['parser'],
+                    recurring=TIME_WORDS[word]['recurring'],
+                    type_=TIME_WORDS[word]['type_']
+                )
+            )
+        if succeeded:
+            result_text.pop()
+            if len(result_text)>0 and result_text[-1].lower() in TIME_WORDS:
+                result_text.pop()
+    result_text = escape_html_chars(
+        ' '.join(result_text)
+    )
+    parsers = list(
+        filter(
+            lambda x: 'result' in x and x['result'],
+            parsers
+        )
+    )
+    recurring_event = False
+    weekly = False
+    _timedelta = datetime.timedelta()
+    _datetime = None
+    _now = datetime.datetime.now()
+    for parser in parsers:
+        if parser['recurring']:
+            recurring_event = True
+        type_ = parser['type_']
+        for result in parser['result']:
+            if not result['ok']:
+                continue
+            if recurring_event and 'weekly' in result and result['weekly']:
+                weekly = True
+            if type_ == 'set':
+                if _datetime is None:
+                    _datetime = _now
+                _datetime = _datetime.replace(
+                    **{
+                        result['unit']: result['value']
+                    }
+                )
+            elif type_ == 'delta':
+                _timedelta += datetime.timedelta(
+                    **{
+                        result['unit']: result['value']
+                    }
+                )
+    if _datetime:
+        result_datetime = _datetime
+    if _timedelta:
+        if result_datetime is None: result_datetime = _now
+        if recurring_event:
+            result_timedelta = _timedelta
+            if weekly:
+                result_timedelta = datetime.timedelta(days=7)
+        else:
+            result_datetime += _timedelta
+    while result_datetime and result_datetime < datetime.datetime.now():
+        result_datetime += (result_timedelta if result_timedelta else datetime.timedelta(days=1))
+    return result_text, result_datetime, result_timedelta
+
+DAY_GAPS = {
+    -1: 'ieri',
+    -2: 'avantieri',
+    0: 'oggi',
+    1: 'domani',
+    2: 'dopodomani'
+}
+
+MONTH_NAMES_ITA = MyOD()
+MONTH_NAMES_ITA[1] = "gennaio"
+MONTH_NAMES_ITA[2] = "febbraio"
+MONTH_NAMES_ITA[3] = "marzo"
+MONTH_NAMES_ITA[4] = "aprile"
+MONTH_NAMES_ITA[5] = "maggio"
+MONTH_NAMES_ITA[6] = "giugno"
+MONTH_NAMES_ITA[7] = "luglio"
+MONTH_NAMES_ITA[8] = "agosto"
+MONTH_NAMES_ITA[9] = "settembre"
+MONTH_NAMES_ITA[10] = "ottobre"
+MONTH_NAMES_ITA[11] = "novembre"
+MONTH_NAMES_ITA[12] = "dicembre"
+
+def beautytd(td):
+    result = ''
+    if type(td) is int:
+        td = datetime.timedelta(seconds=td)
+    assert isinstance(td, datetime.timedelta), "td must be a datetime.timedelta object!"
+    mtd = datetime.timedelta
+    if td < mtd(minutes=1):
+        result = "{:.0f} secondi".format(
+            td.total_seconds()
+        )
+    elif td < mtd(minutes=10):
+        result = "{:.0f} min{}".format(
+            td.total_seconds()//60,
+            (
+                " {:.0f} s".format(
+                    td.total_seconds()%60
+                )
+            ) if td.total_seconds()%60 else ''
+        )
+    elif td < mtd(days=1):
+        result = "{:.0f} h{}".format(
+            td.total_seconds()//3600,
+            (
+                " {:.0f} min".format(
+                (td.total_seconds()%3600)//60)
+            ) if td.total_seconds()%3600 else ''
+        )
+    elif td < mtd(days=30):
+        result = "{} giorni{}".format(
+            td.days,
+            (
+                " {:.0f} h".format(
+                    td.total_seconds()%(3600*24)//3600
+                )
+            ) if td.total_seconds()%(3600*24) else ''
+        )
+    return result
+
+def beautydt(dt):
+    """Format a datetime in a smart way
+    """
+    if type(dt) is str:
+        dt = str_to_datetime(dt)
+    assert isinstance(dt, datetime.datetime), "dt must be a datetime.datetime object!"
+    now = datetime.datetime.now()
+    gap = dt - now
+    gap_days = (dt.date() - now.date()).days
+    result = "{dt:alle %H:%M}".format(
+        dt=dt
+    )
+    if abs(gap) < datetime.timedelta(minutes=30):
+        result += "{dt::%S}".format(dt=dt)
+    if -2 <= gap_days <= 2:
+        result += " di {dg}".format(
+            dg=DAY_GAPS[gap_days]
+        )
+    elif gap.days not in (-1, 0):
+        result += " del {d}{m}".format(
+            d=dt.day,
+            m=(
+                "" if now.year == dt.year and now.month == dt.month
+                else " {m}{y}".format(
+                    m=MONTH_NAMES_ITA[dt.month].title(),
+                    y="" if now.year == dt.year
+                    else " {}".format(dt.year)
+                )
+            )
+        )
+    return result
+
+HTML_SYMBOLS = MyOD()
+HTML_SYMBOLS["&"] = "&amp;"
+HTML_SYMBOLS["<"] = "&lt;"
+HTML_SYMBOLS[">"] = "&gt;"
+HTML_SYMBOLS["\""] = "&quot;"
+HTML_SYMBOLS["&lt;b&gt;"] = "<b>"
+HTML_SYMBOLS["&lt;/b&gt;"] = "</b>"
+HTML_SYMBOLS["&lt;i&gt;"] = "<i>"
+HTML_SYMBOLS["&lt;/i&gt;"] = "</i>"
+HTML_SYMBOLS["&lt;code&gt;"] = "<code>"
+HTML_SYMBOLS["&lt;/code&gt;"] = "</code>"
+HTML_SYMBOLS["&lt;pre&gt;"] = "<pre>"
+HTML_SYMBOLS["&lt;/pre&gt;"] = "</pre>"
+HTML_SYMBOLS["&lt;a href=&quot;"] = "<a href=\""
+HTML_SYMBOLS["&quot;&gt;"] = "\">"
+HTML_SYMBOLS["&lt;/a&gt;"] = "</a>"
+
+HTML_TAGS = [
+    None, "<b>", "</b>",
+    None, "<i>", "</i>",
+    None, "<code>", "</code>",
+    None, "<pre>", "</pre>",
+    None, "<a href=\"", "\">", "</a>",
+    None
+]
+
+def remove_html_tags(text):
+    for tag in HTML_TAGS:
+        if tag is None:
+            continue
+        text = text.replace(tag, '')
+    return text
+
+def escape_html_chars(text):
+    for s, r in HTML_SYMBOLS.items():
+        text = text.replace(s, r)
+    copy = text
+    expected_tag = None
+    while copy:
+        min_ = min(
+            (
+                dict(
+                    position=copy.find(tag) if tag in copy else len(copy),
+                    tag=tag
+                )
+                for tag in HTML_TAGS
+                if tag
+            ),
+            key=lambda x: x['position'],
+            default=0
+        )
+        if min_['position'] == len(copy):
+            break
+        if expected_tag and min_['tag'] != expected_tag:
+            return text.replace('<', '_').replace('>', '_')
+        expected_tag = HTML_TAGS[HTML_TAGS.index(min_['tag'])+1]
+        copy = extract(copy, min_['tag'])
+    return text
+
+def accents_to_jolly(text, lower=True):
+    to_be_replaced = ('à', 'è', 'é', 'ì', 'ò', 'ù')
+    if lower:
+        text = text.lower()
+    else:
+        to_be_replaced += tuple(s.upper() for s in to_be_replaced)
+    for s in to_be_replaced:
+        text = text.replace(s, '_')
+    return text.replace("'", "''")
+
+def get_secure_key(allowed_chars=None, length=6):
+    if allowed_chars is None:
+        allowed_chars = string.ascii_uppercase + string.digits
+    return ''.join(
+        random.SystemRandom().choice(
+            allowed_chars
+        )
+        for _ in range(length)
+    )
+
+def round_to_minute(datetime_):
+    return (
+        datetime_ + datetime.timedelta(seconds=30)
+    ).replace(second=0, microsecond=0)
+
+def get_line_by_content(text, key):
+    for line in text.split('\n'):
+        if key in line:
+            return line
+    return
+
+def str_to_int(string):
+    string = ''.join(
+        char
+        for char in string
+        if char.isnumeric()
+    )
+    if len(string) == 0:
+        string = '0'
+    return int(string)

+ 35 - 0
requirements.txt

@@ -0,0 +1,35 @@
+aiohttp==3.4.4
+alembic==1.0.1
+async-timeout==3.0.1
+attrs==18.2.0
+banal==0.3.8
+beautifulsoup4==4.6.3
+bs4==0.0.1
+certifi==2018.10.15
+chardet==3.0.4
+cycler==0.10.0
+dataset==1.1.0
+idna==2.7
+idna-ssl==1.1.0
+kiwisolver==1.0.1
+Mako==1.0.7
+MarkupSafe==1.0
+matplotlib==3.0.0
+multidict==4.4.2
+normality==0.6.1
+numpy==1.15.2
+oauthlib==2.1.0
+pkg-resources==0.0.0
+pyparsing==2.2.2
+PySocks==1.6.8
+python-dateutil==2.7.3
+python-editor==1.0.3
+requests==2.19.1
+requests-oauthlib==1.0.0
+scipy==1.1.0
+six==1.11.0
+SQLAlchemy==1.2.12
+telepot==12.7
+tweepy==3.6.0
+urllib3==1.23
+yarl==1.2.6

+ 53 - 0
setup.py

@@ -0,0 +1,53 @@
+import codecs
+import os
+import sys
+
+if sys.version_info < (3,5):
+    raise RuntimeError("Python3.5+ is needed to run async code")
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+def read(*parts):
+    with codecs.open(os.path.join(here, *parts), 'r') as fp:
+        return fp.read()
+
+def find_information(info, *file_paths):
+    version_file = read(*file_paths)
+    version_match = re.search(
+        r"^__{info}__ = ['\"]([^'\"]*)['\"]".format(
+            info=info
+        ),
+        version_file,
+        re.M
+    )
+    if version_match:
+        return version_match.group(1)
+    raise RuntimeError("Unable to find version string.")
+
+def setup(**kwargs):
+    for key, val in kwargs.items():
+        print(key, val, sep='\t\t')
+
+with open("README.md", "r") as readme_file:
+    long_description = readme_file.read()
+
+setup(
+    name='datelepot',
+    version=find_information("version", "datelepot", "__init__.py"),
+    author=find_information("version", "datelepot", "__init__.py"),
+    description="telepot.aio.Bot convenient subclass, featuring dataset-powered SQLite.",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://bitbucket.org/davte/datelepot",
+    packages=setuptools.find_packages(),
+    classifiers=[
+        "Development Status :: 3 - Alpha",
+        "Framework :: AsyncIO",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python :: 3 :: Only",
+        "Topic :: Communications :: Chat",
+    ],
+)