diff --git a/.gitignore b/.gitignore index 5ed07d3..ffb9376 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,7 @@ cython_debug/ .idea/ # Custom files -run.py +config/miner.json chromedriver* # Folders diff --git a/Dockerfile b/Dockerfile index b0a328c..437e730 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,4 +44,6 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --fix-missing --no-ins && rm -rf /usr/share/doc/* ADD ./TwitchChannelPointsMiner ./TwitchChannelPointsMiner +ADD ./assets ./assets +ADD ./run.py ./run.py ENTRYPOINT [ "python", "run.py" ] diff --git a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py index de2d301..8f77c87 100644 --- a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py +++ b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py @@ -194,6 +194,7 @@ class TwitchChannelPointsMiner: refresh=refresh, days_ago=days_ago, username=self.username, + miner=self, ) http_server.daemon = True http_server.name = "Analytics Thread" @@ -510,4 +511,4 @@ class TwitchChannelPointsMiner: logger.info( f"{streamer_gain}\n{streamer_history}", extra={"emoji": ":moneybag:"}, - ) \ No newline at end of file + ) diff --git a/TwitchChannelPointsMiner/classes/AnalyticsServer.py b/TwitchChannelPointsMiner/classes/AnalyticsServer.py index 280a9d8..16fac59 100644 --- a/TwitchChannelPointsMiner/classes/AnalyticsServer.py +++ b/TwitchChannelPointsMiner/classes/AnalyticsServer.py @@ -1,13 +1,16 @@ import json import logging import os +import threading from datetime import datetime from pathlib import Path from threading import Thread import pandas as pd -from flask import Flask, Response, cli, render_template, request +from flask import Flask, Response, cli, jsonify, render_template, request +from TwitchChannelPointsMiner.classes import ConfigManager +from TwitchChannelPointsMiner.classes.Settings import FollowersOrder from TwitchChannelPointsMiner.classes.Settings import Settings from TwitchChannelPointsMiner.utils import download_file @@ -207,6 +210,9 @@ def check_assets(): required_files = [ "banner.png", "charts.html", + "config.css", + "config.html", + "config.js", "script.js", "style.css", "dark-theme.css", @@ -231,7 +237,8 @@ class AnalyticsServer(Thread): port: int = 5000, refresh: int = 5, days_ago: int = 7, - username: str = None + username: str = None, + miner=None ): super(AnalyticsServer, self).__init__() @@ -242,6 +249,7 @@ class AnalyticsServer(Thread): self.refresh = refresh self.days_ago = days_ago self.username = username + self.miner = miner def generate_log(): global last_sent_log_index # Use the global variable @@ -269,8 +277,9 @@ class AnalyticsServer(Thread): template_folder=os.path.join(Path().absolute(), "assets"), static_folder=os.path.join(Path().absolute(), "assets"), ) + self.app.add_url_rule("/", "config", self.config_page, methods=["GET"]) self.app.add_url_rule( - "/", + "/analytics", "index", index, defaults={"refresh": refresh, "days_ago": days_ago}, @@ -285,6 +294,50 @@ class AnalyticsServer(Thread): json_all, methods=["GET"]) self.app.add_url_rule( "/log", "log", generate_log, methods=["GET"]) + self.app.add_url_rule("/config", "config_alias", self.config_page, methods=["GET"]) + self.app.add_url_rule("/api/config", "api_config_get", self.get_config, methods=["GET"]) + self.app.add_url_rule("/api/config", "api_config_put", self.put_config, methods=["PUT"]) + self.app.add_url_rule("/api/meta", "api_meta", self.get_meta, methods=["GET"]) + self.app.add_url_rule( + "/api/followers/import", + "api_followers_import", + self.import_followers, + methods=["POST"], + ) + self.app.add_url_rule("/api/restart", "api_restart", self.restart, methods=["POST"]) + + def config_page(self): + return render_template("config.html") + + def get_meta(self): + return jsonify(ConfigManager.metadata()) + + def get_config(self): + return jsonify(ConfigManager.client_config(ConfigManager.load_config())) + + def put_config(self): + config = request.get_json(force=True) + saved = ConfigManager.save_config(config) + return jsonify(ConfigManager.client_config(saved)) + + def import_followers(self): + if self.miner is None or self.miner.twitch is None: + return jsonify({"error": "Miner is not ready yet."}), 503 + payload = request.get_json(silent=True) or {} + order = ConfigManager.enum_member( + FollowersOrder, + payload.get("followers_order", "ASC"), + FollowersOrder.ASC, + ) + limit = int(payload.get("limit", 100)) + return jsonify({"followers": self.miner.twitch.get_followers(limit=limit, order=order)}) + + def restart(self): + def exit_process(): + os._exit(0) + + threading.Timer(0.5, exit_process).start() + return jsonify({"status": "restarting"}) def run(self): logger.info( diff --git a/TwitchChannelPointsMiner/classes/ConfigManager.py b/TwitchChannelPointsMiner/classes/ConfigManager.py new file mode 100644 index 0000000..e38a747 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/ConfigManager.py @@ -0,0 +1,462 @@ +import copy +import json +import logging +import os +from pathlib import Path + +from TwitchChannelPointsMiner import TwitchChannelPointsMiner +from TwitchChannelPointsMiner.classes.Chat import ChatPresence +from TwitchChannelPointsMiner.classes.Discord import Discord +from TwitchChannelPointsMiner.classes.Gotify import Gotify +from TwitchChannelPointsMiner.classes.Matrix import Matrix +from TwitchChannelPointsMiner.classes.Pushover import Pushover +from TwitchChannelPointsMiner.classes.Settings import Events, FollowersOrder, Priority +from TwitchChannelPointsMiner.classes.Telegram import Telegram +from TwitchChannelPointsMiner.classes.Webhook import Webhook +from TwitchChannelPointsMiner.classes.entities.Bet import ( + BetSettings, + Condition, + DelayMode, + FilterCondition, + OutcomeKeys, + Strategy, +) +from TwitchChannelPointsMiner.classes.entities.Streamer import ( + Streamer, + StreamerSettings, +) +from TwitchChannelPointsMiner.logger import ColorPalette, LoggerSettings + +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path(os.environ.get("MINER_CONFIG_PATH", "config/miner.json")) +MASKED_SECRET = "********" +SECRET_PATHS = [ + "account.password", + "notifications.telegram.token", + "notifications.discord.webhook_api", + "notifications.webhook.endpoint", + "notifications.matrix.password", + "notifications.pushover.userkey", + "notifications.pushover.token", + "notifications.gotify.endpoint", +] + +OUTCOME_KEYS = [ + OutcomeKeys.PERCENTAGE_USERS, + OutcomeKeys.ODDS_PERCENTAGE, + OutcomeKeys.ODDS, + OutcomeKeys.TOP_POINTS, + OutcomeKeys.TOTAL_USERS, + OutcomeKeys.TOTAL_POINTS, + OutcomeKeys.DECISION_USERS, + OutcomeKeys.DECISION_POINTS, +] + +DEFAULT_CONFIG = { + "account": { + "username": "", + "password": "", + }, + "miner": { + "claim_drops_startup": False, + "enable_analytics": True, + "disable_ssl_cert_verification": False, + "disable_at_in_nickname": False, + "priority": ["STREAK", "DROPS", "ORDER"], + "followers": False, + "followers_order": "ASC", + "blacklist": [], + }, + "analytics": { + "host": "0.0.0.0", + "port": 5000, + "refresh": 5, + "days_ago": 7, + }, + "logger": { + "save": True, + "console_level": "INFO", + "console_username": False, + "auto_clear": True, + "time_zone": "", + "file_level": "INFO", + "emoji": True, + "less": True, + "colored": True, + "color_palette": { + "STREAMER_ONLINE": "GREEN", + "STREAMER_OFFLINE": "RED", + "BET_WIN": "MAGENTA", + }, + }, + "notifications": { + "telegram": { + "enabled": False, + "chat_id": "", + "token": "", + "events": [], + "disable_notification": True, + }, + "discord": {"enabled": False, "webhook_api": "", "events": []}, + "webhook": { + "enabled": False, + "endpoint": "", + "method": "GET", + "events": [], + }, + "matrix": { + "enabled": False, + "username": "", + "password": "", + "homeserver": "matrix.org", + "room_id": "", + "events": [], + }, + "pushover": { + "enabled": False, + "userkey": "", + "token": "", + "priority": 0, + "sound": "pushover", + "events": [], + }, + "gotify": {"enabled": False, "endpoint": "", "priority": 8, "events": []}, + }, + "streamer_defaults": { + "make_predictions": True, + "follow_raid": True, + "claim_drops": True, + "claim_moments": True, + "watch_streak": True, + "community_goals": False, + "chat": "ONLINE", + "bet": { + "strategy": "SMART", + "percentage": 5, + "percentage_gap": 20, + "max_points": 50000, + "minimum_points": 0, + "stealth_mode": False, + "delay_mode": "FROM_END", + "delay": 6, + "filter_condition": { + "enabled": False, + "by": "total_users", + "where": "GTE", + "value": 1, + }, + }, + }, + "streamers": [], +} + + +def metadata(): + return { + "events": enum_names(Events), + "priorities": enum_names(Priority), + "followers_orders": enum_names(FollowersOrder), + "chat_presences": enum_names(ChatPresence), + "bet_strategies": enum_names(Strategy), + "delay_modes": enum_names(DelayMode), + "filter_conditions": enum_names(Condition), + "outcome_keys": OUTCOME_KEYS, + "log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + "colors": [ + "BLACK", + "RED", + "GREEN", + "YELLOW", + "BLUE", + "MAGENTA", + "CYAN", + "WHITE", + "RESET", + ], + } + + +def enum_names(enum_class): + return [item.name for item in enum_class] + + +def load_config(path=CONFIG_PATH): + config = copy.deepcopy(DEFAULT_CONFIG) + if Path(path).is_file(): + with open(path, "r", encoding="utf-8") as file: + config = deep_merge(config, json.load(file)) + return config + + +def save_config(config, path=CONFIG_PATH, preserve_secrets=True): + if preserve_secrets and Path(path).is_file(): + config = preserve_existing_secrets(config, load_config(path)) + normalized = deep_merge(copy.deepcopy(DEFAULT_CONFIG), config) + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temp = target.with_suffix(target.suffix + ".tmp") + with open(temp, "w", encoding="utf-8") as file: + json.dump(normalized, file, indent=2) + file.write("\n") + os.replace(temp, target) + return normalized + + +def client_config(config): + sanitized = copy.deepcopy(config) + for path in SECRET_PATHS: + value = get_path(sanitized, path) + if value not in (None, ""): + set_path(sanitized, path, MASKED_SECRET) + return sanitized + + +def preserve_existing_secrets(incoming, existing): + merged = copy.deepcopy(incoming) + for path in SECRET_PATHS: + value = get_path(merged, path) + if value in ("", None, MASKED_SECRET): + set_path(merged, path, get_path(existing, path)) + return merged + + +def deep_merge(base, override): + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = deep_merge(base[key], value) + else: + base[key] = value + return base + + +def get_path(config, path): + current = config + for part in path.split("."): + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def set_path(config, path, value): + parts = path.split(".") + current = config + for part in parts[:-1]: + current = current.setdefault(part, {}) + current[parts[-1]] = value + + +def build_runtime(config): + config = deep_merge(copy.deepcopy(DEFAULT_CONFIG), config) + logger_settings = build_logger_settings(config) + streamer_defaults = build_streamer_settings(config["streamer_defaults"]) + + miner = TwitchChannelPointsMiner( + username=config["account"]["username"], + password=config["account"].get("password") or None, + claim_drops_startup=as_bool(config["miner"]["claim_drops_startup"]), + enable_analytics=as_bool(config["miner"]["enable_analytics"]), + disable_ssl_cert_verification=as_bool( + config["miner"]["disable_ssl_cert_verification"] + ), + disable_at_in_nickname=as_bool(config["miner"]["disable_at_in_nickname"]), + priority=[ + enum_member(Priority, item, Priority.ORDER) + for item in config["miner"].get("priority", []) + ], + logger_settings=logger_settings, + streamer_settings=streamer_defaults, + ) + + streamers = [ + build_streamer(item) + for item in config.get("streamers", []) + if item.get("enabled", True) and item.get("username", "").strip() + ] + + return { + "miner": miner, + "analytics": config["analytics"], + "mine": { + "streamers": streamers, + "blacklist": config["miner"].get("blacklist", []), + "followers": as_bool(config["miner"].get("followers", False)), + "followers_order": enum_member( + FollowersOrder, + config["miner"].get("followers_order"), + FollowersOrder.ASC, + ), + }, + } + + +def build_logger_settings(config): + logger_config = config["logger"] + notifications = config["notifications"] + return LoggerSettings( + save=as_bool(logger_config["save"]), + console_level=log_level(logger_config["console_level"]), + console_username=as_bool(logger_config["console_username"]), + auto_clear=as_bool(logger_config["auto_clear"]), + time_zone=logger_config.get("time_zone") or "", + file_level=log_level(logger_config["file_level"]), + emoji=as_bool(logger_config["emoji"]), + less=as_bool(logger_config["less"]), + colored=as_bool(logger_config["colored"]), + color_palette=ColorPalette(**logger_config.get("color_palette", {})), + telegram=build_telegram(notifications["telegram"]), + discord=build_discord(notifications["discord"]), + webhook=build_webhook(notifications["webhook"]), + matrix=build_matrix(notifications["matrix"]), + pushover=build_pushover(notifications["pushover"]), + gotify=build_gotify(notifications["gotify"]), + ) + + +def build_telegram(config): + if not as_bool(config.get("enabled")): + return None + chat_id = config.get("chat_id") + return Telegram( + chat_id=int(chat_id) if str(chat_id).strip() else 123456789, + token=config.get("token", ""), + events=event_list(config.get("events", [])), + disable_notification=as_bool(config.get("disable_notification", False)), + ) + + +def build_discord(config): + if not as_bool(config.get("enabled")): + return None + return Discord(config.get("webhook_api", ""), event_list(config.get("events", []))) + + +def build_webhook(config): + if not as_bool(config.get("enabled")): + return None + return Webhook( + config.get("endpoint", ""), + config.get("method", "GET"), + event_list(config.get("events", [])), + ) + + +def build_matrix(config): + if not as_bool(config.get("enabled")): + return None + return Matrix( + config.get("username", ""), + config.get("password", ""), + config.get("homeserver", "matrix.org"), + config.get("room_id", ""), + event_list(config.get("events", [])), + ) + + +def build_pushover(config): + if not as_bool(config.get("enabled")): + return None + return Pushover( + config.get("userkey", ""), + config.get("token", ""), + int(config.get("priority", 0)), + config.get("sound", "pushover"), + event_list(config.get("events", [])), + ) + + +def build_gotify(config): + if not as_bool(config.get("enabled")): + return None + return Gotify( + config.get("endpoint", ""), + int(config.get("priority", 8)), + event_list(config.get("events", [])), + ) + + +def build_streamer(config): + settings_config = config.get("settings") + settings = build_streamer_settings(settings_config) if settings_config else None + return Streamer(config["username"], settings=settings) + + +def build_streamer_settings(config): + return StreamerSettings( + make_predictions=nullable_bool(config.get("make_predictions")), + follow_raid=nullable_bool(config.get("follow_raid")), + claim_drops=nullable_bool(config.get("claim_drops")), + claim_moments=nullable_bool(config.get("claim_moments")), + watch_streak=nullable_bool(config.get("watch_streak")), + community_goals=nullable_bool(config.get("community_goals")), + chat=enum_member(ChatPresence, config.get("chat"), None), + bet=build_bet_settings(config.get("bet", {})), + ) + + +def build_bet_settings(config): + return BetSettings( + strategy=enum_member(Strategy, config.get("strategy"), None), + percentage=nullable_int(config.get("percentage")), + percentage_gap=nullable_int(config.get("percentage_gap")), + max_points=nullable_int(config.get("max_points")), + minimum_points=nullable_int(config.get("minimum_points")), + stealth_mode=nullable_bool(config.get("stealth_mode")), + delay=nullable_float(config.get("delay")), + delay_mode=enum_member(DelayMode, config.get("delay_mode"), None), + filter_condition=build_filter_condition(config.get("filter_condition", {})), + ) + + +def build_filter_condition(config): + if not as_bool(config.get("enabled")): + return None + return FilterCondition( + by=config.get("by", OutcomeKeys.TOTAL_USERS), + where=enum_member(Condition, config.get("where"), Condition.GTE), + value=nullable_float(config.get("value")) or 0, + ) + + +def event_list(values): + return [enum_member(Events, value, None) for value in values if enum_member(Events, value, None)] + + +def enum_member(enum_class, value, default): + if value is None: + return default + if isinstance(value, enum_class): + return value + name = str(value).upper() + return getattr(enum_class, name, default) + + +def log_level(value): + if isinstance(value, int): + return value + return getattr(logging, str(value).upper(), logging.INFO) + + +def nullable_int(value): + if value in (None, ""): + return None + return int(value) + + +def nullable_float(value): + if value in (None, ""): + return None + return float(value) + + +def nullable_bool(value): + if value is None: + return None + return as_bool(value) + + +def as_bool(value): + if isinstance(value, bool): + return value + return str(value).lower() in ["1", "true", "yes", "on"] diff --git a/assets/charts.html b/assets/charts.html index 51bcf20..2b02381 100644 --- a/assets/charts.html +++ b/assets/charts.html @@ -145,6 +145,7 @@
+ Config