From b107cc105d9b3a084342045b6f83a4adcaf72549 Mon Sep 17 00:00:00 2001 From: Davte Date: Mon, 27 Apr 2020 18:00:53 +0200 Subject: [PATCH 01/17] Missing whitespace --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 0ee3ce1..eb80408 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.2" +__version__ = "2.5.3" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 582ee8d..68c2bb9 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -802,7 +802,7 @@ async def _version_command(bot: davtelepot_bot, update, user_record): 'admin', 'version_command', 'header', last_commit=last_commit, update=update, user_record=user_record - ) + ) + '\n\n' text += '\n'.join( f"{package.__name__}: " f"{package.__version__}" From e7274dce070735d90e34d2eb08e9402c83d15c81 Mon Sep 17 00:00:00 2001 From: Davte Date: Mon, 27 Apr 2020 19:07:10 +0200 Subject: [PATCH 02/17] Text made more clear --- davtelepot/__init__.py | 2 +- davtelepot/messages.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index eb80408..6e4ce83 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.3" +__version__ = "2.5.4" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 384328a..3df1d7f 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -125,8 +125,8 @@ default_admin_messages = { }, 'new_version': { 'title': { - 'en': "🔔 New version detected! 📰", - 'it': "🔔 Rilevata nuova versione! 📰", + 'en': "🔔 New version installed ✅", + 'it': "🔔 Rilevata nuova versione installata! ✅", }, 'last_commit': { 'en': "Old commit: {old_record[last_commit]}\n" From a4ed0ec1b1507f3c1f1363887c65e2f1395c3936 Mon Sep 17 00:00:00 2001 From: Davte Date: Tue, 28 Apr 2020 00:26:38 +0200 Subject: [PATCH 03/17] Deprecated admin_tools submodule --- davtelepot/admin_tools.py | 546 -------------------------------------- 1 file changed, 546 deletions(-) delete mode 100644 davtelepot/admin_tools.py diff --git a/davtelepot/admin_tools.py b/davtelepot/admin_tools.py deleted file mode 100644 index 1014e70..0000000 --- a/davtelepot/admin_tools.py +++ /dev/null @@ -1,546 +0,0 @@ -"""WARNING: this is only a legacy module. - -For newer versions use `administration_tools.py`. - ----------- -Administration tools for telegram bots. - -Usage: -``` -import davtelepot -my_bot = davtelepot.Bot.get('my_token', 'my_database.db') -davtelepot.admin_tools.init(my_bot) -``` -""" - -# Third party modules -from davtelepot.utilities import ( - async_wrapper, Confirmator, get_cleaned_text, get_user, escape_html_chars, - extract, line_drawing_unordered_list, make_button, make_inline_keyboard, - remove_html_tags -) - - -TALK_MESSAGES = dict( - admin_session_ended=dict( - en=( - 'Session with user {u} ended.' - ), - it=( - 'Sessione terminata con l\'utente {u}.' - ), - ), - admin_warning=dict( - en=( - 'You are now talking to {u}.\n' - 'Until you end this session, your messages will be ' - 'forwarded to each other.' - ), - it=( - 'Sei ora connesso con {u}.\n' - 'Finché non chiuderai la connessione, i messaggi che scriverai ' - 'qui saranno inoltrati a {u}, e ti inoltrerò i suoi.' - ), - ), - end_session=dict( - en=( - 'End session?' - ), - it=( - 'Chiudere la sessione?' - ), - ), - help_text=dict( - en='Press the button to search for user.', - it='Premi il pulsante per scegliere un utente.' - ), - search_button=dict( - en="🔍 Search for user", - it="🔍 Cerca utente", - ), - select_user=dict( - en='Which user would you like to talk to?', - it='Con quale utente vorresti parlare?' - ), - user_not_found=dict( - en=( - "Sory, but no user matches your query for\n" - "{q}" - ), - it=( - "Spiacente, ma nessun utente corrisponde alla ricerca per\n" - "{q}" - ), - ), - instructions=dict( - en=( - 'Write a part of name, surname or username of the user you want ' - 'to talk to.' - ), - it=( - 'Scrivi una parte del nome, cognome o username dell\'utente con ' - 'cui vuoi parlare.' - ), - ), - stop=dict( - en=( - 'End session' - ), - it=( - 'Termina la sessione' - ), - ), - user_session_ended=dict( - en=( - 'Session with admin {u} ended.' - ), - it=( - 'Sessione terminata con l\'amministratore {u}.' - ), - ), - user_warning=dict( - en=( - '{u}, admin of this bot, wants to talk to you.\n' - 'Until this session is ended by {u}, your messages will be ' - 'forwarded to each other.' - ), - it=( - '{u}, amministratore di questo bot, vuole parlare con te.\n' - 'Finché non chiuderà la connessione, i messaggi che scriverai ' - 'qui saranno inoltrati a {u}, e ti inoltrerò i suoi.' - ), - ), - # key=dict( - # en='', - # it='', - # ), - # key=dict( - # en=( - # '' - # ), - # it=( - # '' - # ), - # ), -) - - -async def _forward_to(update, bot, sender, addressee, is_admin=False): - if update['text'].lower() in ['stop'] and is_admin: - with bot.db as db: - admin_record = db['users'].find_one( - telegram_id=sender - ) - session_record = db['talking_sessions'].find_one( - admin=admin_record['id'], - cancelled=0 - ) - user_record = db['users'].find_one( - id=session_record['user'] - ) - await end_session( - bot=bot, - user_record=user_record, - admin_record=admin_record - ) - else: - bot.set_individual_text_message_handler( - await async_wrapper( - _forward_to, - bot=bot, - sender=sender, - addressee=addressee, - is_admin=is_admin - ), - sender - ) - await bot.forward_message( - chat_id=addressee, - update=update - ) - return - - -def get_talk_panel(update, bot, text=''): - """Return text and reply markup of talk panel. - - `text` may be: - - `user_id` as string - - `username` as string - - `''` (empty string) for main menu (default) - """ - users = [] - if len(text): - with bot.db as db: - if text.isnumeric(): - users = list( - db['users'].find(id=int(text)) - ) - else: - users = list( - db.query( - """SELECT * - FROM users - WHERE COALESCE( - first_name || last_name || username, - last_name || username, - first_name || username, - username, - first_name || last_name, - last_name, - first_name - ) LIKE '%{username}%' - ORDER BY LOWER( - COALESCE( - first_name || last_name || username, - last_name || username, - first_name || username, - username, - first_name || last_name, - last_name, - first_name - ) - ) - LIMIT 26 - """.format( - username=text - ) - ) - ) - if len(text) == 0: - text = ( - bot.get_message( - 'talk', - 'help_text', - update=update, - q=escape_html_chars( - remove_html_tags(text) - ) - ) - ) - reply_markup = make_inline_keyboard( - [ - make_button( - bot.get_message( - 'talk', 'search_button', - update=update - ), - prefix='talk:///', - data=['search'] - ) - ], - 1 - ) - elif len(users) == 0: - text = ( - bot.get_message( - 'talk', - 'user_not_found', - update=update, - q=escape_html_chars( - remove_html_tags(text) - ) - ) - ) - reply_markup = make_inline_keyboard( - [ - make_button( - bot.get_message( - 'talk', 'search_button', - update=update - ), - prefix='talk:///', - data=['search'] - ) - ], - 1 - ) - else: - text = "{header}\n\n{u}{etc}".format( - header=bot.get_message( - 'talk', 'select_user', - update=update - ), - u=line_drawing_unordered_list( - [ - get_user(user) - for user in users[:25] - ] - ), - etc=( - '\n\n[...]' - if len(users) > 25 - else '' - ) - ) - reply_markup = make_inline_keyboard( - [ - make_button( - '👤 {u}'.format( - u=get_user( - { - key: val - for key, val in user.items() - if key in ( - 'first_name', - 'last_name', - 'username' - ) - } - ) - ), - prefix='talk:///', - data=[ - 'select', - user['id'] - ] - ) - for user in users[:25] - ], - 2 - ) - return text, reply_markup - - -async def _talk_command(update, bot): - text = get_cleaned_text( - update, - bot, - ['talk'] - ) - text, reply_markup = get_talk_panel(update, bot, text) - return dict( - text=text, - parse_mode='HTML', - reply_markup=reply_markup, - ) - - -async def start_session(bot, user_record, admin_record): - """Start talking session between user and admin. - - Register session in database, so it gets loaded before message_loop starts. - Send a notification both to admin and user, set custom parsers and return. - """ - with bot.db as db: - db['talking_sessions'].insert( - dict( - user=user_record['id'], - admin=admin_record['id'], - cancelled=0 - ) - ) - await bot.send_message( - chat_id=user_record['telegram_id'], - text=bot.get_message( - 'talk', 'user_warning', - user_record=user_record, - u=get_user(admin_record) - ) - ) - await bot.send_message( - chat_id=admin_record['telegram_id'], - text=bot.get_message( - 'talk', 'admin_warning', - user_record=admin_record, - u=get_user(user_record) - ), - reply_markup=make_inline_keyboard( - [ - make_button( - bot.get_message( - 'talk', 'stop', - user_record=admin_record - ), - prefix='talk:///', - data=['stop', user_record['id']] - ) - ] - ) - ) - bot.set_individual_text_message_handler( - await async_wrapper( - _forward_to, - bot=bot, - sender=user_record['telegram_id'], - addressee=admin_record['telegram_id'], - is_admin=False - ), - user_record['telegram_id'] - ) - bot.set_individual_text_message_handler( - await async_wrapper( - _forward_to, - bot=bot, - sender=admin_record['telegram_id'], - addressee=user_record['telegram_id'], - is_admin=True - ), - admin_record['telegram_id'] - ) - return - - -async def end_session(bot, user_record, admin_record): - """End talking session between user and admin. - - Cancel session in database, so it will not be loaded anymore. - Send a notification both to admin and user, clear custom parsers - and return. - """ - with bot.db as db: - db['talking_sessions'].update( - dict( - admin=admin_record['id'], - cancelled=1 - ), - ['admin'] - ) - await bot.send_message( - chat_id=user_record['telegram_id'], - text=bot.get_message( - 'talk', 'user_session_ended', - user_record=user_record, - u=get_user(admin_record) - ) - ) - await bot.send_message( - chat_id=admin_record['telegram_id'], - text=bot.get_message( - 'talk', 'admin_session_ended', - user_record=admin_record, - u=get_user(user_record) - ), - ) - for record in (admin_record, user_record, ): - bot.remove_individual_text_message_handler(record['telegram_id']) - return - - -async def _talk_button(update, bot): - telegram_id = update['from']['id'] - command, *arguments = extract(update['data'], '///').split('|') - result, text, reply_markup = '', '', None - if command == 'search': - bot.set_individual_text_message_handler( - await async_wrapper( - _talk_command, - bot=bot - ), - update - ) - text = bot.get_message( - 'talk', 'instructions', - update=update - ) - reply_markup = None - elif command == 'select': - if ( - len(arguments) < 1 - or not arguments[0].isnumeric() - ): - result = "Errore!" - else: - with bot.db as db: - user_record = db['users'].find_one( - id=int(arguments[0]) - ) - admin_record = db['users'].find_one( - telegram_id=telegram_id - ) - await start_session( - bot, - user_record=user_record, - admin_record=admin_record - ) - elif command == 'stop': - if ( - len(arguments) < 1 - or not arguments[0].isnumeric() - ): - result = "Errore!" - elif not Confirmator.get('stop_bots').confirm(telegram_id): - result = bot.get_message( - 'talk', 'end_session', - update=update, - ) - else: - with bot.db as db: - user_record = db['users'].find_one( - id=int(arguments[0]) - ) - admin_record = db['users'].find_one( - telegram_id=telegram_id - ) - await end_session( - bot, - user_record=user_record, - admin_record=admin_record - ) - text = "Session ended." - reply_markup = None - if text: - return dict( - text=result, - edit=dict( - text=text, - parse_mode='HTML', - reply_markup=reply_markup, - disable_web_page_preview=True - ) - ) - return result - - -def init(bot): - """Assign parsers, commands, buttons and queries to given `bot`.""" - if not hasattr(bot, 'messages'): - bot.messages = dict() - bot.messages['talk'] = TALK_MESSAGES - with bot.db as db: - if 'talking_sessions' not in db.tables: - db['talking_sessions'].insert( - dict( - user=0, - admin=0, - cancelled=1 - ) - ) - - @bot.additional_task(when='BEFORE') - async def load_talking_sessions(): - sessions = [] - with bot.db as db: - for session in db.query( - """SELECT * - FROM talking_sessions - WHERE NOT cancelled - """ - ): - sessions.append( - dict( - user_record=db['users'].find_one( - id=session['user'] - ), - admin_record=db['users'].find_one( - id=session['admin'] - ), - ) - ) - for session in sessions: - await start_session( - bot=bot, - user_record=session['user_record'], - admin_record=session['admin_record'] - ) - - @bot.command(command='/talk', aliases=[], show_in_keyboard=False, - descr="Choose a user and forward messages to each other.", - auth='admin') - async def talk_command(update): - return await _talk_command(update, bot) - - @bot.button(data='talk:///', auth='admin') - async def talk_button(update): - return await _talk_button(update, bot) - return From e61d757c1aed4543eb51aa87be16853ead9fc2d3 Mon Sep 17 00:00:00 2001 From: Davte Date: Tue, 28 Apr 2020 00:35:00 +0200 Subject: [PATCH 04/17] New version --- davtelepot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 6e4ce83..5d60bd7 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.4" +__version__ = "2.5.5" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" From ab6a0bc0ad6df6339682594e26c6b4023e7d6e07 Mon Sep 17 00:00:00 2001 From: Davte Date: Tue, 28 Apr 2020 09:47:41 +0200 Subject: [PATCH 05/17] Do not notify package updates too often --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 37 ++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 5d60bd7..5a1c329 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.5" +__version__ = "2.5.6" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 68c2bb9..2d07534 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -14,6 +14,8 @@ import datetime import json import logging +from typing import Union + # Third party modules from sqlalchemy.exc import ResourceClosedError @@ -871,7 +873,18 @@ async def notify_new_version(bot: davtelepot_bot): async def get_package_updates(bot: davtelepot_bot, - monitoring_interval: int = 60 * 60): + monitoring_interval: Union[ + int, datetime.timedelta + ] = 60 * 60, + notification_interval: Union[ + int, datetime.timedelta + ] = 60 * 60 * 24): + if isinstance(monitoring_interval, datetime.timedelta): + monitoring_interval = monitoring_interval.total_seconds() + if type(notification_interval) is int: + notification_interval = datetime.timedelta( + seconds=notification_interval + ) while 1: news = dict() for package in bot.packages: @@ -887,7 +900,17 @@ async def get_package_updates(bot: davtelepot_bot, continue new_version = web_page['info']['version'] current_version = package.__version__ - if new_version != current_version: + notification_record = bot.db['updates_notifications'].find_one( + package=package.__name__, + order_by=['-id'], + _limit=1 + ) + if ( + new_version != current_version + and (notification_record is None + or notification_record['notified_at'] + < datetime.datetime.now() - notification_interval) + ): news[package.__name__] = { 'current': current_version, 'new': new_version @@ -909,6 +932,16 @@ async def get_package_updates(bot: davtelepot_bot, disable_notification=True, text=text ) + bot.db['updates_notifications'].insert_many( + [ + { + "package": package, + "version": information['new'], + 'notified_at': datetime.datetime.now() + } + for package, information in news.items() + ] + ) await asyncio.sleep(monitoring_interval) From 3896776f0eccd5c38828313e192bb3d0c95d2af7 Mon Sep 17 00:00:00 2001 From: Davte Date: Tue, 28 Apr 2020 12:07:37 +0200 Subject: [PATCH 06/17] Type hinting --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 63 +++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 5a1c329..9f2a66a 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.6" +__version__ = "2.5.7" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 2d07534..fbb58d9 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -13,14 +13,16 @@ import asyncio import datetime import json import logging +import types -from typing import Union +from typing import Union, List # Third party modules from sqlalchemy.exc import ResourceClosedError # Project modules -from . import bot as davtelepot_bot, messages +from . import messages +from .bot import Bot from .utilities import ( async_wrapper, CachedPage, Confirmator, extract, get_cleaned_text, get_user, escape_html_chars, line_drawing_unordered_list, make_button, @@ -29,7 +31,11 @@ from .utilities import ( ) -async def _forward_to(update, bot, sender, addressee, is_admin=False): +async def _forward_to(update, + bot: Bot, + sender, + addressee, + is_admin=False): if update['text'].lower() in ['stop'] and is_admin: with bot.db as db: admin_record = db['users'].find_one( @@ -64,7 +70,10 @@ async def _forward_to(update, bot, sender, addressee, is_admin=False): return -def get_talk_panel(bot, update, user_record=None, text=''): +def get_talk_panel(bot: Bot, + update, + user_record=None, + text: str = ''): """Return text and reply markup of talk panel. `text` may be: @@ -202,7 +211,9 @@ def get_talk_panel(bot, update, user_record=None, text=''): return text, reply_markup -async def _talk_command(bot, update, user_record): +async def _talk_command(bot: Bot, + update, + user_record): text = get_cleaned_text( update, bot, @@ -217,7 +228,9 @@ async def _talk_command(bot, update, user_record): ) -async def start_session(bot, other_user_record, admin_record): +async def start_session(bot: Bot, + other_user_record, + admin_record): """Start talking session between user and admin. Register session in database, so it gets loaded before message_loop starts. @@ -280,7 +293,9 @@ async def start_session(bot, other_user_record, admin_record): return -async def end_session(bot, other_user_record, admin_record): +async def end_session(bot: Bot, + other_user_record, + admin_record): """End talking session between user and admin. Cancel session in database, so it will not be loaded anymore. @@ -316,7 +331,10 @@ async def end_session(bot, other_user_record, admin_record): return -async def _talk_button(bot, update, user_record, data): +async def _talk_button(bot: Bot, + update, + user_record, + data): telegram_id = user_record['telegram_id'] command, *arguments = data result, text, reply_markup = '', '', None @@ -390,7 +408,9 @@ async def _talk_button(bot, update, user_record, data): return result -async def _restart_command(bot, update, user_record): +async def _restart_command(bot: Bot, + update, + user_record): with bot.db as db: db['restart_messages'].insert( dict( @@ -415,7 +435,9 @@ async def _restart_command(bot, update, user_record): return -async def _stop_command(bot, update, user_record): +async def _stop_command(bot: Bot, + update, + user_record): text = bot.get_message( 'admin', 'stop_command', 'text', update=update, user_record=user_record @@ -448,14 +470,17 @@ async def _stop_command(bot, update, user_record): ) -async def stop_bots(bot): +async def stop_bots(bot: Bot): """Stop bots in `bot` class.""" await asyncio.sleep(2) bot.__class__.stop(message='=== STOP ===', final_state=0) return -async def _stop_button(bot, update, user_record, data): +async def _stop_button(bot: Bot, + update, + user_record, + data: List[Union[int, str]]): result, text, reply_markup = '', '', None telegram_id = user_record['telegram_id'] command = data[0] if len(data) > 0 else 'None' @@ -798,7 +823,7 @@ async def get_last_commit(): return last_commit -async def _version_command(bot: davtelepot_bot, update, user_record): +async def _version_command(bot: Bot, update, user_record): last_commit = await get_last_commit() text = bot.get_message( 'admin', 'version_command', 'header', @@ -813,7 +838,7 @@ async def _version_command(bot: davtelepot_bot, update, user_record): return text -async def notify_new_version(bot: davtelepot_bot): +async def notify_new_version(bot: Bot): """Notify `bot` administrators about new versions. Notify admins when last commit and/or davtelepot version change. @@ -872,7 +897,7 @@ async def notify_new_version(bot: davtelepot_bot): return -async def get_package_updates(bot: davtelepot_bot, +async def get_package_updates(bot: Bot, monitoring_interval: Union[ int, datetime.timedelta ] = 60 * 60, @@ -945,10 +970,10 @@ async def get_package_updates(bot: davtelepot_bot, await asyncio.sleep(monitoring_interval) -def init(telegram_bot, - talk_messages=None, - admin_messages=None, - packages=None): +def init(telegram_bot: Bot, + talk_messages: dict = None, + admin_messages: dict = None, + packages: List[types.ModuleType] = None): """Assign parsers, commands, buttons and queries to given `bot`.""" if packages is None: packages = [] From a4079cf91de351b197e1e2296aec86a674a880c9 Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 7 May 2020 15:28:17 +0200 Subject: [PATCH 07/17] Implemented /father command. It allows to see commands stored by BotFather, get default command list, change it and send it to BotFather. --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 990 +++++++++++++++++++++++++---- davtelepot/api.py | 27 +- davtelepot/api_helper.py | 1 + davtelepot/bot.py | 110 ++-- davtelepot/messages.py | 320 ++++++++++ davtelepot/utilities.py | 6 +- 7 files changed, 1267 insertions(+), 189 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 9f2a66a..7d166f8 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.7" +__version__ = "2.5.8" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index fbb58d9..7a0af55 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -13,9 +13,11 @@ import asyncio import datetime import json import logging +import re import types -from typing import Union, List +from collections import OrderedDict +from typing import Union, List, Tuple # Third party modules from sqlalchemy.exc import ResourceClosedError @@ -27,9 +29,14 @@ from .utilities import ( async_wrapper, CachedPage, Confirmator, extract, get_cleaned_text, get_user, escape_html_chars, line_drawing_unordered_list, make_button, make_inline_keyboard, remove_html_tags, send_part_of_text_file, - send_csv_file + send_csv_file, make_lines_of_buttons ) +# Use this parameter in SQL `LIMIT x OFFSET y` clauses +rows_number_limit = 10 + +command_description_parser = re.compile(r'(?P\w+)(\s?-\s?(?P.*))?') + async def _forward_to(update, bot: Bot, @@ -970,6 +977,729 @@ async def get_package_updates(bot: Bot, await asyncio.sleep(monitoring_interval) +async def _send_start_messages(bot: Bot): + """Send restart messages at restart.""" + for restart_message in bot.db['restart_messages'].find(sent=None): + asyncio.ensure_future( + bot.send_message( + **{ + key: val + for key, val in restart_message.items() + if key in ( + 'chat_id', + 'text', + 'parse_mode', + 'reply_to_message_id' + ) + } + ) + ) + bot.db['restart_messages'].update( + dict( + sent=datetime.datetime.now(), + id=restart_message['id'] + ), + ['id'], + ensure=True + ) + return + + +async def _load_talking_sessions(bot: Bot): + sessions = [] + for session in bot.db.query( + """SELECT * + FROM talking_sessions + WHERE NOT cancelled + """ + ): + sessions.append( + dict( + other_user_record=bot.db['users'].find_one( + id=session['user'] + ), + admin_record=bot.db['users'].find_one( + id=session['admin'] + ), + ) + ) + for session in sessions: + await start_session( + bot=bot, + other_user_record=session['other_user_record'], + admin_record=session['admin_record'] + ) + + +def get_current_commands(bot: Bot, language: str = None) -> List[dict]: + return sorted( + [ + { + 'command': name, + 'description': bot.get_message( + messages=information['description'], + language=language + ) + } + for name, information in bot.commands.items() + if 'description' in information + and information['description'] + and 'authorization_level' in information + and information['authorization_level'] in ('registered_user', 'everybody',) + ], + key=(lambda c: c['command']) + ) + + +def get_custom_commands(bot: Bot, language: str = None) -> List[dict]: + additional_commands = [ + { + 'command': record['command'], + 'description': record['description'] + } + for record in bot.db['bot_father_commands'].find( + cancelled=None, + hidden=False + ) + ] + hidden_commands_names = [ + record['command'] + for record in bot.db['bot_father_commands'].find( + cancelled=None, + hidden=True + ) + ] + return sorted( + [ + command + for command in (get_current_commands(bot=bot, language=language) + + additional_commands) + if command['command'] not in hidden_commands_names + ], + key=(lambda c: c['command']) + ) + + +async def _father_command(bot, language): + modes = [ + { + key: ( + bot.get_message(messages=val, + language=language) + if isinstance(val, dict) + else val + ) + for key, val in mode.items() + } + for mode in bot.messages['admin']['father_command']['modes'] + ] + text = "\n\n".join( + [ + bot.get_message( + 'admin', 'father_command', 'title', + language=language + ) + ] + [ + "{m[symbol]} {m[name]}\n{m[description]}".format(m=mode) + for mode in modes + ] + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text="{m[symbol]} {m[name]}".format(m=mode), + prefix='father:///', + delimiter='|', + data=[mode['id']] + ) + for mode in modes + ], + 2 + ) + return dict( + text=text, + reply_markup=reply_markup + ) + + +def browse_bot_father_settings_records(bot: Bot, + language: str, + page: int = 0) -> Tuple[str, str, dict]: + """Return a reply keyboard to edit bot father settings records.""" + result, text, reply_markup = '', '', None + records = list( + bot.db['bot_father_commands'].find( + cancelled=None, + _limit=(rows_number_limit + 1), + _offset=(page * rows_number_limit) + ) + ) + for record in bot.db.query( + "SELECT COUNT(*) AS c " + "FROM bot_father_commands " + "WHERE cancelled IS NULL" + ): + records_count = record['c'] + break + else: + records_count = 0 + text = bot.get_message( + 'admin', 'father_command', 'settings', 'browse_records', + language=language, + record_interval=((page * rows_number_limit + 1) if records else 0, + min((page + 1) * rows_number_limit, len(records)), + records_count), + commands_list='\n'.join( + f"{'➖' if record['hidden'] else '➕'} {record['command']}" + for record in records[:rows_number_limit] + ) + ) + buttons = make_lines_of_buttons( + [ + make_button( + text=f"{'➖' if record['hidden'] else '➕'} {record['command']}", + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'select', record['id']] + ) + for record in records[:rows_number_limit] + ], + 3 + ) + buttons += make_lines_of_buttons( + ( + [ + make_button( + text='⬅', + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'go', page - 1] + ) + ] + if page > 0 + else [] + ) + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['settings'] + ) + ] + ( + [ + make_button( + text='️➡️', + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'go', page + 1] + ) + ] + if len(records) > rows_number_limit + else [] + ), + 3 + ) + reply_markup = dict( + inline_keyboard=buttons + ) + return result, text, reply_markup + + +def get_bot_father_settings_editor(mode: str, + record: OrderedDict = None): + """Get a coroutine to edit or create a record in bot father settings table. + + Modes: + - add + - hide + """ + async def bot_father_settings_editor(bot: Bot, update: dict, + language: str): + """Edit or create a record in bot father settings table.""" + nonlocal record + if record is not None: + record_id = record['id'] + else: + record_id = None + # Cancel if user used /cancel command, or remove trailing forward_slash + input_text = update['text'] + if input_text.startswith('/'): + if language not in bot.messages['admin']['cancel']['lower']: + language = bot.default_language + if input_text.lower().endswith(bot.messages['admin']['cancel']['lower'][language]): + return bot.get_message( + 'admin', 'cancel', 'done', + language=language + ) + else: + input_text = input_text[1:] + if record is None: + # Use regex compiled pattern to search for command and description + re_search = command_description_parser.search(input_text) + if re_search is None: + return bot.get_message( + 'admin', 'error', 'text', + language=language + ) + re_search = re_search.groupdict() + command = re_search['command'].lower() + description = re_search['description'] + else: + command = record['command'] + description = input_text + error = None + # A description (str 3-256) is required + if mode in ('add', 'edit'): + if description is None or len(description) < 3: + error = 'missing_description' + elif type(description) is str and len(description) > 255: + error = 'description_too_long' + elif mode == 'add': + duplicate = bot.db['bot_father_commands'].find_one( + command=command, + cancelled=None + ) + if duplicate: + error = 'duplicate_record' + if error: + text = bot.get_message( + 'admin', 'father_command', 'settings', 'modes', + 'add', 'error', error, + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message( + 'admin', 'father_command', 'back', + language=language + ), + prefix='father:///', + delimiter='|', + data=['settings'] + ) + ] + ) + else: + table = bot.db['bot_father_commands'] + new_record = dict( + command=command, + description=description, + hidden=(mode == 'hide'), + cancelled=None + ) + if record_id is None: + record_id = table.insert( + new_record + ) + else: + new_record['id'] = record_id + table.upsert( + new_record, + ['id'] + ) + text = bot.get_message( + 'admin', 'father_command', 'settings', 'modes', + mode, ('edit' if 'id' in new_record else 'add'), 'done', + command=command, + description=(description if description else '-'), + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message( + 'admin', 'father_command', 'settings', 'modes', + 'edit', 'button', + language=language + ), + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'select', record_id] + ), make_button( + text=bot.get_message( + 'admin', 'father_command', 'back', + language=language + ), + prefix='father:///', + delimiter='|', + data=['settings'] + ) + ], + 2 + ) + asyncio.ensure_future( + bot.delete_message(update=update) + ) + return dict( + text=text, + reply_markup=reply_markup + ) + return bot_father_settings_editor + + +async def edit_bot_father_settings_via_message(bot: Bot, + user_record: OrderedDict, + language: str, + mode: str, + record: OrderedDict = None): + result, text, reply_markup = '', '', None + modes = bot.messages['admin']['father_command']['settings']['modes'] + if mode not in modes: + result = bot.get_message( + 'admin', 'father_command', 'error', + language=language + ) + else: + result = bot.get_message( + ('add' if record is None else 'edit'), 'popup', + messages=modes[mode], + language=language, + command=(record['command'] if record is not None else None) + ) + text = bot.get_message( + ('add' if record is None else 'edit'), 'text', + messages=modes[mode], + language=language, + command=(record['command'] if record is not None else None) + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message( + 'admin', 'cancel', 'button', + language=language, + ), + prefix='father:///', + delimiter='|', + data=['cancel'] + ) + ] + ) + bot.set_individual_text_message_handler( + get_bot_father_settings_editor(mode=mode, record=record), + user_id=user_record['telegram_id'], + ) + return result, text, reply_markup + + +async def _father_button(bot: Bot, user_record: OrderedDict, + language: str, data: list): + """Handle BotFather button. + + Operational modes + - main: back to main page (see _father_command) + - get: show commands stored by @BotFather + - set: edit commands stored by @BotFather + """ + result, text, reply_markup = '', '', None + command, *data = data + if command == 'cancel': + bot.remove_individual_text_message_handler(user_id=user_record['telegram_id']) + result = text = bot.get_message( + 'admin', 'cancel', 'done', + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ] + ) + elif command == 'get': + commands = await bot.getMyCommands() + text = '' + '\n'.join( + "{c[command]} - {c[description]}".format(c=command) + for command in commands + ) + '' + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ] + ) + elif command == 'main': + return dict( + text='', + edit=(await _father_command(bot=bot, language=language)) + ) + elif command == 'set': + stored_commands = await bot.getMyCommands() + current_commands = get_custom_commands(bot=bot, language=language) + if len(data) > 0 and data[0] == 'confirm': + if not Confirmator.get('set_bot_father_commands', + confirm_timedelta=3 + ).confirm(user_record['id']): + return bot.get_message( + 'admin', 'confirm', + language=language + ) + if stored_commands == current_commands: + text = bot.get_message( + 'admin', 'father_command', 'set', 'no_change', + language=language + ) + else: + if isinstance( + await bot.setMyCommands(current_commands), + Exception + ): + text = bot.get_message( + 'admin', 'father_command', 'set', 'error', + language=language + ) + else: + text = bot.get_message( + 'admin', 'father_command', 'set', 'done', + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ] + ) + else: + stored_commands_names = [c['command'] for c in stored_commands] + current_commands_names = [c['command'] for c in current_commands] + # Show preview of new, edited and removed commands + # See 'legend' in bot.messages['admin']['father_command']['set'] + text = bot.get_message( + 'admin', 'father_command', 'set', 'header', + language=language + ) + '\n\n' + '\n\n'.join([ + '\n'.join( + ('✅ ' if c in stored_commands + else '☑️ ' if c['command'] not in stored_commands_names + else '✏️') + c['command'] + for c in current_commands + ), + '\n'.join( + f'❌ {command}' + for command in stored_commands_names + if command not in current_commands_names + ), + bot.get_message( + 'admin', 'father_command', 'set', 'legend', + language=language + ) + ]) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message('admin', 'father_command', 'set', + 'button', + language=language), + prefix='father:///', + delimiter='|', + data=['set', 'confirm'] + ) + ] + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ], + 1 + ) + elif command == 'settings': + if len(data) == 0: + additional_commands = '\n'.join( + f"{record['command']} - {record['description']}" + for record in bot.db['bot_father_commands'].find( + cancelled=None, + hidden=False + ) + ) + if not additional_commands: + additional_commands = '-' + hidden_commands = '\n'.join( + f"{record['command']}" + for record in bot.db['bot_father_commands'].find( + cancelled=None, + hidden=True + ) + ) + if not hidden_commands: + hidden_commands = '-' + text = bot.get_message( + 'admin', 'father_command', 'settings', 'panel', + language=language, + additional_commands=additional_commands, + hidden_commands=hidden_commands + ) + modes = bot.messages['admin']['father_command']['settings']['modes'] + reply_markup = make_inline_keyboard( + [ + make_button( + text=modes[code]['symbol'] + ' ' + bot.get_message( + messages=modes[code]['name'], + language=language + ), + prefix='father:///', + delimiter='|', + data=['settings', code] + ) + for code, mode in modes.items() + ] + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ], + 2 + ) + elif data[0] in ('add', 'hide', ): + result, text, reply_markup = await edit_bot_father_settings_via_message( + bot=bot, + user_record=user_record, + language=language, + mode=data[0] + ) + elif data[0] == 'edit': + if len(data) > 2 and data[1] == 'select': + selected_record = bot.db['bot_father_commands'].find_one(id=data[2]) + if selected_record is None: + return bot.get_message( + 'admin', 'error', + language=language + ) + if len(data) == 3: + text = bot.get_message( + 'admin', 'father_command', 'settings', + 'modes', 'edit', 'panel', 'text', + language=language, + command=selected_record['command'], + description=selected_record['description'], + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message( + 'admin', 'father_command', 'settings', + 'modes', 'edit', 'panel', + 'edit_description', 'button', + language=language, + ), + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'select', + selected_record['id'], 'edit_descr'] + ), + make_button( + text=bot.get_message( + 'admin', 'father_command', 'settings', + 'modes', 'edit', 'panel', + 'delete', 'button', + language=language, + ), + prefix='father:///', + delimiter='|', + data=['settings', 'edit', 'select', + selected_record['id'], 'del'] + ), + make_button( + text=bot.get_message( + 'admin', 'father_command', 'back', + language=language, + ), + prefix='father:///', + delimiter='|', + data=['settings', 'edit'] + ) + ], + 2 + ) + elif len(data) > 3 and data[3] == 'edit_descr': + result, text, reply_markup = await edit_bot_father_settings_via_message( + bot=bot, + user_record=user_record, + language=language, + mode=data[0], + record=selected_record + ) + elif len(data) > 3 and data[3] == 'del': + if not Confirmator.get('set_bot_father_commands', + confirm_timedelta=3 + ).confirm(user_record['id']): + result = bot.get_message( + 'admin', 'confirm', + language=language + ) + else: + bot.db['bot_father_commands'].update( + dict( + id=selected_record['id'], + cancelled=True + ), + ['id'] + ) + result = bot.get_message( + 'admin', 'father_command', 'settings', + 'modes', 'edit', 'panel', 'delete', + 'done', 'popup', + language=language + ) + text = bot.get_message( + 'admin', 'father_command', 'settings', + 'modes', 'edit', 'panel', 'delete', + 'done', 'text', + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message( + 'admin', 'father_command', + 'back', + language=language + ), + prefix='father:///', + delimiter='|', + data=['settings'] + ) + ], + 1 + ) + elif len(data) == 1 or data[1] == 'go': + result, text, reply_markup = browse_bot_father_settings_records( + bot=bot, + language=language, + page=(data[2] if len(data) > 2 else 0) + ) + if text: + return dict( + text=result, + edit=dict( + text=text, + reply_markup=reply_markup + ) + ) + return result + + def init(telegram_bot: Bot, talk_messages: dict = None, admin_messages: dict = None, @@ -989,60 +1719,134 @@ def init(telegram_bot: Bot, admin_messages = messages.default_admin_messages telegram_bot.messages['admin'] = admin_messages db = telegram_bot.db - if 'talking_sessions' not in db.tables: - db['talking_sessions'].insert( - dict( - user=0, - admin=0, - cancelled=1 - ) + if 'bot_father_commands' not in db.tables: + table = db.create_table( + table_name='bot_father_commands' ) - - allowed_during_maintenance = [ + table.create_column( + 'command', + db.types.string + ) + table.create_column( + 'description', + db.types.string + ) + table.create_column( + 'hidden', + db.types.boolean + ) + table.create_column( + 'cancelled', + db.types.boolean + ) + if 'talking_sessions' not in db.tables: + table = db.create_table( + table_name='users' + ) + table.create_column( + 'user', + db.types.integer + ) + table.create_column( + 'admin', + db.types.integer + ) + table.create_column( + 'cancelled', + db.types.integer + ) + for exception in [ get_maintenance_exception_criterion(telegram_bot, command) for command in ['stop', 'restart', 'maintenance'] - ] + ]: + telegram_bot.allow_during_maintenance(exception) + # Tasks to complete before starting bot @telegram_bot.additional_task(when='BEFORE') async def load_talking_sessions(): - sessions = [] - for session in db.query( - """SELECT * - FROM talking_sessions - WHERE NOT cancelled - """ - ): - sessions.append( - dict( - other_user_record=db['users'].find_one( - id=session['user'] - ), - admin_record=db['users'].find_one( - id=session['admin'] - ), - ) - ) - for session in sessions: - await start_session( - bot=telegram_bot, - other_user_record=session['other_user_record'], - admin_record=session['admin_record'] - ) + return await _load_talking_sessions(bot=telegram_bot) - @telegram_bot.command(command='/talk', + @telegram_bot.additional_task(when='BEFORE', bot=telegram_bot) + async def notify_version(bot): + return await notify_new_version(bot=bot) + + @telegram_bot.additional_task('BEFORE') + async def send_restart_messages(): + return await _send_start_messages(bot=telegram_bot) + + # Administration commands + @telegram_bot.command(command='/db', aliases=[], show_in_keyboard=False, description=admin_messages[ - 'talk_command']['description'], + 'db_command']['description'], authorization_level='admin') - async def talk_command(bot, update, user_record): - return await _talk_command(bot, update, user_record) + async def send_bot_database(bot, update, user_record): + return await _send_bot_database(bot, update, user_record) - @telegram_bot.button(prefix='talk:///', + @telegram_bot.command(command='/errors', + aliases=[], + show_in_keyboard=False, + description=admin_messages[ + 'errors_command']['description'], + authorization_level='admin') + async def errors_command(bot, update, user_record): + return await _errors_command(bot, update, user_record) + + @telegram_bot.command(command='/father', + aliases=[], + show_in_keyboard=False, + **{ + key: value + for key, value in admin_messages['father_command'].items() + if key in ('description', ) + }, + authorization_level='admin') + async def father_command(bot, language): + return await _father_command(bot=bot, language=language) + + @telegram_bot.button(prefix='father:///', separator='|', authorization_level='admin') - async def talk_button(bot, update, user_record, data): - return await _talk_button(bot, update, user_record, data) + async def query_button(bot, user_record, language, data): + return await _father_button(bot=bot, + user_record=user_record, + language=language, + data=data) + + @telegram_bot.command(command='/log', + aliases=[], + show_in_keyboard=False, + description=admin_messages[ + 'log_command']['description'], + authorization_level='admin') + async def log_command(bot, update, user_record): + return await _log_command(bot, update, user_record) + + @telegram_bot.command(command='/maintenance', aliases=[], + show_in_keyboard=False, + description=admin_messages[ + 'maintenance_command']['description'], + authorization_level='admin') + async def maintenance_command(bot, update, user_record): + return await _maintenance_command(bot, update, user_record) + + @telegram_bot.command(command='/query', + aliases=[], + show_in_keyboard=False, + description=admin_messages[ + 'query_command']['description'], + authorization_level='admin') + async def query_command(bot, update, user_record): + return await _query_command(bot, update, user_record) + + @telegram_bot.button(prefix='db_query:///', + separator='|', + description=admin_messages[ + 'query_command']['description'], + authorization_level='admin') + async def query_button(bot, update, user_record, data): + return await _query_button(bot, update, user_record, data) @telegram_bot.command(command='/restart', aliases=[], @@ -1053,33 +1857,14 @@ def init(telegram_bot: Bot, async def restart_command(bot, update, user_record): return await _restart_command(bot, update, user_record) - @telegram_bot.additional_task('BEFORE') - async def send_restart_messages(): - """Send restart messages at restart.""" - for restart_message in db['restart_messages'].find(sent=None): - asyncio.ensure_future( - telegram_bot.send_message( - **{ - key: val - for key, val in restart_message.items() - if key in ( - 'chat_id', - 'text', - 'parse_mode', - 'reply_to_message_id' - ) - } - ) - ) - db['restart_messages'].update( - dict( - sent=datetime.datetime.now(), - id=restart_message['id'] - ), - ['id'], - ensure=True - ) - return + @telegram_bot.command(command='/select', + aliases=[], + show_in_keyboard=False, + description=admin_messages[ + 'select_command']['description'], + authorization_level='admin') + async def select_command(bot, update, user_record): + return await _query_command(bot, update, user_record) @telegram_bot.command(command='/stop', aliases=[], @@ -1098,69 +1883,20 @@ def init(telegram_bot: Bot, async def stop_button(bot, update, user_record, data): return await _stop_button(bot, update, user_record, data) - @telegram_bot.command(command='/db', + @telegram_bot.command(command='/talk', aliases=[], show_in_keyboard=False, description=admin_messages[ - 'db_command']['description'], + 'talk_command']['description'], authorization_level='admin') - async def send_bot_database(bot, update, user_record): - return await _send_bot_database(bot, update, user_record) + async def talk_command(bot, update, user_record): + return await _talk_command(bot, update, user_record) - @telegram_bot.command(command='/query', - aliases=[], - show_in_keyboard=False, - description=admin_messages[ - 'query_command']['description'], - authorization_level='admin') - async def query_command(bot, update, user_record): - return await _query_command(bot, update, user_record) - - @telegram_bot.command(command='/select', - aliases=[], - show_in_keyboard=False, - description=admin_messages[ - 'select_command']['description'], - authorization_level='admin') - async def select_command(bot, update, user_record): - return await _query_command(bot, update, user_record) - - @telegram_bot.button(prefix='db_query:///', + @telegram_bot.button(prefix='talk:///', separator='|', - description=admin_messages[ - 'query_command']['description'], authorization_level='admin') - async def query_button(bot, update, user_record, data): - return await _query_button(bot, update, user_record, data) - - @telegram_bot.command(command='/log', - aliases=[], - show_in_keyboard=False, - description=admin_messages[ - 'log_command']['description'], - authorization_level='admin') - async def log_command(bot, update, user_record): - return await _log_command(bot, update, user_record) - - @telegram_bot.command(command='/errors', - aliases=[], - show_in_keyboard=False, - description=admin_messages[ - 'errors_command']['description'], - authorization_level='admin') - async def errors_command(bot, update, user_record): - return await _errors_command(bot, update, user_record) - - for exception in allowed_during_maintenance: - telegram_bot.allow_during_maintenance(exception) - - @telegram_bot.command(command='/maintenance', aliases=[], - show_in_keyboard=False, - description=admin_messages[ - 'maintenance_command']['description'], - authorization_level='admin') - async def maintenance_command(bot, update, user_record): - return await _maintenance_command(bot, update, user_record) + async def talk_button(bot, update, user_record, data): + return await _talk_button(bot, update, user_record, data) @telegram_bot.command(command='/version', aliases=[], @@ -1175,7 +1911,3 @@ def init(telegram_bot: Bot, return await _version_command(bot=bot, update=update, user_record=user_record) - - @telegram_bot.additional_task(when='BEFORE', bot=telegram_bot) - async def notify_version(bot): - return await notify_new_version(bot=bot) diff --git a/davtelepot/api.py b/davtelepot/api.py index c265f0b..bd88fc7 100644 --- a/davtelepot/api.py +++ b/davtelepot/api.py @@ -664,18 +664,33 @@ class TelegramBot: parameters=locals() ) - async def sendPoll(self, chat_id, question, options, - dummy=None, - disable_notification=None, - reply_to_message_id=None, + async def sendPoll(self, + chat_id: Union[int, str], + question: str, + options: List[str], + is_anonymous: bool = True, + type_: str = 'regular', + allows_multiple_answers: bool = False, + correct_option_id: int = None, + explanation: str = None, + explanation_parse_mode: str = None, + open_period: int = None, + close_date: int = None, + is_closed: bool = None, + disable_notification: bool = None, + reply_to_message_id: int = None, reply_markup=None): """Send a native poll in a group, a supergroup or channel. See https://core.telegram.org/bots/api#sendpoll for details. """ + # To avoid shadowing `type`, this workaround is required + parameters = locals().copy() + parameters['type'] = parameters['type_'] + del parameters['type_'] return await self.api_request( 'sendPoll', - parameters=locals() + parameters=parameters ) async def sendChatAction(self, chat_id, action): @@ -1409,7 +1424,7 @@ class TelegramBot: parameters=locals() ) - async def setMyCommands(self, commands: List[Command]): + async def setMyCommands(self, commands: List[Union[Command, dict]]): """Change the list of the bot's commands. Use this method to change the list of the bot's commands. diff --git a/davtelepot/api_helper.py b/davtelepot/api_helper.py index c2f98f7..1e63e4c 100644 --- a/davtelepot/api_helper.py +++ b/davtelepot/api_helper.py @@ -136,6 +136,7 @@ async def print_api_methods(loop=None, parameters_table = tag break # Stop searching in siblings if is found description += tag.get_text() + # Methods start with a lowercase letter if method_name and method_name[0] == method_name[0].lower(): methods.append( TelegramApiMethod( diff --git a/davtelepot/bot.py b/davtelepot/bot.py index 742f074..3fe3a37 100644 --- a/davtelepot/bot.py +++ b/davtelepot/bot.py @@ -17,11 +17,11 @@ Usage database_url='my_other_db') @long_polling_bot.command('/foo') - async def foo_command(bot, update, user_record): + async def foo_command(bot, update, user_record, language): return "Bar!" @webhook_bot.command('/bar') - async def bar_command(bot, update, user_record): + async def bar_command(bot, update, user_record, language): return "Foo!" exit_state = Bot.run( @@ -579,17 +579,19 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): def administrators(self): return self._get_administrators(self) - async def message_router(self, update, user_record): + async def message_router(self, update, user_record, language): """Route Telegram `message` update to appropriate message handler.""" for key, value in update.items(): if key in self.message_handlers: - return await self.message_handlers[key](update, user_record) + return await self.message_handlers[key](update=update, + user_record=user_record, + language=language) logging.error( f"The following message update was received: {update}\n" "However, this message type is unknown." ) - async def edited_message_handler(self, update, user_record): + async def edited_message_handler(self, update, user_record, language=None): """Handle Telegram `edited_message` update.""" logging.info( f"The following update was received: {update}\n" @@ -597,7 +599,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def channel_post_handler(self, update, user_record): + async def channel_post_handler(self, update, user_record, language=None): """Handle Telegram `channel_post` update.""" logging.info( f"The following update was received: {update}\n" @@ -605,7 +607,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def edited_channel_post_handler(self, update, user_record): + async def edited_channel_post_handler(self, update, user_record, language=None): """Handle Telegram `edited_channel_post` update.""" logging.info( f"The following update was received: {update}\n" @@ -613,7 +615,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def inline_query_handler(self, update, user_record): + async def inline_query_handler(self, update, user_record, language=None): """Handle Telegram `inline_query` update. Answer it with results or log errors. @@ -648,7 +650,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): logging.info("Error answering inline query\n{}".format(e)) return - async def chosen_inline_result_handler(self, update, user_record): + async def chosen_inline_result_handler(self, update, user_record, language=None): """Handle Telegram `chosen_inline_result` update.""" if user_record is not None: user_id = user_record['telegram_id'] @@ -678,7 +680,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): self.chosen_inline_result_handlers[user_id][result_id] = handler return - async def callback_query_handler(self, update, user_record): + async def callback_query_handler(self, update, user_record, language=None): """Handle Telegram `callback_query` update. A callback query is sent when users press inline keyboard buttons. @@ -699,7 +701,8 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): answer = await _function( bot=self, update=update, - user_record=user_record + user_record=user_record, + language=language ) break if answer is None: @@ -733,7 +736,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): logging.error(e) return - async def shipping_query_handler(self, update, user_record): + async def shipping_query_handler(self, update, user_record, language=None): """Handle Telegram `shipping_query` update.""" logging.info( f"The following update was received: {update}\n" @@ -741,7 +744,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def pre_checkout_query_handler(self, update, user_record): + async def pre_checkout_query_handler(self, update, user_record, language=None): """Handle Telegram `pre_checkout_query` update.""" logging.info( f"The following update was received: {update}\n" @@ -749,7 +752,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def poll_handler(self, update, user_record): + async def poll_handler(self, update, user_record, language=None): """Handle Telegram `poll` update.""" logging.info( f"The following update was received: {update}\n" @@ -757,7 +760,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def text_message_handler(self, update, user_record): + async def text_message_handler(self, update, user_record, language=None): """Handle `text` message update.""" replier, reply = None, None text = update['text'].lower() @@ -817,56 +820,56 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def audio_file_handler(self, update, user_record): + async def audio_file_handler(self, update, user_record, language=None): """Handle `audio` file update.""" logging.info( "A audio file update was received, " "but this handler does nothing yet." ) - async def document_message_handler(self, update, user_record): + async def document_message_handler(self, update, user_record, language=None): """Handle `document` message update.""" logging.info( "A document message update was received, " "but this handler does nothing yet." ) - async def animation_message_handler(self, update, user_record): + async def animation_message_handler(self, update, user_record, language=None): """Handle `animation` message update.""" logging.info( "A animation message update was received, " "but this handler does nothing yet." ) - async def game_message_handler(self, update, user_record): + async def game_message_handler(self, update, user_record, language=None): """Handle `game` message update.""" logging.info( "A game message update was received, " "but this handler does nothing yet." ) - async def photo_message_handler(self, update, user_record): + async def photo_message_handler(self, update, user_record, language=None): """Handle `photo` message update.""" logging.info( "A photo message update was received, " "but this handler does nothing yet." ) - async def sticker_message_handler(self, update, user_record): + async def sticker_message_handler(self, update, user_record, language=None): """Handle `sticker` message update.""" logging.info( "A sticker message update was received, " "but this handler does nothing yet." ) - async def video_message_handler(self, update, user_record): + async def video_message_handler(self, update, user_record, language=None): """Handle `video` message update.""" logging.info( "A video message update was received, " "but this handler does nothing yet." ) - async def voice_message_handler(self, update, user_record): + async def voice_message_handler(self, update, user_record, language=None): """Handle `voice` message update.""" replier, reply = None, None user_id = update['from']['id'] if 'from' in update else None @@ -896,21 +899,21 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def video_note_message_handler(self, update, user_record): + async def video_note_message_handler(self, update, user_record, language=None): """Handle `video_note` message update.""" logging.info( "A video_note message update was received, " "but this handler does nothing yet." ) - async def contact_message_handler(self, update, user_record): + async def contact_message_handler(self, update, user_record, language=None): """Handle `contact` message update.""" logging.info( "A contact message update was received, " "but this handler does nothing yet." ) - async def location_message_handler(self, update, user_record): + async def location_message_handler(self, update, user_record, language=None): """Handle `location` message update.""" replier, reply = None, None user_id = update['from']['id'] if 'from' in update else None @@ -940,56 +943,56 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) return - async def venue_message_handler(self, update, user_record): + async def venue_message_handler(self, update, user_record, language=None): """Handle `venue` message update.""" logging.info( "A venue message update was received, " "but this handler does nothing yet." ) - async def poll_message_handler(self, update, user_record): + async def poll_message_handler(self, update, user_record, language=None): """Handle `poll` message update.""" logging.info( "A poll message update was received, " "but this handler does nothing yet." ) - async def new_chat_members_message_handler(self, update, user_record): + async def new_chat_members_message_handler(self, update, user_record, language=None): """Handle `new_chat_members` message update.""" logging.info( "A new_chat_members message update was received, " "but this handler does nothing yet." ) - async def left_chat_member_message_handler(self, update, user_record): + async def left_chat_member_message_handler(self, update, user_record, language=None): """Handle `left_chat_member` message update.""" logging.info( "A left_chat_member message update was received, " "but this handler does nothing yet." ) - async def new_chat_title_message_handler(self, update, user_record): + async def new_chat_title_message_handler(self, update, user_record, language=None): """Handle `new_chat_title` message update.""" logging.info( "A new_chat_title message update was received, " "but this handler does nothing yet." ) - async def new_chat_photo_message_handler(self, update, user_record): + async def new_chat_photo_message_handler(self, update, user_record, language=None): """Handle `new_chat_photo` message update.""" logging.info( "A new_chat_photo message update was received, " "but this handler does nothing yet." ) - async def delete_chat_photo_message_handler(self, update, user_record): + async def delete_chat_photo_message_handler(self, update, user_record, language=None): """Handle `delete_chat_photo` message update.""" logging.info( "A delete_chat_photo message update was received, " "but this handler does nothing yet." ) - async def group_chat_created_message_handler(self, update, user_record): + async def group_chat_created_message_handler(self, update, user_record, language=None): """Handle `group_chat_created` message update.""" logging.info( "A group_chat_created message update was received, " @@ -1004,63 +1007,63 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): "but this handler does nothing yet." ) - async def channel_chat_created_message_handler(self, update, user_record): + async def channel_chat_created_message_handler(self, update, user_record, language=None): """Handle `channel_chat_created` message update.""" logging.info( "A channel_chat_created message update was received, " "but this handler does nothing yet." ) - async def migrate_to_chat_id_message_handler(self, update, user_record): + async def migrate_to_chat_id_message_handler(self, update, user_record, language=None): """Handle `migrate_to_chat_id` message update.""" logging.info( "A migrate_to_chat_id message update was received, " "but this handler does nothing yet." ) - async def migrate_from_chat_id_message_handler(self, update, user_record): + async def migrate_from_chat_id_message_handler(self, update, user_record, language=None): """Handle `migrate_from_chat_id` message update.""" logging.info( "A migrate_from_chat_id message update was received, " "but this handler does nothing yet." ) - async def pinned_message_message_handler(self, update, user_record): + async def pinned_message_message_handler(self, update, user_record, language=None): """Handle `pinned_message` message update.""" logging.info( "A pinned_message message update was received, " "but this handler does nothing yet." ) - async def invoice_message_handler(self, update, user_record): + async def invoice_message_handler(self, update, user_record, language=None): """Handle `invoice` message update.""" logging.info( "A invoice message update was received, " "but this handler does nothing yet." ) - async def successful_payment_message_handler(self, update, user_record): + async def successful_payment_message_handler(self, update, user_record, language=None): """Handle `successful_payment` message update.""" logging.info( "A successful_payment message update was received, " "but this handler does nothing yet." ) - async def connected_website_message_handler(self, update, user_record): + async def connected_website_message_handler(self, update, user_record, language=None): """Handle `connected_website` message update.""" logging.info( "A connected_website message update was received, " "but this handler does nothing yet." ) - async def passport_data_message_handler(self, update, user_record): + async def passport_data_message_handler(self, update, user_record, language=None): """Handle `passport_data` message update.""" logging.info( "A passport_data message update was received, " "but this handler does nothing yet." ) - async def dice_handler(self, update, user_record): + async def dice_handler(self, update, user_record, language=None): """Handle `dice` message update.""" logging.info( "A dice message update was received, " @@ -2013,7 +2016,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): """ self._allowed_during_maintenance.append(criterion) - async def handle_update_during_maintenance(self, update, user_record=None): + async def handle_update_during_maintenance(self, update, user_record=None, language=None): """Handle an update while bot is under maintenance. Handle all types of updates. @@ -2106,7 +2109,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): Decorate command handlers like this: ``` @bot.command('/my_command', ['Button'], True, "My command", 'user') - async def command_handler(bot, update, user_record): + async def command_handler(bot, update, user_record, language): return "Result" ``` When a message text starts with `/command[@bot_name]`, or with an @@ -2165,7 +2168,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): command = command.strip('/ ').lower() def command_decorator(command_handler): - async def decorated_command_handler(bot, update, user_record): + async def decorated_command_handler(bot, update, user_record, language=None): logging.info( f"Command `{command}@{bot.name}` called by " f"`{update['from'] if 'from' in update else update['chat']}`" @@ -2223,7 +2226,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): return 'from' in update @bot.parser(custom_criteria, authorization_level='user') - async def text_parser(bot, update, user_record): + async def text_parser(bot, update, user_record, language): return "Result" ``` If condition evaluates True when run on a message text @@ -2241,7 +2244,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) def parser_decorator(parser): - async def decorated_parser(bot, update, user_record): + async def decorated_parser(bot, update, user_record, language=None): logging.info( f"Text message update matching condition " f"`{condition.__name__}@{bot.name}` from " @@ -2310,7 +2313,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ``` @bot.button('a_prefix:///', description="A button", authorization_level='user') - async def button_handler(bot, update, user_record, data): + async def button_handler(bot, update, user_record, language, data): return "Result" ``` `separator` will be used to parse callback data received when a button @@ -2325,7 +2328,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) def button_decorator(handler): - async def decorated_button_handler(bot, update, user_record): + async def decorated_button_handler(bot, update, user_record, language=None): logging.info( f"Button `{update['data']}`@{bot.name} pressed by " f"`{update['from']}`" @@ -2382,7 +2385,7 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ) def query_decorator(handler): - async def decorated_query_handler(bot, update, user_record): + async def decorated_query_handler(bot, update, user_record, language=None): logging.info( f"Inline query matching condition " f"`{condition.__name__}@{bot.name}` from " @@ -2881,9 +2884,12 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): for key, value in update.items(): if key in self.routing_table: user_record = self.get_user_record(update=value) + language = self.get_language(update=update, + user_record=user_record) return await self.routing_table[key]( update=value, - user_record=user_record + user_record=user_record, + language=language ) logging.error(f"Unknown type of update.\n{update}") diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 3df1d7f..3b04159 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1,6 +1,24 @@ """Default messages for bot functions.""" default_admin_messages = { + 'cancel': { + 'button': { + 'en': "↩️ Cancel", + 'it': "↩️ Annulla" + }, + 'done': { + 'en': "↩️ Operation cancelled", + 'it': "↩️ Operazione annullata", + }, + 'lower': { + 'en': "cancel", + 'it': "annulla", + }, + }, + 'confirm': { + 'en': "🔄 Click again to confirm", + 'it': "🔄 Clicka di nuovo per confermare", + }, 'db_command': { 'description': { 'en': "Ask for bot database via Telegram", @@ -23,6 +41,12 @@ default_admin_messages = { 'it': "Database inviato." } }, + 'error': { + 'text': { + 'en': "❌️ Error!", + 'it': "❌️ Errore!" + }, + }, 'errors_command': { 'description': { 'en': "Receive bot error log file, if set", @@ -66,6 +90,302 @@ default_admin_messages = { "L'ordine è cronologico, con i messaggi nuovi in alto." } }, + 'father_command': { + 'back': { + 'en': "↩️ Back", + 'it': "↩️ Indietro", + }, + 'description': { + 'en': "Edit the @BotFather commands", + 'it': "Modifica i comandi con @BotFather", + }, + 'error': { + 'en': "❌ Error! ❌", + 'it': "❌ Errore! ❌", + }, + 'modes': [ + { + 'id': "get", + 'name': { + 'en': "See", + 'it': "Consulta" + }, + 'symbol': "ℹ️", + 'description': { + 'en': "See commands stored by @BotFather", + 'it': "Consulta i comandi salvati su @BotFather" + }, + }, + { + 'id': "set", + 'name': { + 'en': "Change", + 'it': "Modifica" + }, + 'symbol': "✏️", + 'description': { + 'en': "Change commands stored by @BotFather", + 'it': "Modifica i comandi salvati su @BotFather" + }, + }, + { + 'id': "settings", + 'name': { + 'en': "Settings", + 'it': "Impostazioni" + }, + 'symbol': "⚙️", + 'description': { + 'en': "Set commands to hide or to add", + 'it': "Imposta comandi da nascondere o aggiungere" + }, + }, + ], + 'set': { + 'button': { + 'en': "⚠️ Set these commands 🔧", + 'it': "⚠️ Imposta questi comandi 🔧", + }, + 'done': { + 'en': "✅ Done!", + 'it': "✅ Fatto!", + }, + 'error': { + 'en': "Something went wrong 😕", + 'it': "Qualcosa è andato storto 😕", + }, + 'header': { + 'en': "✏️ Change commands stored by @BotFather 🤖", + 'it': "✏️ Modifica i comandi salvati su @BotFather 🤖", + }, + 'legend': { + 'en': "Legend\n" + "✅ Already stored\n" + "✏️ New description\n" + "☑ New command\n" + "❌ Will be removed", + 'it': "Legenda\n" + "✅ Già presente\n" + "✏️ Nuova descrizione\n" + "☑ Nuovo comando\n" + "❌ Comando da eliminare", + }, + 'no_change': { + 'en': "❌ No change detected", + 'it': "❌ Nessuna modifica", + }, + }, + 'settings': { + 'browse_records': { + 'en': "✏️ Edit BotFather settings ⚙️\n\n" + "Select a record to edit.\n\n" + "{commands_list}\n\n" + "Legend\n" + "➕ Added commands\n" + "➖ Hidden commands\n\n" + "Showing records from {record_interval[0]} to " + "{record_interval[1]} of {record_interval[2]}", + 'it': "✏️ Modifica impostazioni di BotFather ⚙\n\n️" + "Seleziona un'impostazione da modificare.\n\n" + "{commands_list}\n\n" + "Legenda\n" + "➕ Comandi aggiunti\n" + "➖ Comandi nascosti\n\n" + "Record da {record_interval[0]} a " + "{record_interval[1]} di {record_interval[2]}", + }, + 'modes': { + 'add': { + 'add': { + 'done': { + 'en': "➕️️ Added additional command\n\n" + "Command: {command}\n" + "Description: {description}", + 'it': "➕️️ Inserito comando aggiuntivo\n\n" + "Comando: {command}\n" + "Descrizione: {description}", + }, + 'popup': { + 'en': "Write the command to add", + 'it': "Scrivimi il comando da aggiungere", + }, + 'text': { + 'en': "Write the command to add or /cancel this operation", + 'it': "Scrivimi il comando da aggiungere o /annulla", + }, + }, + 'description': { + 'en': "Add command to default list", + 'it': "Aggiungi un comando dalla lista autogenerata" + }, + 'edit': { + 'done': { + 'en': "✏️ Edited additional command\n\n" + "Command: {command}\n" + "Description: {description}", + 'it': "✏️ Comando da nascondere modificato\n\n" + "Comando: {command}\n" + "Descrizione: {description}", + }, + }, + 'error': { + 'description_too_long': { + 'en': "Description is too long\n\n" + "Description length must be 3-256 chars.", + 'it': "Descrizione troppo lunga\n\n" + "La descrizione deve essere di 3-256 caratteri.", + }, + 'duplicate_record': { + 'en': "Duplicate record\n\n" + "Command is already being added to default " + "output. Edit that record if you need to.", + 'it': "Record già presente\n\n" + "Questo comando è già aggiunto a quelli di " + "default. Modifica il record già presente se " + "necessario.", + }, + 'missing_description': { + 'en': "Missing description\n\n" + "Additional commands must have a description " + "(3-256 chars).", + 'it': "Descrizione mancante\n\n" + "I comandi aggiuntivi devono avere una " + "descrizione di 3-256 caratteri.", + }, + 'unhandled_exception': { + 'en': "❌ Unhandled exception ⚠️", + 'it': "❌ Errore imprevisto ⚠️", + }, + }, + 'name': { + 'en': "Add", + 'it': "Aggiungi" + }, + 'symbol': "➕️", + }, + 'hide': { + 'add': { + 'done': { + 'en': "➖ Added hidden command\n\n" + "Command: {command}\n", + 'it': "➖ Comando da nascondere aggiunto" + "Comando: {command}\n", + }, + 'popup': { + 'en': "Write the command to hide", + 'it': "Scrivimi il comando da nascondere", + }, + 'text': { + 'en': "Write the command to hide or /cancel this operation", + 'it': "Scrivimi il comando da nascondere o /annulla", + } + }, + 'description': { + 'en': "Hide command from default list", + 'it': "Nascondi un comando dalla lista autogenerata" + }, + 'edit': { + 'done': { + 'en': "✏️ Edited hidden command\n\n" + "Command: {command}\n" + "Description: {description}", + 'it': "✏️ Comando da nascondere modificato\n\n" + "Comando: {command}\n" + "Descrizione: {description}", + }, + }, + 'name': { + 'en': "Hide", + 'it': "Nascondi" + }, + 'symbol': "➖️", + }, + 'edit': { + 'button': { + 'en': "✏️ Edit record", + 'it': "✏️ Modifica record" + }, + 'description': { + 'en': "Edit added or hidden commands", + 'it': "Modifica i comandi aggiunti o nascosti" + }, + 'edit': { + 'popup': { + 'en': "Write the new description", + 'it': "Scrivimi la nuova descrizione", + }, + 'text': { + 'en': "Write the new description for command " + "{command} or /cancel", + 'it': "Scrivimi la nuova descrizione per il " + "comando {command} o /annulla", + }, + 'done': { + 'en': "✏️ Edit succeeded ✅\n\n" + "Command: {command}\n""" + "Description: {description}", + 'it': "✏️ Modifica completata ✅\n\n" + "Comando: {command}\n""" + "Descrizione: {description}", + } + }, + 'name': { + 'en': "Edit", + 'it': "Modifica" + }, + 'panel': { + 'delete': { + 'button': { + 'en': "❌ Delete record", + 'it': "❌ Elimina record", + }, + 'done': { + 'popup': { + 'en': "Record deleted ✅", + 'it': "Record eliminato ✅", + }, + 'text': { + 'en': "Record deleted ✅", + 'it': "Record eliminato ✅", + }, + }, + }, + 'edit_description': { + 'button': { + 'en': "✏️ Edit description", + 'it': "✏️ Modifica descrizione", + }, + }, + 'text': { + 'en': "✏️ Edit record ✅\n\n" + "Command: {command}\n""" + "Description: {description}", + 'it': "✏️ Modifica record\n\n" + "Comando: {command}\n""" + "Descrizione: {description}", + }, + }, + 'symbol': "✏️", + }, + }, + 'panel': { + 'en': "🤖 @BotFather settings ⚙️\n\n" + "➕ Additional commands\n" + "{additional_commands}\n\n" + "➖ Hidden commands\n" + "{hidden_commands}", + 'it': "⚙️ Impostazioni di @BotFather 🤖\n\n" + "➕ Comandi aggiuntivi\n" + "{additional_commands}\n\n" + "➖ Comandi nascosti\n" + "{hidden_commands}", + }, + }, + 'title': { + 'en': "🤖 BotFather", + 'it': "🤖 BotFather", + }, + }, 'log_command': { 'description': { 'en': "Receive bot log file, if set", diff --git a/davtelepot/utilities.py b/davtelepot/utilities.py index f89315c..622cad8 100644 --- a/davtelepot/utilities.py +++ b/davtelepot/utilities.py @@ -18,6 +18,8 @@ import time from difflib import SequenceMatcher # Third party modules +from typing import Union + import aiohttp from aiohttp import web from bs4 import BeautifulSoup @@ -703,7 +705,7 @@ class Confirmable(): CONFIRM_TIMEDELTA = datetime.timedelta(seconds=10) - def __init__(self, confirm_timedelta=None): + def __init__(self, confirm_timedelta: Union[datetime.timedelta, int] = None): """Instantiate Confirmable instance. If `confirm_timedelta` is not passed, @@ -711,6 +713,8 @@ class Confirmable(): """ if confirm_timedelta is None: confirm_timedelta = self.__class__.CONFIRM_TIMEDELTA + elif type(confirm_timedelta) is int: + confirm_timedelta = datetime.timedelta(seconds=confirm_timedelta) self.set_confirm_timedelta(confirm_timedelta) self._confirm_datetimes = {} From 34cf987aa75c9ff9ba1f110e244c78b85234108d Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 7 May 2020 22:52:42 +0200 Subject: [PATCH 08/17] Implemented button to delete all commands stored by BotFather --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 59 ++++++++++++++++++++++++++++-- davtelepot/messages.py | 40 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 7d166f8..a190c2f 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.8" +__version__ = "2.5.9" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 7a0af55..7a50d81 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -1412,12 +1412,63 @@ async def _father_button(bot: Bot, user_record: OrderedDict, ) ] ) + elif command == 'del': + if not Confirmator.get('del_bot_father_commands', + confirm_timedelta=3 + ).confirm(user_record['id']): + return bot.get_message( + 'admin', 'confirm', + language=language + ) + stored_commands = await bot.getMyCommands() + if not len(stored_commands): + text = bot.get_message( + 'admin', 'father_command', 'del', 'no_change', + language=language + ) + else: + if isinstance( + await bot.setMyCommands([]), + Exception + ): + text = bot.get_message( + 'admin', 'father_command', 'del', 'error', + language=language + ) + else: + text = bot.get_message( + 'admin', 'father_command', 'del', 'done', + language=language + ) + reply_markup = make_inline_keyboard( + [ + make_button( + text=bot.get_message('admin', 'father_command', 'back', + language=language), + prefix='father:///', + delimiter='|', + data=['main'] + ) + ] + ) elif command == 'get': commands = await bot.getMyCommands() - text = '' + '\n'.join( - "{c[command]} - {c[description]}".format(c=command) - for command in commands - ) + '' + if len(commands) == 0: + commands = bot.get_message( + 'admin', 'father_command', 'get', 'empty', + language=language, + commands=commands + ) + else: + commands = '' + '\n'.join( + "{c[command]} - {c[description]}".format(c=command) + for command in commands + ) + '' + text = bot.get_message( + 'admin', 'father_command', 'get', 'panel', + language=language, + commands=commands + ) reply_markup = make_inline_keyboard( [ make_button( diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 3b04159..1d88067 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -95,6 +95,20 @@ default_admin_messages = { 'en': "↩️ Back", 'it': "↩️ Indietro", }, + 'del': { + 'done': { + 'en': "✅ Commands deleted", + 'it': "✅ Comandi rimossi", + }, + 'error': { + 'en': "Something went wrong 😕", + 'it': "Qualcosa è andato storto 😕", + }, + 'no_change': { + 'en': "❌ No command stored", + 'it': "❌ Nessun comando salvato", + }, + }, 'description': { 'en': "Edit the @BotFather commands", 'it': "Modifica i comandi con @BotFather", @@ -103,6 +117,20 @@ default_admin_messages = { 'en': "❌ Error! ❌", 'it': "❌ Errore! ❌", }, + 'get': { + 'empty': { + 'en': "No command set yet. You may use ✏️ Change " + "button", + 'it': "Nessun comando impostato ancora. Puoi usare il bottone " + "✏️ Modifica", + }, + 'panel': { + 'en': "🤖 BotFather commands ℹ️\n\n" + "{commands}", + 'it': "🤖 Comandi su BotFather ℹ️\n\n" + "{commands}", + }, + }, 'modes': [ { 'id': "get", @@ -128,6 +156,18 @@ default_admin_messages = { 'it': "Modifica i comandi salvati su @BotFather" }, }, + { + 'id': "del", + 'name': { + 'en': "Delete", + 'it': "Cancella" + }, + 'symbol': "🗑", + 'description': { + 'en': "Delete commands stored by @BotFather", + 'it': "Cancella i comandi salvati su @BotFather" + }, + }, { 'id': "settings", 'name': { From d7244426042f3e70a46f04a2a9ec436a86349d96 Mon Sep 17 00:00:00 2001 From: Davte Date: Wed, 13 May 2020 15:28:49 +0200 Subject: [PATCH 09/17] /ban command implemented --- davtelepot/__init__.py | 2 +- davtelepot/authorization.py | 270 +++++++++++++++++++++++------------- davtelepot/messages.py | 4 +- davtelepot/suggestions.py | 2 +- 4 files changed, 181 insertions(+), 97 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index a190c2f..102665e 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.9" +__version__ = "2.5.10" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/authorization.py b/davtelepot/authorization.py index eea4b4f..53ac7f4 100644 --- a/davtelepot/authorization.py +++ b/davtelepot/authorization.py @@ -2,7 +2,7 @@ # Standard library modules from collections import OrderedDict -from typing import Callable, Union +from typing import Callable, List, Union # Project modules from .bot import Bot @@ -68,14 +68,14 @@ class Role: roles = OrderedDict() default_role_code = 100 - def __init__(self, code, name, symbol, singular, plural, - can_appoint, can_be_appointed_by): + def __init__(self, code: int, name: str, symbol: str, + singular: str, plural: str, + can_appoint: List[int], can_be_appointed_by: List[int]): """Instantiate Role object. code : int The higher the code, the less privileges are connected to that - role. - Use 0 for banned users. + role. Use 0 for banned users. name : str Short name for role. symbol : str @@ -84,7 +84,7 @@ class Role: Singular full name of role. plural : str Plural full name of role. - can_appoint : lsit of int + can_appoint : list of int List of role codes that this role can appoint. can_be_appointed_by : list of int List of role codes this role can be appointed by. @@ -99,42 +99,42 @@ class Role: self.__class__.roles[self.code] = self @property - def code(self): + def code(self) -> int: """Return code.""" return self._code @property - def name(self): + def name(self) -> str: """Return name.""" return self._name @property - def symbol(self): + def symbol(self) -> str: """Return symbol.""" return self._symbol @property - def singular(self): + def singular(self) -> str: """Return singular.""" return self._singular @property - def plural(self): + def plural(self) -> str: """Return plural.""" return self._plural @property - def can_appoint(self): + def can_appoint(self) -> List[int]: """Return can_appoint.""" return self._can_appoint @property - def can_be_appointed_by(self): + def can_be_appointed_by(self) -> List[int]: """Return roles whom this role can be appointed by.""" return self._can_be_appointed_by @classmethod - def get_by_role_id(cls, role_id=100): + def get_by_role_id(cls, role_id=100) -> 'Role': """Given a `role_id`, return the corresponding `Role` instance.""" for code, role in cls.roles.items(): if code == role_id: @@ -142,7 +142,7 @@ class Role: raise IndexError(f"Unknown role id: {role_id}") @classmethod - def get_role_by_name(cls, name='everybody'): + def get_role_by_name(cls, name='everybody') -> 'Role': """Given a `name`, return the corresponding `Role` instance.""" for role in cls.roles.values(): if role.name == name: @@ -150,7 +150,9 @@ class Role: raise IndexError(f"Unknown role name: {name}") @classmethod - def get_user_role(cls, user_record=None, user_role_id=None): + def get_user_role(cls, + user_record: OrderedDict = None, + user_role_id: int = None) -> 'Role': """Given a `user_record`, return its `Role`. `role_id` may be passed as keyword argument or as user_record. @@ -170,7 +172,7 @@ class Role: return cls.get_by_role_id(role_id=user_role_id) @classmethod - def set_default_role_code(cls, role): + def set_default_role_code(cls, role: int) -> None: """Set class default role code. It will be returned if a specific role code cannot be evaluated. @@ -178,30 +180,82 @@ class Role: cls.default_role_code = role @classmethod - def get_user_role_panel(cls, user_record): - """Get text and buttons for user role panel.""" - user_role = cls.get_user_role(user_record=user_record) - text = ( - """👤 {u[username]}\n""" + def get_user_role_text(cls, + user_record: OrderedDict, + user_role: 'Role' = None) -> str: + """ + Get a string to describe the role of a user. + + @param user_record: record of table `users` about the user; it must + contain at least a [username | last_name | first_name] and a + telegram identifier. + @param user_role: Role instance about user permissions. + @return: String to describe the role of a user, like this: + ``` + 👤 LinkedUsername + 🔑 Admin ⚜️ + ``` + """ + if user_role is None: + user_role = cls.get_user_role(user_record=user_record) + return ( + f"""👤 {get_user(record=user_record)}\n""" f"🔑 {user_role.singular.capitalize()} {user_role.symbol}" - ).format( - u=user_record, ) - buttons = [ + + @classmethod + def get_user_role_buttons(cls, + user_record: OrderedDict, + admin_record: OrderedDict, + user_role: 'Role' = None, + admin_role: 'Role' = None) -> List[dict]: + """ Return buttons to edit user permissions. + @param user_record: record of table `users` about the user; it must + contain at least a [username | last_name | first_name] and a + telegram identifier. + @param admin_record: record of table `users` about the admin; it must + contain at least a [username | last_name | first_name] and a + telegram identifier. + @param user_role: Role instance about user permissions. + @param admin_role: Role instance about admin permissions. + @return: list of `InlineKeyboardButton`s. + """ + if admin_role is None: + admin_role = cls.get_user_role(user_record=admin_record) + if user_role is None: + user_role = cls.get_user_role(user_record=user_record) + return [ make_button( f"{role.symbol} {role.singular.capitalize()}", prefix='auth:///', data=['set', user_record['id'], code] ) for code, role in cls.roles.items() + if (admin_role > user_role + and code in admin_role.can_appoint + and code != user_role.code) ] + + @classmethod + def get_user_role_text_and_buttons(cls, + user_record: OrderedDict, + admin_record: OrderedDict): + """Get text and buttons for user role panel.""" + admin_role = cls.get_user_role(user_record=admin_record) + user_role = cls.get_user_role(user_record=user_record) + text = cls.get_user_role_text(user_record=user_record, + user_role=user_role) + buttons = cls.get_user_role_buttons(user_record=user_record, + user_role=user_role, + admin_record=admin_record, + admin_role=admin_role) return text, buttons - def __eq__(self, other): + def __eq__(self, other: 'Role'): """Return True if self is equal to other.""" return self.code == other.code - def __gt__(self, other): + def __gt__(self, other: 'Role'): """Return True if self can appoint other.""" return ( ( @@ -211,19 +265,19 @@ class Role: and self.code in other.can_be_appointed_by ) - def __ge__(self, other): + def __ge__(self, other: 'Role'): """Return True if self >= other.""" return self.__gt__(other) or self.__eq__(other) - def __lt__(self, other): + def __lt__(self, other: 'Role'): """Return True if self can not appoint other.""" return not self.__ge__(other) - def __le__(self, other): + def __le__(self, other: 'Role'): """Return True if self is superior or equal to other.""" return not self.__gt__(other) - def __ne__(self, other): + def __ne__(self, other: 'Role'): """Return True if self is not equal to other.""" return not self.__eq__(other) @@ -232,7 +286,7 @@ class Role: return f"" -def get_authorization_function(bot): +def get_authorization_function(bot: Bot): """Take a `bot` and return its authorization_function.""" def is_authorized(update, user_record=None, authorization_level=2): @@ -258,51 +312,69 @@ def get_authorization_function(bot): return is_authorized -async def _authorization_command(bot, update, user_record): - text = get_cleaned_text(bot=bot, update=update, replace=['auth']) +async def _authorization_command(bot: Bot, + update: dict, + user_record: OrderedDict, + mode: str = 'auth'): + db = bot.db + text = get_cleaned_text(bot=bot, update=update, replace=[mode]) reply_markup = None - # noinspection PyUnusedLocal + admin_record = user_record.copy() + user_record = None + admin_role = bot.Role.get_user_role(user_record=admin_record) result = bot.get_message( 'authorization', 'auth_command', 'unhandled_case', - update=update, user_record=user_record + update=update, user_record=admin_record ) - if not text: - if 'reply_to_message' not in update: - return bot.get_message( + if not text: # No text provided: command must be used in reply + if 'reply_to_message' not in update: # No text and not in reply + result = bot.get_message( 'authorization', 'auth_command', 'instructions', - update=update, user_record=user_record + update=update, user_record=admin_record, + command=mode ) - else: - with bot.db as db: + else: # No text, command used in reply to another message + update = update['reply_to_message'] + # Forwarded message: get both the user who forwarded and the original author + if ('forward_from' in update + and update['from']['id'] != update['forward_from']['id']): + user_record = list( + db['users'].find( + telegram_id=[update['from']['id'], + update['forward_from']['id']] + ) + ) + else: # Otherwise: get the author of the message user_record = db['users'].find_one( - telegram_id=update['reply_to_message']['from']['id'] - ) - else: - with bot.db as db: - user_record = list( - db.query( - "SELECT * " - "FROM users " - "WHERE COALESCE(" - " first_name || last_name || username," - " last_name || username," - " first_name || username," - " username," - " first_name || last_name," - " last_name," - " first_name" - f") LIKE '%{text}%'" + telegram_id=update['from']['id'] ) + else: # Get users matching the input text + user_record = list( + db.query( + "SELECT * " + "FROM users " + "WHERE COALESCE(" + " first_name || last_name || username," + " last_name || username," + " first_name || username," + " username," + " first_name || last_name," + " last_name," + " first_name" + f") LIKE '%{text}%'" ) - if user_record is None: + ) + if len(user_record) == 1: + user_record = user_record[0] + if user_record is None: # If query was not provided and user cannot be found result = bot.get_message( 'authorization', 'auth_command', 'unknown_user', - update=update, user_record=user_record + update=update, user_record=admin_record ) - elif type(user_record) is list and len(user_record) > 1: + elif type(user_record) is list and len(user_record) > 1: # If many users match result = bot.get_message( 'authorization', 'auth_command', 'choose_user', - update=update, user_record=user_record, + update=update, user_record=admin_record, n=len(user_record) ) reply_markup = make_inline_keyboard( @@ -316,15 +388,25 @@ async def _authorization_command(bot, update, user_record): ], 3 ) - elif type(user_record) is list and len(user_record) == 0: + elif type(user_record) is list and len(user_record) == 0: # If query was provided but no user matches result = bot.get_message( 'authorization', 'auth_command', 'no_match', - update=update, user_record=user_record, + update=update, user_record=admin_record, + ) + elif isinstance(user_record, dict): # If 1 user matches + # Ban user if admin can do it + user_role = bot.Role.get_user_role(user_record=user_record) + if mode == 'ban' and admin_role > user_role: + user_record['privileges'] = 0 + db['users'].update( + user_record, + ['id'] + ) + # Show user panel (text and buttons) to edit user permissions + result, buttons = bot.Role.get_user_role_text_and_buttons( + user_record=user_record, + admin_record=admin_record ) - else: - if type(user_record) is list: - user_record = user_record[0] - result, buttons = bot.Role.get_user_role_panel(user_record) reply_markup = make_inline_keyboard(buttons, 1) return dict( text=result, @@ -333,7 +415,10 @@ async def _authorization_command(bot, update, user_record): ) -async def _authorization_button(bot, update, user_record, data): +async def _authorization_button(bot: Bot, + update: dict, + user_record: OrderedDict, + data: Union[str, List[Union[int, str]]]): if len(data) == 0: data = [''] command, *arguments = data @@ -343,10 +428,13 @@ async def _authorization_button(bot, update, user_record, data): else: other_user_id = None result, text, reply_markup = '', '', None + db = bot.db if command in ['show']: - with bot.db as db: - other_user_record = db['users'].find_one(id=other_user_id) - text, buttons = bot.Role.get_user_role_panel(other_user_record) + other_user_record = db['users'].find_one(id=other_user_id) + text, buttons = bot.Role.get_user_role_text_and_buttons( + user_record=other_user_record, + admin_record=user_record + ) reply_markup = make_inline_keyboard(buttons, 1) elif command in ['set'] and len(arguments) > 1: other_user_id, new_privileges, *_ = arguments @@ -358,8 +446,7 @@ async def _authorization_button(bot, update, user_record, data): 'authorization', 'auth_button', 'confirm', update=update, user_record=user_record, ) - with bot.db as db: - other_user_record = db['users'].find_one(id=other_user_id) + other_user_record = db['users'].find_one(id=other_user_id) user_role = bot.Role.get_user_role(user_record=user_record) other_user_role = bot.Role.get_user_role(user_record=other_user_record) if other_user_role.code == new_privileges: @@ -404,20 +491,22 @@ async def _authorization_button(bot, update, user_record, data): 1 ) else: - with bot.db as db: - db['users'].update( - dict( - id=other_user_id, - privileges=new_privileges - ), - ['id'] - ) - other_user_record = db['users'].find_one(id=other_user_id) + db['users'].update( + dict( + id=other_user_id, + privileges=new_privileges + ), + ['id'] + ) + other_user_record = db['users'].find_one(id=other_user_id) result = bot.get_message( 'authorization', 'auth_button', 'appointed', update=update, user_record=user_record ) - text, buttons = bot.Role.get_user_role_panel(other_user_record) + text, buttons = bot.Role.get_user_role_text_and_buttons( + user_record=other_user_record, + admin_record=user_record + ) reply_markup = make_inline_keyboard(buttons, 1) if text: return dict( @@ -431,14 +520,9 @@ async def _authorization_button(bot, update, user_record, data): return result -async def _ban_command(bot, update, user_record): - # TODO define this function! - return - - def default_get_administrators_function(bot: Bot): return list( - bot.db['users'].find(privileges=[1,2]) + bot.db['users'].find(privileges=[1, 2]) ) @@ -498,6 +582,6 @@ def init(telegram_bot: Bot, @telegram_bot.command('/ban', aliases=[], show_in_keyboard=False, description=authorization_messages['ban_command']['description'], - authorization_level='admin') + authorization_level='moderator') async def ban_command(bot, update, user_record): - return await _ban_command(bot, update, user_record) + return await _authorization_command(bot, update, user_record, mode='ban') diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 1d88067..7121f2f 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -649,9 +649,9 @@ default_authorization_messages = { }, 'instructions': { 'en': "Reply with this command to a user or write " - "/auth username to edit their permissions.", + "/{command} username to edit their permissions.", 'it': "Usa questo comando in risposta a un utente " - "oppure scrivi /auth username per " + "oppure scrivi /{command} username per " "cambiarne il grado di autorizzazione." }, 'unknown_user': { diff --git a/davtelepot/suggestions.py b/davtelepot/suggestions.py index de30225..63f72b8 100644 --- a/davtelepot/suggestions.py +++ b/davtelepot/suggestions.py @@ -162,7 +162,7 @@ async def _suggestions_button(bot: davtelepot.bot.Bot, update, user_record, data )['suggestion'] suggestion_message = bot.get_message( 'suggestions', 'suggestions_command', 'received_suggestion', 'text', - user=bot.Role.get_user_role_panel(registered_user)[0], + user=bot.Role.get_user_role_text(user_record=registered_user), suggestion=suggestion_text, bot=bot, update=update, user_record=user_record, From f840f9fe13ab6620a8dce2d46d416bf2e22b2f9e Mon Sep 17 00:00:00 2001 From: Davte Date: Wed, 13 May 2020 19:45:15 +0200 Subject: [PATCH 10/17] /version command looks for updates --- davtelepot/__init__.py | 2 +- davtelepot/administration_tools.py | 95 ++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index 102665e..f0b5941 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.10" +__version__ = "2.5.11" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 7a50d81..41fda79 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -830,6 +830,46 @@ async def get_last_commit(): return last_commit +async def get_new_versions(bot: Bot, + notification_interval: datetime.timedelta = None) -> dict: + """Get new versions of packages in bot.packages. + + Result: {"name": {"current": "0.1", "new": "0.2"}} + """ + if notification_interval is None: + notification_interval = datetime.timedelta(seconds=0) + news = dict() + for package in bot.packages: + package_web_page = CachedPage.get( + f'https://pypi.python.org/pypi/{package.__name__}/json', + cache_time=2, + mode='json' + ) + web_page = await package_web_page.get_page() + if web_page is None or isinstance(web_page, Exception): + logging.error(f"Cannot get updates for {package.__name__}, " + "skipping...") + continue + new_version = web_page['info']['version'] + current_version = package.__version__ + notification_record = bot.db['updates_notifications'].find_one( + package=package.__name__, + order_by=['-id'], + _limit=1 + ) + if ( + new_version != current_version + and (notification_record is None + or notification_record['notified_at'] + < datetime.datetime.now() - notification_interval) + ): + news[package.__name__] = { + 'current': current_version, + 'new': new_version + } + return news + + async def _version_command(bot: Bot, update, user_record): last_commit = await get_last_commit() text = bot.get_message( @@ -842,7 +882,29 @@ async def _version_command(bot: Bot, update, user_record): f"{package.__version__}" for package in bot.packages ) - return text + temporary_message = await bot.send_message( + text=text + '\n\n⏳ Checking for updates... ☑️', + update=update, + send_default_keyboard=False + ) + news = await get_new_versions(bot=bot) + if not news: + text += '\n\n⌛️ All packages are updated! ✅' + else: + text += '\n\n' + bot.get_message( + 'admin', 'updates_available', 'header', + user_record=user_record + ) + '\n\n' + text += '\n'.join( + f"{package}: " + f"{versions['current']} —> " + f"{versions['new']}" + for package, versions in news.items() + ) + await bot.edit_message_text( + text=text, + update=temporary_message + ) async def notify_new_version(bot: Bot): @@ -918,35 +980,8 @@ async def get_package_updates(bot: Bot, seconds=notification_interval ) while 1: - news = dict() - for package in bot.packages: - package_web_page = CachedPage.get( - f'https://pypi.python.org/pypi/{package.__name__}/json', - cache_time=2, - mode='json' - ) - web_page = await package_web_page.get_page() - if web_page is None or isinstance(web_page, Exception): - logging.error(f"Cannot get updates for {package.__name__}, " - "skipping...") - continue - new_version = web_page['info']['version'] - current_version = package.__version__ - notification_record = bot.db['updates_notifications'].find_one( - package=package.__name__, - order_by=['-id'], - _limit=1 - ) - if ( - new_version != current_version - and (notification_record is None - or notification_record['notified_at'] - < datetime.datetime.now() - notification_interval) - ): - news[package.__name__] = { - 'current': current_version, - 'new': new_version - } + news = await get_new_versions(bot=bot, + notification_interval=notification_interval) if news: for admin in bot.administrators: text = bot.get_message( From e6fdacd2f4246dee1e317de78d85b4307a1ebf4c Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 15:32:06 +0200 Subject: [PATCH 11/17] Allow language-labelled commands --- davtelepot/__init__.py | 2 +- davtelepot/bot.py | 39 ++++++++++++++++++++++++++++++++++----- davtelepot/helper.py | 17 ++++++++++------- davtelepot/languages.py | 40 +++++++++++++++++++++------------------- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index f0b5941..a810614 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.11" +__version__ = "2.5.12" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/bot.py b/davtelepot/bot.py index 3fe3a37..c47c8bd 100644 --- a/davtelepot/bot.py +++ b/davtelepot/bot.py @@ -43,7 +43,7 @@ import re import sys from collections import OrderedDict -from typing import Callable +from typing import Callable, Union, Dict # Third party modules from aiohttp import web @@ -2100,10 +2100,14 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): help_section['authorization_level'] = 'admin' self.messages['help_sections'][help_section['name']] = help_section - def command(self, command, aliases=None, reply_keyboard_button=None, + def command(self, + command: Union[str, Dict[str, str]], + aliases=None, + reply_keyboard_button=None, show_in_keyboard=False, description="", help_section=None, - authorization_level='admin'): + authorization_level='admin', + language_labelled_commands: Dict[str, str] = None): """Associate a bot command with a custom handler function. Decorate command handlers like this: @@ -2114,7 +2118,8 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ``` 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 /). + `command` is the command name (with or without /). Language-labeled + commands are supported in the form of {'en': 'command', ...} `aliases` is a list of aliases; each will call the command handler function; the first alias will appear as button in reply keyboard if `reply_keyboard_button` is not set. @@ -2142,7 +2147,30 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): } `authorization_level` is the lowest authorization level needed to run the command. + + For advanced examples see `davtelepot.helper` or other modules + (suggestions, administration_tools, ...). """ + if language_labelled_commands is None: + language_labelled_commands = dict() + # Handle language-labelled commands: + # choose one main command and add others to `aliases` + if isinstance(command, dict) and len(command) > 0: + language_labelled_commands = command.copy() + if 'main' in language_labelled_commands: + command = language_labelled_commands['main'] + elif self.default_language in language_labelled_commands: + command = language_labelled_commands[self.default_language] + else: + for command in language_labelled_commands.values(): + break + if aliases is None: + aliases = [] + aliases += [ + alias + for alias in language_labelled_commands.values() + if alias != command + ] if not isinstance(command, str): raise TypeError(f'Command `{command}` is not a string') if isinstance(reply_keyboard_button, dict): @@ -2192,7 +2220,8 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): self.commands[command] = dict( handler=decorated_command_handler, description=description, - authorization_level=authorization_level + authorization_level=authorization_level, + language_labelled_commands=language_labelled_commands ) if type(description) is dict: self.messages['commands'][command] = dict( diff --git a/davtelepot/helper.py b/davtelepot/helper.py index d31c5ac..ae0e378 100644 --- a/davtelepot/helper.py +++ b/davtelepot/helper.py @@ -1,16 +1,15 @@ """Make a self-consistent bot help section.""" -# Third party modules -from davtelepot.utilities import ( +# Project modules +from .bot import Bot +from .messages import default_help_messages +from .utilities import ( get_cleaned_text, make_inline_keyboard, make_lines_of_buttons, make_button ) -# Project modules -from .messages import default_help_messages - -def get_commands_description(bot, update, user_record): +def get_commands_description(bot: Bot, update, user_record): """Get a string description of `bot` commands. Show only commands available for `update` sender. @@ -31,7 +30,11 @@ def get_commands_description(bot, update, user_record): commands[command_role.code] = [] commands[command_role.code].append( "/{command}{authorization_level}: {description}".format( - command=command, + command=bot.get_message( + messages=details['language_labelled_commands'], + default_message=command, + user_record=user_record, update=update + ), authorization_level=( f" [{command_role.plural}]" if command_role.code != bot.Role.default_role_code diff --git a/davtelepot/languages.py b/davtelepot/languages.py index 7b874ac..1ba554f 100644 --- a/davtelepot/languages.py +++ b/davtelepot/languages.py @@ -140,26 +140,9 @@ class MultiLanguageObject(object): result = messages or self.messages for field in fields: if field not in result: - logging.debug( - "Please define self.message{f}".format( - f=''.join( - '[\'{field}\']'.format( - field=field - ) - for field in fields - ) - ) - ) - return default_message or self.missing_message - result = result[field] - if language not in result: - # For specific languages, try generic ones - language = language.partition('-')[0] - if language not in result: - language = 'en' - if language not in result: + if not default_message: logging.debug( - "Please define self.message{f}['en']".format( + "Please define self.message{f}".format( f=''.join( '[\'{field}\']'.format( field=field @@ -168,6 +151,25 @@ class MultiLanguageObject(object): ) ) ) + return default_message or self.missing_message + result = result[field] + if language not in result: + # For specific languages, try generic ones + language = language.partition('-')[0] + if language not in result: + language = 'en' + if language not in result: + if not default_message: + logging.debug( + "Please define self.message{f}['en']".format( + f=''.join( + '[\'{field}\']'.format( + field=field + ) + for field in fields + ) + ) + ) return default_message or self.missing_message if type(result) is str: return result From cf6a2e1baaffe0d47629871f8083fd210460b62c Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 18:49:40 +0200 Subject: [PATCH 12/17] Language-labelled commands are accepted only for selected language. /father uses selected language for commands --- davtelepot/administration_tools.py | 8 +- davtelepot/bot.py | 49 ++++++----- davtelepot/utilities.py | 131 +++++++++++++++++------------ 3 files changed, 115 insertions(+), 73 deletions(-) diff --git a/davtelepot/administration_tools.py b/davtelepot/administration_tools.py index 41fda79..2b8fa5b 100644 --- a/davtelepot/administration_tools.py +++ b/davtelepot/administration_tools.py @@ -3,7 +3,7 @@ Usage: ``` import davtelepot -my_bot = davtelepot.Bot.get('my_token', 'my_database.db') +my_bot = davtelepot.bot.Bot(token='my_token', database_url='my_database.db') davtelepot.admin_tools.init(my_bot) ``` """ @@ -1070,7 +1070,11 @@ def get_current_commands(bot: Bot, language: str = None) -> List[dict]: return sorted( [ { - 'command': name, + 'command': bot.get_message( + messages=information['language_labelled_commands'], + default_message=name, + language=language + ), 'description': bot.get_message( messages=information['description'], language=language diff --git a/davtelepot/bot.py b/davtelepot/bot.py index c47c8bd..f7bd50b 100644 --- a/davtelepot/bot.py +++ b/davtelepot/bot.py @@ -778,6 +778,19 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): ).group(0) # Get the first group of characters matching pattern if command in self.commands: replier = self.commands[command]['handler'] + elif command in [ + description['language_labelled_commands'][language] + for c, description in self.commands.items() + if 'language_labelled_commands' in description + and language in description['language_labelled_commands'] + ]: + replier = [ + description['handler'] + for c, description in self.commands.items() + if 'language_labelled_commands' in description + and language in description['language_labelled_commands'] + and command == description['language_labelled_commands'][language] + ][0] elif 'chat' in update and update['chat']['id'] > 0: reply = dict(text=self.unknown_command_message) else: # Handle command aliases and text parsers @@ -2153,6 +2166,10 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): """ if language_labelled_commands is None: language_labelled_commands = dict() + language_labelled_commands = { + key: val.strip('/').lower() + for key, val in language_labelled_commands.items() + } # Handle language-labelled commands: # choose one main command and add others to `aliases` if isinstance(command, dict) and len(command) > 0: @@ -2164,31 +2181,25 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): else: for command in language_labelled_commands.values(): break - if aliases is None: - aliases = [] - aliases += [ - alias - for alias in language_labelled_commands.values() - if alias != command - ] + if aliases is None: + aliases = [] if not isinstance(command, str): raise TypeError(f'Command `{command}` is not a string') if isinstance(reply_keyboard_button, dict): for button in reply_keyboard_button.values(): if button not in aliases: aliases.append(button) - if aliases: - if not isinstance(aliases, list): - raise TypeError(f'Aliases is not a list: `{aliases}`') - if not all( - [ - isinstance(alias, str) - for alias in aliases - ] - ): - raise TypeError( - f'Aliases {aliases} is not a list of strings string' - ) + if not isinstance(aliases, list): + raise TypeError(f'Aliases is not a list: `{aliases}`') + if not all( + [ + isinstance(alias, str) + for alias in aliases + ] + ): + raise TypeError( + f'Aliases {aliases} is not a list of strings' + ) if isinstance(help_section, dict): if 'authorization_level' not in help_section: help_section['authorization_level'] = authorization_level diff --git a/davtelepot/utilities.py b/davtelepot/utilities.py index 622cad8..c554589 100644 --- a/davtelepot/utilities.py +++ b/davtelepot/utilities.py @@ -98,7 +98,7 @@ def extract(text, starter=None, ender=None): def make_button(text=None, callback_data='', - prefix='', delimiter='|', data=[]): + prefix='', delimiter='|', data=None): """Return a Telegram bot API-compliant button. callback_data can be either a ready-to-use string or a @@ -107,6 +107,8 @@ def make_button(text=None, callback_data='', it gets truncated at the last delimiter before that limit. If absent, text is the same as callback_data. """ + if data is None: + data = [] if len(data): callback_data += delimiter.join(map(str, data)) callback_data = "{p}{c}".format( @@ -170,7 +172,7 @@ async def async_get(url, mode='json', **kwargs): del kwargs['mode'] return await async_request( url, - type='get', + method='get', mode=mode, **kwargs ) @@ -188,13 +190,13 @@ async def async_post(url, mode='html', **kwargs): """ return await async_request( url, - type='post', + method='post', mode=mode, **kwargs ) -async def async_request(url, type='get', mode='json', encoding=None, errors='strict', +async def async_request(url, method='get', mode='json', encoding=None, errors='strict', **kwargs): """Make an async html request. @@ -214,7 +216,7 @@ async def async_request(url, type='get', mode='json', encoding=None, errors='str async with aiohttp.ClientSession() as s: async with ( s.get(url, timeout=30) - if type == 'get' + if method == 'get' else s.post(url, timeout=30, data=kwargs) ) as r: if mode in ['html', 'json', 'string']: @@ -246,12 +248,14 @@ async def async_request(url, type='get', mode='json', encoding=None, errors='str return result -def json_read(file_, default={}, encoding='utf-8', **kwargs): +def json_read(file_, default=None, encoding='utf-8', **kwargs): """Return json parsing of `file_`, or `default` if file does not exist. `encoding` refers to how the file should be read. `kwargs` will be passed to json.load() """ + if default is None: + default = {} if not os.path.isfile(file_): return default with open(file_, "r", encoding=encoding) as f: @@ -268,7 +272,7 @@ def json_write(what, file_, encoding='utf-8', **kwargs): return json.dump(what, f, indent=4, **kwargs) -def csv_read(file_, default=[], encoding='utf-8', +def csv_read(file_, default=None, encoding='utf-8', delimiter=',', quotechar='"', **kwargs): """Return csv parsing of `file_`, or `default` if file does not exist. @@ -277,6 +281,8 @@ def csv_read(file_, default=[], encoding='utf-8', `quotechar` is the string delimiter. `kwargs` will be passed to csv.reader() """ + if default is None: + default = [] if not os.path.isfile(file_): return default result = [] @@ -299,7 +305,7 @@ def csv_read(file_, default=[], encoding='utf-8', return result -def csv_write(info=[], file_='output.csv', encoding='utf-8', +def csv_write(info=None, file_='output.csv', encoding='utf-8', delimiter=',', quotechar='"', **kwargs): """Store `info` in CSV `file_`. @@ -309,6 +315,8 @@ def csv_write(info=[], file_='output.csv', encoding='utf-8', `encoding` refers to how the file should be written. `kwargs` will be passed to csv.writer() """ + if info is None: + info = [] assert ( type(info) is list and len(info) > 0 @@ -403,19 +411,19 @@ class MyOD(collections.OrderedDict): return None -def line_drawing_unordered_list(l): +def line_drawing_unordered_list(list_): """Draw an old-fashioned unordered list. - Unorderd list example + Unordered list example ├ An element ├ Another element └Last element """ result = "" - if l: - for x in l[:-1]: + if list_: + for x in list_[:-1]: result += "├ {}\n".format(x) - result += "└ {}".format(l[-1]) + result += "└ {}".format(list_[-1]) return result @@ -444,7 +452,7 @@ def datetime_to_str(d): return '{:%Y-%m-%d %H:%M:%S.%f}'.format(d) -class MyCounter(): +class MyCounter: """Counter object, with a `lvl` method incrementing `n` property.""" def __init__(self): @@ -523,7 +531,7 @@ def forwarded(by=None): Decorator: such decorated functions have effect only if update is forwarded from someone (you can specify `by` whom). """ - def is_forwarded_by(update, by): + def is_forwarded_by(update): if 'forward_from' not in update: return False if by and update['forward_from']['id'] != by: @@ -533,11 +541,11 @@ def forwarded(by=None): def decorator(view_func): if asyncio.iscoroutinefunction(view_func): async def decorated(update): - if is_forwarded_by(update, by): + if is_forwarded_by(update): return await view_func(update) else: def decorated(update): - if is_forwarded_by(update, by): + if is_forwarded_by(update): return view_func(update) return decorated return decorator @@ -549,7 +557,7 @@ def chat_selective(chat_id=None): Such decorated functions have effect only if update comes from a specific (if `chat_id` is given) or generic chat. """ - def check_function(update, chat_id): + def check_function(update): if 'chat' not in update: return False if chat_id: @@ -560,17 +568,17 @@ def chat_selective(chat_id=None): def decorator(view_func): if asyncio.iscoroutinefunction(view_func): async def decorated(update): - if check_function(update, chat_id): + if check_function(update): return await view_func(update) else: def decorated(update): - if check_function(update, chat_id): + if check_function(update): return view_func(update) return decorated return decorator -async def sleep_until(when): +async def sleep_until(when: Union[datetime.datetime, datetime.timedelta]): """Sleep until now > `when`. `when` could be a datetime.datetime or a datetime.timedelta instance. @@ -587,6 +595,8 @@ async def sleep_until(when): delta = when - datetime.datetime.now() elif isinstance(when, datetime.timedelta): delta = when + else: + delta = datetime.timedelta(seconds=1) if delta.days >= 0: await asyncio.sleep( delta.seconds @@ -672,30 +682,40 @@ ARTICOLI[4] = { } -class Gettable(): +class Gettable: """Gettable objects can be retrieved from memory without being duplicated. Key is the primary key. - Use classmethod get to instantiate (or retrieve) Gettable objects. + Use class method get to instantiate (or retrieve) Gettable objects. Assign SubClass.instances = {}, otherwise Gettable.instances will contain SubClass objects. """ instances = {} + def __init__(self, *args, key=None, **kwargs): + if key is None: + key = args[0] + if key not in self.__class__.instances: + self.__class__.instances[key] = self + @classmethod - def get(cls, key, *args, **kwargs): + def get(cls, *args, key=None, **kwargs): """Instantiate and/or retrieve Gettable object. SubClass.instances is searched if exists. Gettable.instances is searched otherwise. """ + if key is None: + key = args[0] + else: + kwargs['key'] = key if key not in cls.instances: - cls.instances[key] = cls(key, *args, **kwargs) + cls.instances[key] = cls(*args, **kwargs) return cls.instances[key] -class Confirmable(): +class Confirmable: """Confirmable objects are provided with a confirm instance method. It evaluates True if it was called within self._confirm_timedelta, @@ -715,6 +735,7 @@ class Confirmable(): confirm_timedelta = self.__class__.CONFIRM_TIMEDELTA elif type(confirm_timedelta) is int: confirm_timedelta = datetime.timedelta(seconds=confirm_timedelta) + self._confirm_timedelta = None self.set_confirm_timedelta(confirm_timedelta) self._confirm_datetimes = {} @@ -756,18 +777,18 @@ class Confirmable(): return True -class HasBot(): +class HasBot: """Objects having a Bot subclass object as `.bot` attribute. HasBot objects have a .bot and .db properties for faster access. """ - bot = None + _bot = None @property def bot(self): """Class bot.""" - return self.__class__.bot + return self.__class__._bot @property def db(self): @@ -777,11 +798,11 @@ class HasBot(): @classmethod def set_bot(cls, bot): """Change class bot.""" - cls.bot = bot + cls._bot = bot class CachedPage(Gettable): - """Cache a webpage and return it during CACHE_TIME, otherwise refresh. + """Cache a web page and return it during CACHE_TIME, otherwise refresh. Usage: cached_page = CachedPage.get( @@ -815,6 +836,7 @@ class CachedPage(Gettable): self._page = None self._last_update = datetime.datetime.now() - self.cache_time self._async_get_kwargs = async_get_kwargs + super().__init__(key=url) @property def url(self): @@ -847,7 +869,7 @@ class CachedPage(Gettable): return datetime.datetime.now() > self.last_update + self.cache_time async def refresh(self): - """Update cached webpage.""" + """Update cached web page.""" try: self._page = await async_get(self.url, **self.async_get_kwargs) self._last_update = datetime.datetime.now() @@ -860,11 +882,10 @@ class CachedPage(Gettable): ), exc_info=False ) # Set exc_info=True to debug - return 1 return 1 async def get_page(self): - """Refresh if necessary and return webpage.""" + """Refresh if necessary and return web page.""" if self.is_old: await self.refresh() return self.page @@ -878,14 +899,17 @@ class Confirmator(Gettable, Confirmable): def __init__(self, key, *args, confirm_timedelta=None): """Call Confirmable.__init__ passing `confirm_timedelta`.""" Confirmable.__init__(self, confirm_timedelta) + Gettable.__init__(self, key=key, *args) -def get_cleaned_text(update, bot=None, replace=[], strip='/ @'): +def get_cleaned_text(update, bot=None, replace=None, strip='/ @'): """Clean `update`['text'] and return it. Strip `bot`.name and items to be `replace`d from the beginning of text. Strip `strip` characters from both ends. """ + if replace is None: + replace = [] if bot is not None: replace.append( '@{.name}'.format( @@ -1120,9 +1144,6 @@ WEEKDAY_NAMES_ENG = ["Monday", "Tuesday", "Wednesday", "Thursday", 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: @@ -1196,7 +1217,8 @@ def parse_datetime_interval_string(text): parsers = [] result_text, result_datetime, result_timedelta = [], None, None is_quoted_text = False - text = re.sub('\s\s+', ' ', text) # Replace multiple spaces with single space character + # Replace multiple spaces with single space character + text = re.sub(r'\s\s+', ' ', text) for word in text.split(' '): if word.count('"') % 2: is_quoted_text = not is_quoted_text @@ -1247,7 +1269,7 @@ def parse_datetime_interval_string(text): recurring_event = True type_ = parser['type_'] for result in parser['result']: - if not result['ok']: + if not isinstance(result, dict) or not result['ok']: continue if recurring_event and 'weekly' in result and result['weekly']: weekly = True @@ -1363,11 +1385,11 @@ def beautydt(dt): now = datetime.datetime.now() gap = dt - now gap_days = (dt.date() - now.date()).days - result = "{dt:alle %H:%M}".format( + result = "alle {dt:%H:%M}".format( dt=dt ) if abs(gap) < datetime.timedelta(minutes=30): - result += "{dt::%S}".format(dt=dt) + result += ":{dt:%S}".format(dt=dt) if -2 <= gap_days <= 2: result += " di {dg}".format( dg=DAY_GAPS[gap_days] @@ -1493,16 +1515,16 @@ def get_line_by_content(text, key): return -def str_to_int(string): +def str_to_int(string_): """Cast str to int, ignoring non-numeric characters.""" - string = ''.join( + string_ = ''.join( char - for char in string + for char in string_ if char.isnumeric() ) - if len(string) == 0: - string = '0' - return int(string) + if len(string_) == 0: + string_ = '0' + return int(string_) def starting_with_or_similar_to(a, b): @@ -1581,18 +1603,21 @@ def make_inline_query_answer(answer): return answer +# noinspection PyUnusedLocal async def dummy_coroutine(*args, **kwargs): - """Accept everthing as argument and do nothing.""" + """Accept everything as argument and do nothing.""" return async def send_csv_file(bot, chat_id, query, caption=None, - file_name='File.csv', user_record=None, update=dict()): + file_name='File.csv', user_record=None, update=None): """Run a query on `bot` database and send result as CSV file to `chat_id`. Optional parameters `caption` and `file_name` may be passed to this function. """ + if update is None: + update = dict() try: with bot.db as db: record = db.query( @@ -1627,7 +1652,7 @@ async def send_csv_file(bot, chat_id, query, caption=None, async def send_part_of_text_file(bot, chat_id, file_path, caption=None, file_name='File.txt', user_record=None, - update=dict(), + update=None, reversed_=True, limit=None): """Send `lines` lines of text file via `bot` in `chat_id`. @@ -1637,10 +1662,12 @@ async def send_part_of_text_file(bot, chat_id, file_path, caption=None, way to allow `reversed` files, but it is inefficient and requires a lot of memory. """ + if update is None: + update = dict() try: with open(file_path, 'r') as log_file: lines = log_file.readlines() - if reversed: + if reversed_: lines = lines[::-1] if limit: lines = lines[:limit] From a7e1b40f93bf7da9d143aa0169466069b2d1805f Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 18:50:05 +0200 Subject: [PATCH 13/17] Useful tools module added --- davtelepot/__init__.py | 8 +++-- davtelepot/messages.py | 40 +++++++++++++++++++++++++ davtelepot/useful_tools.py | 61 ++++++++++++++++++++++++++++++++++++++ davtelepot/utilities.py | 18 +++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 davtelepot/useful_tools.py diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index a810614..e8059b4 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,10 +11,12 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.12" +__version__ = "2.5.13" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" -from . import administration_tools, authorization, bot, helper, languages, suggestions, utilities +from . import (administration_tools, authorization, bot, helper, languages, + suggestions, useful_tools, utilities) -__all__ = [administration_tools, authorization, bot, helper, languages, suggestions, utilities] +__all__ = [administration_tools, authorization, bot, helper, languages, + suggestions, useful_tools, utilities] diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 7121f2f..40a2e3f 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1006,3 +1006,43 @@ default_unknown_command_message = { 'en': "Unknown command! Touch /help to read the guide and available commands.", 'it': "Comando sconosciuto! Fai /help per leggere la guida e i comandi." } + +default_useful_tools_messages = { + 'length_command': { + 'description': { + 'en': "Use this command in reply to a message to get its length.", + 'it': "Usa questo comando in risposta a un messaggio per sapere " + "quanti caratteri contenga.", + }, + 'help_section': { + 'description': { + 'en': "Use the /length command in reply to a message to get " + "its length.\n" + "Beware that emojis may count as multiple characters.", + 'it': "Usa il comando /caratteri in risposta a un messaggio " + "per sapere quanti caratteri contenga.\n" + "Attenzione alle emoji, che contano come più caratteri.", + }, + 'label': { + 'en': "Length #️⃣", + 'it': "Caratteri #️⃣" + }, + 'name': "length", + }, + 'instructions': { + 'en': "Use this command in reply to a message to get its length.", + 'it': "Usa questo comando in risposta a un messaggio per sapere " + "quanti caratteri contenga.", + }, + 'language_labelled_commands': { + 'en': "length", + 'it': "caratteri", + }, + 'result': { + 'en': "According to my calculations, this message is " + "{n} characters long.", + 'it': "Questo messaggio contiene {n} " + "caratteri secondo i miei calcoli.", + }, + }, +} \ No newline at end of file diff --git a/davtelepot/useful_tools.py b/davtelepot/useful_tools.py new file mode 100644 index 0000000..82cc8e7 --- /dev/null +++ b/davtelepot/useful_tools.py @@ -0,0 +1,61 @@ +"""General purpose functions for Telegram bots.""" + +# Standard library +from collections import OrderedDict + +# Project modules +from .bot import Bot +from .messages import default_useful_tools_messages +from .utilities import recursive_dictionary_update + + +async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): + if 'reply_to_message' not in update: + text = bot.get_message( + 'useful_tools', 'length_command', 'instructions', + user_record=user_record, update=update + ) + else: + text = bot.get_message( + 'useful_tools', 'length_command', 'result', + user_record=user_record, update=update, + n=len(update['reply_to_message']['text']) + ) + update = update['reply_to_message'] + reply_to_message_id = update['message_id'] + return dict( + chat_id=update['chat']['id'], + text=text, + parse_mode='HTML', + reply_to_message_id=reply_to_message_id + ) + + +def init(telegram_bot: Bot, useful_tools_messages=None): + """Define commands for `telegram_bot`. + + You may provide customized `useful_tools_messages` that will overwrite + `default_useful_tools_messages`. Missing entries will be kept default. + """ + if useful_tools_messages is None: + useful_tools_messages = dict() + useful_tools_messages = recursive_dictionary_update( + default_useful_tools_messages, + useful_tools_messages + ) + telegram_bot.messages['useful_tools'] = useful_tools_messages + + @telegram_bot.command( + command='/length', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{ + key: val + for key, val in useful_tools_messages['length_command'].items() + if key in ('description', 'help_section', 'language_labelled_commands') + }, + authorization_level='everybody' + ) + async def length_command(bot, update, user_record): + return await _length_command(bot=bot, update=update, user_record=user_record) diff --git a/davtelepot/utilities.py b/davtelepot/utilities.py index c554589..bd51cda 100644 --- a/davtelepot/utilities.py +++ b/davtelepot/utilities.py @@ -1682,3 +1682,21 @@ async def send_part_of_text_file(bot, chat_id, file_path, caption=None, ) except Exception as e: return e + + +def recursive_dictionary_update(one: dict, other: dict) -> dict: + """Extension of `dict.update()` method. + + For each key of `other`, if key is not in `one` or the values differ, set + `one[key]` to `other[key]`. If the value is a dict, apply this function + recursively. + """ + for key, val in other.items(): + if key not in one: + one[key] = val + elif one[key] != val: + if isinstance(val, dict): + one[key] = recursive_dictionary_update(one[key], val) + else: + one[key] = val + return one From 82d120dc5806d77a9ee42e71f0e9f36f70d4353d Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 20:25:29 +0200 Subject: [PATCH 14/17] /ping command implemented /length command returns characters in text message even, not only in reply --- davtelepot/messages.py | 12 +++++++--- davtelepot/useful_tools.py | 47 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 40a2e3f..80fe1fc 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1010,9 +1010,9 @@ default_unknown_command_message = { default_useful_tools_messages = { 'length_command': { 'description': { - 'en': "Use this command in reply to a message to get its length.", + 'en': "Use this command in reply to a message to get its length", 'it': "Usa questo comando in risposta a un messaggio per sapere " - "quanti caratteri contenga.", + "quanti caratteri contenga", }, 'help_section': { 'description': { @@ -1045,4 +1045,10 @@ default_useful_tools_messages = { "caratteri secondo i miei calcoli.", }, }, -} \ No newline at end of file + 'ping_command': { + 'description': { + 'en': "Check if bot is online", + 'it': "Verifica se il bot è online", + }, + }, +} diff --git a/davtelepot/useful_tools.py b/davtelepot/useful_tools.py index 82cc8e7..0796e45 100644 --- a/davtelepot/useful_tools.py +++ b/davtelepot/useful_tools.py @@ -6,11 +6,31 @@ from collections import OrderedDict # Project modules from .bot import Bot from .messages import default_useful_tools_messages -from .utilities import recursive_dictionary_update +from .utilities import get_cleaned_text, recursive_dictionary_update async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): - if 'reply_to_message' not in update: + message_text = get_cleaned_text( + update=update, + bot=bot, + replace=[ + alias + for alias in bot.messages[ + 'useful_tools' + ][ + 'length_command' + ][ + 'language_labelled_commands' + ].values() + ] + ) + if message_text: + text = bot.get_message( + 'useful_tools', 'length_command', 'result', + user_record=user_record, update=update, + n=len(message_text) + ) + elif 'reply_to_message' not in update: text = bot.get_message( 'useful_tools', 'length_command', 'instructions', user_record=user_record, update=update @@ -31,6 +51,14 @@ async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): ) +async def _ping_command(bot: Bot, update: dict): + """Return `pong` only in private chat.""" + chat_id = bot.get_chat_id(update=update) + if chat_id < 0: + return + return "Pong!" + + def init(telegram_bot: Bot, useful_tools_messages=None): """Define commands for `telegram_bot`. @@ -59,3 +87,18 @@ def init(telegram_bot: Bot, useful_tools_messages=None): ) async def length_command(bot, update, user_record): return await _length_command(bot=bot, update=update, user_record=user_record) + + @telegram_bot.command( + command='/ping', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{ + key: val + for key, val in useful_tools_messages['ping_command'].items() + if key in ('description', 'help_section', 'language_labelled_commands') + }, + authorization_level='everybody' + ) + async def ping_command(bot, update): + return await _ping_command(bot=bot, update=update) From 21222d3d18e72e247c5cf8989363a5470e4ed17f Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 21:58:38 +0200 Subject: [PATCH 15/17] /info command implemented --- davtelepot/messages.py | 21 ++++++++++ davtelepot/useful_tools.py | 86 +++++++++++++++++++++++++++----------- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 80fe1fc..0c8e6b2 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1008,6 +1008,27 @@ default_unknown_command_message = { } default_useful_tools_messages = { + 'info_command': { + 'description': { + 'en': "Use this command in reply to get information about a message", + 'it': "Usa questo comando in risposta per ottenere informazioni " + "su un messaggio", + }, + 'help_section': None, + 'instructions': { + 'en': "Use this command in reply to a message to get information " + "about it.", + 'it': "Usa questo comando in risposta per ottenere informazioni " + "su un messaggio.", + }, + 'result': { + 'en': "Here is the information about the selected " + "message:\n\n" + "{info}", + 'it': "Ecco le informazioni sul messaggio selezionato:\n\n" + "{info}", + }, + }, 'length_command': { 'description': { 'en': "Use this command in reply to a message to get its length", diff --git a/davtelepot/useful_tools.py b/davtelepot/useful_tools.py index 0796e45..95d5b8f 100644 --- a/davtelepot/useful_tools.py +++ b/davtelepot/useful_tools.py @@ -1,14 +1,44 @@ """General purpose functions for Telegram bots.""" # Standard library +import json + from collections import OrderedDict # Project modules +from .api import TelegramError from .bot import Bot from .messages import default_useful_tools_messages from .utilities import get_cleaned_text, recursive_dictionary_update +async def _message_info_command(bot: Bot, update: dict, language: str): + """Provide information about selected update. + + Selected update: the message `update` is sent in reply to. If `update` is + not a reply to anything, it gets selected. + The update containing the command, if sent in reply, is deleted. + """ + if 'reply_to_message' in update: + selected_update = update['reply_to_message'] + else: + selected_update = update + await bot.send_message( + text=bot.get_message( + 'useful_tools', 'info_command', 'result', + language=language, + info=json.dumps(selected_update, indent=2) + ), + update=update, + reply_to_message_id=selected_update['message_id'], + ) + if selected_update != update: + try: + await bot.delete_message(update=update) + except TelegramError: + pass + + async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): message_text = get_cleaned_text( update=update, @@ -73,32 +103,40 @@ def init(telegram_bot: Bot, useful_tools_messages=None): ) telegram_bot.messages['useful_tools'] = useful_tools_messages - @telegram_bot.command( - command='/length', - aliases=None, - reply_keyboard_button=None, - show_in_keyboard=False, - **{ - key: val - for key, val in useful_tools_messages['length_command'].items() - if key in ('description', 'help_section', 'language_labelled_commands') - }, - authorization_level='everybody' - ) + @telegram_bot.command(command='/info', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{key: val for key, val + in useful_tools_messages['info_command'].items() + if key in ('description', 'help_section', + 'language_labelled_commands')}, + authorization_level='moderator') + async def message_info_command(bot, update, language): + return await _message_info_command(bot=bot, + update=update, + language=language) + + @telegram_bot.command(command='/length', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{key: val for key, val + in useful_tools_messages['length_command'].items() + if key in ('description', 'help_section', + 'language_labelled_commands')}, + authorization_level='everybody') async def length_command(bot, update, user_record): return await _length_command(bot=bot, update=update, user_record=user_record) - @telegram_bot.command( - command='/ping', - aliases=None, - reply_keyboard_button=None, - show_in_keyboard=False, - **{ - key: val - for key, val in useful_tools_messages['ping_command'].items() - if key in ('description', 'help_section', 'language_labelled_commands') - }, - authorization_level='everybody' - ) + @telegram_bot.command(command='/ping', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{key: val for key, val + in useful_tools_messages['ping_command'].items() + if key in ('description', 'help_section', + 'language_labelled_commands')}, + authorization_level='everybody') async def ping_command(bot, update): return await _ping_command(bot=bot, update=update) From 9ab3fb3616b78271d1d9f0e6740c985fb20632fb Mon Sep 17 00:00:00 2001 From: Davte Date: Thu, 14 May 2020 22:18:21 +0200 Subject: [PATCH 16/17] /when command implemented --- davtelepot/__init__.py | 2 +- davtelepot/messages.py | 28 +++++++++++++++++ davtelepot/useful_tools.py | 62 +++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index e8059b4..e6c8390 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.13" +__version__ = "2.5.14" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 0c8e6b2..19d469f 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1072,4 +1072,32 @@ default_useful_tools_messages = { 'it': "Verifica se il bot è online", }, }, + 'when_command': { + 'description': { + 'en': "Use this command in reply to get information about a message", + 'it': "Usa questo comando in risposta per ottenere informazioni " + "su un messaggio", + }, + 'help_section': None, + 'forwarded_message': { + 'en': "— Original message —", + 'it': "— Messaggio originale —", + }, + 'instructions': { + 'en': "Use this command in reply to a message to get its original " + "sending time.", + 'it': "Usa questo comando in risposta per ottenere l'ora di invio " + "di un messaggio.", + }, + 'language_labelled_commands': { + 'en': "when", + 'it': "quando", + }, + 'who_when': { + 'en': "👤 {who}\n" + "🗓 {when:%Y-%m-%d ore %H:%M:%S}", + 'it': "👤 {who}\n" + "🗓 {when:%Y-%m-%d ore %H:%M:%S}", + }, + } } diff --git a/davtelepot/useful_tools.py b/davtelepot/useful_tools.py index 95d5b8f..d5c4d38 100644 --- a/davtelepot/useful_tools.py +++ b/davtelepot/useful_tools.py @@ -1,6 +1,7 @@ """General purpose functions for Telegram bots.""" # Standard library +import datetime import json from collections import OrderedDict @@ -9,7 +10,7 @@ from collections import OrderedDict from .api import TelegramError from .bot import Bot from .messages import default_useful_tools_messages -from .utilities import get_cleaned_text, recursive_dictionary_update +from .utilities import get_cleaned_text, recursive_dictionary_update, get_user async def _message_info_command(bot: Bot, update: dict, language: str): @@ -89,6 +90,53 @@ async def _ping_command(bot: Bot, update: dict): return "Pong!" +async def _when_command(bot: Bot, update: dict, language: str): + reply_markup = None + text = '' + if 'reply_to_message' not in update: + return bot.get_message( + 'useful_tools', 'when_command', 'instructions', + language=language + ) + update = update['reply_to_message'] + date = ( + datetime.datetime.fromtimestamp(update['date']) + if 'date' in update + else None + ) + text += bot.get_message( + 'useful_tools', 'when_command', 'who_when', + language=language, + who=get_user(update['from']), + when=date + ) + if 'forward_date' in update: + original_datetime= ( + datetime.datetime.fromtimestamp(update['forward_date']) + if 'forward_from' in update + else None + ) + text += "\n\n" + bot.get_message( + 'useful_tools', 'when_command', 'forwarded_message', + language=language, + who=get_user(update['forward_from']), + when=original_datetime + ) + "\n" + text += bot.get_message( + 'useful_tools', 'when_command', 'who_when', + language=language, + who=get_user(update['forward_from']), + when=original_datetime + ) + await bot.send_message( + text=text, + reply_markup=reply_markup, + reply_to_message_id=update['message_id'], + disable_notification=True, + chat_id=update['chat']['id'] + ) + + def init(telegram_bot: Bot, useful_tools_messages=None): """Define commands for `telegram_bot`. @@ -140,3 +188,15 @@ def init(telegram_bot: Bot, useful_tools_messages=None): authorization_level='everybody') async def ping_command(bot, update): return await _ping_command(bot=bot, update=update) + + @telegram_bot.command(command='/when', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{key: val for key, val + in useful_tools_messages['when_command'].items() + if key in ('description', 'help_section', + 'language_labelled_commands')}, + authorization_level='everybody') + async def when_command(bot, update, language): + return await _when_command(bot=bot, update=update, language=language) From 11d07b45d671d82e06f4dba538b7465536344abf Mon Sep 17 00:00:00 2001 From: Davte Date: Mon, 18 May 2020 15:08:15 +0200 Subject: [PATCH 17/17] /calc command implemented --- davtelepot/__init__.py | 2 +- davtelepot/bot.py | 3 +- davtelepot/messages.py | 69 +++++ davtelepot/useful_tools.py | 524 +++++++++++++++++++++++++++++++++++-- 4 files changed, 571 insertions(+), 27 deletions(-) diff --git a/davtelepot/__init__.py b/davtelepot/__init__.py index e6c8390..2a58146 100644 --- a/davtelepot/__init__.py +++ b/davtelepot/__init__.py @@ -11,7 +11,7 @@ __author__ = "Davide Testa" __email__ = "davide@davte.it" __credits__ = ["Marco Origlia", "Nick Lee @Nickoala"] __license__ = "GNU General Public License v3.0" -__version__ = "2.5.14" +__version__ = "2.5.15" __maintainer__ = "Davide Testa" __contact__ = "t.me/davte" diff --git a/davtelepot/bot.py b/davtelepot/bot.py index f7bd50b..62f268c 100644 --- a/davtelepot/bot.py +++ b/davtelepot/bot.py @@ -2232,7 +2232,8 @@ class Bot(TelegramBot, ObjectWithDatabase, MultiLanguageObject): handler=decorated_command_handler, description=description, authorization_level=authorization_level, - language_labelled_commands=language_labelled_commands + language_labelled_commands=language_labelled_commands, + aliases=aliases ) if type(description) is dict: self.messages['commands'][command] = dict( diff --git a/davtelepot/messages.py b/davtelepot/messages.py index 19d469f..b7b16cf 100644 --- a/davtelepot/messages.py +++ b/davtelepot/messages.py @@ -1008,6 +1008,75 @@ default_unknown_command_message = { } default_useful_tools_messages = { + 'calculate_command': { + 'description': { + 'en': "Do calculations", + 'it': "Calcola", + }, + 'help_section': None, + 'instructions': { + 'en': "🔢 Calculator 🧮\n\n" + "Enter an algebraic expression after /calc to get its " + "result, or use the command in reply to a message containing " + "an expression, or use the keyboard below.\n\n" + "- ℹ️: show information about special keys\n", + 'it': "🔢 Calcolatrice 🧮\n\n" + "Inserisci un'espressione algebrica dopo /calcola per " + "ottenerne il risultato, oppure usa il comando in risposta, " + "o ancora usa la tastiera qui sotto.\n\n" + "- ℹ️: mostra informazioni sui tasti speciali\n", + }, + 'invalid_expression': { + 'en': "Invalid expression: {error}", + 'it': "Espressione non valida: {error}", + }, + 'language_labelled_commands': { + 'en': "calculate", + 'it': "calcola", + }, + 'message_input': { + 'en': "🔢 Calculator 🧮\n\n" + "Enter an expression", + 'it': "🔢 Calcolatrice 🧮\n\n" + "Mandami l'espressione", + }, + 'special_keys': { + 'en': "Special keys\n" + "- **: exponentiation\n" + "- //: floor division\n" + "- mod: modulus (remainder of division)\n" + "- MR: result of last expression\n" + "- ℹ️: show this help message\n" + "- 💬: write your expression in a message\n" + "- ⬅️: delete last character\n" + "- : start a new line (and a new expression)\n", + 'it': "Tasti speciali\n" + "- **: elevamento a potenza\n" + "- //: quoziente della divisione\n" + "- mod: resto della divisione\n" + "- MR: risultato dell'espressione precedente\n" + "- ℹ️: mostra questo messaggio\n" + "- 💬: invia un messaggio con l'espressione\n" + "- ⬅️: cancella ultimo carattere\n" + "- : vai a capo (inizia una nuova espressione)\n", + }, + 'use_buttons': { + 'en': "Use buttons to enter an algebraic expression.\n\n" + "The input will be displayed after you stop typing for a " + "while.", + 'it': "Usa i pulsanti per comporre un'espressione algebrica.\n\n" + "L'espressione verrà mostrata quando smetterai di " + "digitare per un po'.", + }, + 'result': { + 'en': "🔢 Calculator 🧮\n\n" + "Expressions evaluation:\n\n" + "{expressions}", + 'it': "🔢 Calcolatrice 🧮\n\n" + "Risultato delle espresisoni:\n\n" + "{expressions}", + }, + }, 'info_command': { 'description': { 'en': "Use this command in reply to get information about a message", diff --git a/davtelepot/useful_tools.py b/davtelepot/useful_tools.py index d5c4d38..29fdc79 100644 --- a/davtelepot/useful_tools.py +++ b/davtelepot/useful_tools.py @@ -1,43 +1,443 @@ """General purpose functions for Telegram bots.""" # Standard library +import ast +import asyncio import datetime import json +import logging +import operator from collections import OrderedDict +from typing import List, Union # Project modules from .api import TelegramError from .bot import Bot from .messages import default_useful_tools_messages -from .utilities import get_cleaned_text, recursive_dictionary_update, get_user +from .utilities import (get_cleaned_text, get_user, make_button, + make_inline_keyboard, recursive_dictionary_update, ) -async def _message_info_command(bot: Bot, update: dict, language: str): - """Provide information about selected update. - - Selected update: the message `update` is sent in reply to. If `update` is - not a reply to anything, it gets selected. - The update containing the command, if sent in reply, is deleted. - """ - if 'reply_to_message' in update: - selected_update = update['reply_to_message'] - else: - selected_update = update - await bot.send_message( - text=bot.get_message( - 'useful_tools', 'info_command', 'result', - language=language, - info=json.dumps(selected_update, indent=2) - ), - update=update, - reply_to_message_id=selected_update['message_id'], +def get_calc_buttons() -> OrderedDict: + buttons = OrderedDict() + buttons['**'] = dict( + value='**', + symbol='**', + order='A1', ) - if selected_update != update: + buttons['//'] = dict( + value=' // ', + symbol='//', + order='A2', + ) + buttons['%'] = dict( + value=' % ', + symbol='mod', + order='A3', + ) + buttons['_'] = dict( + value='_', + symbol='MR', + order='B5', + ) + buttons[0] = dict( + value='0', + symbol='0', + order='E1', + ) + buttons[1] = dict( + value='1', + symbol='1', + order='D1', + ) + buttons[2] = dict( + value='2', + symbol='2', + order='D2', + ) + buttons[3] = dict( + value='3', + symbol='3', + order='D3', + ) + buttons[4] = dict( + value='4', + symbol='4', + order='C1', + ) + buttons[5] = dict( + value='5', + symbol='5', + order='C2', + ) + buttons[6] = dict( + value='6', + symbol='6', + order='C3', + ) + buttons[7] = dict( + value='7', + symbol='7', + order='B1', + ) + buttons[8] = dict( + value='8', + symbol='8', + order='B2', + ) + buttons[9] = dict( + value='9', + symbol='9', + order='B3', + ) + buttons['+'] = dict( + value=' + ', + symbol='+', + order='B4', + ) + buttons['-'] = dict( + value=' - ', + symbol='-', + order='C4', + ) + buttons['*'] = dict( + value=' * ', + symbol='*', + order='D4', + ) + buttons['/'] = dict( + value=' / ', + symbol='/', + order='E4', + ) + buttons['.'] = dict( + value='.', + symbol='.', + order='E2', + ) + buttons['thousands'] = dict( + value='000', + symbol='000', + order='E3', + ) + buttons['end'] = dict( + value='\n', + symbol='✅', + order='F1', + ) + buttons['del'] = dict( + value='del', + symbol='⬅️', + order='E5', + ) + buttons['('] = dict( + value='(', + symbol='(️', + order='A4', + ) + buttons[')'] = dict( + value=')', + symbol=')️', + order='A5', + ) + buttons['info'] = dict( + value='info', + symbol='ℹ️️', + order='C5', + ) + + buttons['parser'] = dict( + value='parser', + symbol='💬️', + order='D5', + ) + + return buttons + + +def get_operators() -> dict: + def multiply(a, b): + """Call operator.mul only if a and b are small enough.""" + if abs(max(a, b)) > 10 ** 21: + raise Exception("Numbers were too large!") + return operator.mul(a, b) + + def power(a, b): + """Call operator.pow only if a and b are small enough.""" + if abs(a) > 1000 or abs(b) > 100: + raise Exception("Numbers were too large!") + return operator.pow(a, b) + + return { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: multiply, + ast.Div: operator.truediv, + ast.Pow: power, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod + } + + +calc_buttons = get_calc_buttons() +operators = get_operators() + + +def get_calculator_keyboard(additional_data: list = None): + if additional_data is None: + additional_data = [] + return make_inline_keyboard( + [ + make_button( + text=button['symbol'], + prefix='calc:///', + delimiter='|', + data=[*additional_data, code] + ) + for code, button in sorted(calc_buttons.items(), + key=lambda b: b[1]['order']) + ], + 5 + ) + + +async def _calculate_button(bot: Bot, + update: dict, + user_record: OrderedDict, + language: str, + data: List[Union[int, str]]): + text, reply_markup = '', None + if len(data) < 2: + record_id = bot.db['calculations'].insert( + dict( + user_id=user_record['id'], + created=datetime.datetime.now() + ) + ) + data = [record_id, *data] + text = bot.get_message( + 'useful_tools', 'calculate_command', 'use_buttons', + language=language + ) + else: + record_id = data[0] + reply_markup = get_calculator_keyboard( + additional_data=([record_id] if record_id else None) + ) + if record_id not in bot.shared_data['calc']: + bot.shared_data['calc'][record_id] = [] + asyncio.ensure_future( + calculate_session(bot=bot, + record_id=record_id, + language=language) + ) + update['data'] = data + if len(data) and data[-1] in ('info', 'parser'): + command = data[-1] + if command == 'parser': + reply_markup = None + bot.set_individual_text_message_handler( + handler=_calculate_command, + user_id=user_record['telegram_id'] + ) + elif command == 'info': + reply_markup = make_inline_keyboard( + [ + make_button( + text='Ok', + prefix='calc:///', + delimiter='|', + data=[record_id, 'back'] + ) + ] + ) + text = bot.get_message( + 'useful_tools', 'calculate_command', ( + 'special_keys' if command == 'info' + else 'message_input' if command == 'parser' + else '' + ), + language=language + ) + else: + bot.shared_data['calc'][record_id].append(update) + # Edit the update with the button if a new text is specified + if not text: + return + return dict( + text='', + edit=dict( + text=text, + reply_markup=reply_markup + ) + ) + + +def eval_(node): + """Evaluate ast nodes.""" + if isinstance(node, ast.Num): # + return node.n + elif isinstance(node, ast.BinOp): # + return operators[type(node.op)](eval_(node.left), eval_(node.right)) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + # noinspection PyArgumentList + return operators[type(node.op)](eval_(node.operand)) + else: + raise Exception("Invalid operator") + + +def evaluate_expression(expr): + """Evaluate expressions in a safe way.""" + return eval_( + ast.parse( + expr, + mode='eval' + ).body + ) + + +def evaluate_expressions(bot: Bot, + expressions: str, + language: str = None) -> str: + """Evaluate a string containing lines of expressions. + + `expressions` must be a string containing one expression per line. + """ + line_result, result = 0, [] + for line in expressions.split('\n'): + if not line: + continue try: - await bot.delete_message(update=update) - except TelegramError: + line_result = evaluate_expression( + line.replace('_', str(line_result)) + ) + except Exception as e: + line_result = bot.get_message( + 'useful_tools', 'calculate_command', 'invalid_expression', + language=language, + error=e + ) + result.append( + f"{line}\n= {line_result}" + ) + return '\n\n'.join(result) + + +async def calculate_session(bot: Bot, + record_id: int, + language: str, + buffer_seconds: Union[int, float] = 1): + # Wait until input ends + queue = bot.shared_data['calc'][record_id] + queue_len = None + while queue_len != len(queue): + queue_len = len(queue) + await asyncio.sleep(buffer_seconds) + last_entry = max(queue, key=lambda u: u['id'], default=None) + # Delete record-associated queue + queue = queue.copy() + del bot.shared_data['calc'][record_id] + + record = bot.db['calculations'].find_one( + id=record_id + ) + if record is None: + logging.error("Invalid record identifier!") + return + expression = record['expression'] or '' + reply_markup = get_calculator_keyboard(additional_data=[record['id']]) + + # It would be nice to do: + # for update in sorted(queue, key=lambda u: u['id']) + # Alas, 'id's are not progressive... Telegram's fault! + for i, update in enumerate(queue): + if i % 5 == 0: + await asyncio.sleep(.1) + data = update['data'] + if len(data) != 2: + logging.error(f"Something went wrong: invalid data received.\n{data}") + return + input_value = data[1] + if input_value == 'del': + expression = expression[:-1] + elif input_value == 'back': pass + elif input_value in calc_buttons: + expression += calc_buttons[input_value]['value'] + else: + logging.error(f"Invalid input from calculator button: {input_value}") + if record: + bot.db['calculations'].update( + dict( + id=record['id'], + modified=datetime.datetime.now(), + expression=expression + ), + ['id'] + ) + if expression: + text = bot.get_message( + 'useful_tools', 'calculate_command', 'result', + language=language, + expressions=evaluate_expressions(bot=bot, + expressions=expression, + language=language) + ) + else: + text = bot.get_message( + 'useful_tools', 'calculate_command', 'instructions', + language=language + ) + if last_entry is None: + return + await bot.edit_message_text( + text=text, + update=last_entry, + reply_markup=reply_markup + ) + + +async def _calculate_command(bot: Bot, + update: dict, + user_record: OrderedDict, + language: str, + command_name: str = 'calc'): + if 'reply_to_message' in update: + update = update['reply_to_message'] + command_aliases = [command_name] + if command_name in bot.commands: + command_aliases += list( + bot.commands[command_name]['language_labelled_commands'].values() + ) + bot.commands[command_name]['aliases'] + text = get_cleaned_text(bot=bot, + update=update, + replace=command_aliases) + if not text: + text = bot.get_message( + 'useful_tools', 'calculate_command', 'instructions', + language=language + ) + reply_markup = get_calculator_keyboard() + else: + record_id = bot.db['calculations'].insert( + dict( + user_id=user_record['id'], + created=datetime.datetime.now(), + expression=text + ) + ) + text = bot.get_message( + 'useful_tools', 'calculate_command', 'result', + language=language, + expressions=evaluate_expressions(bot=bot, + expressions=text, + language=language) + ) + reply_markup = get_calculator_keyboard(additional_data=[record_id]) + await bot.send_message(text=text, + update=update, + reply_markup=reply_markup) async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): @@ -82,6 +482,33 @@ async def _length_command(bot: Bot, update: dict, user_record: OrderedDict): ) +async def _message_info_command(bot: Bot, update: dict, language: str): + """Provide information about selected update. + + Selected update: the message `update` is sent in reply to. If `update` is + not a reply to anything, it gets selected. + The update containing the command, if sent in reply, is deleted. + """ + if 'reply_to_message' in update: + selected_update = update['reply_to_message'] + else: + selected_update = update + await bot.send_message( + text=bot.get_message( + 'useful_tools', 'info_command', 'result', + language=language, + info=json.dumps(selected_update, indent=2) + ), + update=update, + reply_to_message_id=selected_update['message_id'], + ) + if selected_update != update: + try: + await bot.delete_message(update=update) + except TelegramError: + pass + + async def _ping_command(bot: Bot, update: dict): """Return `pong` only in private chat.""" chat_id = bot.get_chat_id(update=update) @@ -111,7 +538,7 @@ async def _when_command(bot: Bot, update: dict, language: str): when=date ) if 'forward_date' in update: - original_datetime= ( + original_datetime = ( datetime.datetime.fromtimestamp(update['forward_date']) if 'forward_from' in update else None @@ -150,6 +577,53 @@ def init(telegram_bot: Bot, useful_tools_messages=None): useful_tools_messages ) telegram_bot.messages['useful_tools'] = useful_tools_messages + telegram_bot.shared_data['calc'] = dict() + + if 'calculations' not in telegram_bot.db.tables: + types = telegram_bot.db.types + table = telegram_bot.db.create_table( + table_name='calculations' + ) + table.create_column( + 'user_id', + types.integer + ) + table.create_column( + 'created', + types.datetime + ) + table.create_column( + 'modified', + types.datetime + ) + table.create_column( + 'expression', + types.string + ) + + @telegram_bot.command(command='/calc', + aliases=None, + reply_keyboard_button=None, + show_in_keyboard=False, + **{key: val for key, val + in useful_tools_messages['calculate_command'].items() + if key in ('description', 'help_section', + 'language_labelled_commands')}, + authorization_level='everybody') + async def calculate_command(bot, update, user_record, language): + return await _calculate_command(bot=bot, + update=update, + user_record=user_record, + language=language, + command_name='calc') + + @telegram_bot.button(prefix='calc:///', + separator='|', + authorization_level='everybody') + async def calculate_button(bot, update, user_record, language, data): + return await _calculate_button(bot=bot, user_record=user_record, + update=update, + language=language, data=data) @telegram_bot.command(command='/info', aliases=None, @@ -159,7 +633,7 @@ def init(telegram_bot: Bot, useful_tools_messages=None): in useful_tools_messages['info_command'].items() if key in ('description', 'help_section', 'language_labelled_commands')}, - authorization_level='moderator') + authorization_level='everybody') async def message_info_command(bot, update, language): return await _message_info_command(bot=bot, update=update,