diff --git a/core.py b/core.py index bf9e6f5..3c1f601 100644 --- a/core.py +++ b/core.py @@ -1,10 +1,12 @@ +import importlib import sys +import threading + import telegram -import worker + import configloader import utils -import threading -import importlib +import worker language = configloader.config["Config"]["language"] strings = importlib.import_module("strings." + language) diff --git a/database.py b/database.py index 4bdbfe4..67225de 100644 --- a/database.py +++ b/database.py @@ -1,13 +1,17 @@ +import datetime +import importlib import typing -from sqlalchemy import create_engine, Column, ForeignKey, UniqueConstraint -from sqlalchemy import Integer, BigInteger, String, Text, LargeBinary, DateTime, Boolean -from sqlalchemy.orm import sessionmaker, relationship, backref + +import requests +import telegram +from sqlalchemy import (BigInteger, Boolean, Column, DateTime, ForeignKey, + Integer, LargeBinary, String, Text, UniqueConstraint, + create_engine) from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import backref, relationship, sessionmaker + import configloader -import telegram -import requests import utils -import importlib language = configloader.config["Config"]["language"] strings = importlib.import_module("strings." + language) @@ -31,9 +35,10 @@ class User(TableDeclarativeBase): first_name = Column(String, nullable=False) last_name = Column(String) username = Column(String) + last_seen = Column(DateTime, default=datetime.datetime.utcnow) # Current wallet credit - credit = Column(Integer, nullable=False) + credit = Column(Integer, nullable=False, server_default=0) # Extra table parameters __tablename__ = "users" @@ -46,8 +51,6 @@ def __init__(self, telegram_chat: telegram.Chat, **kwargs): self.first_name = telegram_chat.first_name self.last_name = telegram_chat.last_name self.username = telegram_chat.username - # The starting wallet value is 0 - self.credit = 0 def __str__(self): """Describe the user in the best way possible given the available data.""" diff --git a/strings/en_US.py b/strings/en_US.py index f153e77..e9d9cc0 100644 --- a/strings/en_US.py +++ b/strings/en_US.py @@ -115,8 +115,13 @@ conversation_open_help_menu = "What kind of help do you need?" # Conversation: confirm promotion to admin -conversation_confirm_admin_promotion = "Are you sure you want to promote this user to 💼 Manager?\n" \ - "It is an irreversible action!" +conversation_confirm_admin_promotion = "Are you sure you want to promote this user to 💼 Manager?\n" + +# Conversation: remove administrator +conversation_admin_dismissal_menu = "🗑 Remove Manager" + +# Conversation: administrator removal confirmation +conversation_confirm_admin_dismissal = "🗑 User was successfully removed from the Managers!" # Conversation: switching to user mode conversation_switch_to_user_mode = " You are switching to 👤 Customer mode.\n" \ @@ -296,6 +301,9 @@ "It might take a while... Please be patient!\n" \ "I won't be able to answer you while I'm downloading." +downloading_image_failed = "Something went wrong with the image upload handling" \ + "Please, try again" + # Edit product: current value edit_current_value = "The current value is:\n" \ "
{value}
\n" \ diff --git a/strings/it_IT.py b/strings/it_IT.py index 5499ede..c905953 100644 --- a/strings/it_IT.py +++ b/strings/it_IT.py @@ -115,8 +115,13 @@ conversation_open_help_menu = "Che tipo di assistenza desideri ricevere?" # Conversation: confirm promotion to admin -conversation_confirm_admin_promotion = "Sei sicuro di voler promuovere questo utente a 💼 Gestore?\n" \ - "E' un'azione irreversibile!" +conversation_confirm_admin_promotion = "Sei sicuro di voler promuovere questo utente a 💼 Gestore?\n" + +# Conversation: remove administrator +conversation_admin_dismissal_menu = "🗑 Elimina Gestore" + +# Conversation: administrator removal confirmation +conversation_confirm_admin_dismissal = "🗑 Gestore eliminato con successo!" # Conversation: switching to user mode conversation_switch_to_user_mode = "Stai passando alla modalità 👤 Cliente.\n" \ @@ -297,6 +302,10 @@ "Potrei metterci un po'... Abbi pazienza!\n" \ "Non sarò in grado di risponderti durante il download." +# There was an error reading the image the user sent +downloading_image_failed = "Si è verificato un errore durante il download dell'immagine...\n" \ + "Riprova, per favore!" + # Edit product: current value edit_current_value = "Il valore attuale è:\n" \ "
{value}
\n" \ diff --git a/strings/ru_RU.py b/strings/ru_RU.py index e093572..440f3ca 100644 --- a/strings/ru_RU.py +++ b/strings/ru_RU.py @@ -111,8 +111,13 @@ conversation_open_help_menu = "Чем могу Вам помочь?" # Conversation: confirm promotion to admin -conversation_confirm_admin_promotion = "Вы уверены, что хотите повысить этого пользователя до 💼 Менеджера?\n" \ - "Это действие невозможно отменить!" +conversation_confirm_admin_promotion = "Вы уверены, что хотите повысить этого пользователя до 💼 Менеджера?\n" + +# Conversation: remove administrator +conversation_admin_dismissal_menu = "🗑 Удалить менеджера" + +# Conversation: administrator removal confirmation +conversation_confirm_admin_dismissal = "🗑 Менеджер удален" # Conversation: switching to user mode conversation_switch_to_user_mode = " Вы перешли в режим 👤 Покупателя.\n" \ @@ -265,7 +270,7 @@ # Edit credit: notes? ask_transaction_notes = " Добавьте сообщение к транзакции.\n" \ " Сообщение будет доступно 👤 Покупателю после пополнения/списания средств" \ - " и 💼 Администратору в логах транзакций." + " и 💼 Менеджеру в логах транзакций." # Edit credit: amount? ask_credit = "Вы хотите изменить баланс Покупателя?\n" \ @@ -294,6 +299,9 @@ "Это может занять некоторое время...!\n" \ "Я не смогу отвечать, пока идет загрузка." +downloading_image_failed = "Ошибка при загрузке изображения" \ + "Попробуйте еще раз..." + # Edit product: current value edit_current_value = "Текущее значение:\n" \ "
{value}
\n" \ diff --git a/strings/uk_UA.py b/strings/uk_UA.py index a9afe08..768d87b 100644 --- a/strings/uk_UA.py +++ b/strings/uk_UA.py @@ -111,8 +111,13 @@ conversation_open_help_menu = "Як можемо Вам допомогти?" # Conversation: confirm promotion to admin -conversation_confirm_admin_promotion = "Ви впевнені, що хочете підвищити цього користувача до 💼 Менеджера?\n" \ - "Цю дію неможливо відмінити!" +conversation_confirm_admin_promotion = "Ви впевнені, що хочете підвищити цього користувача до 💼 Менеджера?\n" + +# Conversation: remove administrator +conversation_admin_dismissal_menu = "🗑 Видалити менеджера" + +# Conversation: administrator removal confirmation +conversation_confirm_admin_dismissal = "🗑 Администратора видалено" # Conversation: switching to user mode conversation_switch_to_user_mode = " Ви перейшли в режим 👤 Замовника.\n" \ @@ -265,7 +270,7 @@ # Edit credit: notes? ask_transaction_notes = " Додайте повідомлення до транзакції.\n" \ "👤 Повідомлення буде доступне замовнику після поповнення/списання" \ - " і 💼 Адміністратору в логах транзакцій." + " і 💼 Менеджеру в логах транзакцій." # Edit credit: amount? ask_credit = "Як ви хочете змінити баланс замовника?\n" \ @@ -294,6 +299,9 @@ "Може зайняти деякий час... Майте терпіння!\n" \ "Я не зможу відповідати, поки йде завантаження." +downloading_image_failed = "Помилка при завантаженні зображення" \ + "Спробуйте ще раз..." + # Edit product: current value edit_current_value = "Поточне значення:\n" \ "
{value}
\n" \ diff --git a/utils.py b/utils.py index 296cbeb..ae8a32f 100644 --- a/utils.py +++ b/utils.py @@ -1,11 +1,13 @@ +import importlib +import os +import sys +import time +import typing + import telegram import telegram.error -import time + from configloader import config -import typing -import os -import sys -import importlib language = config["Config"]["language"] try: diff --git a/worker.py b/worker.py index e9126c4..c70c896 100644 --- a/worker.py +++ b/worker.py @@ -1,19 +1,23 @@ +import importlib +import os +import queue as queuem +import re +import sys import threading -from typing import * +import traceback import uuid -import datetime +from datetime import datetime +from html import escape +from operator import attrgetter +from typing import Dict, List, Optional, Union + +import requests import telegram +from sqlalchemy import or_ + import configloader -import sys -import queue as queuem import database as db -import re import utils -import os -import traceback -from html import escape -import requests -import importlib language = configloader.config["Config"]["language"] strings = importlib.import_module("strings." + language) @@ -91,6 +95,10 @@ def run(self): self.session.add(self.admin) # Commit the transaction self.session.commit() + else: + # Update users last seen date + self.user.last_seen = datetime.now() + self.session.commit() # Capture exceptions that occour during the conversation try: # If the user is not an admin, send him to the user menu @@ -200,7 +208,7 @@ def __wait_for_regex(self, regex: str, cancellable: bool = False) -> Union[str, if match is None: continue # Return the first capture group - return match.group(1) + return match.group(1) or match.group(2) def __wait_for_precheckoutquery(self, cancellable: bool = False) -> Union[telegram.PreCheckoutQuery, CancelSignal]: @@ -258,8 +266,21 @@ def __wait_for_photo(self, cancellable: bool = False) -> Union[List[telegram.Pho # Ensure the message contains a photo if update.message.photo is None: continue - # Return the photo array - return update.message.photo + # If photo message has been sent + if len(update.message.photo) > 0: + # Find object with maximum width + photo = max(update.message.photo, key=attrgetter('width')) + break + else: + self.bot.send_message(self.chat.id, strings.downloading_image_failed) + continue + + # If a photo has been sent... + # Notify the user that the bot is downloading the image and might be inactive for a while + self.bot.send_message(self.chat.id, strings.downloading_image) + self.bot.send_chat_action(self.chat.id, action="upload_photo") + # Return file object associated with the photo + return self.bot.get_file(photo.file_id) def __wait_for_inlinekeyboard_callback(self, cancellable: bool = False) \ -> Union[telegram.CallbackQuery, CancelSignal]: @@ -286,8 +307,11 @@ def __wait_for_inlinekeyboard_callback(self, cancellable: bool = False) \ def __user_select(self) -> Union[db.User, CancelSignal]: """Select an user from the ones in the database.""" - # Find all the users in the database - users = self.session.query(db.User).order_by(db.User.user_id).all() + # Find first five users in the database + users = self.session.query(db.User) \ + .order_by(db.User.last_seen.desc()) \ + .limit(5) \ + .all() # Create a list containing all the keyboard button strings keyboard_buttons = [[strings.menu_cancel]] # Add to the list all the users @@ -300,12 +324,14 @@ def __user_select(self) -> Union[db.User, CancelSignal]: # Send the keyboard self.bot.send_message(self.chat.id, strings.conversation_admin_select_user, reply_markup=keyboard) # Wait for a reply - reply = self.__wait_for_regex("user_([0-9]+)", cancellable=True) + reply = self.__wait_for_regex("user_([0-9]+)|\@(.*)", cancellable=True) # Propagate CancelSignals if isinstance(reply, CancelSignal): return reply # Find the user in the database - user = self.session.query(db.User).filter_by(user_id=int(reply)).one_or_none() + user = self.session.query(db.User) \ + .filter(or_(db.User.user_id==reply, db.User.username==reply)) \ + .one_or_none() # Ensure the user exists if not user: self.bot.send_message(self.chat.id, strings.error_user_does_not_exist) @@ -492,7 +518,7 @@ def __order_menu(self): notes = self.__wait_for_regex(r"(.*)", cancellable=True) # Create a new Order order = db.Order(user=self.user, - creation_date=datetime.datetime.now(), + creation_date=datetime.now(), notes=notes if not isinstance(notes, CancelSignal) else "") # Add the record to the session and get an ID self.session.add(order) @@ -805,7 +831,7 @@ def __products_menu(self): self.bot.send_message(self.chat.id, strings.conversation_admin_select_product, reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) # Wait for a reply from the user - selection = self.__wait_for_specific_message(product_names, cancellable = True) + selection = self.__wait_for_specific_message(product_names, cancellable=True) # If the user has selected the Cancel option... if isinstance(selection, CancelSignal): # Exit the menu @@ -875,10 +901,7 @@ def __edit_product_menu(self, product: Optional[db.Product] = None): price = None else: price = utils.Price(price) - # Ask for the product image - self.bot.send_message(self.chat.id, strings.ask_product_image, reply_markup=cancel) - # Wait for an answer - photo_list = self.__wait_for_photo(cancellable=True) + # If a new product is being added... if not product: # Create the db record for the product @@ -895,20 +918,13 @@ def __edit_product_menu(self, product: Optional[db.Product] = None): product.name = name if not isinstance(name, CancelSignal) else product.name product.description = description if not isinstance(description, CancelSignal) else product.description product.price = int(price) if not isinstance(price, CancelSignal) else product.price - # If a photo has been sent... - if isinstance(photo_list, list): - # Find the largest photo id - largest_photo = photo_list[0] - for photo in photo_list[1:]: - if photo.width > largest_photo.width: - largest_photo = photo - # Get the file object associated with the photo - photo_file = self.bot.get_file(largest_photo.file_id) - # Notify the user that the bot is downloading the image and might be inactive for a while - self.bot.send_message(self.chat.id, strings.downloading_image) - self.bot.send_chat_action(self.chat.id, action="upload_photo") - # Set the image for that product - product.set_image(photo_file) + # Ask for the product image + self.bot.send_message(self.chat.id, strings.ask_product_image, reply_markup=cancel) + # Wait for an answer + photo = self.__wait_for_photo(cancellable=True) + # Set the image for that product + if not isinstance(photo, CancelSignal): + product.set_image(photo) # Commit the session changes self.session.commit() # Notify the user @@ -927,7 +943,7 @@ def __delete_product_menu(self): self.bot.send_message(self.chat.id, strings.conversation_admin_select_product_to_delete, reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) # Wait for a reply from the user - selection = self.__wait_for_specific_message(product_names, cancellable = True) + selection = self.__wait_for_specific_message(product_names, cancellable=True) if isinstance(selection, CancelSignal): # Exit the menu return @@ -988,7 +1004,7 @@ def __orders_menu(self): # If the user pressed the complete order button, complete the order if update.data == "order_complete": # Mark the order as complete - order.delivery_date = datetime.datetime.now() + order.delivery_date = datetime.now() # Commit the transaction self.session.commit() # Update order message @@ -1011,7 +1027,7 @@ def __orders_menu(self): self.bot.delete_message(self.chat.id, reason_msg.message_id) continue # Mark the order as refunded - order.refund_date = datetime.datetime.now() + order.refund_date = datetime.now() # Save the refund reason order.refund_reason = reply # Refund the credit, reverting the old transaction @@ -1199,66 +1215,83 @@ def __transactions_file(self): # Delete the created file os.remove(f"transactions_{self.chat.id}.csv") - def __add_admin(self): + def __select_or_create_admin(self): """Add an administrator to the bot.""" # Let the admin select an administrator to promote user = self.__user_select() # Allow the cancellation of the operation if isinstance(user, CancelSignal): - return + return None, None # Check if the user is already an administrator admin = self.session.query(db.Admin).filter_by(user_id=user.user_id).one_or_none() + # Return if user is not an admin if admin is None: # Create the keyboard to be sent keyboard = telegram.ReplyKeyboardMarkup([[strings.emoji_yes, strings.emoji_no]], one_time_keyboard=True) - # Ask for confirmation + # Ask for confirmation on the admin deletion self.bot.send_message(self.chat.id, strings.conversation_confirm_admin_promotion, reply_markup=keyboard) # Wait for an answer selection = self.__wait_for_specific_message([strings.emoji_yes, strings.emoji_no]) # Proceed only if the answer is yes if selection == strings.emoji_no: - return + return None, None # Create a new admin admin = db.Admin(user=user, - edit_products=False, - receive_orders=False, - create_transactions=False, - is_owner=False, - display_on_help=False) + edit_products=False, + receive_orders=False, + create_transactions=False, + is_owner=False, + display_on_help=False) self.session.add(admin) + self.session.commit() + + return user + + def __add_admin(self): + """Add an administrator to the bot.""" + user = self.__select_or_create_admin() + + if (user is None or user.admin is None): + return + # Send the empty admin message and record the id - message = self.bot.send_message(self.chat.id, strings.admin_properties.format(name=str(admin.user))) + message = self.bot.send_message(self.chat.id, strings.admin_properties.format(name=str(user))) # Start accepting edits while True: # Create the inline keyboard with the admin status inline_keyboard = telegram.InlineKeyboardMarkup([ [telegram.InlineKeyboardButton(f"{utils.boolmoji(admin.edit_products)} {strings.prop_edit_products}", - callback_data="toggle_edit_products")], + callback_data="toggle_edit_products")], [telegram.InlineKeyboardButton(f"{utils.boolmoji(admin.receive_orders)} {strings.prop_receive_orders}", - callback_data="toggle_receive_orders")], + callback_data="toggle_receive_orders")], [telegram.InlineKeyboardButton( f"{utils.boolmoji(admin.create_transactions)} {strings.prop_create_transactions}", callback_data="toggle_create_transactions")], [telegram.InlineKeyboardButton( f"{utils.boolmoji(admin.display_on_help)} {strings.prop_display_on_help}", callback_data="toggle_display_on_help")], + [telegram.InlineKeyboardButton(strings.conversation_admin_dismissal_menu, callback_data="cmd_remove")], [telegram.InlineKeyboardButton(strings.menu_done, callback_data="cmd_done")] ]) # Update the inline keyboard self.bot.edit_message_reply_markup(message_id=message.message_id, - chat_id=self.chat.id, - reply_markup=inline_keyboard) + chat_id=self.chat.id, + reply_markup=inline_keyboard) # Wait for an user answer callback = self.__wait_for_inlinekeyboard_callback() # Toggle the correct property if callback.data == "toggle_edit_products": - admin.edit_products = not admin.edit_products1 + admin.edit_products = not admin.edit_products elif callback.data == "toggle_receive_orders": admin.receive_orders = not admin.receive_orders elif callback.data == "toggle_create_transactions": admin.create_transactions = not admin.create_transactions elif callback.data == "toggle_display_on_help": admin.display_on_help = not admin.display_on_help + elif callback.data == "cmd_remove": + self.session.delete(user.admin) + message = self.bot.send_message(self.chat.id, strings.conversation_confirm_admin_dismissal) + break elif callback.data == "cmd_done": break self.session.commit()