|
@@ -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.
|
|
|
+def get_calc_buttons() -> OrderedDict:
|
|
|
+ buttons = OrderedDict()
|
|
|
+ buttons['**'] = dict(
|
|
|
+ value='**',
|
|
|
+ symbol='**',
|
|
|
+ order='A1',
|
|
|
+ )
|
|
|
+ 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',
|
|
|
+ )
|
|
|
|
|
|
- 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']
|
|
|
+ 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:
|
|
|
- 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'],
|
|
|
+ record_id = data[0]
|
|
|
+ reply_markup = get_calculator_keyboard(
|
|
|
+ additional_data=([record_id] if record_id else None)
|
|
|
)
|
|
|
- if selected_update != update:
|
|
|
+ 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): # <number>
|
|
|
+ return node.n
|
|
|
+ elif isinstance(node, ast.BinOp): # <left> <operator> <right>
|
|
|
+ return operators[type(node.op)](eval_(node.left), eval_(node.right))
|
|
|
+ elif isinstance(node, ast.UnaryOp): # <operator> <operand> 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"<code>{line}</code>\n<b>= {line_result}</b>"
|
|
|
+ )
|
|
|
+ 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,
|