diff --git a/.gitignore b/.gitignore index 981870b..e83cfc0 100644 --- a/.gitignore +++ b/.gitignore @@ -102,5 +102,6 @@ ENV/ .idea/ config/config.ini +config/config.toml *.sqlite *.sqlite-journal \ No newline at end of file diff --git a/config/template_config.ini b/config/template_config.ini deleted file mode 100644 index 14f69ae..0000000 --- a/config/template_config.ini +++ /dev/null @@ -1,110 +0,0 @@ -# greed configuration file - -# boolean parameters should be written in lowercase - -# Config file parameters -[Config] -; Config file version. DO NOT EDIT THIS! -version = 19 -; Set this to no when you are done editing the file -is_template = yes - -# Language parameters -[Language] -; Available languages: -; it - Italian, by https://github.com/Steffo99 -; en - English, by https://github.com/DarrenWestwood -; uk - Ukrainian, by https://github.com/pzhuk -; ru - Russian, by https://github.com/pzhuk -; zh_cn - Simplified Chinese, by https://github.com/zhihuiyuze -; he - Hebrew, by https://github.com/netanelkoli -; The lanugages that messages can be displayed in -enabled_languages = it | en | uk | ru | zh_cn | he -; The default language to be set for users whose language cannot be autodetected or whose language is not enabled -default_language = it -; The language to fallback to if a string is missing in a specific language -fallback_language = en - -# Telegram bot parameters -[Telegram] -; Your bot token goes here. Get one from https://t.me/BotFather! -token = 123456789:YOUR_TOKEN_GOES_HERE_______________ -; Time in seconds before a conversation (thread) with no new messages expires -; A lower value reduces memory usage, but can be inconvenient for the users -conversation_timeout = 7200 -; Time to wait before sending another update request if there are no messages -long_polling_timeout = 30 -; Time in seconds before retrying a request if it times out -timed_out_pause = 1 -; Time in seconds before retrying a request that returned an error -error_pause = 5 - -# Database parameters -[Database] -; The database engine you want to use. -; Refer to http://docs.sqlalchemy.org/en/latest/core/engines.html for the possible settings. -engine = sqlite:///database.sqlite - -# General payment settings -[Payments] -; ISO currency code -currency = EUR -; Currency exp parameter. You can find that on https://core.telegram.org/bots/payments/currencies.json. -; It has a value of 2 in most currencies (EUR, USD, GBP...) -currency_exp = 2 -; Currency symbol which is show to the client users when displaying prices and transaction values -; If not defined here, default language specific currency symbol from strings would be used -currency_symbol = € - -# Credit card payment settings -[Credit Card] -; Telegram Payments provider token obtainable at https://t.me/BotFather in the bot's Payments menu -; If empty, credit card payments are disabled. -# credit_card_token = -credit_card_token = 123456789:YOUR_TOKEN_HERE_ -; Minimum wallet payment accepted (in miniumum currency units, $1.00 = 100 units) -min_amount = 1000 -; Maximum wallet payment accepted (in miniumum currency units, $1.00 = 100 units) -max_amount = 10000 -; The preset selections that can be made when adding credit to the wallet with a credit card -; Presets are pipe-separated |, and should never be outside the bounds provided by the min_amount and max_amount options -payment_presets = 10.00 | 25.00 | 50.00 | 100.00 -; Make the user pay a extra fee when adding credit to the wallet with a credit card -; The formula for determining the total cost is: -; cost = added_funds + added_funds * fee_percentage / 100 + fee_fixed -; Set these values to 0 to disable the feature. -fee_percentage = 2.9 -fee_fixed = 30 -; "Shipping" information -; Telegram can ask for extra information when charging the user for a credit card transaction -; Set to yes the data you want to be required -; This data will be stored in the database -name_required = yes -email_required = yes -phone_required = yes - -# Bot appearance settings -[Appearance] -; Display the full order information to the customers instead of the shortened version -; The full order information includes the order number and the timestamp of the order placement -full_order_info = no -; Allow balance refill during the order checkout in case of unsufficient balance -refill_on_checkout = yes -; Display welcome message (conversation_after_start) when the user sends /start -display_welcome_message = yes - - -# Exception reporting settings -[Error Reporting] -; Optional sentry token: get the token at https://sentry.io/ or ask @Steffo for one -; Needed to automatically report bugs found by the users in the code. -sentry_token = https://00000000000000000000000000000000:00000000000000000000000000000000@sentry.io/0000000 - -# Logging settings -[Logging] -; The output format for the messages printed to the console -; See https://docs.python.org/3/library/logging.html#logrecord-attributes for information about the {}-attributes -format = {asctime} | {threadName} | {name} | {message} -; Logging level: ignore all log entries with a level lower than the specified one -; Valid options are FATAL, ERROR, WARNING, INFO, and DEBUG -level = INFO diff --git a/config/template_config.toml b/config/template_config.toml new file mode 100644 index 0000000..fc7c0b0 --- /dev/null +++ b/config/template_config.toml @@ -0,0 +1,100 @@ +# greed configuration file + +# Language parameters +[Language] + # Available languages: + # it - Italian, by https://github.com/Steffo99 + # en - English, by https://github.com/DarrenWestwood + # uk - Ukrainian, by https://github.com/pzhuk + # ru - Russian, by https://github.com/pzhuk + # zh_cn - Simplified Chinese, by https://github.com/zhihuiyuze + # he - Hebrew, by https://github.com/netanelkoli + # The lanugages that messages can be displayed in + enabled_languages = ["it", "en", "uk", "ru", "zh_cn", "he"] + # The default language to be set for users whose language cannot be autodetected or whose language is not enabled + default_language = "it" + # The language to fallback to if a string is missing in a specific language + fallback_language = "en" + + +# Database parameters +[Database] + # The database engine you want to use. + # Refer to http://docs.sqlalchemy.org/en/latest/core/engines.html for the possible settings. + engine = "sqlite:///database.sqlite" + + +# Telegram bot parameters +[Telegram] + # Your bot token goes here. Get one from https://t.me/BotFather! + token = "123456789:YOUR_TOKEN_GOES_HERE_______________" + # Time in seconds before a conversation (thread) with no new messages expires + # A lower value reduces memory usage, but can be inconvenient for the users + conversation_timeout = 7200 + # Time to wait before sending another update request if there are no messages + long_polling_timeout = 30 + # Time in seconds before retrying a request if it times out + timed_out_pause = 1 + # Time in seconds before retrying a request that returned an error + error_pause = 5 + + +# General payment settings +[Payments] + # ISO currency code + currency = "EUR" + # Currency exp parameter. You can find that on https://core.telegram.org/bots/payments/currencies.json. + # It has a value of 2 in most currencies (EUR, USD, GBP...) + currency_exp = 2 + # Currency symbol which is show to the client users when displaying prices and transaction values + # If not defined here, default language specific currency symbol from strings would be used + currency_symbol = "€" + + + # Credit card payment settings + [Payments.CreditCard] + # Telegram Payments provider token obtainable at https://t.me/BotFather in the bot's Payments menu + # If empty, credit card payments are disabled. + # credit_card_token = + credit_card_token = "123456789:YOUR_TOKEN_HERE_" + # Minimum wallet payment accepted (in miniumum currency units, $1.00 = 100 units) + min_amount = 1000 + # Maximum wallet payment accepted (in miniumum currency units, $1.00 = 100 units) + max_amount = 10000 + # The preset selections that can be made when adding credit to the wallet with a credit card + # Presets are pipe-separated |, and should never be outside the bounds provided by the min_amount and max_amount options + payment_presets = [10.00, 25.00, 50.00, 100.00] + # Make the user pay a extra fee when adding credit to the wallet with a credit card + # The formula for determining the total cost is: + # cost = added_funds + added_funds * fee_percentage / 100 + fee_fixed + # Set these values to 0 to disable the feature. + fee_percentage = 2.9 + fee_fixed = 30 + # "Shipping" information + # Telegram can ask for extra information when charging the user for a credit card transaction + # Set to yes the data you want to be required + # This data will be stored in the database + name_required = true + email_required = true + phone_required = true + + +# Bot appearance settings +[Appearance] + # Display the full order information to the customers instead of the shortened version + # The full order information includes the order number and the timestamp of the order placement + full_order_info = false + # Allow balance refill during the order checkout in case of unsufficient balance + refill_on_checkout = true + # Display welcome message (conversation_after_start) when the user sends /start + display_welcome_message = true + + +# Logging settings +[Logging] + # The output format for the messages printed to the console + # See https://docs.python.org/3/library/logging.html#logrecord-attributes for information about the {}-attributes + format = "{asctime} | {threadName} | {name} | {message}" + # Logging level: ignore all log entries with a level lower than the specified one + # Valid options are FATAL, ERROR, WARNING, INFO, and DEBUG + level = "INFO" diff --git a/configloader.py b/configloader.py deleted file mode 100644 index 9d8d264..0000000 --- a/configloader.py +++ /dev/null @@ -1,55 +0,0 @@ -import sys -import os -import configparser -import logging - - -# Logs won't show up for this file as it is imported before logging is configured -log = logging.getLogger(__name__) - - -# Check if the config file exists, and create one if it doesn't -if not os.path.isfile("config/config.ini"): - log.debug("Creating config.ini from template_config.ini") - # Open the template file and create the config file - with open("config/template_config.ini", encoding="utf8") as template_file, \ - open("config/config.ini", "w", encoding="utf8") as config_file: - # Copy the template file to the config file - config_file.write(template_file.read()) - -with open("config/template_config.ini", encoding="utf8") as template_file: - # Find the template version number - config = configparser.ConfigParser() - config.read_file(template_file) - template_version = int(config["Config"]["version"]) - log.debug(f"Template is version {template_version}") - -# Overwrite the template config with the values in the config -with open("config/config.ini", encoding="utf8") as config_file: - config.read_file(config_file) - config_version = int(config["Config"]["version"]) - log.debug(f"Config is version {template_version}") - -# Check if the file has been edited -if config["Config"]["is_template"] == "yes": - log.debug("Config is a template, aborting...") - log.fatal("A config file has been created in config/config.ini.\n" - "Edit it with your configuration, set the is_template flag to 'no' and restart this script.") - sys.exit(1) - -# Check if the version has changed from the template -if template_version > config_version: - log.debug("Config is older than Template, trying to merge...") - # Reset the is_template flag - config["Config"]["is_template"] = "yes" - # Update the config version - config["Config"]["version"] = str(template_version) - # Save the file - with open("config/config.ini", "w", encoding="utf8") as config_file: - log.debug("Writing merged config file...") - config.write(config_file) - # Notify the user and quit - log.debug("Config is now a template, aborting...") - log.fatal("The config file in config/config.ini has been updated.\n" - "Edit it with the new required data, set the is_template flag to true and restart this script.") - sys.exit(1) diff --git a/core.py b/core.py index 69059a3..dec6d04 100644 --- a/core.py +++ b/core.py @@ -1,11 +1,16 @@ +import os import sys import telegram import worker -import configloader -import utils +import nuconfig import threading import localization import logging +import duckbot +import sqlalchemy +import sqlalchemy.orm +import sqlalchemy.ext.declarative as sed +import database try: import coloredlogs @@ -18,14 +23,47 @@ def main(): # Rename the main thread for presentation purposes threading.current_thread().name = "Core" - # Setup logging + # Start logging setup log = logging.getLogger("core") - logging.root.setLevel(configloader.config["Logging"]["level"]) + logging.root.setLevel("INFO") + log.debug("Set logging level to INFO while the config is being loaded") + + # Ensure the template config file exists + if not os.path.isfile("config/template_config.toml"): + log.fatal("config/template_config.toml does not exist!") + exit(254) + + # If the config file does not exist, clone the template and exit + if not os.path.isfile("config/config.toml"): + log.debug("config/config.toml does not exist.") + + with open("config/template_config.toml", encoding="utf8") as template_cfg_file, \ + open("config/config.toml", "w", encoding="utf8") as user_cfg_file: + # Copy the template file to the config file + user_cfg_file.write(template_cfg_file.read()) + + log.fatal("A config file has been created in config/config.toml." + " Customize it, then restart greed!") + exit(1) + + # Compare the template config with the user-made one + with open("config/template_config.toml", encoding="utf8") as template_cfg_file, \ + open("config/config.toml", encoding="utf8") as user_cfg_file: + template_cfg = nuconfig.NuConfig(template_cfg_file) + user_cfg = nuconfig.NuConfig(user_cfg_file) + if not template_cfg.cmplog(user_cfg): + log.fatal("There were errors while parsing the config.toml file. Please fix them and restart greed!") + exit(2) + else: + log.debug("Configuration parsed successfully!") + + # Finish logging setup + logging.root.setLevel(user_cfg["Logging"]["level"]) stream_handler = logging.StreamHandler() if coloredlogs is not None: - stream_handler.formatter = coloredlogs.ColoredFormatter(configloader.config["Logging"]["format"], style="{") + stream_handler.formatter = coloredlogs.ColoredFormatter(user_cfg["Logging"]["format"], style="{") else: - stream_handler.formatter = logging.Formatter(configloader.config["Logging"]["format"], style="{") + stream_handler.formatter = logging.Formatter(user_cfg["Logging"]["format"], style="{") logging.root.handlers.clear() logging.root.addHandler(stream_handler) log.debug("Logging setup successfully!") @@ -33,8 +71,18 @@ def main(): # Ignore most python-telegram-bot logs, as they are useless most of the time logging.getLogger("telegram").setLevel("ERROR") + # Create the database engine + log.debug("Creating the sqlalchemy engine...") + engine = sqlalchemy.create_engine(user_cfg["Database"]["engine"]) + log.debug("Preparing the tables through deferred reflection...") + sed.DeferredReflection.prepare(engine) + log.debug("Binding metadata to the engine...") + database.TableDeclarativeBase.metadata.bind = engine + log.debug("Creating all missing tables...") + database.TableDeclarativeBase.metadata.create_all() + # Create a bot instance - bot = utils.DuckBot(configloader.config["Telegram"]["token"]) + bot = duckbot.factory(user_cfg)() # Test the specified token log.debug("Testing bot token...") @@ -46,7 +94,7 @@ def main(): log.debug("Bot token is valid!") # Finding default language - default_language = configloader.config["Language"]["default_language"] + default_language = user_cfg["Language"]["default_language"] # Creating localization object default_loc = localization.Localization(language=default_language, fallback=default_language) @@ -63,9 +111,10 @@ def main(): # Main loop of the program while True: # Get a new batch of 100 updates and mark the last 100 parsed as read - log.debug("Getting updates from Telegram") + update_timeout = user_cfg["Telegram"]["long_polling_timeout"] + log.debug(f"Getting updates from Telegram with a timeout of {update_timeout} seconds") updates = bot.get_updates(offset=next_update, - timeout=int(configloader.config["Telegram"]["long_polling_timeout"])) + timeout=update_timeout) # Parse all the updates for update in updates: # If the update is a message... @@ -89,7 +138,9 @@ def main(): # Initialize a new worker for the chat new_worker = worker.Worker(bot=bot, chat=update.message.chat, - telegram_user=update.message.from_user) + telegram_user=update.message.from_user, + cfg=user_cfg, + engine=engine) # Start the worker log.debug(f"Starting {new_worker.name}") new_worker.start() diff --git a/database.py b/database.py index c305828..1066cad 100644 --- a/database.py +++ b/database.py @@ -1,29 +1,24 @@ 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 -from sqlalchemy.ext.declarative import declarative_base -import configloader +from sqlalchemy.orm import relationship, backref +from sqlalchemy.ext.declarative import declarative_base, DeferredReflection import telegram import requests import utils -import localization import logging +if typing.TYPE_CHECKING: + import worker + + log = logging.getLogger(__name__) -# Create a (lazy) database engine -engine = create_engine(configloader.config["Database"]["engine"]) - # Create a base class to define all the database subclasses -TableDeclarativeBase = declarative_base(bind=engine) - -# Create a Session class able to initialize database sessions -Session = sessionmaker() - +TableDeclarativeBase = declarative_base() # Define all the database tables using the sqlalchemy declarative base -class User(TableDeclarativeBase): +class User(DeferredReflection, TableDeclarativeBase): """A Telegram user who used the bot at least once.""" # Telegram data @@ -39,16 +34,18 @@ class User(TableDeclarativeBase): # Extra table parameters __tablename__ = "users" - def __init__(self, telegram_user: telegram.User, **kwargs): + def __init__(self, w: "worker.Worker", **kwargs): # Initialize the super super().__init__(**kwargs) # Get the data from telegram - self.user_id = telegram_user.id - self.first_name = telegram_user.first_name - self.last_name = telegram_user.last_name - self.username = telegram_user.username - self.language = telegram_user.language_code if telegram_user.language_code else configloader.config["Language"][ - "default_language"] + self.user_id = w.telegram_user.id + self.first_name = w.telegram_user.first_name + self.last_name = w.telegram_user.last_name + self.username = w.telegram_user.username + if w.telegram_user.language_code: + self.language = w.telegram_user.language_code + else: + self.language = w.cfg["Language"]["default_language"] # The starting wallet value is 0 self.credit = 0 @@ -88,7 +85,7 @@ class User(TableDeclarativeBase): return f"" -class Product(TableDeclarativeBase): +class Product(DeferredReflection, TableDeclarativeBase): """A purchasable product.""" # Product id @@ -109,37 +106,37 @@ class Product(TableDeclarativeBase): # No __init__ is needed, the default one is sufficient - def text(self, *, loc: localization.Localization, style: str = "full", cart_qty: int = None): + def text(self, w: "worker.Worker", *, style: str = "full", cart_qty: int = None): """Return the product details formatted with Telegram HTML. The image is omitted.""" if style == "short": - return f"{cart_qty}x {utils.telegram_html_escape(self.name)} - {str(utils.Price(self.price, loc) * cart_qty)}" + return f"{cart_qty}x {utils.telegram_html_escape(self.name)} - {str(w.Price(self.price) * cart_qty)}" elif style == "full": if cart_qty is not None: - cart = loc.get("in_cart_format_string", quantity=cart_qty) + cart = w.loc.get("in_cart_format_string", quantity=cart_qty) else: cart = '' - return loc.get("product_format_string", name=utils.telegram_html_escape(self.name), - description=utils.telegram_html_escape(self.description), - price=str(utils.Price(self.price, loc)), - cart=cart) + return w.loc.get("product_format_string", name=utils.telegram_html_escape(self.name), + description=utils.telegram_html_escape(self.description), + price=str(w.Price(self.price)), + cart=cart) else: raise ValueError("style is not an accepted value") def __repr__(self): return f"" - def send_as_message(self, loc: localization.Localization, chat_id: int) -> dict: + def send_as_message(self, w: "worker.Worker", chat_id: int) -> dict: """Send a message containing the product data.""" if self.image is None: - r = requests.get(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendMessage", + r = requests.get(f"https://api.telegram.org/bot{w.cfg['Telegram']['token']}/sendMessage", params={"chat_id": chat_id, - "text": self.text(loc=loc), + "text": self.text(w), "parse_mode": "HTML"}) else: - r = requests.post(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendPhoto", + r = requests.post(f"https://api.telegram.org/bot{w.cfg['Telegram']['token']}/sendPhoto", files={"photo": self.image}, params={"chat_id": chat_id, - "caption": self.text(loc=loc), + "caption": self.text(w), "parse_mode": "HTML"}) return r.json() @@ -152,7 +149,7 @@ class Product(TableDeclarativeBase): self.image = r.content -class Transaction(TableDeclarativeBase): +class Transaction(DeferredReflection, TableDeclarativeBase): """A greed wallet transaction. Wallet credit ISN'T calculated from these, but they can be used to recalculate it.""" # TODO: split this into multiple tables @@ -188,10 +185,10 @@ class Transaction(TableDeclarativeBase): __tablename__ = "transactions" __table_args__ = (UniqueConstraint("provider", "provider_charge_id"),) - def text(self, *, loc: localization.Localization): - string = f"T{self.transaction_id} | {str(self.user)} | {utils.Price(self.value, loc)}" + def text(self, w: "worker.Worker"): + string = f"T{self.transaction_id} | {str(self.user)} | {w.Price(self.value)}" if self.refunded: - string += f" | {loc.get('emoji_refunded')}" + string += f" | {w.loc['emoji_refunded']}" if self.provider: string += f" | {self.provider}" if self.notes: @@ -202,7 +199,7 @@ class Transaction(TableDeclarativeBase): return f"" -class Admin(TableDeclarativeBase): +class Admin(DeferredReflection, TableDeclarativeBase): """A greed administrator with his permissions.""" # The telegram id @@ -224,7 +221,7 @@ class Admin(TableDeclarativeBase): return f"" -class Order(TableDeclarativeBase): +class Order(DeferredReflection, TableDeclarativeBase): """An order which has been placed by an user. It may include multiple products, available in the OrderItem table.""" @@ -254,41 +251,41 @@ class Order(TableDeclarativeBase): def __repr__(self): return f"" - def text(self, *, loc: localization.Localization, session, user=False): + def text(self, w: "worker.Worker", session, user=False): joined_self = session.query(Order).filter_by(order_id=self.order_id).join(Transaction).one() items = "" for item in self.items: - items += item.text(loc=loc) + "\n" + items += item.text(w) + "\n" if self.delivery_date is not None: - status_emoji = loc.get("emoji_completed") - status_text = loc.get("text_completed") + status_emoji = w.loc.get("emoji_completed") + status_text = w.loc.get("text_completed") elif self.refund_date is not None: - status_emoji = loc.get("emoji_refunded") - status_text = loc.get("text_refunded") + status_emoji = w.loc.get("emoji_refunded") + status_text = w.loc.get("text_refunded") else: - status_emoji = loc.get("emoji_not_processed") - status_text = loc.get("text_not_processed") - if user and configloader.config["Appearance"]["full_order_info"] == "no": - return loc.get("user_order_format_string", - status_emoji=status_emoji, - status_text=status_text, - items=items, - notes=self.notes, - value=str(utils.Price(-joined_self.transaction.value, loc))) + \ - (loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "") + status_emoji = w.loc.get("emoji_not_processed") + status_text = w.loc.get("text_not_processed") + if user and w.cfg["Appearance"]["full_order_info"] == "no": + return w.loc.get("user_order_format_string", + status_emoji=status_emoji, + status_text=status_text, + items=items, + notes=self.notes, + value=str(w.Price(-joined_self.transaction.value))) + \ + (w.loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "") else: return status_emoji + " " + \ - loc.get("order_number", id=self.order_id) + "\n" + \ - loc.get("order_format_string", - user=self.user.mention(), - date=self.creation_date.isoformat(), - items=items, - notes=self.notes if self.notes is not None else "", - value=str(utils.Price(-joined_self.transaction.value, loc))) + \ - (loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "") + w.loc.get("order_number", id=self.order_id) + "\n" + \ + w.loc.get("order_format_string", + user=self.user.mention(), + date=self.creation_date.isoformat(), + items=items, + notes=self.notes if self.notes is not None else "", + value=str(w.Price(-joined_self.transaction.value))) + \ + (w.loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "") -class OrderItem(TableDeclarativeBase): +class OrderItem(DeferredReflection, TableDeclarativeBase): """A product that has been purchased as part of an order.""" # The unique item id @@ -302,11 +299,8 @@ class OrderItem(TableDeclarativeBase): # Extra table parameters __tablename__ = "orderitems" - def text(self, *, loc: localization.Localization): - return f"{self.product.name} - {str(utils.Price(self.product.price, loc))}" + def text(self, w: "worker.Worker"): + return f"{self.product.name} - {str(w.Price(self.product.price))}" def __repr__(self): return f"" - - -TableDeclarativeBase.metadata.create_all() diff --git a/duckbot.py b/duckbot.py new file mode 100644 index 0000000..59f6cb0 --- /dev/null +++ b/duckbot.py @@ -0,0 +1,116 @@ +import nuconfig +import telegram.error +import logging +import time +import traceback +import sys + +log = logging.getLogger(__name__) + + +def factory(cfg: nuconfig.NuConfig): + """Construct a DuckBot type based on the passed config.""" + + def catch_telegram_errors(func): + """Decorator, can be applied to any function to retry in case of Telegram errors.""" + + def result_func(*args, **kwargs): + while True: + try: + return func(*args, **kwargs) + # Bot was blocked by the user + except telegram.error.Unauthorized: + log.debug(f"Unauthorized to call {func.__name__}(), skipping.") + break + # Telegram API didn't answer in time + except telegram.error.TimedOut: + log.warning(f"Timed out while calling {func.__name__}()," + f" retrying in {cfg['Telegram']['timed_out_pause']} secs...") + time.sleep(cfg["Telegram"]["timed_out_pause"]) + # Telegram is not reachable + except telegram.error.NetworkError as error: + log.error(f"Network error while calling {func.__name__}()," + f" retrying in {cfg['Telegram']['error_pause']} secs...\n" + f"Full error: {error.message}") + time.sleep(cfg["Telegram"]["error_pause"]) + # Unknown error + except telegram.error.TelegramError as error: + if error.message.lower() in ["bad gateway", "invalid server response"]: + log.warning(f"Bad Gateway while calling {func.__name__}()," + f" retrying in {cfg['Telegram']['error_pause']} secs...") + time.sleep(cfg["Telegram"]["error_pause"]) + elif error.message.lower() == "timed out": + log.warning(f"Timed out while calling {func.__name__}()," + f" retrying in {cfg['Telegram']['timed_out_pause']} secs...") + time.sleep(cfg["Telegram"]["timed_out_pause"]) + else: + log.error(f"Telegram error while calling {func.__name__}()," + f" retrying in {cfg['Telegram']['error_pause']} secs...\n" + f"Full error: {error.message}") + traceback.print_exception(*sys.exc_info()) + time.sleep(cfg["Telegram"]["error_pause"]) + + return result_func + + class DuckBot: + def __init__(self, *args, **kwargs): + self.bot = telegram.Bot(token=cfg["Telegram"]["token"], *args, **kwargs) + + @catch_telegram_errors + def send_message(self, *args, **kwargs): + # All messages are sent in HTML parse mode + return self.bot.send_message(parse_mode="HTML", *args, **kwargs) + + @catch_telegram_errors + def edit_message_text(self, *args, **kwargs): + # All messages are sent in HTML parse mode + return self.bot.edit_message_text(parse_mode="HTML", *args, **kwargs) + + @catch_telegram_errors + def edit_message_caption(self, *args, **kwargs): + # All messages are sent in HTML parse mode + return self.bot.edit_message_caption(parse_mode="HTML", *args, **kwargs) + + @catch_telegram_errors + def edit_message_reply_markup(self, *args, **kwargs): + return self.bot.edit_message_reply_markup(*args, **kwargs) + + @catch_telegram_errors + def get_updates(self, *args, **kwargs): + return self.bot.get_updates(*args, **kwargs) + + @catch_telegram_errors + def get_me(self, *args, **kwargs): + return self.bot.get_me(*args, **kwargs) + + @catch_telegram_errors + def answer_callback_query(self, *args, **kwargs): + return self.bot.answer_callback_query(*args, **kwargs) + + @catch_telegram_errors + def answer_pre_checkout_query(self, *args, **kwargs): + return self.bot.answer_pre_checkout_query(*args, **kwargs) + + @catch_telegram_errors + def send_invoice(self, *args, **kwargs): + return self.bot.send_invoice(*args, **kwargs) + + @catch_telegram_errors + def get_file(self, *args, **kwargs): + return self.bot.get_file(*args, **kwargs) + + @catch_telegram_errors + def send_chat_action(self, *args, **kwargs): + return self.bot.send_chat_action(*args, **kwargs) + + @catch_telegram_errors + def delete_message(self, *args, **kwargs): + return self.bot.delete_message(*args, **kwargs) + + @catch_telegram_errors + def send_document(self, *args, **kwargs): + return self.bot.send_document(*args, **kwargs) + + # More methods can be added here + + return DuckBot diff --git a/nuconfig.py b/nuconfig.py new file mode 100644 index 0000000..e77cca8 --- /dev/null +++ b/nuconfig.py @@ -0,0 +1,91 @@ +from typing import * +import toml +import logging + + +log = logging.getLogger(__name__) +CompareReport = Dict[str, Union[str, List[str], "Missing"]] + + +class NuConfig: + def __init__(self, file: "TextIO"): + self.data = toml.load(file) + + def __getitem__(self, item): + return self.data.__getitem__(item) + + def cmplog(self, other) -> bool: + """Compare two different NuConfig objects and log information about which keys are missing or invalid. + Returns a bool, which is false if there was something to report and true otherwise.""" + compare_report: CompareReport = self.compare(other) + self.__cmplog_log(compare_report) + return compare_report == {} + + @staticmethod + def __cmplog_log(compare_report: CompareReport, root: str = "") -> None: + """The recursive portion of :meth:`.cmplog`.""" + for item in compare_report.get("__missing__", []): + log.error(f"Missing key: {root}{item}") + + for item in compare_report.get("__invalid__", []): + log.error(f"Key has an invalid type: {root}{item}") + + for key, value in compare_report.items(): + if key == "__missing__" or key == "__invalid__": + continue + NuConfig.__cmplog_log(value, root=f"{root}{key}.") + + def compare(self, other: "NuConfig") -> CompareReport: + """Compare two different NuConfig objects and return a dictionary of the keys missing in the other.""" + if not isinstance(other, NuConfig): + raise TypeError("You can only compare two NuConfig objects.") + return self.__compare_recurse(self.data, other.data) + + @staticmethod + def __compare_miss(self: dict) -> CompareReport: + """Mark all keys of a dict as missing.""" + missing = [] + + result = {} + + for key, value in self.items(): + missing.append(key) + if isinstance(value, dict): + result[key] = NuConfig.__compare_miss(value) + + if missing: + result["__missing__"] = missing + + return result + + @staticmethod + def __compare_recurse(self: dict, other: dict) -> CompareReport: + """The recursive portion of :meth:`.compare`.""" + invalid = [] + missing = [] + + result = {} + + for key, value in self.items(): + try: + other_value = other[key] + except KeyError: + missing.append(key) + if isinstance(value, dict): + result[key] = NuConfig.__compare_miss(value) + else: + if type(value) != type(other_value): + invalid.append(key) + if isinstance(value, dict): + result[key] = NuConfig.__compare_miss(value) + elif isinstance(value, dict): + recursive_result = NuConfig.__compare_recurse(value, other_value) + if recursive_result != {}: + result[key] = recursive_result + + if invalid: + result["__invalid__"] = invalid + if missing: + result["__missing__"] = missing + + return result diff --git a/requirements.txt b/requirements.txt index 218352f..2ba74d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ python-telegram-bot sqlalchemy requests -raven +toml diff --git a/utils.py b/utils.py index 0b2e47d..41482c8 100644 --- a/utils.py +++ b/utils.py @@ -1,233 +1,5 @@ -import telegram -import telegram.error -import time -from configloader import config -import typing -import os -import sys -import importlib -import logging -import traceback -import localization - - -log = logging.getLogger(__name__) - - -if config["Error Reporting"]["sentry_token"] != \ - "https://00000000000000000000000000000000:00000000000000000000000000000000@sentry.io/0000000": - import raven - import raven.exceptions - try: - release = raven.fetch_git_sha(os.path.dirname(__file__)) - except raven.exceptions.InvalidGitRepository: - release = "Unknown" - sentry_client = raven.Client(config["Error Reporting"]["sentry_token"], - release=release, - environment="Dev" if __debug__ else "Prod") -else: - sentry_client = None - - -class Price: - """The base class for the prices in greed. - Its int value is in minimum units, while its float and str values are in decimal format.int(""" - - def __init__(self, value: typing.Union[int, float, str, "Price"], loc: localization.Localization): - # Keep a reference to the localization file - self.loc = loc - if isinstance(value, int): - # Keep the value as it is - self.value = int(value) - elif isinstance(value, float): - # Convert the value to minimum units - self.value = int(value * (10 ** int(config["Payments"]["currency_exp"]))) - elif isinstance(value, str): - # Remove decimal points, then cast to int - self.value = int(float(value.replace(",", ".")) * (10 ** int(config["Payments"]["currency_exp"]))) - elif isinstance(value, Price): - # Copy self - self.value = value.value - - def __repr__(self): - return f"" - - def __str__(self): - return self.loc.get("currency_format_string", - symbol=config["Payments"]["currency_symbol"], - value="{0:.2f}".format(self.value / (10 ** int(config["Payments"]["currency_exp"])))) - - def __int__(self): - return self.value - - def __float__(self): - return self.value / (10 ** int(config["Payments"]["currency_exp"])) - - def __ge__(self, other): - return self.value >= Price(other, self.loc).value - - def __le__(self, other): - return self.value <= Price(other, self.loc).value - - def __eq__(self, other): - return self.value == Price(other, self.loc).value - - def __gt__(self, other): - return self.value > Price(other, self.loc).value - - def __lt__(self, other): - return self.value < Price(other, self.loc).value - - def __add__(self, other): - return Price(self.value + Price(other, self.loc).value, self.loc) - - def __sub__(self, other): - return Price(self.value - Price(other, self.loc).value, self.loc) - - def __mul__(self, other): - return Price(int(self.value * other), self.loc) - - def __floordiv__(self, other): - return Price(int(self.value // other), self.loc) - - def __radd__(self, other): - return self.__add__(other) - - def __rsub__(self, other): - return Price(Price(other, self.loc).value - self.value, self.loc) - - def __rmul__(self, other): - - return self.__mul__(other) - - def __iadd__(self, other): - self.value += Price(other, self.loc).value - return self - - def __isub__(self, other): - self.value -= Price(other, self.loc).value - return self - - def __imul__(self, other): - self.value *= other - self.value = int(self.value) - return self - - def __ifloordiv__(self, other): - self.value //= other - return self - - def telegram_html_escape(string: str): return string.replace("<", "<") \ .replace(">", ">") \ .replace("&", "&") \ .replace('"', """) - - -def catch_telegram_errors(func): - """Decorator, can be applied to any function to retry in case of Telegram errors.""" - - def result_func(*args, **kwargs): - while True: - try: - return func(*args, **kwargs) - # Bot was blocked by the user - except telegram.error.Unauthorized: - log.debug(f"Unauthorized to call {func.__name__}(), skipping.") - break - # Telegram API didn't answer in time - except telegram.error.TimedOut: - log.warning(f"Timed out while calling {func.__name__}()," - f" retrying in {config['Telegram']['timed_out_pause']} secs...") - time.sleep(int(config["Telegram"]["timed_out_pause"])) - # Telegram is not reachable - except telegram.error.NetworkError as error: - log.error(f"Network error while calling {func.__name__}()," - f" retrying in {config['Telegram']['error_pause']} secs...\n" - f"Full error: {error.message}") - time.sleep(int(config["Telegram"]["error_pause"])) - # Unknown error - except telegram.error.TelegramError as error: - if error.message.lower() in ["bad gateway", "invalid server response"]: - log.warning(f"Bad Gateway while calling {func.__name__}()," - f" retrying in {config['Telegram']['error_pause']} secs...") - time.sleep(int(config["Telegram"]["error_pause"])) - elif error.message.lower() == "timed out": - log.warning(f"Timed out while calling {func.__name__}()," - f" retrying in {config['Telegram']['timed_out_pause']} secs...") - time.sleep(int(config["Telegram"]["timed_out_pause"])) - else: - log.error(f"Telegram error while calling {func.__name__}()," - f" retrying in {config['Telegram']['error_pause']} secs...\n" - f"Full error: {error.message}") - # Send the error to the Sentry server - if sentry_client is not None: - sentry_client.captureException(exc_info=sys.exc_info()) - else: - traceback.print_exception(*sys.exc_info()) - time.sleep(int(config["Telegram"]["error_pause"])) - - return result_func - - -class DuckBot: - def __init__(self, *args, **kwargs): - self.bot = telegram.Bot(*args, **kwargs) - - @catch_telegram_errors - def send_message(self, *args, **kwargs): - # All messages are sent in HTML parse mode - return self.bot.send_message(parse_mode="HTML", *args, **kwargs) - - @catch_telegram_errors - def edit_message_text(self, *args, **kwargs): - # All messages are sent in HTML parse mode - return self.bot.edit_message_text(parse_mode="HTML", *args, **kwargs) - - @catch_telegram_errors - def edit_message_caption(self, *args, **kwargs): - # All messages are sent in HTML parse mode - return self.bot.edit_message_caption(parse_mode="HTML", *args, **kwargs) - - @catch_telegram_errors - def edit_message_reply_markup(self, *args, **kwargs): - return self.bot.edit_message_reply_markup(*args, **kwargs) - - @catch_telegram_errors - def get_updates(self, *args, **kwargs): - return self.bot.get_updates(*args, **kwargs) - - @catch_telegram_errors - def get_me(self, *args, **kwargs): - return self.bot.get_me(*args, **kwargs) - - @catch_telegram_errors - def answer_callback_query(self, *args, **kwargs): - return self.bot.answer_callback_query(*args, **kwargs) - - @catch_telegram_errors - def answer_pre_checkout_query(self, *args, **kwargs): - return self.bot.answer_pre_checkout_query(*args, **kwargs) - - @catch_telegram_errors - def send_invoice(self, *args, **kwargs): - return self.bot.send_invoice(*args, **kwargs) - - @catch_telegram_errors - def get_file(self, *args, **kwargs): - return self.bot.get_file(*args, **kwargs) - - @catch_telegram_errors - def send_chat_action(self, *args, **kwargs): - return self.bot.send_chat_action(*args, **kwargs) - - @catch_telegram_errors - def delete_message(self, *args, **kwargs): - return self.bot.delete_message(*args, **kwargs) - - @catch_telegram_errors - def send_document(self, *args, **kwargs): - return self.bot.send_document(*args, **kwargs) - - # More methods can be added here diff --git a/worker.py b/worker.py index c909476..5aadf32 100644 --- a/worker.py +++ b/worker.py @@ -3,18 +3,18 @@ from typing import * import uuid import datetime import telegram -import configloader +import nuconfig 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 logging import localization +import sqlalchemy.orm log = logging.getLogger(__name__) @@ -33,16 +33,24 @@ class CancelSignal: class Worker(threading.Thread): """A worker for a single conversation. A new one is created every time the /start command is sent.""" - def __init__(self, bot: utils.DuckBot, chat: telegram.Chat, telegram_user: telegram.User, *args, **kwargs): + def __init__(self, + bot, + chat: telegram.Chat, + telegram_user: telegram.User, + cfg: nuconfig.NuConfig, + engine, + *args, + **kwargs): # Initialize the thread super().__init__(name=f"Worker {chat.id}", *args, **kwargs) - # Store the bot and chat info inside the class - self.bot: utils.DuckBot = bot + # Store the bot, chat info and config inside the class + self.bot = bot self.chat: telegram.Chat = chat self.telegram_user: telegram.User = telegram_user + self.cfg = cfg # Open a new database session log.debug(f"Opening new database session for {self.name}") - self.session = db.Session() + self.session = sqlalchemy.orm.sessionmaker(bind=engine)() # Get the user db data from the users and admin tables self.user: Optional[db.User] = None self.admin: Optional[db.Admin] = None @@ -50,23 +58,103 @@ class Worker(threading.Thread): self.queue = queuem.Queue() # The current active invoice payload; reject all invoices with a different payload self.invoice_payload = None - # The localization strings for this user - self.loc = None - # The Sentry client for reporting errors encountered by the user - if configloader.config["Error Reporting"]["sentry_token"] != \ - "https://00000000000000000000000000000000:00000000000000000000000000000000@sentry.io/0000000": - import raven - self.sentry_client = raven.Client(configloader.config["Error Reporting"]["sentry_token"], - release=raven.fetch_git_sha(os.path.dirname(__file__)), - environment="Dev" if __debug__ else "Prod") - log.debug("Sentry: enabled") - else: - self.sentry_client = None - log.debug("Sentry: disabled") + # The price class of this worker. + self.Price = self.price_factory() def __repr__(self): return f"<{self.__class__.__qualname__} {self.chat.id}>" + # noinspection PyMethodParameters + def price_factory(worker): + class Price: + """The base class for the prices in greed. + Its int value is in minimum units, while its float and str values are in decimal format.""" + + def __init__(self, value: Union[int, float, str, "Price"]): + if isinstance(value, int): + # Keep the value as it is + self.value = int(value) + elif isinstance(value, float): + # Convert the value to minimum units + self.value = int(value * (10 ** worker.cfg["Payments"]["currency_exp"])) + elif isinstance(value, str): + # Remove decimal points, then cast to int + self.value = int(float(value.replace(",", ".")) * (10 ** worker.cfg["Payments"]["currency_exp"])) + elif isinstance(value, Price): + # Copy self + self.value = value.value + + def __repr__(self): + return f"<{self.__class__.__qualname__} of value {self.value}>" + + def __str__(self): + return worker.loc.get( + "currency_format_string", + symbol=worker.cfg["Payments"]["currency_symbol"], + value="{0:.2f}".format(self.value / (10 ** worker.cfg["Payments"]["currency_exp"])) + ) + + def __int__(self): + return self.value + + def __float__(self): + return self.value / (10 ** worker.cfg["Payments"]["currency_exp"]) + + def __ge__(self, other): + return self.value >= Price(other).value + + def __le__(self, other): + return self.value <= Price(other).value + + def __eq__(self, other): + return self.value == Price(other).value + + def __gt__(self, other): + return self.value > Price(other).value + + def __lt__(self, other): + return self.value < Price(other).value + + def __add__(self, other): + return Price(self.value + Price(other).value) + + def __sub__(self, other): + return Price(self.value - Price(other).value) + + def __mul__(self, other): + return Price(int(self.value * other)) + + def __floordiv__(self, other): + return Price(int(self.value // other)) + + def __radd__(self, other): + return self.__add__(other) + + def __rsub__(self, other): + return Price(Price(other).value - self.value) + + def __rmul__(self, other): + return self.__mul__(other) + + def __iadd__(self, other): + self.value += Price(other).value + return self + + def __isub__(self, other): + self.value -= Price(other).value + return self + + def __imul__(self, other): + self.value *= other + self.value = int(self.value) + return self + + def __ifloordiv__(self, other): + self.value //= other + return self + + return Price + def run(self): """The conversation code.""" log.debug("Starting conversation") @@ -78,7 +166,7 @@ class Worker(threading.Thread): # Check if there are other registered users: if there aren't any, the first user will be owner of the bot will_be_owner = (self.session.query(db.Admin).first() is None) # Create the new record - self.user = db.User(self.telegram_user) + self.user = db.User(w=self) # Add the new record to the db self.session.add(self.user) # Flush the session to get an userid @@ -106,7 +194,7 @@ class Worker(threading.Thread): # noinspection PyBroadException try: # Welcome the user to the bot - if configloader.config["Appearance"]["display_welcome_message"] == "yes": + if self.cfg["Appearance"]["display_welcome_message"] == "yes": self.bot.send_message(self.chat.id, self.loc.get("conversation_after_start")) # If the user is not an admin, send him to the user menu if self.admin is None: @@ -119,16 +207,14 @@ class Worker(threading.Thread): self.session.commit() # Open the admin menu self.__admin_menu() - except Exception: + except Exception as e: # Try to notify the user of the exception # noinspection PyBroadException try: self.bot.send_message(self.chat.id, self.loc.get("fatal_conversation_exception")) - except Exception: - pass - # If the Sentry integration is enabled, log the exception - if self.sentry_client is not None: - self.sentry_client.captureException() + except Exception as ne: + log.error(f"Failed to notify the user of a conversation exception: {ne}") + log.error(f"Exception in {self}: {e}") traceback.print_exception(*sys.exc_info()) def is_ready(self): @@ -155,7 +241,7 @@ class Worker(threading.Thread): If a stop signal is sent, try to gracefully stop the thread.""" # Pop data from the queue try: - data = self.queue.get(timeout=int(configloader.config["Telegram"]["conversation_timeout"])) + data = self.queue.get(timeout=self.cfg["Telegram"]["conversation_timeout"]) except queuem.Empty: # If the conversation times out, gracefully stop the thread self.__graceful_stop(StopSignal("timeout")) @@ -365,7 +451,7 @@ class Worker(threading.Thread): # Send the previously created keyboard to the user (ensuring it can be clicked only 1 time) self.bot.send_message(self.chat.id, self.loc.get("conversation_open_user_menu", - credit=utils.Price(self.user.credit, self.loc)), + credit=self.Price(self.user.credit)), reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) # Wait for a reply from the user selection = self.__wait_for_specific_message([ @@ -417,7 +503,7 @@ class Worker(threading.Thread): if product.price is None: continue # Send the message without the keyboard to get the message id - message = product.send_as_message(loc=self.loc, chat_id=self.chat.id) + message = product.send_as_message(w=self, chat_id=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 @@ -428,12 +514,12 @@ class Worker(threading.Thread): if product.image is None: self.bot.edit_message_text(chat_id=self.chat.id, message_id=message['result']['message_id'], - text=product.text(loc=self.loc), + text=product.text(w=self), reply_markup=inline_keyboard) else: self.bot.edit_message_caption(chat_id=self.chat.id, message_id=message['result']['message_id'], - caption=product.text(loc=self.loc), + caption=product.text(w=self), reply_markup=inline_keyboard) # Create the keyboard with the cancel button inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_cancel"), @@ -477,13 +563,13 @@ class Worker(threading.Thread): if product.image is None: self.bot.edit_message_text(chat_id=self.chat.id, message_id=callback.message.message_id, - text=product.text(loc=self.loc, + text=product.text(w=self, cart_qty=cart[callback.message.message_id][1]), 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(loc=self.loc, + caption=product.text(w=self, cart_qty=cart[callback.message.message_id][1]), reply_markup=product_inline_keyboard) @@ -525,13 +611,13 @@ class Worker(threading.Thread): # Edit the product 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(loc=self.loc, + text=product.text(w=self, cart_qty=cart[callback.message.message_id][1]), 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(loc=self.loc, + caption=product.text(w=self, cart_qty=cart[callback.message.message_id][1]), reply_markup=product_inline_keyboard) @@ -573,12 +659,12 @@ class Worker(threading.Thread): if credit_required > 0: self.bot.send_message(self.chat.id, self.loc.get("error_not_enough_credit")) # Suggest payment for missing credit value if configuration allows refill - if configloader.config["Credit Card"]["credit_card_token"] != "" \ - and configloader.config["Appearance"]["refill_on_checkout"] == 'yes' \ - and utils.Price(int(configloader.config["Credit Card"]["min_amount"]), self.loc) <= \ + if self.cfg["Payments"]["CreditCard"]["credit_card_token"] != "" \ + and self.cfg["Appearance"]["refill_on_checkout"] == 'yes' \ + and self.Price(self.cfg["Payments"]["CreditCard"]["min_amount"]) <= \ credit_required <= \ - utils.Price(int(configloader.config["Credit Card"]["max_amount"]), self.loc): - self.__make_payment(utils.Price(credit_required, self.loc)) + self.Price(self.cfg["Payments"]["CreditCard"]["max_amount"]): + self.__make_payment(self.Price(credit_required)) # If afer requested payment credit is still insufficient (either payment failure or cancel) if self.user.credit < self.__get_cart_value(cart): # Rollback all the changes @@ -589,7 +675,7 @@ class Worker(threading.Thread): def __get_cart_value(self, cart): # Calculate total items value in cart - value = utils.Price(0, self.loc) + value = self.Price(0) for product in cart: value += cart[product][0].price * cart[product][1] return value @@ -599,7 +685,7 @@ class Worker(threading.Thread): product_list = "" for product_id in cart: if cart[product_id][1] > 0: - product_list += cart[product_id][0].text(loc=self.loc, + product_list += cart[product_id][0].text(w=self, style="short", cart_qty=cart[product_id][1]) + "\n" return product_list @@ -621,7 +707,7 @@ class Worker(threading.Thread): def __order_notify_admins(self, order): # Notify the user of the order result - self.bot.send_message(self.chat.id, self.loc.get("success_order_created", order=order.text(loc=self.loc, + self.bot.send_message(self.chat.id, self.loc.get("success_order_created", order=order.text(w=self, session=self.session, user=True))) # Notify the admins (in Live Orders mode) of the new order @@ -636,7 +722,7 @@ class Worker(threading.Thread): for admin in admins: self.bot.send_message(admin.user_id, self.loc.get('notification_order_placed', - order=order.text(loc=self.loc, session=self.session)), + order=order.text(w=self, session=self.session)), reply_markup=order_keyboard) def __order_status(self): @@ -653,7 +739,7 @@ class Worker(threading.Thread): self.bot.send_message(self.chat.id, self.loc.get("error_no_orders")) # Display the order status to the user for order in orders: - self.bot.send_message(self.chat.id, order.text(loc=self.loc, session=self.session, user=True)) + self.bot.send_message(self.chat.id, order.text(w=self, session=self.session, user=True)) # TODO: maybe add a page displayer instead of showing the latest 5 orders def __add_credit_menu(self): @@ -665,7 +751,7 @@ class Worker(threading.Thread): # Cash keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cash"))]) # Telegram Payments - if configloader.config["Credit Card"]["credit_card_token"] != "": + if self.cfg["Payments"]["CreditCard"]["credit_card_token"] != "": keyboard.append([telegram.KeyboardButton(self.loc.get("menu_credit_card"))]) # Keyboard: go back to the previous menu keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cancel"))]) @@ -693,8 +779,8 @@ class Worker(threading.Thread): """Add money to the wallet through a credit card payment.""" log.debug("Displaying __add_credit_cc") # Create a keyboard to be sent later - presets = list(map(lambda s: s.strip(" "), configloader.config["Credit Card"]["payment_presets"].split('|'))) - keyboard = [[telegram.KeyboardButton(str(utils.Price(preset, self.loc)))] for preset in presets] + presets = list(map(lambda s: s.strip(" "), self.cfg["Payments"]["CreditCard"]["payment_presets"].split('|'))) + keyboard = [[telegram.KeyboardButton(str(self.Price(preset)))] for preset in presets] keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cancel"))]) # Boolean variable to check if the user has cancelled the action cancelled = False @@ -711,15 +797,15 @@ class Worker(threading.Thread): cancelled = True continue # Convert the amount to an integer - value = utils.Price(selection, self.loc) + value = self.Price(selection) # Ensure the amount is within the range - if value > utils.Price(int(configloader.config["Credit Card"]["max_amount"]), self.loc): + if value > self.Price(self.cfg["Payments"]["CreditCard"]["max_amount"]): self.bot.send_message(self.chat.id, - self.loc.get("error_payment_amount_over_max", max_amount=utils.Price(configloader.config["Credit Card"]["max_amount"], self.loc))) + self.loc.get("error_payment_amount_over_max", max_amount=self.Price(self.cfg["Credit Card"]["max_amount"]))) continue - elif value < utils.Price(int(configloader.config["Credit Card"]["min_amount"]), self.loc): + elif value < self.Price(self.cfg["Payments"]["CreditCard"]["min_amount"]): self.bot.send_message(self.chat.id, - self.loc.get("error_payment_amount_under_min", min_amount=utils.Price(configloader.config["Credit Card"]["min_amount"], self.loc))) + self.loc.get("error_payment_amount_under_min", min_amount=self.Price(self.cfg["Credit Card"]["min_amount"]))) continue break # If the user cancelled the action... @@ -740,7 +826,8 @@ class Worker(threading.Thread): prices.append(telegram.LabeledPrice(label=self.loc.get("payment_invoice_fee_label"), amount=fee)) # Create the invoice keyboard - inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_pay"), pay=True)], + inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_pay"), + pay=True)], [telegram.InlineKeyboardButton(self.loc.get("menu_cancel"), callback_data="cmd_cancel")]]) # The amount is valid, send the invoice @@ -748,13 +835,13 @@ class Worker(threading.Thread): title=self.loc.get("payment_invoice_title"), description=self.loc.get("payment_invoice_description", amount=str(amount)), payload=self.invoice_payload, - provider_token=configloader.config["Credit Card"]["credit_card_token"], + provider_token=self.cfg["Payments"]["CreditCard"]["credit_card_token"], start_parameter="tempdeeplink", - currency=configloader.config["Payments"]["currency"], + currency=self.cfg["Payments"]["currency"], prices=prices, - need_name=configloader.config["Credit Card"]["name_required"] == "yes", - need_email=configloader.config["Credit Card"]["email_required"] == "yes", - need_phone_number=configloader.config["Credit Card"]["phone_required"] == "yes", + need_name=self.cfg["Payments"]["CreditCard"]["name_required"], + need_email=self.cfg["Payments"]["CreditCard"]["email_required"], + need_phone_number=self.cfg["Payments"]["CreditCard"]["phone_required"], reply_markup=inline_keyboard) # Wait for the precheckout query precheckoutquery = self.__wait_for_precheckoutquery(cancellable=True) @@ -782,11 +869,10 @@ class Worker(threading.Thread): # Commit all the changes self.session.commit() - @staticmethod - def __get_total_fee(amount): + def __get_total_fee(self, amount): # Calculate a fee for the required amount - fee_percentage = float(configloader.config["Credit Card"]["fee_percentage"]) / 100 - fee_fixed = int(configloader.config["Credit Card"]["fee_fixed"]) + fee_percentage = self.cfg["Payments"]["CreditCard"]["fee_percentage"] / 100 + fee_fixed = self.cfg["Payments"]["CreditCard"]["fee_fixed"] total_fee = amount * fee_percentage + fee_fixed if total_fee > 0: return total_fee @@ -818,8 +904,7 @@ class Worker(threading.Thread): keyboard.append([self.loc.get("menu_user_mode")]) # Send the previously created keyboard to the user (ensuring it can be clicked only 1 time) self.bot.send_message(self.chat.id, self.loc.get("conversation_open_admin_menu"), - reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True), - ) + reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True)) # Wait for a reply from the user selection = self.__wait_for_specific_message([self.loc.get("menu_products"), self.loc.get("menu_orders"), @@ -934,8 +1019,8 @@ class Worker(threading.Thread): if product: self.bot.send_message(self.chat.id, self.loc.get("edit_current_value", - value=(str(utils.Price(product.price, self.loc)) - if product.price is not None else 'Non in vendita')), + value=(str(self.Price(product.price)) + if product.price is not None else 'Non in vendita')), reply_markup=cancel) # Wait for an answer price = self.__wait_for_regex(r"([0-9]+(?:[.,][0-9]{1,2})?|[Xx])", @@ -946,7 +1031,7 @@ class Worker(threading.Thread): elif price.lower() == "x": price = None else: - price = utils.Price(price, self.loc) + price = self.Price(price) # Ask for the product image self.bot.send_message(self.chat.id, self.loc.get("ask_product_image"), reply_markup=cancel) # Wait for an answer @@ -1044,7 +1129,7 @@ class Worker(threading.Thread): # Create a message for every one of them for order in orders: # Send the created message - self.bot.send_message(self.chat.id, order.text(loc=self.loc, session=self.session), + self.bot.send_message(self.chat.id, order.text(w=self, session=self.session), reply_markup=order_keyboard) # Set the Live mode flag to True self.admin.live_mode = True @@ -1073,11 +1158,11 @@ class Worker(threading.Thread): # Commit the transaction self.session.commit() # Update order message - self.bot.edit_message_text(order.text(loc=self.loc, session=self.session), chat_id=self.chat.id, + self.bot.edit_message_text(order.text(w=self, session=self.session), chat_id=self.chat.id, message_id=update.message.message_id) # Notify the user of the completition self.bot.send_message(order.user_id, - self.loc.get("notification_order_completed", order=order.text(loc=self.loc, session=self.session, user=True))) + self.loc.get("notification_order_completed", order=order.text(w=self, session=self.session, user=True))) # If the user pressed the refund order button, refund the order... elif update.data == "order_refund": # Ask for a refund reason @@ -1101,12 +1186,12 @@ class Worker(threading.Thread): # Commit the changes self.session.commit() # Update the order message - self.bot.edit_message_text(order.text(loc=self.loc, session=self.session), + self.bot.edit_message_text(order.text(w=self, session=self.session), chat_id=self.chat.id, message_id=update.message.message_id) # Notify the user of the refund self.bot.send_message(order.user_id, - self.loc.get("notification_order_refunded", order=order.text(loc=self.loc, + self.loc.get("notification_order_refunded", order=order.text(w=self, session=self.session, user=True))) # Notify the admin of the refund @@ -1131,7 +1216,7 @@ class Worker(threading.Thread): if isinstance(reply, CancelSignal): return # Convert the reply to a price object - price = utils.Price(reply, self.loc) + price = self.Price(reply) # Ask the user for notes self.bot.send_message(self.chat.id, self.loc.get("ask_transaction_notes"), reply_markup=cancel) # Wait for an answer @@ -1152,10 +1237,10 @@ class Worker(threading.Thread): # Notify the user of the credit/debit self.bot.send_message(user.user_id, self.loc.get("notification_transaction_created", - transaction=transaction.text(loc=self.loc))) + transaction=transaction.text(w=self))) # Notify the admin of the success self.bot.send_message(self.chat.id, self.loc.get("success_transaction_created", - transaction=transaction.text(loc=self.loc))) + transaction=transaction.text(w=self))) def __help_menu(self): """Help menu. Allows the user to ask for assistance, get a guide or see some info about the bot.""" @@ -1221,7 +1306,7 @@ class Worker(threading.Thread): # Create the inline keyboard markup inline_keyboard = telegram.InlineKeyboardMarkup(inline_keyboard_list) # Create the message text - transactions_string = "\n".join([transaction.text(loc=self.loc) for transaction in transactions]) + transactions_string = "\n".join([transaction.text(w=self) for transaction in transactions]) text = self.loc.get("transactions_page", page=page + 1, transactions=transactions_string) # Update the previously sent message self.bot.edit_message_text(chat_id=self.chat.id, message_id=message.message_id, text=text, @@ -1280,7 +1365,7 @@ class Worker(threading.Thread): # Reopen the file for reading with open(f"transactions_{self.chat.id}.csv") as file: # Send the file via a manual request to Telegram - requests.post(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendDocument", + requests.post(f"https://api.telegram.org/bot{self.cfg['Telegram']['token']}/sendDocument", files={"document": file}, params={"chat_id": self.chat.id, "parse_mode": "HTML"}) @@ -1299,9 +1384,11 @@ class Worker(threading.Thread): admin = self.session.query(db.Admin).filter_by(user_id=user.user_id).one_or_none() if admin is None: # Create the keyboard to be sent - keyboard = telegram.ReplyKeyboardMarkup([[self.loc.get("emoji_yes"), self.loc.get("emoji_no")]], one_time_keyboard=True) + keyboard = telegram.ReplyKeyboardMarkup([[self.loc.get("emoji_yes"), self.loc.get("emoji_no")]], + one_time_keyboard=True) # Ask for confirmation - self.bot.send_message(self.chat.id, self.loc.get("conversation_confirm_admin_promotion"), reply_markup=keyboard) + self.bot.send_message(self.chat.id, self.loc.get("conversation_confirm_admin_promotion"), + reply_markup=keyboard) # Wait for an answer selection = self.__wait_for_specific_message([self.loc.get("emoji_yes"), self.loc.get("emoji_no")]) # Proceed only if the answer is yes @@ -1321,17 +1408,26 @@ class Worker(threading.Thread): while True: # Create the inline keyboard with the admin status inline_keyboard = telegram.InlineKeyboardMarkup([ - [telegram.InlineKeyboardButton(f"{self.loc.boolmoji(admin.edit_products)} {self.loc.get('prop_edit_products')}", - callback_data="toggle_edit_products")], - [telegram.InlineKeyboardButton(f"{self.loc.boolmoji(admin.receive_orders)} {self.loc.get('prop_receive_orders')}", - callback_data="toggle_receive_orders")], + [telegram.InlineKeyboardButton( + f"{self.loc.boolmoji(admin.edit_products)} {self.loc.get('prop_edit_products')}", + callback_data="toggle_edit_products" + )], + [telegram.InlineKeyboardButton( + f"{self.loc.boolmoji(admin.receive_orders)} {self.loc.get('prop_receive_orders')}", + callback_data="toggle_receive_orders" + )], [telegram.InlineKeyboardButton( f"{self.loc.boolmoji(admin.create_transactions)} {self.loc.get('prop_create_transactions')}", - callback_data="toggle_create_transactions")], + callback_data="toggle_create_transactions" + )], [telegram.InlineKeyboardButton( f"{self.loc.boolmoji(admin.display_on_help)} {self.loc.get('prop_display_on_help')}", - callback_data="toggle_display_on_help")], - [telegram.InlineKeyboardButton(self.loc.get('menu_done'), callback_data="cmd_done")] + callback_data="toggle_display_on_help" + )], + [telegram.InlineKeyboardButton( + self.loc.get('menu_done'), + callback_data="cmd_done" + )] ]) # Update the inline keyboard self.bot.edit_message_reply_markup(message_id=message.message_id, @@ -1358,27 +1454,27 @@ class Worker(threading.Thread): keyboard = [] options: Dict[str, str] = {} # https://en.wikipedia.org/wiki/List_of_language_names - if "it" in configloader.config["Language"]["enabled_languages"]: + if "it" in self.cfg["Language"]["enabled_languages"]: lang = "🇮🇹 Italiano" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "it" - if "en" in configloader.config["Language"]["enabled_languages"]: + if "en" in self.cfg["Language"]["enabled_languages"]: lang = "🇬🇧 English" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "en" - if "ru" in configloader.config["Language"]["enabled_languages"]: + if "ru" in self.cfg["Language"]["enabled_languages"]: lang = "🇷🇺 Русский" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "ru" - if "uk" in configloader.config["Language"]["enabled_languages"]: + if "uk" in self.cfg["Language"]["enabled_languages"]: lang = "🇺🇦 Українська" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "uk" - if "zh_cn" in configloader.config["Language"]["enabled_languages"]: + if "zh_cn" in self.cfg["Language"]["enabled_languages"]: lang = "🇨🇳 简体中文" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "zh_cn" - if "he" in configloader.config["Language"]["enabled_languages"]: + if "he" in self.cfg["Language"]["enabled_languages"]: lang = "🇮🇱 עברית" keyboard.append([telegram.KeyboardButton(lang)]) options[lang] = "he" @@ -1397,14 +1493,14 @@ class Worker(threading.Thread): def __create_localization(self): # Check if the user's language is enabled; if it isn't, change it to the default - if self.user.language not in configloader.config["Language"]["enabled_languages"]: + if self.user.language not in self.cfg["Language"]["enabled_languages"]: log.debug(f"User's language '{self.user.language}' is not enabled, changing it to the default") - self.user.language = configloader.config["Language"]["default_language"] + self.user.language = self.cfg["Language"]["default_language"] self.session.commit() # Create a new Localization object self.loc = localization.Localization( language=self.user.language, - fallback=configloader.config["Language"]["fallback_language"], + fallback=self.cfg["Language"]["fallback_language"], replacements={ "user_string": str(self.user), "user_mention": self.user.mention(),