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

Completely rework the configuration system (#76)

* Do a lot of progress on the new config system

* Completely rework the configuration system

* Improve logging messages
This commit is contained in:
Steffo 2020-09-07 17:11:22 +02:00 committed by GitHub
parent 378bbbad43
commit 6a49756394
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 627 additions and 571 deletions

1
.gitignore vendored
View file

@ -102,5 +102,6 @@ ENV/
.idea/
config/config.ini
config/config.toml
*.sqlite
*.sqlite-journal

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)

73
core.py
View file

@ -1,11 +1,16 @@
import os
import sys
import telegram
import worker
import configloader
import utils
import nuconfig
import threading
import localization
import logging
import duckbot
import sqlalchemy
import sqlalchemy.orm
import sqlalchemy.ext.declarative as sed
import database
try:
import coloredlogs
@ -18,14 +23,47 @@ def main():
# Rename the main thread for presentation purposes
threading.current_thread().name = "Core"
# Setup logging
# Start logging setup
log = logging.getLogger("core")
logging.root.setLevel(configloader.config["Logging"]["level"])
logging.root.setLevel("INFO")
log.debug("Set logging level to INFO while the config is being loaded")
# Ensure the template config file exists
if not os.path.isfile("config/template_config.toml"):
log.fatal("config/template_config.toml does not exist!")
exit(254)
# If the config file does not exist, clone the template and exit
if not os.path.isfile("config/config.toml"):
log.debug("config/config.toml does not exist.")
with open("config/template_config.toml", encoding="utf8") as template_cfg_file, \
open("config/config.toml", "w", encoding="utf8") as user_cfg_file:
# Copy the template file to the config file
user_cfg_file.write(template_cfg_file.read())
log.fatal("A config file has been created in config/config.toml."
" Customize it, then restart greed!")
exit(1)
# Compare the template config with the user-made one
with open("config/template_config.toml", encoding="utf8") as template_cfg_file, \
open("config/config.toml", encoding="utf8") as user_cfg_file:
template_cfg = nuconfig.NuConfig(template_cfg_file)
user_cfg = nuconfig.NuConfig(user_cfg_file)
if not template_cfg.cmplog(user_cfg):
log.fatal("There were errors while parsing the config.toml file. Please fix them and restart greed!")
exit(2)
else:
log.debug("Configuration parsed successfully!")
# Finish logging setup
logging.root.setLevel(user_cfg["Logging"]["level"])
stream_handler = logging.StreamHandler()
if coloredlogs is not None:
stream_handler.formatter = coloredlogs.ColoredFormatter(configloader.config["Logging"]["format"], style="{")
stream_handler.formatter = coloredlogs.ColoredFormatter(user_cfg["Logging"]["format"], style="{")
else:
stream_handler.formatter = logging.Formatter(configloader.config["Logging"]["format"], style="{")
stream_handler.formatter = logging.Formatter(user_cfg["Logging"]["format"], style="{")
logging.root.handlers.clear()
logging.root.addHandler(stream_handler)
log.debug("Logging setup successfully!")
@ -33,8 +71,18 @@ def main():
# Ignore most python-telegram-bot logs, as they are useless most of the time
logging.getLogger("telegram").setLevel("ERROR")
# Create the database engine
log.debug("Creating the sqlalchemy engine...")
engine = sqlalchemy.create_engine(user_cfg["Database"]["engine"])
log.debug("Preparing the tables through deferred reflection...")
sed.DeferredReflection.prepare(engine)
log.debug("Binding metadata to the engine...")
database.TableDeclarativeBase.metadata.bind = engine
log.debug("Creating all missing tables...")
database.TableDeclarativeBase.metadata.create_all()
# Create a bot instance
bot = utils.DuckBot(configloader.config["Telegram"]["token"])
bot = duckbot.factory(user_cfg)()
# Test the specified token
log.debug("Testing bot token...")
@ -46,7 +94,7 @@ def main():
log.debug("Bot token is valid!")
# Finding default language
default_language = configloader.config["Language"]["default_language"]
default_language = user_cfg["Language"]["default_language"]
# Creating localization object
default_loc = localization.Localization(language=default_language, fallback=default_language)
@ -63,9 +111,10 @@ def main():
# Main loop of the program
while True:
# Get a new batch of 100 updates and mark the last 100 parsed as read
log.debug("Getting updates from Telegram")
update_timeout = user_cfg["Telegram"]["long_polling_timeout"]
log.debug(f"Getting updates from Telegram with a timeout of {update_timeout} seconds")
updates = bot.get_updates(offset=next_update,
timeout=int(configloader.config["Telegram"]["long_polling_timeout"]))
timeout=update_timeout)
# Parse all the updates
for update in updates:
# If the update is a message...
@ -89,7 +138,9 @@ def main():
# Initialize a new worker for the chat
new_worker = worker.Worker(bot=bot,
chat=update.message.chat,
telegram_user=update.message.from_user)
telegram_user=update.message.from_user,
cfg=user_cfg,
engine=engine)
# Start the worker
log.debug(f"Starting {new_worker.name}")
new_worker.start()

View file

@ -1,29 +1,24 @@
import typing
from sqlalchemy import create_engine, Column, ForeignKey, UniqueConstraint
from sqlalchemy import Integer, BigInteger, String, Text, LargeBinary, DateTime, Boolean
from sqlalchemy.orm import sessionmaker, relationship, backref
from sqlalchemy.ext.declarative import declarative_base
import configloader
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.declarative import declarative_base, DeferredReflection
import telegram
import requests
import utils
import localization
import logging
if typing.TYPE_CHECKING:
import worker
log = logging.getLogger(__name__)
# Create a (lazy) database engine
engine = create_engine(configloader.config["Database"]["engine"])
# Create a base class to define all the database subclasses
TableDeclarativeBase = declarative_base(bind=engine)
# Create a Session class able to initialize database sessions
Session = sessionmaker()
TableDeclarativeBase = declarative_base()
# Define all the database tables using the sqlalchemy declarative base
class User(TableDeclarativeBase):
class User(DeferredReflection, TableDeclarativeBase):
"""A Telegram user who used the bot at least once."""
# Telegram data
@ -39,16 +34,18 @@ class User(TableDeclarativeBase):
# Extra table parameters
__tablename__ = "users"
def __init__(self, telegram_user: telegram.User, **kwargs):
def __init__(self, w: "worker.Worker", **kwargs):
# Initialize the super
super().__init__(**kwargs)
# Get the data from telegram
self.user_id = telegram_user.id
self.first_name = telegram_user.first_name
self.last_name = telegram_user.last_name
self.username = telegram_user.username
self.language = telegram_user.language_code if telegram_user.language_code else configloader.config["Language"][
"default_language"]
self.user_id = w.telegram_user.id
self.first_name = w.telegram_user.first_name
self.last_name = w.telegram_user.last_name
self.username = w.telegram_user.username
if w.telegram_user.language_code:
self.language = w.telegram_user.language_code
else:
self.language = w.cfg["Language"]["default_language"]
# The starting wallet value is 0
self.credit = 0
@ -88,7 +85,7 @@ class User(TableDeclarativeBase):
return f"<User {self.mention()} having {self.credit} credit>"
class Product(TableDeclarativeBase):
class Product(DeferredReflection, TableDeclarativeBase):
"""A purchasable product."""
# Product id
@ -109,37 +106,37 @@ class Product(TableDeclarativeBase):
# No __init__ is needed, the default one is sufficient
def text(self, *, loc: localization.Localization, style: str = "full", cart_qty: int = None):
def text(self, w: "worker.Worker", *, style: str = "full", cart_qty: int = None):
"""Return the product details formatted with Telegram HTML. The image is omitted."""
if style == "short":
return f"{cart_qty}x {utils.telegram_html_escape(self.name)} - {str(utils.Price(self.price, loc) * cart_qty)}"
return f"{cart_qty}x {utils.telegram_html_escape(self.name)} - {str(w.Price(self.price) * cart_qty)}"
elif style == "full":
if cart_qty is not None:
cart = loc.get("in_cart_format_string", quantity=cart_qty)
cart = w.loc.get("in_cart_format_string", quantity=cart_qty)
else:
cart = ''
return loc.get("product_format_string", name=utils.telegram_html_escape(self.name),
description=utils.telegram_html_escape(self.description),
price=str(utils.Price(self.price, loc)),
cart=cart)
return w.loc.get("product_format_string", name=utils.telegram_html_escape(self.name),
description=utils.telegram_html_escape(self.description),
price=str(w.Price(self.price)),
cart=cart)
else:
raise ValueError("style is not an accepted value")
def __repr__(self):
return f"<Product {self.name}>"
def send_as_message(self, loc: localization.Localization, chat_id: int) -> dict:
def send_as_message(self, w: "worker.Worker", chat_id: int) -> dict:
"""Send a message containing the product data."""
if self.image is None:
r = requests.get(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendMessage",
r = requests.get(f"https://api.telegram.org/bot{w.cfg['Telegram']['token']}/sendMessage",
params={"chat_id": chat_id,
"text": self.text(loc=loc),
"text": self.text(w),
"parse_mode": "HTML"})
else:
r = requests.post(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendPhoto",
r = requests.post(f"https://api.telegram.org/bot{w.cfg['Telegram']['token']}/sendPhoto",
files={"photo": self.image},
params={"chat_id": chat_id,
"caption": self.text(loc=loc),
"caption": self.text(w),
"parse_mode": "HTML"})
return r.json()
@ -152,7 +149,7 @@ class Product(TableDeclarativeBase):
self.image = r.content
class Transaction(TableDeclarativeBase):
class Transaction(DeferredReflection, TableDeclarativeBase):
"""A greed wallet transaction.
Wallet credit ISN'T calculated from these, but they can be used to recalculate it."""
# TODO: split this into multiple tables
@ -188,10 +185,10 @@ class Transaction(TableDeclarativeBase):
__tablename__ = "transactions"
__table_args__ = (UniqueConstraint("provider", "provider_charge_id"),)
def text(self, *, loc: localization.Localization):
string = f"<b>T{self.transaction_id}</b> | {str(self.user)} | {utils.Price(self.value, loc)}"
def text(self, w: "worker.Worker"):
string = f"<b>T{self.transaction_id}</b> | {str(self.user)} | {w.Price(self.value)}"
if self.refunded:
string += f" | {loc.get('emoji_refunded')}"
string += f" | {w.loc['emoji_refunded']}"
if self.provider:
string += f" | {self.provider}"
if self.notes:
@ -202,7 +199,7 @@ class Transaction(TableDeclarativeBase):
return f"<Transaction {self.transaction_id} for User {self.user_id}>"
class Admin(TableDeclarativeBase):
class Admin(DeferredReflection, TableDeclarativeBase):
"""A greed administrator with his permissions."""
# The telegram id
@ -224,7 +221,7 @@ class Admin(TableDeclarativeBase):
return f"<Admin {self.user_id}>"
class Order(TableDeclarativeBase):
class Order(DeferredReflection, TableDeclarativeBase):
"""An order which has been placed by an user.
It may include multiple products, available in the OrderItem table."""
@ -254,41 +251,41 @@ class Order(TableDeclarativeBase):
def __repr__(self):
return f"<Order {self.order_id} placed by User {self.user_id}>"
def text(self, *, loc: localization.Localization, session, user=False):
def text(self, w: "worker.Worker", session, user=False):
joined_self = session.query(Order).filter_by(order_id=self.order_id).join(Transaction).one()
items = ""
for item in self.items:
items += item.text(loc=loc) + "\n"
items += item.text(w) + "\n"
if self.delivery_date is not None:
status_emoji = loc.get("emoji_completed")
status_text = loc.get("text_completed")
status_emoji = w.loc.get("emoji_completed")
status_text = w.loc.get("text_completed")
elif self.refund_date is not None:
status_emoji = loc.get("emoji_refunded")
status_text = loc.get("text_refunded")
status_emoji = w.loc.get("emoji_refunded")
status_text = w.loc.get("text_refunded")
else:
status_emoji = loc.get("emoji_not_processed")
status_text = loc.get("text_not_processed")
if user and configloader.config["Appearance"]["full_order_info"] == "no":
return loc.get("user_order_format_string",
status_emoji=status_emoji,
status_text=status_text,
items=items,
notes=self.notes,
value=str(utils.Price(-joined_self.transaction.value, loc))) + \
(loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "")
status_emoji = w.loc.get("emoji_not_processed")
status_text = w.loc.get("text_not_processed")
if user and w.cfg["Appearance"]["full_order_info"] == "no":
return w.loc.get("user_order_format_string",
status_emoji=status_emoji,
status_text=status_text,
items=items,
notes=self.notes,
value=str(w.Price(-joined_self.transaction.value))) + \
(w.loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "")
else:
return status_emoji + " " + \
loc.get("order_number", id=self.order_id) + "\n" + \
loc.get("order_format_string",
user=self.user.mention(),
date=self.creation_date.isoformat(),
items=items,
notes=self.notes if self.notes is not None else "",
value=str(utils.Price(-joined_self.transaction.value, loc))) + \
(loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "")
w.loc.get("order_number", id=self.order_id) + "\n" + \
w.loc.get("order_format_string",
user=self.user.mention(),
date=self.creation_date.isoformat(),
items=items,
notes=self.notes if self.notes is not None else "",
value=str(w.Price(-joined_self.transaction.value))) + \
(w.loc.get("refund_reason", reason=self.refund_reason) if self.refund_date is not None else "")
class OrderItem(TableDeclarativeBase):
class OrderItem(DeferredReflection, TableDeclarativeBase):
"""A product that has been purchased as part of an order."""
# The unique item id
@ -302,11 +299,8 @@ class OrderItem(TableDeclarativeBase):
# Extra table parameters
__tablename__ = "orderitems"
def text(self, *, loc: localization.Localization):
return f"{self.product.name} - {str(utils.Price(self.product.price, loc))}"
def text(self, w: "worker.Worker"):
return f"{self.product.name} - {str(w.Price(self.product.price))}"
def __repr__(self):
return f"<OrderItem {self.item_id}>"
TableDeclarativeBase.metadata.create_all()

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 key: {root}{item}")
for item in compare_report.get("__invalid__", []):
log.error(f"Key has an invalid type: {root}{item}")
for key, value in compare_report.items():
if key == "__missing__" or key == "__invalid__":
continue
NuConfig.__cmplog_log(value, root=f"{root}{key}.")
def compare(self, other: "NuConfig") -> CompareReport:
"""Compare two different NuConfig objects and return a dictionary of the keys missing in the other."""
if not isinstance(other, NuConfig):
raise TypeError("You can only compare two NuConfig objects.")
return self.__compare_recurse(self.data, other.data)
@staticmethod
def __compare_miss(self: dict) -> CompareReport:
"""Mark all keys of a dict as missing."""
missing = []
result = {}
for key, value in self.items():
missing.append(key)
if isinstance(value, dict):
result[key] = NuConfig.__compare_miss(value)
if missing:
result["__missing__"] = missing
return result
@staticmethod
def __compare_recurse(self: dict, other: dict) -> CompareReport:
"""The recursive portion of :meth:`.compare`."""
invalid = []
missing = []
result = {}
for key, value in self.items():
try:
other_value = other[key]
except KeyError:
missing.append(key)
if isinstance(value, dict):
result[key] = NuConfig.__compare_miss(value)
else:
if type(value) != type(other_value):
invalid.append(key)
if isinstance(value, dict):
result[key] = NuConfig.__compare_miss(value)
elif isinstance(value, dict):
recursive_result = NuConfig.__compare_recurse(value, other_value)
if recursive_result != {}:
result[key] = recursive_result
if invalid:
result["__invalid__"] = invalid
if missing:
result["__missing__"] = missing
return result

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

290
worker.py
View file

@ -3,18 +3,18 @@ from typing import *
import uuid
import datetime
import telegram
import configloader
import nuconfig
import sys
import queue as queuem
import database as db
import re
import utils
import os
import traceback
from html import escape
import requests
import logging
import localization
import sqlalchemy.orm
log = logging.getLogger(__name__)
@ -33,16 +33,24 @@ class CancelSignal:
class Worker(threading.Thread):
"""A worker for a single conversation. A new one is created every time the /start command is sent."""
def __init__(self, bot: utils.DuckBot, chat: telegram.Chat, telegram_user: telegram.User, *args, **kwargs):
def __init__(self,
bot,
chat: telegram.Chat,
telegram_user: telegram.User,
cfg: nuconfig.NuConfig,
engine,
*args,
**kwargs):
# Initialize the thread
super().__init__(name=f"Worker {chat.id}", *args, **kwargs)
# Store the bot and chat info inside the class
self.bot: utils.DuckBot = bot
# Store the bot, chat info and config inside the class
self.bot = bot
self.chat: telegram.Chat = chat
self.telegram_user: telegram.User = telegram_user
self.cfg = cfg
# Open a new database session
log.debug(f"Opening new database session for {self.name}")
self.session = db.Session()
self.session = sqlalchemy.orm.sessionmaker(bind=engine)()
# Get the user db data from the users and admin tables
self.user: Optional[db.User] = None
self.admin: Optional[db.Admin] = None
@ -50,23 +58,103 @@ class Worker(threading.Thread):
self.queue = queuem.Queue()
# The current active invoice payload; reject all invoices with a different payload
self.invoice_payload = None
# The localization strings for this user
self.loc = None
# The Sentry client for reporting errors encountered by the user
if configloader.config["Error Reporting"]["sentry_token"] != \
"https://00000000000000000000000000000000:00000000000000000000000000000000@sentry.io/0000000":
import raven
self.sentry_client = raven.Client(configloader.config["Error Reporting"]["sentry_token"],
release=raven.fetch_git_sha(os.path.dirname(__file__)),
environment="Dev" if __debug__ else "Prod")
log.debug("Sentry: enabled")
else:
self.sentry_client = None
log.debug("Sentry: disabled")
# The price class of this worker.
self.Price = self.price_factory()
def __repr__(self):
return f"<{self.__class__.__qualname__} {self.chat.id}>"
# noinspection PyMethodParameters
def price_factory(worker):
class Price:
"""The base class for the prices in greed.
Its int value is in minimum units, while its float and str values are in decimal format."""
def __init__(self, value: Union[int, float, str, "Price"]):
if isinstance(value, int):
# Keep the value as it is
self.value = int(value)
elif isinstance(value, float):
# Convert the value to minimum units
self.value = int(value * (10 ** worker.cfg["Payments"]["currency_exp"]))
elif isinstance(value, str):
# Remove decimal points, then cast to int
self.value = int(float(value.replace(",", ".")) * (10 ** worker.cfg["Payments"]["currency_exp"]))
elif isinstance(value, Price):
# Copy self
self.value = value.value
def __repr__(self):
return f"<{self.__class__.__qualname__} of value {self.value}>"
def __str__(self):
return worker.loc.get(
"currency_format_string",
symbol=worker.cfg["Payments"]["currency_symbol"],
value="{0:.2f}".format(self.value / (10 ** worker.cfg["Payments"]["currency_exp"]))
)
def __int__(self):
return self.value
def __float__(self):
return self.value / (10 ** worker.cfg["Payments"]["currency_exp"])
def __ge__(self, other):
return self.value >= Price(other).value
def __le__(self, other):
return self.value <= Price(other).value
def __eq__(self, other):
return self.value == Price(other).value
def __gt__(self, other):
return self.value > Price(other).value
def __lt__(self, other):
return self.value < Price(other).value
def __add__(self, other):
return Price(self.value + Price(other).value)
def __sub__(self, other):
return Price(self.value - Price(other).value)
def __mul__(self, other):
return Price(int(self.value * other))
def __floordiv__(self, other):
return Price(int(self.value // other))
def __radd__(self, other):
return self.__add__(other)
def __rsub__(self, other):
return Price(Price(other).value - self.value)
def __rmul__(self, other):
return self.__mul__(other)
def __iadd__(self, other):
self.value += Price(other).value
return self
def __isub__(self, other):
self.value -= Price(other).value
return self
def __imul__(self, other):
self.value *= other
self.value = int(self.value)
return self
def __ifloordiv__(self, other):
self.value //= other
return self
return Price
def run(self):
"""The conversation code."""
log.debug("Starting conversation")
@ -78,7 +166,7 @@ class Worker(threading.Thread):
# Check if there are other registered users: if there aren't any, the first user will be owner of the bot
will_be_owner = (self.session.query(db.Admin).first() is None)
# Create the new record
self.user = db.User(self.telegram_user)
self.user = db.User(w=self)
# Add the new record to the db
self.session.add(self.user)
# Flush the session to get an userid
@ -106,7 +194,7 @@ class Worker(threading.Thread):
# noinspection PyBroadException
try:
# Welcome the user to the bot
if configloader.config["Appearance"]["display_welcome_message"] == "yes":
if self.cfg["Appearance"]["display_welcome_message"] == "yes":
self.bot.send_message(self.chat.id, self.loc.get("conversation_after_start"))
# If the user is not an admin, send him to the user menu
if self.admin is None:
@ -119,16 +207,14 @@ class Worker(threading.Thread):
self.session.commit()
# Open the admin menu
self.__admin_menu()
except Exception:
except Exception as e:
# Try to notify the user of the exception
# noinspection PyBroadException
try:
self.bot.send_message(self.chat.id, self.loc.get("fatal_conversation_exception"))
except Exception:
pass
# If the Sentry integration is enabled, log the exception
if self.sentry_client is not None:
self.sentry_client.captureException()
except Exception as ne:
log.error(f"Failed to notify the user of a conversation exception: {ne}")
log.error(f"Exception in {self}: {e}")
traceback.print_exception(*sys.exc_info())
def is_ready(self):
@ -155,7 +241,7 @@ class Worker(threading.Thread):
If a stop signal is sent, try to gracefully stop the thread."""
# Pop data from the queue
try:
data = self.queue.get(timeout=int(configloader.config["Telegram"]["conversation_timeout"]))
data = self.queue.get(timeout=self.cfg["Telegram"]["conversation_timeout"])
except queuem.Empty:
# If the conversation times out, gracefully stop the thread
self.__graceful_stop(StopSignal("timeout"))
@ -365,7 +451,7 @@ class Worker(threading.Thread):
# Send the previously created keyboard to the user (ensuring it can be clicked only 1 time)
self.bot.send_message(self.chat.id,
self.loc.get("conversation_open_user_menu",
credit=utils.Price(self.user.credit, self.loc)),
credit=self.Price(self.user.credit)),
reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True))
# Wait for a reply from the user
selection = self.__wait_for_specific_message([
@ -417,7 +503,7 @@ class Worker(threading.Thread):
if product.price is None:
continue
# Send the message without the keyboard to get the message id
message = product.send_as_message(loc=self.loc, chat_id=self.chat.id)
message = product.send_as_message(w=self, chat_id=self.chat.id)
# Add the product to the cart
cart[message['result']['message_id']] = [product, 0]
# Create the inline keyboard to add the product to the cart
@ -428,12 +514,12 @@ class Worker(threading.Thread):
if product.image is None:
self.bot.edit_message_text(chat_id=self.chat.id,
message_id=message['result']['message_id'],
text=product.text(loc=self.loc),
text=product.text(w=self),
reply_markup=inline_keyboard)
else:
self.bot.edit_message_caption(chat_id=self.chat.id,
message_id=message['result']['message_id'],
caption=product.text(loc=self.loc),
caption=product.text(w=self),
reply_markup=inline_keyboard)
# Create the keyboard with the cancel button
inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_cancel"),
@ -477,13 +563,13 @@ class Worker(threading.Thread):
if product.image is None:
self.bot.edit_message_text(chat_id=self.chat.id,
message_id=callback.message.message_id,
text=product.text(loc=self.loc,
text=product.text(w=self,
cart_qty=cart[callback.message.message_id][1]),
reply_markup=product_inline_keyboard)
else:
self.bot.edit_message_caption(chat_id=self.chat.id,
message_id=callback.message.message_id,
caption=product.text(loc=self.loc,
caption=product.text(w=self,
cart_qty=cart[callback.message.message_id][1]),
reply_markup=product_inline_keyboard)
@ -525,13 +611,13 @@ class Worker(threading.Thread):
# Edit the product message
if product.image is None:
self.bot.edit_message_text(chat_id=self.chat.id, message_id=callback.message.message_id,
text=product.text(loc=self.loc,
text=product.text(w=self,
cart_qty=cart[callback.message.message_id][1]),
reply_markup=product_inline_keyboard)
else:
self.bot.edit_message_caption(chat_id=self.chat.id,
message_id=callback.message.message_id,
caption=product.text(loc=self.loc,
caption=product.text(w=self,
cart_qty=cart[callback.message.message_id][1]),
reply_markup=product_inline_keyboard)
@ -573,12 +659,12 @@ class Worker(threading.Thread):
if credit_required > 0:
self.bot.send_message(self.chat.id, self.loc.get("error_not_enough_credit"))
# Suggest payment for missing credit value if configuration allows refill
if configloader.config["Credit Card"]["credit_card_token"] != "" \
and configloader.config["Appearance"]["refill_on_checkout"] == 'yes' \
and utils.Price(int(configloader.config["Credit Card"]["min_amount"]), self.loc) <= \
if self.cfg["Payments"]["CreditCard"]["credit_card_token"] != "" \
and self.cfg["Appearance"]["refill_on_checkout"] == 'yes' \
and self.Price(self.cfg["Payments"]["CreditCard"]["min_amount"]) <= \
credit_required <= \
utils.Price(int(configloader.config["Credit Card"]["max_amount"]), self.loc):
self.__make_payment(utils.Price(credit_required, self.loc))
self.Price(self.cfg["Payments"]["CreditCard"]["max_amount"]):
self.__make_payment(self.Price(credit_required))
# If afer requested payment credit is still insufficient (either payment failure or cancel)
if self.user.credit < self.__get_cart_value(cart):
# Rollback all the changes
@ -589,7 +675,7 @@ class Worker(threading.Thread):
def __get_cart_value(self, cart):
# Calculate total items value in cart
value = utils.Price(0, self.loc)
value = self.Price(0)
for product in cart:
value += cart[product][0].price * cart[product][1]
return value
@ -599,7 +685,7 @@ class Worker(threading.Thread):
product_list = ""
for product_id in cart:
if cart[product_id][1] > 0:
product_list += cart[product_id][0].text(loc=self.loc,
product_list += cart[product_id][0].text(w=self,
style="short",
cart_qty=cart[product_id][1]) + "\n"
return product_list
@ -621,7 +707,7 @@ class Worker(threading.Thread):
def __order_notify_admins(self, order):
# Notify the user of the order result
self.bot.send_message(self.chat.id, self.loc.get("success_order_created", order=order.text(loc=self.loc,
self.bot.send_message(self.chat.id, self.loc.get("success_order_created", order=order.text(w=self,
session=self.session,
user=True)))
# Notify the admins (in Live Orders mode) of the new order
@ -636,7 +722,7 @@ class Worker(threading.Thread):
for admin in admins:
self.bot.send_message(admin.user_id,
self.loc.get('notification_order_placed',
order=order.text(loc=self.loc, session=self.session)),
order=order.text(w=self, session=self.session)),
reply_markup=order_keyboard)
def __order_status(self):
@ -653,7 +739,7 @@ class Worker(threading.Thread):
self.bot.send_message(self.chat.id, self.loc.get("error_no_orders"))
# Display the order status to the user
for order in orders:
self.bot.send_message(self.chat.id, order.text(loc=self.loc, session=self.session, user=True))
self.bot.send_message(self.chat.id, order.text(w=self, session=self.session, user=True))
# TODO: maybe add a page displayer instead of showing the latest 5 orders
def __add_credit_menu(self):
@ -665,7 +751,7 @@ class Worker(threading.Thread):
# Cash
keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cash"))])
# Telegram Payments
if configloader.config["Credit Card"]["credit_card_token"] != "":
if self.cfg["Payments"]["CreditCard"]["credit_card_token"] != "":
keyboard.append([telegram.KeyboardButton(self.loc.get("menu_credit_card"))])
# Keyboard: go back to the previous menu
keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cancel"))])
@ -693,8 +779,8 @@ class Worker(threading.Thread):
"""Add money to the wallet through a credit card payment."""
log.debug("Displaying __add_credit_cc")
# Create a keyboard to be sent later
presets = list(map(lambda s: s.strip(" "), configloader.config["Credit Card"]["payment_presets"].split('|')))
keyboard = [[telegram.KeyboardButton(str(utils.Price(preset, self.loc)))] for preset in presets]
presets = list(map(lambda s: s.strip(" "), self.cfg["Payments"]["CreditCard"]["payment_presets"].split('|')))
keyboard = [[telegram.KeyboardButton(str(self.Price(preset)))] for preset in presets]
keyboard.append([telegram.KeyboardButton(self.loc.get("menu_cancel"))])
# Boolean variable to check if the user has cancelled the action
cancelled = False
@ -711,15 +797,15 @@ class Worker(threading.Thread):
cancelled = True
continue
# Convert the amount to an integer
value = utils.Price(selection, self.loc)
value = self.Price(selection)
# Ensure the amount is within the range
if value > utils.Price(int(configloader.config["Credit Card"]["max_amount"]), self.loc):
if value > self.Price(self.cfg["Payments"]["CreditCard"]["max_amount"]):
self.bot.send_message(self.chat.id,
self.loc.get("error_payment_amount_over_max", max_amount=utils.Price(configloader.config["Credit Card"]["max_amount"], self.loc)))
self.loc.get("error_payment_amount_over_max", max_amount=self.Price(self.cfg["Credit Card"]["max_amount"])))
continue
elif value < utils.Price(int(configloader.config["Credit Card"]["min_amount"]), self.loc):
elif value < self.Price(self.cfg["Payments"]["CreditCard"]["min_amount"]):
self.bot.send_message(self.chat.id,
self.loc.get("error_payment_amount_under_min", min_amount=utils.Price(configloader.config["Credit Card"]["min_amount"], self.loc)))
self.loc.get("error_payment_amount_under_min", min_amount=self.Price(self.cfg["Credit Card"]["min_amount"])))
continue
break
# If the user cancelled the action...
@ -740,7 +826,8 @@ class Worker(threading.Thread):
prices.append(telegram.LabeledPrice(label=self.loc.get("payment_invoice_fee_label"),
amount=fee))
# Create the invoice keyboard
inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_pay"), pay=True)],
inline_keyboard = telegram.InlineKeyboardMarkup([[telegram.InlineKeyboardButton(self.loc.get("menu_pay"),
pay=True)],
[telegram.InlineKeyboardButton(self.loc.get("menu_cancel"),
callback_data="cmd_cancel")]])
# The amount is valid, send the invoice
@ -748,13 +835,13 @@ class Worker(threading.Thread):
title=self.loc.get("payment_invoice_title"),
description=self.loc.get("payment_invoice_description", amount=str(amount)),
payload=self.invoice_payload,
provider_token=configloader.config["Credit Card"]["credit_card_token"],
provider_token=self.cfg["Payments"]["CreditCard"]["credit_card_token"],
start_parameter="tempdeeplink",
currency=configloader.config["Payments"]["currency"],
currency=self.cfg["Payments"]["currency"],
prices=prices,
need_name=configloader.config["Credit Card"]["name_required"] == "yes",
need_email=configloader.config["Credit Card"]["email_required"] == "yes",
need_phone_number=configloader.config["Credit Card"]["phone_required"] == "yes",
need_name=self.cfg["Payments"]["CreditCard"]["name_required"],
need_email=self.cfg["Payments"]["CreditCard"]["email_required"],
need_phone_number=self.cfg["Payments"]["CreditCard"]["phone_required"],
reply_markup=inline_keyboard)
# Wait for the precheckout query
precheckoutquery = self.__wait_for_precheckoutquery(cancellable=True)
@ -782,11 +869,10 @@ class Worker(threading.Thread):
# Commit all the changes
self.session.commit()
@staticmethod
def __get_total_fee(amount):
def __get_total_fee(self, amount):
# Calculate a fee for the required amount
fee_percentage = float(configloader.config["Credit Card"]["fee_percentage"]) / 100
fee_fixed = int(configloader.config["Credit Card"]["fee_fixed"])
fee_percentage = self.cfg["Payments"]["CreditCard"]["fee_percentage"] / 100
fee_fixed = self.cfg["Payments"]["CreditCard"]["fee_fixed"]
total_fee = amount * fee_percentage + fee_fixed
if total_fee > 0:
return total_fee
@ -818,8 +904,7 @@ class Worker(threading.Thread):
keyboard.append([self.loc.get("menu_user_mode")])
# Send the previously created keyboard to the user (ensuring it can be clicked only 1 time)
self.bot.send_message(self.chat.id, self.loc.get("conversation_open_admin_menu"),
reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True),
)
reply_markup=telegram.ReplyKeyboardMarkup(keyboard, one_time_keyboard=True))
# Wait for a reply from the user
selection = self.__wait_for_specific_message([self.loc.get("menu_products"),
self.loc.get("menu_orders"),
@ -934,8 +1019,8 @@ class Worker(threading.Thread):
if product:
self.bot.send_message(self.chat.id,
self.loc.get("edit_current_value",
value=(str(utils.Price(product.price, self.loc))
if product.price is not None else 'Non in vendita')),
value=(str(self.Price(product.price))
if product.price is not None else 'Non in vendita')),
reply_markup=cancel)
# Wait for an answer
price = self.__wait_for_regex(r"([0-9]+(?:[.,][0-9]{1,2})?|[Xx])",
@ -946,7 +1031,7 @@ class Worker(threading.Thread):
elif price.lower() == "x":
price = None
else:
price = utils.Price(price, self.loc)
price = self.Price(price)
# Ask for the product image
self.bot.send_message(self.chat.id, self.loc.get("ask_product_image"), reply_markup=cancel)
# Wait for an answer
@ -1044,7 +1129,7 @@ class Worker(threading.Thread):
# Create a message for every one of them
for order in orders:
# Send the created message
self.bot.send_message(self.chat.id, order.text(loc=self.loc, session=self.session),
self.bot.send_message(self.chat.id, order.text(w=self, session=self.session),
reply_markup=order_keyboard)
# Set the Live mode flag to True
self.admin.live_mode = True
@ -1073,11 +1158,11 @@ class Worker(threading.Thread):
# Commit the transaction
self.session.commit()
# Update order message
self.bot.edit_message_text(order.text(loc=self.loc, session=self.session), chat_id=self.chat.id,
self.bot.edit_message_text(order.text(w=self, session=self.session), chat_id=self.chat.id,
message_id=update.message.message_id)
# Notify the user of the completition
self.bot.send_message(order.user_id,
self.loc.get("notification_order_completed", order=order.text(loc=self.loc, session=self.session, user=True)))
self.loc.get("notification_order_completed", order=order.text(w=self, session=self.session, user=True)))
# If the user pressed the refund order button, refund the order...
elif update.data == "order_refund":
# Ask for a refund reason
@ -1101,12 +1186,12 @@ class Worker(threading.Thread):
# Commit the changes
self.session.commit()
# Update the order message
self.bot.edit_message_text(order.text(loc=self.loc, session=self.session),
self.bot.edit_message_text(order.text(w=self, session=self.session),
chat_id=self.chat.id,
message_id=update.message.message_id)
# Notify the user of the refund
self.bot.send_message(order.user_id,
self.loc.get("notification_order_refunded", order=order.text(loc=self.loc,
self.loc.get("notification_order_refunded", order=order.text(w=self,
session=self.session,
user=True)))
# Notify the admin of the refund
@ -1131,7 +1216,7 @@ class Worker(threading.Thread):
if isinstance(reply, CancelSignal):
return
# Convert the reply to a price object
price = utils.Price(reply, self.loc)
price = self.Price(reply)
# Ask the user for notes
self.bot.send_message(self.chat.id, self.loc.get("ask_transaction_notes"), reply_markup=cancel)
# Wait for an answer
@ -1152,10 +1237,10 @@ class Worker(threading.Thread):
# Notify the user of the credit/debit
self.bot.send_message(user.user_id,
self.loc.get("notification_transaction_created",
transaction=transaction.text(loc=self.loc)))
transaction=transaction.text(w=self)))
# Notify the admin of the success
self.bot.send_message(self.chat.id, self.loc.get("success_transaction_created",
transaction=transaction.text(loc=self.loc)))
transaction=transaction.text(w=self)))
def __help_menu(self):
"""Help menu. Allows the user to ask for assistance, get a guide or see some info about the bot."""
@ -1221,7 +1306,7 @@ class Worker(threading.Thread):
# Create the inline keyboard markup
inline_keyboard = telegram.InlineKeyboardMarkup(inline_keyboard_list)
# Create the message text
transactions_string = "\n".join([transaction.text(loc=self.loc) for transaction in transactions])
transactions_string = "\n".join([transaction.text(w=self) for transaction in transactions])
text = self.loc.get("transactions_page", page=page + 1, transactions=transactions_string)
# Update the previously sent message
self.bot.edit_message_text(chat_id=self.chat.id, message_id=message.message_id, text=text,
@ -1280,7 +1365,7 @@ class Worker(threading.Thread):
# Reopen the file for reading
with open(f"transactions_{self.chat.id}.csv") as file:
# Send the file via a manual request to Telegram
requests.post(f"https://api.telegram.org/bot{configloader.config['Telegram']['token']}/sendDocument",
requests.post(f"https://api.telegram.org/bot{self.cfg['Telegram']['token']}/sendDocument",
files={"document": file},
params={"chat_id": self.chat.id,
"parse_mode": "HTML"})
@ -1299,9 +1384,11 @@ class Worker(threading.Thread):
admin = self.session.query(db.Admin).filter_by(user_id=user.user_id).one_or_none()
if admin is None:
# Create the keyboard to be sent
keyboard = telegram.ReplyKeyboardMarkup([[self.loc.get("emoji_yes"), self.loc.get("emoji_no")]], one_time_keyboard=True)
keyboard = telegram.ReplyKeyboardMarkup([[self.loc.get("emoji_yes"), self.loc.get("emoji_no")]],
one_time_keyboard=True)
# Ask for confirmation
self.bot.send_message(self.chat.id, self.loc.get("conversation_confirm_admin_promotion"), reply_markup=keyboard)
self.bot.send_message(self.chat.id, self.loc.get("conversation_confirm_admin_promotion"),
reply_markup=keyboard)
# Wait for an answer
selection = self.__wait_for_specific_message([self.loc.get("emoji_yes"), self.loc.get("emoji_no")])
# Proceed only if the answer is yes
@ -1321,17 +1408,26 @@ class Worker(threading.Thread):
while True:
# Create the inline keyboard with the admin status
inline_keyboard = telegram.InlineKeyboardMarkup([
[telegram.InlineKeyboardButton(f"{self.loc.boolmoji(admin.edit_products)} {self.loc.get('prop_edit_products')}",
callback_data="toggle_edit_products")],
[telegram.InlineKeyboardButton(f"{self.loc.boolmoji(admin.receive_orders)} {self.loc.get('prop_receive_orders')}",
callback_data="toggle_receive_orders")],
[telegram.InlineKeyboardButton(
f"{self.loc.boolmoji(admin.edit_products)} {self.loc.get('prop_edit_products')}",
callback_data="toggle_edit_products"
)],
[telegram.InlineKeyboardButton(
f"{self.loc.boolmoji(admin.receive_orders)} {self.loc.get('prop_receive_orders')}",
callback_data="toggle_receive_orders"
)],
[telegram.InlineKeyboardButton(
f"{self.loc.boolmoji(admin.create_transactions)} {self.loc.get('prop_create_transactions')}",
callback_data="toggle_create_transactions")],
callback_data="toggle_create_transactions"
)],
[telegram.InlineKeyboardButton(
f"{self.loc.boolmoji(admin.display_on_help)} {self.loc.get('prop_display_on_help')}",
callback_data="toggle_display_on_help")],
[telegram.InlineKeyboardButton(self.loc.get('menu_done'), callback_data="cmd_done")]
callback_data="toggle_display_on_help"
)],
[telegram.InlineKeyboardButton(
self.loc.get('menu_done'),
callback_data="cmd_done"
)]
])
# Update the inline keyboard
self.bot.edit_message_reply_markup(message_id=message.message_id,
@ -1358,27 +1454,27 @@ class Worker(threading.Thread):
keyboard = []
options: Dict[str, str] = {}
# https://en.wikipedia.org/wiki/List_of_language_names
if "it" in configloader.config["Language"]["enabled_languages"]:
if "it" in self.cfg["Language"]["enabled_languages"]:
lang = "🇮🇹 Italiano"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "it"
if "en" in configloader.config["Language"]["enabled_languages"]:
if "en" in self.cfg["Language"]["enabled_languages"]:
lang = "🇬🇧 English"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "en"
if "ru" in configloader.config["Language"]["enabled_languages"]:
if "ru" in self.cfg["Language"]["enabled_languages"]:
lang = "🇷🇺 Русский"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "ru"
if "uk" in configloader.config["Language"]["enabled_languages"]:
if "uk" in self.cfg["Language"]["enabled_languages"]:
lang = "🇺🇦 Українська"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "uk"
if "zh_cn" in configloader.config["Language"]["enabled_languages"]:
if "zh_cn" in self.cfg["Language"]["enabled_languages"]:
lang = "🇨🇳 简体中文"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "zh_cn"
if "he" in configloader.config["Language"]["enabled_languages"]:
if "he" in self.cfg["Language"]["enabled_languages"]:
lang = "🇮🇱 עברית"
keyboard.append([telegram.KeyboardButton(lang)])
options[lang] = "he"
@ -1397,14 +1493,14 @@ class Worker(threading.Thread):
def __create_localization(self):
# Check if the user's language is enabled; if it isn't, change it to the default
if self.user.language not in configloader.config["Language"]["enabled_languages"]:
if self.user.language not in self.cfg["Language"]["enabled_languages"]:
log.debug(f"User's language '{self.user.language}' is not enabled, changing it to the default")
self.user.language = configloader.config["Language"]["default_language"]
self.user.language = self.cfg["Language"]["default_language"]
self.session.commit()
# Create a new Localization object
self.loc = localization.Localization(
language=self.user.language,
fallback=configloader.config["Language"]["fallback_language"],
fallback=self.cfg["Language"]["fallback_language"],
replacements={
"user_string": str(self.user),
"user_mention": self.user.mention(),