commit 4b04d10f78115638d318f49567fbb94872e719b3 Author: Davte Date: Tue Oct 23 18:12:07 2018 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f594cb --- /dev/null +++ b/.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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e12e1b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ + This project is licensed under the GNU General Public License v3.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4a800d --- /dev/null +++ b/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. diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datelepot/__init__.py b/datelepot/__init__.py new file mode 100644 index 0000000..bae7907 --- /dev/null +++ b/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 diff --git a/datelepot/custombot.py b/datelepot/custombot.py new file mode 100644 index 0000000..d19c682 --- /dev/null +++ b/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]]))1: + for i in range(1,len(result)): + result[i] = "{tag[0]}[...]{tag[1]}\n{text}".format( + tag=('`','`') if parse_mode=='Markdown' + else ('', '') 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 ('', '') 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 "Bot has just been put under maintenance!\n\nUntil further notice, it will reply to users with the following message:\n\n{}".format( + self.maintenance_message + ) + return "Maintenance ended!" + + 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 , 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 diff --git a/datelepot/utilities.py b/datelepot/utilities.py new file mode 100644 index 0000000..cdcdb5d --- /dev/null +++ b/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 = '{{name}}'.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["&"] = "&" +HTML_SYMBOLS["<"] = "<" +HTML_SYMBOLS[">"] = ">" +HTML_SYMBOLS["\""] = """ +HTML_SYMBOLS["<b>"] = "" +HTML_SYMBOLS["</b>"] = "" +HTML_SYMBOLS["<i>"] = "" +HTML_SYMBOLS["</i>"] = "" +HTML_SYMBOLS["<code>"] = "" +HTML_SYMBOLS["</code>"] = "" +HTML_SYMBOLS["<pre>"] = "
"
+HTML_SYMBOLS["</pre>"] = "
" +HTML_SYMBOLS["<a href=""] = "" +HTML_SYMBOLS["</a>"] = "" + +HTML_TAGS = [ + None, "", "", + None, "", "", + None, "", "", + None, "
", "
", + None, "", "", + 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e1f78ca --- /dev/null +++ b/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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cd51829 --- /dev/null +++ b/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", + ], +)