diff --git a/database.py b/database.py index 2b2893f..cf72499 100644 --- a/database.py +++ b/database.py @@ -1,10 +1,11 @@ from sqlalchemy import create_engine, Column, ForeignKey, UniqueConstraint, CheckConstraint -from sqlalchemy import Integer, BigInteger, String, Numeric, Text +from sqlalchemy import Integer, BigInteger, String, Text, LargeBinary from sqlalchemy.orm import sessionmaker, relationship from sqlalchemy.ext.declarative import declarative_base import configloader import telegram import strings +import requests from html import escape # Create a (lazy) database engine @@ -67,8 +68,8 @@ class Product(TableDeclarativeBase): description = Column(Text) # Product price, if null product is not for sale price = Column(Integer) - # Image filename - image = Column(String) + # Image data + image = Column(LargeBinary) # Stock quantity, if null product has infinite stock stock = Column(Integer) @@ -79,15 +80,29 @@ class Product(TableDeclarativeBase): def __str__(self): """Return the product details formatted with Telegram HTML. The image is omitted.""" - return f"{escape(self.name)}\n" \ - f"{escape(self.description)}\n" \ - f"{strings.in_stock_format_string.format(quantity=self.stock) if self.stock is not None else ''}\n" \ + return f"{escape(self.name)}\n" \ + f"{escape(self.description)}\n\n" \ + f"{strings.in_stock_format_string.format(quantity=self.stock) if self.stock is not None else ''}\n" \ f"{strings.currency_format_string.format(symbol=strings.currency_symbol, value=self.price)}" def __repr__(self): return f"" - # TODO: add get image (and set image?) method(s) + def send_as_message(self, chat_id: int) -> dict: + """Send a message containing the product data.""" + r = requests.post(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendPhoto", + files={"photo": self.image}, + params={"chat_id": chat_id, + "caption": str(self)}) + return r + + def set_image(self, file: telegram.File): + """Download an image from Telegram and store it in the image column. + This is a slow blocking function. Try to avoid calling it directly, use a thread if possible.""" + # Download the photo through a get request + r = requests.get(file.file_path) + # Store the photo in the database record + self.image = r.content class Transaction(TableDeclarativeBase): diff --git a/strings.py b/strings.py index b6e8100..9daec4b 100644 --- a/strings.py +++ b/strings.py @@ -75,7 +75,20 @@ ask_product_name = "Come si deve chiamare il prodotto?" ask_product_description = "Quale deve essere la descrizione del prodotto?" # Add product: price? -ask_product_price = "Quanto deve costare il prodotto?" +ask_product_price = "Quanto deve costare il prodotto?\n" \ + "Scrivi skip se vuoi che il prodotto non sia ancora in vendita." + +# Add product: image? +ask_product_image = "Che immagine vuoi che abbia il prodotto?" + +# Thread has started downloading an image and might be unresponsive +downloading_image = "Sto scaricando la tua foto!\n" \ + "Potrei metterci un po'... Abbi pazienza!\n" \ + "Non sarò in grado di risponderti durante il download." + +# Edit product: current value +edit_current_value = "Il valore attuale è:\n" \ + "
{value}
" # Payment: cash payment info payment_cash = "Puoi pagare in contanti alla sede fisica del negozio.\n" \ @@ -105,6 +118,9 @@ bot_info = 'Questo bot utilizza gree # Success: product has been added to the database success_product_added = "✅ Il prodotto è stato aggiunto con successo!" +# Success: product has been added to the database +success_product_edited = "✅ Il prodotto è stato modificato con successo!" + # Error: message received not in a private chat error_nonprivate_chat = "⚠️ Questo bot funziona solo in chat private." diff --git a/worker.py b/worker.py index a565742..126db57 100644 --- a/worker.py +++ b/worker.py @@ -8,6 +8,8 @@ import sys import queue as queuem import database as db import re +import requests +from html import escape class StopSignal: """A data class that should be sent to the worker when the conversation has to be stopped abnormally.""" @@ -144,6 +146,20 @@ class ChatWorker(threading.Thread): # Return the successfulpayment return update.message.successful_payment + def __wait_for_photo(self) -> typing.List[telegram.PhotoSize]: + """Continue getting updates until a photo is received, then download and return it.""" + while True: + # Get the next update + update = self.__receive_next_update() + # Ensure the update contains a message + if update.message is None: + continue + # Ensure the message contains a photo + if update.message.photo is None: + continue + # Return the photo array + return update.message.photo + def __user_menu(self): """Function called from the run method when the user is not an administrator. Normal bot actions should be placed here.""" @@ -339,47 +355,85 @@ class ChatWorker(threading.Thread): # If the user has selected the Add Product option... if selection == strings.menu_add_product: # Open the add product menu - self.__add_product_menu() + self.__edit_product_menu() # If the user has selected a product else: + # Find the selected product + product = self.session.query(db.Product).filter_by(name=selection).one() # Open the edit menu for that specific product - self.__edit_product_menu(selection) + self.__edit_product_menu(product=product) - def __add_product_menu(self): - """Add a product to the database.""" + def __edit_product_menu(self, product: typing.Optional[db.Product]=None): + """Add a product to the database or edit an existing one.""" # Ask for the product name until a valid product name is specified while True: # Ask the question to the user self.bot.send_message(self.chat.id, strings.ask_product_name) + # Display the current name if you're editing an existing product + if product: + self.bot.send_message(self.chat.id, strings.edit_current_value.format(value=escape(product.name)), parse_mode="HTML") # Wait for an answer name = self.__wait_for_regex(r"(.*)") # Ensure a product with that name doesn't already exist - if self.session.query(db.Product).filter_by(name=name).one_or_none() is None: + if self.session.query(db.Product).filter_by(name=name).one_or_none() in [None, product]: # Exit the loop break self.bot.send_message(self.chat.id, strings.error_duplicate_name) # Ask for the product description self.bot.send_message(self.chat.id, strings.ask_product_description) + # Display the current description if you're editing an existing product + if product: + self.bot.send_message(self.chat.id, strings.edit_current_value.format(value=escape(product.description)), parse_mode="HTML") # Wait for an answer description = self.__wait_for_regex(r"(.*)") # Ask for the product price self.bot.send_message(self.chat.id, strings.ask_product_price) + # Display the current name if you're editing an existing product + if product: + self.bot.send_message(self.chat.id, strings.edit_current_value.format(value=(strings.currency_format_string.format(symbol=strings.currency_symbol, value=(product.price / (10 ** int(configloader.config["Payments"]["currency_exp"]))))) if product.price is not None else 'Non in vendita'), parse_mode="HTML") # Wait for an answer - price = int(self.__wait_for_regex(r"([0-9]{1,3}(?:[.,][0-9]{1,2})?)").replace(".", "").replace(",", "")) * ( - 10 ** int(configloader.config["Payments"]["currency_exp"])) - # TODO: ask for product image - # Create the db record for the product - product = db.Product(name=name, - description=description, - price=price) - # Add the record to the session, then commit - self.session.add(product) + price = self.__wait_for_regex(r"([0-9]{1,3}(?:[.,][0-9]{1,2})?|[Ss][Kk][Ii][Pp])") + # If the price is skipped + if price.lower() == "skip": + price = None + else: + price = int(price.replace(".", "").replace(",", "")) * (10 ** int(configloader.config["Payments"]["currency_exp"])) + # Ask for the product image + self.bot.send_message(self.chat.id, strings.ask_product_image) + # Wait for an answer + photo_list = self.__wait_for_photo() + # If a new product is being added... + if not product: + # Create the db record for the product + product = db.Product(name=name, + description=description, + price=price) + # Add the record to the database + self.session.add(product) + # If a product is being edited... + else: + # Edit the record with the new values + product.name = name + product.description = description + product.price = price + # 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) self.session.commit() # Notify the user - self.bot.send_message(self.chat.id, strings.success_product_added) - - def __edit_product_menu(self, name: str): - raise NotImplementedError() + if product: + self.bot.send_message(self.chat.id, strings.success_product_edited) + else: + self.bot.send_message(self.chat.id, strings.success_product_added) def __orders_menu(self): raise NotImplementedError()