From daaff4357df04248d777c176647f640e9e1e6500 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 6 Sep 2020 23:56:57 +0200 Subject: [PATCH] Do a lot of progress on the new config system --- config/template_config.ini | 110 ---------------- config/template_config.toml | 100 +++++++++++++++ configloader.py | 55 -------- core.py | 53 ++++++-- database.py | 1 - duckbot.py | 116 +++++++++++++++++ nuconfig.py | 91 +++++++++++++ requirements.txt | 2 +- utils.py | 228 --------------------------------- worker.py | 247 +++++++++++++++++++++++++----------- 10 files changed, 522 insertions(+), 481 deletions(-) delete mode 100644 config/template_config.ini create mode 100644 config/template_config.toml delete mode 100644 configloader.py create mode 100644 duckbot.py create mode 100644 nuconfig.py 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..cb9d90e 100644 --- a/core.py +++ b/core.py @@ -1,11 +1,12 @@ +import os import sys import telegram import worker -import configloader -import utils +import nuconfig import threading import localization import logging +import duckbot try: import coloredlogs @@ -18,14 +19,45 @@ 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.ini", encoding="utf8") as template_cfg_file, \ + open("config/config.ini", "w", encoding="utf8") as user_cfg_file: + # Copy the template file to the config file + user_cfg_file.write(template_cfg_file.read()) + + print("A config file has been created in config/config.toml." + " Edit it with your configuration, then restart this script.") + exit(1) + + # Compare the template config with the user-made one + with open("config/template_config.ini", encoding="utf8") as template_cfg_file, \ + open("config/config.ini", 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("Invalid configuration, refusing to start.") + exit(2) + + # 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!") @@ -34,7 +66,7 @@ def main(): logging.getLogger("telegram").setLevel("ERROR") # 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 +78,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) @@ -65,7 +97,7 @@ def main(): # Get a new batch of 100 updates and mark the last 100 parsed as read log.debug("Getting updates from Telegram") updates = bot.get_updates(offset=next_update, - timeout=int(configloader.config["Telegram"]["long_polling_timeout"])) + timeout=int(user_cfg["Telegram"]["long_polling_timeout"])) # Parse all the updates for update in updates: # If the update is a message... @@ -89,7 +121,8 @@ 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) # Start the worker log.debug(f"Starting {new_worker.name}") new_worker.start() diff --git a/database.py b/database.py index c305828..7b6d521 100644 --- a/database.py +++ b/database.py @@ -3,7 +3,6 @@ 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 import telegram import requests import utils 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..24d655a --- /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 config key: {root}{item}") + + for item in compare_report.get("__invalid__", []): + log.error(f"Invalid config key: {root}{item}") + + for key, value in compare_report: + 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..9f17862 100644 --- a/worker.py +++ b/worker.py @@ -3,12 +3,11 @@ 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 @@ -33,13 +32,20 @@ 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, + *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() @@ -50,23 +56,104 @@ 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") @@ -106,7 +193,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 +206,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 +240,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 +450,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([ @@ -573,12 +658,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 +674,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 @@ -665,7 +750,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 +778,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 +796,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 +825,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 +834,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 +868,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 +903,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 +1018,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 +1030,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 @@ -1131,7 +1215,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 @@ -1280,7 +1364,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 +1383,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 +1407,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 +1453,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 +1492,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(),