diff --git a/config.py b/config.py index e42302e..75c42eb 100644 --- a/config.py +++ b/config.py @@ -6,57 +6,118 @@ from dotenv import load_dotenv # Load environment variables from .env file (if it exists) load_dotenv() -def load_from_json(): - #Load config from JSON file if it exists - path = Path("data/config.json") - if not path.exists(): - return {} - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data.get("config", {}) - except Exception as e: - print(f"Error loading config from JSON: {e}") - return {} +class DynamicConfig: + """Dynamic configuration that reloads from JSON on each access""" -# Load from JSON or use defaults -_config = load_from_json() + def __init__(self): + self._json_cache = None + self._cache_time = 0 -# Helper function to get config value from environment, then JSON, then default -def get_config(key: str, default, is_int: bool = False): - # 1. Try environment variable first - env_val = os.getenv(key) - if env_val is not None: - return int(env_val) if is_int else env_val - # 2. Try JSON config - json_val = _config.get(key) - if json_val is not None: - return json_val - # 3. Use default - return default + def _load_from_json(self): + """Load config from JSON file if it exists""" + path = Path("data/config.json") + if not path.exists(): + return {} -# Torn API -TORN_API_KEY = get_config("TORN_API_KEY", "YOUR_TORN_API_KEY_HERE") + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("config", {}) + except Exception as e: + print(f"Error loading config from JSON: {e}") + return {} -# FFScouter API -FFSCOUTER_KEY = get_config("FFSCOUTER_KEY", "YOUR_FFSCOUTER_KEY_HERE") + def _get_value(self, key: str, default, is_int: bool = False): + """Get config value with priority: env vars > json > defaults""" + # 1. Try environment variable first (highest priority) + env_val = os.getenv(key) + if env_val is not None: + return int(env_val) if is_int else env_val -# Discord Bot -DISCORD_TOKEN = get_config("DISCORD_TOKEN", "YOUR_DISCORD_BOT_TOKEN_HERE") -ALLOWED_CHANNEL_ID = get_config("ALLOWED_CHANNEL_ID", 0, is_int=True) + # 2. Try JSON config (reload from file each time) + json_config = self._load_from_json() + json_val = json_config.get(key) + if json_val is not None: + return json_val -# Intervals -HIT_CHECK_INTERVAL = get_config("HIT_CHECK_INTERVAL", 60, is_int=True) -REASSIGN_DELAY = get_config("REASSIGN_DELAY", 120, is_int=True) + # 3. Use default + return default -# Bot Assignment Settings -ASSIGNMENT_TIMEOUT = get_config("ASSIGNMENT_TIMEOUT", 60, is_int=True) # Seconds before reassigning a target -ASSIGNMENT_REMINDER = get_config("ASSIGNMENT_REMINDER", 45, is_int=True) # Seconds before sending reminder message + def reload(self): + """Force reload of JSON config (called after settings update)""" + # Just clear cache - next access will reload + self._json_cache = None + print("[CONFIG] Configuration reloaded from file") -# Chain Timer Settings -CHAIN_TIMER_THRESHOLD = get_config("CHAIN_TIMER_THRESHOLD", 5, is_int=True) # Minutes - start assigning hits when chain timer is at or below this + # Torn API + @property + def TORN_API_KEY(self): + return self._get_value("TORN_API_KEY", "YOUR_TORN_API_KEY_HERE") -# Authentication -AUTH_PASSWORD = get_config("AUTH_PASSWORD", "YOUR_AUTH_PASSWORD_HERE") # Universal password for all users -JWT_SECRET = get_config("JWT_SECRET", "your-secret-key-change-this") # Secret key for JWT tokens + # FFScouter API + @property + def FFSCOUTER_KEY(self): + return self._get_value("FFSCOUTER_KEY", "YOUR_FFSCOUTER_KEY_HERE") + + # Discord Bot + @property + def DISCORD_TOKEN(self): + return self._get_value("DISCORD_TOKEN", "YOUR_DISCORD_BOT_TOKEN_HERE") + + @property + def ALLOWED_CHANNEL_ID(self): + return self._get_value("ALLOWED_CHANNEL_ID", 0, is_int=True) + + # Intervals + @property + def HIT_CHECK_INTERVAL(self): + return self._get_value("HIT_CHECK_INTERVAL", 60, is_int=True) + + @property + def REASSIGN_DELAY(self): + return self._get_value("REASSIGN_DELAY", 120, is_int=True) + + # Bot Assignment Settings + @property + def ASSIGNMENT_TIMEOUT(self): + return self._get_value("ASSIGNMENT_TIMEOUT", 60, is_int=True) + + @property + def ASSIGNMENT_REMINDER(self): + return self._get_value("ASSIGNMENT_REMINDER", 45, is_int=True) + + # Chain Timer Settings + @property + def CHAIN_TIMER_THRESHOLD(self): + return self._get_value("CHAIN_TIMER_THRESHOLD", 5, is_int=True) + + # Authentication + @property + def AUTH_PASSWORD(self): + return self._get_value("AUTH_PASSWORD", "YOUR_AUTH_PASSWORD_HERE") + + @property + def JWT_SECRET(self): + return self._get_value("JWT_SECRET", "your-secret-key-change-this") + + +# Create global config instance +config = DynamicConfig() + +# Export reload function +def reload_config(): + """Reload configuration from file""" + config.reload() + +# For backward compatibility, access config properties directly +# Code using "from config import TORN_API_KEY" needs to be changed to use config.TORN_API_KEY +# But we can't do that without breaking existing code, so we need a different approach + +# Instead, we'll make these read from the config instance each time they're accessed +# by using __getattr__ at the module level +def __getattr__(name): + """Dynamically fetch config values when accessed as module attributes""" + if hasattr(config, name): + return getattr(config, name) + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/main.py b/main.py index bc06567..08f4161 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ # main.py import discord from discord.ext import commands -from config import ALLOWED_CHANNEL_ID, HIT_CHECK_INTERVAL, REASSIGN_DELAY, DISCORD_TOKEN +import config as app_config from cogs.assignments import Assignments from cogs.commands import HitCommands @@ -51,8 +51,8 @@ class HitDispatchBot(commands.Bot): enemy_queue=enemy_queue, active_assignments=active_assignments, enrolled_attackers=enrolled_attackers, - hit_check=HIT_CHECK_INTERVAL, - reassign_delay=REASSIGN_DELAY, + hit_check=app_config.HIT_CHECK_INTERVAL, + reassign_delay=app_config.REASSIGN_DELAY, ) ) @@ -65,7 +65,7 @@ class HitDispatchBot(commands.Bot): ) async def cog_check(self, ctx): - return ctx.channel.id == ALLOWED_CHANNEL_ID + return ctx.channel.id == app_config.ALLOWED_CHANNEL_ID bot = HitDispatchBot(command_prefix="!", intents=intents) @@ -90,7 +90,7 @@ async def on_ready(): async def start_bot(): try: print("Starting Discord bot...") - await bot.start(DISCORD_TOKEN) + await bot.start(app_config.DISCORD_TOKEN) except discord.LoginFailure: print("ERROR: Invalid Discord token! Please set DISCORD_TOKEN in config.py") except Exception as e: diff --git a/routers/config.py b/routers/config.py index b210920..4c2e438 100644 --- a/routers/config.py +++ b/routers/config.py @@ -10,21 +10,11 @@ router = APIRouter(prefix="/api", tags=["config"]) def reload_config_from_file(): - #Reload config values from JSON into module globals - path = Path("data/config.json") - if not path.exists(): - return - - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - # Update config module globals - for key, value in data.get("config", {}).items(): - if hasattr(config_module, key): - setattr(config_module, key, value) - except Exception as e: - print(f"Error reloading config from file: {e}") + """Reload config values from JSON - triggers dynamic reload""" + # With the new dynamic config system, we just need to call reload + # which will cause all future property accesses to read from the updated file + config_module.reload_config() + print("[CONFIG] Configuration reloaded after settings update") @router.get("/config") diff --git a/services/bot_assignment.py b/services/bot_assignment.py index 2263683..50b82c8 100644 --- a/services/bot_assignment.py +++ b/services/bot_assignment.py @@ -7,7 +7,7 @@ from typing import Dict, Optional from datetime import datetime from services.server_state import STATE from services.activity_log import activity_logger -from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID, CHAIN_TIMER_THRESHOLD, TORN_API_KEY +import config class BotAssignmentManager: def __init__(self, bot): @@ -54,7 +54,7 @@ class BotAssignmentManager: return None try: - url = f"https://api.torn.com/v2/faction/{STATE.friendly_faction_id}/chain?key={TORN_API_KEY}" + url = f"https://api.torn.com/v2/faction/{STATE.friendly_faction_id}/chain?key={config.TORN_API_KEY}" async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status != 200: @@ -161,7 +161,7 @@ class BotAssignmentManager: return self.chain_timeout = timeout - threshold_seconds = CHAIN_TIMER_THRESHOLD * 60 + threshold_seconds = config.CHAIN_TIMER_THRESHOLD * 60 # Check if we should enter chain mode if timeout > 0 and timeout <= threshold_seconds and not self.chain_active: @@ -203,7 +203,7 @@ class BotAssignmentManager: async def send_chain_expiration_warning(self): #Send @here alert that chain is about to expire try: - channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) + channel = self.bot.get_channel(config.ALLOWED_CHANNEL_ID) if channel and STATE.friendly_faction_id: faction_link = f"https://www.torn.com/factions.php?step=your#/tab=chain" message = f"@here **CHAIN EXPIRING IN 30 SECONDS!** Attack a faction member to keep it alive!\n{faction_link}" @@ -366,16 +366,16 @@ class BotAssignmentManager: # Send Discord message to channel attack_link = f"https://www.torn.com/loader.php?sid=attack&user2ID={enemy_id}" - message = f"**New target for {discord_user.mention}!**\n\n[**{enemy.name}** (Level {enemy.level})]({attack_link})\n\nYou have {ASSIGNMENT_TIMEOUT} seconds!" + message = f"**New target for {discord_user.mention}!**\n\n[**{enemy.name}** (Level {enemy.level})]({attack_link})\n\nYou have {config.ASSIGNMENT_TIMEOUT} seconds!" - channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) + channel = self.bot.get_channel(config.ALLOWED_CHANNEL_ID) if channel: await channel.send(message) print(f"Assigned {enemy.name} to {friendly.name} (Discord: {discord_user.name})") # Log to activity await activity_logger.log_action("System", "Hit Assigned", f"{friendly.name} -> {enemy.name} (Level {enemy.level})") else: - print(f"Assignment channel {ALLOWED_CHANNEL_ID} not found") + print(f"Assignment channel {config.ALLOWED_CHANNEL_ID} not found") self.active_targets[key]["failed"] = True except Exception as e: print(f"Failed to send Discord message to channel: {e}") @@ -434,12 +434,12 @@ class BotAssignmentManager: continue # Send reminder (only for successful assignments) - if elapsed >= ASSIGNMENT_REMINDER and not data["reminded"]: + if elapsed >= config.ASSIGNMENT_REMINDER and not data["reminded"]: discord_id = data["discord_id"] try: discord_user = await self.bot.fetch_user(discord_id) - remaining = ASSIGNMENT_TIMEOUT - ASSIGNMENT_REMINDER - channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) + remaining = config.ASSIGNMENT_TIMEOUT - config.ASSIGNMENT_REMINDER + channel = self.bot.get_channel(config.ALLOWED_CHANNEL_ID) if channel: await channel.send(f"**Reminder:** {discord_user.mention} - Target {enemy.name} - {remaining} seconds left!") data["reminded"] = True @@ -447,7 +447,7 @@ class BotAssignmentManager: pass # Reassign after timeout - if elapsed >= ASSIGNMENT_TIMEOUT: + if elapsed >= config.ASSIGNMENT_TIMEOUT: to_reassign.append((data["group_id"], enemy_id)) del self.active_targets[key] diff --git a/services/ffscouter.py b/services/ffscouter.py index de02aa9..ca3356a 100644 --- a/services/ffscouter.py +++ b/services/ffscouter.py @@ -1,5 +1,5 @@ import aiohttp -from config import FFSCOUTER_KEY +import config async def fetch_batch_stats(ids: list[int]): #Fetches predicted stats for a list of Torn IDs in a single FFScouter request. @@ -9,7 +9,7 @@ async def fetch_batch_stats(ids: list[int]): return {} ids_str = ",".join(map(str, ids)) - url = f"https://ffscouter.com/api/v1/get-stats?key={FFSCOUTER_KEY}&targets={ids_str}" + url = f"https://ffscouter.com/api/v1/get-stats?key={config.FFSCOUTER_KEY}&targets={ids_str}" async with aiohttp.ClientSession() as session: async with session.get(url) as resp: diff --git a/services/torn_api.py b/services/torn_api.py index 79c2f9d..9d69d81 100644 --- a/services/torn_api.py +++ b/services/torn_api.py @@ -1,7 +1,7 @@ # services/torn_api.py import aiohttp import asyncio -from config import TORN_API_KEY +import config from .ffscouter import fetch_batch_stats from .server_state import STATE @@ -21,7 +21,7 @@ async def populate_faction(faction_id: int, kind: str): #Fetch members + FFScouter estimates once and store in STATE. #kind: "friendly" or "enemy" - url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" + url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={config.TORN_API_KEY}" try: async with aiohttp.ClientSession() as session: @@ -86,7 +86,7 @@ async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, in #Periodically refresh member statuses in STATE. while True: try: - url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" + url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={config.TORN_API_KEY}" async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status != 200: