1
Fork 0
mirror of https://github.com/Steffo99/greed.git synced 2024-11-24 06:44:19 +00:00

Do a lot of progress on the new config system

This commit is contained in:
Steffo 2020-09-06 23:56:57 +02:00
parent 378bbbad43
commit daaff4357d
10 changed files with 522 additions and 481 deletions

View file

@ -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

100
config/template_config.toml Normal file
View file

@ -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"

View file

@ -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)

53
core.py
View file

@ -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()

View file

@ -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

116
duckbot.py Normal file
View file

@ -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

91
nuconfig.py Normal file
View file

@ -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

View file

@ -1,4 +1,4 @@
python-telegram-bot
sqlalchemy
requests
raven
toml

228
utils.py
View file

@ -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"<Price of value {self.value}>"
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("<", "&lt;") \
.replace(">", "&gt;") \
.replace("&", "&amp;") \
.replace('"', "&quot;")
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

245
worker.py
View file

@ -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,7 +1018,7 @@ 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))
value=(str(self.Price(product.price))
if product.price is not None else 'Non in vendita')),
reply_markup=cancel)
# Wait for an answer
@ -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(),