diff --git a/database.py b/database.py index d04e1a4..3bebc4d 100644 --- a/database.py +++ b/database.py @@ -80,25 +80,36 @@ class Product(TableDeclarativeBase): # No __init__ is needed, the default one is sufficient - def __str__(self, one_row=False): + def __str__(self): + return self.text() + + def text(self, one_row:bool=False, cart_qty:int=None): """Return the product details formatted with Telegram HTML. The image is omitted.""" if one_row: return f"{escape(self.name)} - {strings.currency_format_string.format(symbol=strings.currency_symbol, value=self.price)}" - 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)}" + 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" \ + f"{strings.currency_format_string.format(symbol=strings.currency_symbol, value=self.price)}\n" \ + f"{strings.in_cart_format_string.format(quantity=cart_qty) if cart_qty is not None else ''}" def __repr__(self): return f"" - def send_as_message(self, chat_id: int) -> requests.Response: + 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 + if self.image is None: + r = requests.get(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendMessage", + params={"chat_id": chat_id, + "text": str(self), + "parse_mode": "HTML"}) + else: + 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), + "parse_mode": "HTML"}) + return r.json() def set_image(self, file: telegram.File): """Download an image from Telegram and store it in the image column. diff --git a/strings.py b/strings.py index 989129f..19b7bbc 100644 --- a/strings.py +++ b/strings.py @@ -11,6 +11,9 @@ currency_format_string = "{symbol} {value}" # Quantity of a product in stock in_stock_format_string = "{quantity} disponibili" +# Copies of a product in cart +in_cart_format_string = "{quantity} nel carrello" + # Conversation: the start command was sent and the bot should welcome the user conversation_after_start = "Ciao!\n" \ "Benvenuto su greed!" @@ -34,6 +37,9 @@ conversation_admin_select_product = "Che prodotto vuoi modificare?" # Conversation: add extra notes to the order conversation_extra_notes = "Che messaggio vuoi lasciare insieme al tuo ordine?" +# Conversation: click below to pay for the purchase +conversation_cart_actions = "Quando hai finito di aggiungere prodotti al carrello, clicca uno dei pulsanti qui sotto!" + # Conversation: confirm the cart contents conversation_confirm_cart = "Il tuo carrello contiene questi prodotti:\n" \ "{product_list}\n" \ @@ -87,6 +93,12 @@ menu_done = "✅️ Fatto" # Menu: pay invoice menu_pay = "💳 Paga" +# Menu: add to cart +menu_add_to_cart = "➕ Aggiungi" + +# Menu: remove from cart +menu_remove_from_cart = "➖ Rimuovi" + # Add product: name? ask_product_name = "Come si deve chiamare il prodotto?" diff --git a/worker.py b/worker.py index 3a2009e..61b53e2 100644 --- a/worker.py +++ b/worker.py @@ -163,7 +163,7 @@ class ChatWorker(threading.Thread): return update.message.successful_payment def __wait_for_photo(self, cancellable:bool=False) -> typing.Union[typing.List[telegram.PhotoSize], CancelSignal]: - """Continue getting updates until a photo is received, then download and return it.""" + """Continue getting updates until a photo is received, then return it.""" while True: # Get the next update update = self.__receive_next_update() @@ -180,6 +180,17 @@ class ChatWorker(threading.Thread): # Return the photo array return update.message.photo + def __wait_for_inlinekeyboard_callback(self) -> telegram.CallbackQuery: + """Continue getting updates until an inline keyboard callback is received, then return it.""" + while True: + # Get the next update + update = self.__receive_next_update() + # Ensure the update is a CallbackQuery + if update.callback_query is None: + continue + # Return the callbackquery + return update.callback_query + def __user_menu(self): """Function called from the run method when the user is not an administrator. Normal bot actions should be placed here.""" @@ -215,75 +226,98 @@ class ChatWorker(threading.Thread): def __order_menu(self): """User menu to order products from the shop.""" - raise NotImplementedError() - # # Create a list with the requested items - # order_items = [] - # # Get the products list from the db - # products = self.session.query(db.Product).all() - # # TODO: this should be changed - # # Loop exit reason - # exit_reason = None - # # Ask for a list of products to order - # while True: - # # Create a list of product names - # product_names = [product.name for product in products] - # # Add a Cancel button at the end of the keyboard - # product_names.append(strings.menu_cancel) - # # If at least 1 product has been ordered, add a Done button at the start of the keyboard - # if len(order_items) > 0: - # product_names.insert(0, strings.menu_done) - # # Create a keyboard using the product names - # keyboard = [[telegram.KeyboardButton(product_name)] for product_name in product_names] - # # Wait for an answer - # selection = self.__wait_for_specific_message(product_names) - # # If the user selected the Cancel option... - # if selection == strings.menu_cancel: - # exit_reason = "Cancel" - # break - # # If the user selected the Done option... - # elif selection == strings.menu_done: - # exit_reason = "Done" - # break - # # If the user selected a product... - # else: - # # Find the selected product - # product = self.session.query(db.Product).filter_by(name=selection).one() - # # Add the product to the order_items list - # order_items.append(product) - # # Ask for extra notes - # self.bot.send_message(self.chat.id, strings.conversation_extra_notes) - # # Wait for an answer - # notes = self.__wait_for_regex("(.+)") - # # Create the confirmation message and find the total cost - # total_cost = 0 - # product_list_string = "" - # for item in order_items: - # # Add to the string and the cost - # product_list_string += f"{str(item)}\n" - # total_cost += item.price - # # Send the confirmation message - # self.bot.send_message(self.chat.id, strings.conversation_confirm_cart.format(product_list=product_list_string, total_cost=strings.currency_format_string.format(symbol=strings.currency_symbol, value=(total_cost / (10 ** int(configloader.config["Payments"]["currency_exp"])))))) - # # TODO: wait for an answer - # # TODO: create a new transaction - # # TODO: test the code - # # TODO: everything - # # Create the order record and add it to the session - # order = db.Order(user=self.user, - # creation_date=datetime.datetime.now(), - # notes=notes) - # self.session.add(order) - # # Commit the session so the order record gets an id - # self.session.commit() - # # Create the orderitems for the selected products - # for item in order_items: - # item_record = db.OrderItem(product=item, - # order_id=order.order_id) - # # Add the created item to the session - # self.session.add(item_record) - # # Commit the session - # self.session.commit() - # # Send a confirmation to the user - # self.bot.send_message(self.chat.id, strings.success_order_created) + # Get the products list from the db + products = self.session.query(db.Product).all() + # Create a dict to be used as 'cart' + # The key is the message id of the product list + cart = {} # type: typing.Dict[typing.List[db.Product, int]] + # Initialize the products list + for product in products: + # If the product is not for sale, don't display it + if product.price is None: + continue + # Send the message without the keyboard to get the message id + message = product.send_as_message(self.chat.id) + # Add the product to the cart + cart[message['result']['message_id']] = [product, 0] + # Create the inline keyboard to add the product to the cart + inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(strings.menu_add_to_cart, callback_data="cart_add")]]) + # Edit the sent message and add the inline keyboard + if product.image is None: + self.bot.edit_message_text(chat_id=self.chat.id, message_id=message['result']['message_id'], text=str(product), parse_mode="HTML", reply_markup=inline_keyboard) + else: + self.bot.edit_message_caption(chat_id=self.chat.id, message_id=message['result']['message_id'], caption=str(product), parse_mode="HTML", reply_markup=inline_keyboard) + # Create the keyboard with the cancel button + inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(strings.menu_cancel, callback_data="cart_cancel")]]) + # Send a message containing the button to cancel or pay + final = self.bot.send_message(self.chat.id, strings.conversation_cart_actions, reply_markup=inline_keyboard) + # Wait for user input + while True: + callback = self.__wait_for_inlinekeyboard_callback() + # React to the user input + # If the cancel button has been pressed... + if callback.data == "cart_cancel": + # Stop waiting for user input and go back to the previous menu + return + # If a Add to Cart button has been pressed... + elif callback.data == "cart_add": + # Get the selected product + product = cart[callback.message.message_id][0] + # Add 1 copy to the cart + cart[callback.message.message_id][1] += 1 + # Create the product inline keyboard + product_inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(strings.menu_add_to_cart, callback_data="cart_add")], + [telegram.InlineKeyboardButton(strings.menu_remove_from_cart, callback_data="cart_remove")]]) + # Create the final inline keyboard + final_inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(strings.menu_cancel, callback_data="cart_cancel")], + [telegram.InlineKeyboardButton(strings.menu_done, callback_data="cart_done")]]) + # Edit both the product and the final message + if product.image is None: + self.bot.edit_message_text(chat_id=self.chat.id, message_id=callback.message.message_id, + text=product.text(cart_qty=cart[callback.message.message_id][1]), parse_mode="HTML", reply_markup=product_inline_keyboard) + else: + self.bot.edit_message_caption(chat_id=self.chat.id, message_id=callback.message.message_id, + caption=product.text(cart_qty=cart[callback.message.message_id][1]), parse_mode="HTML", reply_markup=product_inline_keyboard) + try: + self.bot.edit_message_text(chat_id=self.chat.id, message_id=final.message_id, + text=strings.conversation_cart_actions, reply_markup=final_inline_keyboard) + except telegram.error.BadRequest: + # Telegram returns an error if the message is not edited + pass + # If the Remove from cart button has been pressed... + elif callback.data == "cart_remove": + # Get the selected product + product = cart[callback.message.message_id][0] + # Remove 1 copy from the cart + if cart[callback.message.message_id][1] > 0: + cart[callback.message.message_id][1] -= 1 + else: + continue + # Create the product inline keyboard + product_inline_list = [[telegram.InlineKeyboardButton(strings.menu_add_to_cart, callback_data="cart_add")]] + if cart[callback.message.message_id][1] > 0: + product_inline_list.append([telegram.InlineKeyboardButton(strings.menu_remove_from_cart, callback_data="cart_remove")]) + product_inline_keyboard = telegram.InlineKeyboardMarkup(product_inline_list) + # Create the final inline keyboard + final_inline_list = [[telegram.InlineKeyboardButton(strings.menu_cancel, callback_data="cart_cancel")]] + for product_id in cart: + if cart[product_id][1] > 0: + final_inline_list.append([telegram.InlineKeyboardButton(strings.menu_done, callback_data="cart_done")]) + break + final_inline_keyboard = telegram.InlineKeyboardMarkup(final_inline_list) + # Edit both the product and the final message + if product.image is None: + self.bot.edit_message_text(chat_id=self.chat.id, message_id=callback.message.message_id, + text=product.text(cart_qty=cart[callback.message.message_id][1]), parse_mode="HTML", reply_markup=product_inline_keyboard) + else: + self.bot.edit_message_caption(chat_id=self.chat.id, message_id=callback.message.message_id, + caption=product.text(cart_qty=cart[callback.message.message_id][1]), parse_mode="HTML", reply_markup=product_inline_keyboard) + try: + self.bot.edit_message_text(chat_id=self.chat.id, message_id=final.message_id, + text=strings.conversation_cart_actions, reply_markup=final_inline_keyboard) + except telegram.error.BadRequest: + # Telegram returns an error if the message is not edited + pass def __order_status(self): raise NotImplementedError()