0.5.1.1.a1
- Major back-end changes
  - Moved most non-sensitive values to dynamic configurations module
  - Removed references to old variables naming scheme for certain settings
  - Essentially, most settings are now capable of being dynamically assigned, instead of fully static
  - Complete rework of the wrapper and compose infrastructure to allow dynamic data changes
- New command: `/shaiadmin`
  - Admin-only (and approved users/roles) have access
  - `/shaiadmin set [setting] [value]`
    - Applies a new setting
    - Settings must be properly formatted, and invalid ones are rejected
  - `/shaiadmin unset [setting]`
    - Unsets/resets a setting to default
  - `/shaiadmin settings download`
    - Offers the current settings file for download. Useful for bulk editing. JSON formatted
  - `/shaiadmin settings upload [file].json`
    - Allows the uploading of a new settings file
    - This file is verified, tested, and processed before being applied
			
			
This commit is contained in:
		
							parent
							
								
									ebbebbacf7
								
							
						
					
					
						commit
						23e122c08a
					
				
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -14,6 +14,7 @@ NOTES.md
 | 
				
			|||||||
sanity/
 | 
					sanity/
 | 
				
			||||||
.offline_data.json
 | 
					.offline_data.json
 | 
				
			||||||
dev/.env.production
 | 
					dev/.env.production
 | 
				
			||||||
 | 
					dev/portainer_config.png
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Tools
 | 
					# Tools
 | 
				
			||||||
wrapper/
 | 
					wrapper/
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								assets/images/escort_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/escort_logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 49 KiB  | 
							
								
								
									
										108
									
								
								assets/images/escort_logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								assets/images/escort_logo.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 19 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/feydakin_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/feydakin_logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 53 KiB  | 
							
								
								
									
										93
									
								
								assets/images/feydakin_logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								assets/images/feydakin_logo.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 16 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								assets/images/harvester_logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/images/harvester_logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 46 KiB  | 
							
								
								
									
										51
									
								
								assets/images/harvester_logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								assets/images/harvester_logo.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 20 KiB  | 
							
								
								
									
										134
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										134
									
								
								bot.py
									
									
									
									
									
								
							@ -9,10 +9,9 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
				
			||||||
VERSION = "0.4.2.1.a1"
 | 
					VERSION = "0.5.1.1.a1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Env loading ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
 | 
					 | 
				
			||||||
load_dotenv()
 | 
					load_dotenv()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _get_env(name: str, default: str = "") -> str:
 | 
					def _get_env(name: str, default: str = "") -> str:
 | 
				
			||||||
@ -20,11 +19,26 @@ def _get_env(name: str, default: str = "") -> str:
 | 
				
			|||||||
    return (v or "").strip().strip('"').strip("'") or default
 | 
					    return (v or "").strip().strip('"').strip("'") or default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
TOKEN = _get_env("DISCORD_TOKEN")
 | 
					TOKEN = _get_env("DISCORD_TOKEN")
 | 
				
			||||||
DATA_FILE = _get_env("SHAI_DATA") or _get_env("SHAI_DATA_FILE") or "/data/data.json"
 | 
					DATA_FILE = _get_env("DATA_FILE") or "./data/data.json"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
 | 
					print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
 | 
				
			||||||
print("[Config] DATA_FILE:", DATA_FILE)
 | 
					print("[Config] DATA_FILE:", DATA_FILE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------- Ensure data path exists (fallback if not writable) ----------
 | 
				
			||||||
 | 
					data_dir = os.path.dirname(DATA_FILE) or "."
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    os.makedirs(data_dir, exist_ok=True)
 | 
				
			||||||
 | 
					except PermissionError:
 | 
				
			||||||
 | 
					    fallback = "./data/data.json"
 | 
				
			||||||
 | 
					    print(f"[Config] No permission to create '{data_dir}'. Falling back to {fallback}")
 | 
				
			||||||
 | 
					    DATA_FILE = fallback
 | 
				
			||||||
 | 
					    data_dir = os.path.dirname(DATA_FILE)
 | 
				
			||||||
 | 
					    os.makedirs(data_dir, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if not os.path.exists(DATA_FILE):
 | 
				
			||||||
 | 
					    with open(DATA_FILE, "w", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					        f.write("{}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Discord intents ----------
 | 
					# ---------- Discord intents ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
intents = discord.Intents.default()
 | 
					intents = discord.Intents.default()
 | 
				
			||||||
@ -52,13 +66,10 @@ bot.data_manager = DataManager(DATA_FILE)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
 | 
					# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
 | 
				
			||||||
def _resolve_channel_id(c, key: str) -> int:
 | 
					def _resolve_channel_id(c, key: str) -> int:
 | 
				
			||||||
    # 1) ENV always wins
 | 
					    """
 | 
				
			||||||
    env_key = f"SHAI_{key.upper()}"
 | 
					    Resolve channel IDs from the runtime settings store (cfg), with a final
 | 
				
			||||||
    raw = os.getenv(env_key, "").strip().strip('"').strip("'")
 | 
					    fallback to legacy bot.config['DEFAULT'] if present. No SHAI_* env usage.
 | 
				
			||||||
    if raw.isdigit():
 | 
					    """
 | 
				
			||||||
        return int(raw)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # 2) Try cfg_helper (if it happens to know)
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        v = int(c.int(key, 0))
 | 
					        v = int(c.int(key, 0))
 | 
				
			||||||
        if v:
 | 
					        if v:
 | 
				
			||||||
@ -66,9 +77,8 @@ def _resolve_channel_id(c, key: str) -> int:
 | 
				
			|||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        pass
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # 3) Last resort: legacy bot.config shapes
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        # bot.config like dict
 | 
					        # legacy DEFAULT mapping (ConfigParser-like or our shim)
 | 
				
			||||||
        v = int(getattr(c, "get", lambda *_: 0)(key, 0))
 | 
					        v = int(getattr(c, "get", lambda *_: 0)(key, 0))
 | 
				
			||||||
        if v:
 | 
					        if v:
 | 
				
			||||||
            return v
 | 
					            return v
 | 
				
			||||||
@ -77,7 +87,6 @@ def _resolve_channel_id(c, key: str) -> int:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return 0
 | 
					    return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
async def _guild_selfcheck(g: discord.Guild, c):
 | 
					async def _guild_selfcheck(g: discord.Guild, c):
 | 
				
			||||||
    problems = []
 | 
					    problems = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -128,92 +137,47 @@ async def on_ready():
 | 
				
			|||||||
        print("[SelfCheck] failed:", repr(e))
 | 
					        print("[SelfCheck] failed:", repr(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---------- Slash command scope & sync ----------
 | 
					    # ---------- Slash command scope & sync ----------
 | 
				
			||||||
    #
 | 
					 | 
				
			||||||
    # Toggle here (or set SHAI_SLASH_GUILD_ONLY=true/false):
 | 
					 | 
				
			||||||
    guild_only = env_cfg.bool("slash_guild_only", True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Choose target guilds for "instant" registration
 | 
					 | 
				
			||||||
    target_gids = set()
 | 
					 | 
				
			||||||
    for key in ("home_guild_id", "dev_guild_id"):
 | 
					 | 
				
			||||||
        val = env_cfg.get(key)
 | 
					 | 
				
			||||||
        if val:
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                target_gids.add(int(val))
 | 
					 | 
				
			||||||
            except Exception:
 | 
					 | 
				
			||||||
                pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        if guild_only and target_gids:
 | 
					        # env_cfg already exists above in on_ready()
 | 
				
			||||||
            print(f"[Slash] Mode: GUILD-ONLY to {sorted(target_gids)}")
 | 
					        gid = env_cfg.int("home_guild_id", 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Copy all currently-loaded global commands to each target guild
 | 
					        if gid > 0:
 | 
				
			||||||
            for gid in sorted(target_gids):
 | 
					            print(f"[Slash] Mode: GUILD-ONLY → {gid}")
 | 
				
			||||||
                g = bot.get_guild(gid)
 | 
					 | 
				
			||||||
                if not g:
 | 
					 | 
				
			||||||
                    print(f"[Slash] Guild {gid}: not in cache; skipping copy/sync.")
 | 
					 | 
				
			||||||
                    continue
 | 
					 | 
				
			||||||
                bot.tree.copy_global_to(guild=g)
 | 
					 | 
				
			||||||
                g_cmds = await bot.tree.sync(guild=g)
 | 
					 | 
				
			||||||
                names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
 | 
					 | 
				
			||||||
                print(f"[Slash] Synced {len(g_cmds)} commands to {g.name} ({g.id}): {names}")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Now remove global commands so only guild-scoped remain
 | 
					            guild_obj = discord.Object(id=gid)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Copy all currently-loaded global commands to HOME guild
 | 
				
			||||||
 | 
					            bot.tree.copy_global_to(guild=guild_obj)
 | 
				
			||||||
 | 
					            g_cmds = await bot.tree.sync(guild=guild_obj)
 | 
				
			||||||
 | 
					            g_names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
 | 
				
			||||||
 | 
					            print(f"[Slash] Synced {len(g_cmds)} commands to guild {gid}: {g_names}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Clear global so only guild-scoped remain
 | 
				
			||||||
            bot.tree.clear_commands(guild=None)
 | 
					            bot.tree.clear_commands(guild=None)
 | 
				
			||||||
            cleared = await bot.tree.sync()  # push empty global set (purges old global copies)
 | 
					            cleared = await bot.tree.sync()  # push empty global set
 | 
				
			||||||
            print(f"[Slash] Cleared global commands (now {len(cleared)}).")
 | 
					            print(f"[Slash] Cleared global commands (now {len(cleared)}).")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        else:
 | 
					            # Debug: list actual state after sync
 | 
				
			||||||
            print("[Slash] Mode: GLOBAL")
 | 
					            try:
 | 
				
			||||||
            # Purge any old per-guild copies in target guilds (to avoid dupes),
 | 
					                global_cmds = await bot.tree.fetch_commands()
 | 
				
			||||||
            # then sync globally once.
 | 
					                print(f"[Slash] Global commands ({len(global_cmds)}): {', '.join(f'/{c.name}' for c in global_cmds) or '(none)'}")
 | 
				
			||||||
            for gid in sorted(target_gids):
 | 
					            except Exception as e:
 | 
				
			||||||
                g = bot.get_guild(gid)
 | 
					                print("[Slash] Failed to fetch global commands:", repr(e))
 | 
				
			||||||
                if not g:
 | 
					 | 
				
			||||||
                    print(f"[Slash] Guild {gid}: not in cache; skip purge.")
 | 
					 | 
				
			||||||
                    continue
 | 
					 | 
				
			||||||
                bot.tree.clear_commands(guild=g)
 | 
					 | 
				
			||||||
                await bot.tree.sync(guild=g)
 | 
					 | 
				
			||||||
                print(f"[Slash] Purged guild-specific commands in {g.name} ({g.id}).")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                g_cmds = await bot.tree.fetch_commands(guild=guild_obj)
 | 
				
			||||||
 | 
					                print(f"[Slash] Guild {gid} commands ({len(g_cmds)}): {', '.join(f'/{c.name}' for c in g_cmds) or '(none)'}")
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            print("[Slash] Mode: GLOBAL (HOME_GUILD_ID not set)")
 | 
				
			||||||
            global_cmds = await bot.tree.sync()
 | 
					            global_cmds = await bot.tree.sync()
 | 
				
			||||||
            names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
 | 
					            names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
 | 
				
			||||||
            print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
 | 
					            print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # --- Always print what actually exists after sync ---
 | 
					 | 
				
			||||||
        def _fmt_cmds(cmds):
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                return ", ".join(f"/{c.name}" for c in cmds) if cmds else "(none)"
 | 
					 | 
				
			||||||
            except Exception:
 | 
					 | 
				
			||||||
                return "(unreadable)"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Global list
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            global_cmds = await bot.tree.fetch_commands()
 | 
					 | 
				
			||||||
            print(f"[Slash] Global commands ({len(global_cmds)}): {_fmt_cmds(global_cmds)}")
 | 
					 | 
				
			||||||
        except Exception as e:
 | 
					 | 
				
			||||||
            print("[Slash] Failed to fetch global commands:", repr(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Guild lists
 | 
					 | 
				
			||||||
        for gid in sorted(target_gids):
 | 
					 | 
				
			||||||
            g = bot.get_guild(gid)
 | 
					 | 
				
			||||||
            if not g:
 | 
					 | 
				
			||||||
                print(f"[Slash] Guild {gid}: not in cache; cannot fetch commands.")
 | 
					 | 
				
			||||||
                continue
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                g_cmds = await bot.tree.fetch_commands(guild=g)
 | 
					 | 
				
			||||||
                print(f"[Slash] {g.name} ({g.id}) guild commands ({len(g_cmds)}): {_fmt_cmds(g_cmds)}")
 | 
					 | 
				
			||||||
            except Exception as e:
 | 
					 | 
				
			||||||
                print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    except Exception as e:
 | 
					    except Exception as e:
 | 
				
			||||||
        print("[Slash] Sync failed:", repr(e))
 | 
					        print("[Slash] Sync failed:", repr(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Post boot status message
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        await post_boot_notice(bot)
 | 
					 | 
				
			||||||
    except Exception as e:
 | 
					 | 
				
			||||||
        print("[BootNotice] failed:", repr(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# ---------- Auto-discover extensions ----------
 | 
					# ---------- Auto-discover extensions ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
modules_path = pathlib.Path(__file__).parent / "modules"
 | 
					modules_path = pathlib.Path(__file__).parent / "modules"
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										0
									
								
								modules/admin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								modules/admin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										168
									
								
								modules/admin/shaiadmin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								modules/admin/shaiadmin.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,168 @@
 | 
				
			|||||||
 | 
					# modules/admin/shaiadmin.py
 | 
				
			||||||
 | 
					import io
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					from typing import Any, Dict, List
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import discord
 | 
				
			||||||
 | 
					from discord import app_commands
 | 
				
			||||||
 | 
					from discord.ext import commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from modules.common.settings import (
 | 
				
			||||||
 | 
					    cfg, SETTINGS_SCHEMA, settings_path, settings_get_all,
 | 
				
			||||||
 | 
					    settings_set, settings_reset, settings_import_bulk, ValidationError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from mod_perms import is_moderator_member  # keep if you want mods as managers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _user_has_role_ids(member: discord.Member, role_ids: List[int]) -> bool:
 | 
				
			||||||
 | 
					    if not isinstance(member, discord.Member) or not role_ids:
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					    rset = set(role_ids)
 | 
				
			||||||
 | 
					    return any(r.id in rset for r in member.roles)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def _is_owner(bot: commands.Bot, user: discord.abc.User) -> bool:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        return await bot.is_owner(user)
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _get_admin_lists(bot: commands.Bot) -> Dict[str, List[int]]:
 | 
				
			||||||
 | 
					    r = cfg(bot)
 | 
				
			||||||
 | 
					    users, roles = [], []
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        users = json.loads(r.get("admin_user_ids", "[]"))
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        users = []
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        roles = json.loads(r.get("admin_role_ids", "[]"))
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        roles = []
 | 
				
			||||||
 | 
					    return {"users": users, "roles": roles}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def _check_admin(inter: discord.Interaction) -> bool:
 | 
				
			||||||
 | 
					    bot: commands.Bot = inter.client  # type: ignore
 | 
				
			||||||
 | 
					    user = inter.user
 | 
				
			||||||
 | 
					    if await _is_owner(bot, user):
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					    if isinstance(user, discord.Member):
 | 
				
			||||||
 | 
					        lists = _get_admin_lists(bot)
 | 
				
			||||||
 | 
					        if user.id in set(lists["users"]):
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        if _user_has_role_ids(user, lists["roles"]):
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        if is_moderator_member(user, bot):  # optional; remove if not desired
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					    if not inter.response.is_done():
 | 
				
			||||||
 | 
					        await inter.response.send_message("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        await inter.followup.send("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
 | 
				
			||||||
 | 
					    return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ShaiAdminCog(commands.Cog):
 | 
				
			||||||
 | 
					    """Runtime settings administration (file-backed)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, bot: commands.Bot):
 | 
				
			||||||
 | 
					        self.bot = bot
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Guild-only group; prefix description with [ADMIN]
 | 
				
			||||||
 | 
					    shaiadmin = app_commands.Group(
 | 
				
			||||||
 | 
					        name="shaiadmin",
 | 
				
			||||||
 | 
					        description="[ADMIN] Owner/approved-only settings manager.",
 | 
				
			||||||
 | 
					        guild_only=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ---- bound coroutine for autocomplete ----
 | 
				
			||||||
 | 
					    async def ac_setting_keys(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]:
 | 
				
			||||||
 | 
					        cur = (current or "").lower()
 | 
				
			||||||
 | 
					        keys = [k for k in sorted(SETTINGS_SCHEMA.keys()) if cur in k]
 | 
				
			||||||
 | 
					        return [app_commands.Choice(name=k, value=k) for k in keys[:25]]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # /shaiadmin set
 | 
				
			||||||
 | 
					    @shaiadmin.command(name="set", description="[ADMIN] Set a setting (validated, persisted, applied).")
 | 
				
			||||||
 | 
					    @app_commands.describe(setting_name="Which setting to change", value="New value (type depends on setting)")
 | 
				
			||||||
 | 
					    @app_commands.autocomplete(setting_name=ac_setting_keys)
 | 
				
			||||||
 | 
					    async def set_value(self, inter: discord.Interaction, setting_name: str, value: str):
 | 
				
			||||||
 | 
					        if not await _check_admin(inter):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        await inter.response.defer(ephemeral=True, thinking=True)
 | 
				
			||||||
 | 
					        setting_name = setting_name.lower().strip()
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            changed = settings_set(setting_name, value)
 | 
				
			||||||
 | 
					            await inter.followup.send(
 | 
				
			||||||
 | 
					                f"✅ `{setting_name}` updated and applied." if changed else "ℹ️ No change.",
 | 
				
			||||||
 | 
					                ephemeral=True,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        except ValidationError as ve:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ Failed to set `{setting_name}`: {e!r}", ephemeral=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # /shaiadmin unset
 | 
				
			||||||
 | 
					    @shaiadmin.command(name="unset", description="[ADMIN] Reset/unset a setting to its default.")
 | 
				
			||||||
 | 
					    @app_commands.describe(setting_name="Which setting to reset")
 | 
				
			||||||
 | 
					    @app_commands.autocomplete(setting_name=ac_setting_keys)
 | 
				
			||||||
 | 
					    async def unset_value(self, inter: discord.Interaction, setting_name: str):
 | 
				
			||||||
 | 
					        if not await _check_admin(inter):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        await inter.response.defer(ephemeral=True, thinking=True)
 | 
				
			||||||
 | 
					        setting_name = setting_name.lower().strip()
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            settings_reset(setting_name)
 | 
				
			||||||
 | 
					            await inter.followup.send(f"✅ `{setting_name}` reset to default and applied.", ephemeral=True)
 | 
				
			||||||
 | 
					        except ValidationError as ve:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ Failed to reset `{setting_name}`: {e!r}", ephemeral=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # /shaiadmin settings (download/upload)
 | 
				
			||||||
 | 
					    settings = app_commands.Group(
 | 
				
			||||||
 | 
					        name="settings",
 | 
				
			||||||
 | 
					        description="[ADMIN] Download or upload the full settings JSON.",
 | 
				
			||||||
 | 
					        parent=shaiadmin,
 | 
				
			||||||
 | 
					        guild_only=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @settings.command(name="download", description="[ADMIN] Download the current settings.json")
 | 
				
			||||||
 | 
					    async def download(self, inter: discord.Interaction):
 | 
				
			||||||
 | 
					        if not await _check_admin(inter):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        await inter.response.defer(ephemeral=True, thinking=True)
 | 
				
			||||||
 | 
					        data = settings_get_all()
 | 
				
			||||||
 | 
					        buf = io.BytesIO(json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8"))
 | 
				
			||||||
 | 
					        buf.seek(0)
 | 
				
			||||||
 | 
					        await inter.followup.send(
 | 
				
			||||||
 | 
					            content=f"📦 Current settings from `{settings_path()}`",
 | 
				
			||||||
 | 
					            file=discord.File(buf, filename="settings.json"),
 | 
				
			||||||
 | 
					            ephemeral=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @settings.command(name="upload", description="[ADMIN] Upload and apply a settings.json")
 | 
				
			||||||
 | 
					    @app_commands.describe(file="A JSON file exported by /shaiadmin settings download (or matching the schema).")
 | 
				
			||||||
 | 
					    async def upload(self, inter: discord.Interaction, file: discord.Attachment):
 | 
				
			||||||
 | 
					        if not await _check_admin(inter):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        await inter.response.defer(ephemeral=True, thinking=True)
 | 
				
			||||||
 | 
					        if not file or not file.filename.lower().endswith(".json"):
 | 
				
			||||||
 | 
					            await inter.followup.send("Please attach a `.json` file.", ephemeral=True)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            raw = await file.read()
 | 
				
			||||||
 | 
					            obj = json.loads(raw.decode("utf-8"))
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            await inter.followup.send("❌ Invalid JSON file.", ephemeral=True)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            changed_keys = settings_import_bulk(obj)
 | 
				
			||||||
 | 
					            await inter.followup.send(
 | 
				
			||||||
 | 
					                f"✅ Uploaded and applied `{len(changed_keys)}` keys: {', '.join(sorted(changed_keys))}."
 | 
				
			||||||
 | 
					                if changed_keys else "ℹ️ No changes detected.",
 | 
				
			||||||
 | 
					                ephemeral=True,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        except ValidationError as ve:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            await inter.followup.send(f"❌ Upload failed: {e!r}", ephemeral=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def setup(bot: commands.Bot):
 | 
				
			||||||
 | 
					    await bot.add_cog(ShaiAdminCog(bot))
 | 
				
			||||||
@ -1,4 +1,3 @@
 | 
				
			|||||||
# modules/common/boot_notice.py
 | 
					 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
@ -81,18 +80,18 @@ def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
def _auth_headers_from_cfg(r):
 | 
					def _auth_headers_from_cfg(r):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Build Authorization header using SHAI_REPO_AHTOKEN (cfg: repo_ahtoken).
 | 
					    Build Authorization header using repo auth tokens.
 | 
				
			||||||
    Value may be raw; we prefix 'token ' if needed.
 | 
					    - Preferred: cfg('repo_ahtoken') (comes from settings.json or env REPO_AHTOKEN)
 | 
				
			||||||
    Also supports SHAI_GITEA_TOKEN / SHAI_GITEA_USER as secondary.
 | 
					    - Fallbacks: GITEA_TOKEN / GITEA_USER envs (non-SHAI)
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    ahtoken = r.get('repo_ahtoken', '').strip()  # SHAI_REPO_AHTOKEN
 | 
					    ahtoken = r.get('repo_ahtoken', '').strip()  # REPO_AHTOKEN via settings/env
 | 
				
			||||||
    if ahtoken:
 | 
					    if ahtoken:
 | 
				
			||||||
        if not ahtoken.lower().startswith('token '):
 | 
					        if not ahtoken.lower().startswith('token '):
 | 
				
			||||||
            ahtoken = f"token {ahtoken}"
 | 
					            ahtoken = f"token {ahtoken}"
 | 
				
			||||||
        return {"Authorization": ahtoken}
 | 
					        return {"Authorization": ahtoken}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    tok = os.getenv("SHAI_GITEA_TOKEN", "").strip()
 | 
					    tok = os.getenv("GITEA_TOKEN", "").strip()
 | 
				
			||||||
    usr = os.getenv("SHAI_GITEA_USER", "").strip()
 | 
					    usr = os.getenv("GITEA_USER", "").strip()
 | 
				
			||||||
    if tok and usr:
 | 
					    if tok and usr:
 | 
				
			||||||
        import base64
 | 
					        import base64
 | 
				
			||||||
        b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
 | 
					        b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
 | 
				
			||||||
@ -102,6 +101,7 @@ def _auth_headers_from_cfg(r):
 | 
				
			|||||||
    return {}
 | 
					    return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
 | 
					async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
 | 
				
			||||||
 | 
					    import aiohttp
 | 
				
			||||||
    timeout = aiohttp.ClientTimeout(total=timeout_sec)
 | 
					    timeout = aiohttp.ClientTimeout(total=timeout_sec)
 | 
				
			||||||
    async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
 | 
					    async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
 | 
				
			||||||
        async with sess.get(url) as resp:
 | 
					        async with sess.get(url) as resp:
 | 
				
			||||||
@ -121,6 +121,7 @@ async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str
 | 
				
			|||||||
    /api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
 | 
					    /api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
 | 
				
			||||||
    If branch is falsy, omit 'sha' to use server default.
 | 
					    If branch is falsy, omit 'sha' to use server default.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					    from urllib.parse import urlencode
 | 
				
			||||||
    params = {
 | 
					    params = {
 | 
				
			||||||
        "stat": "false",
 | 
					        "stat": "false",
 | 
				
			||||||
        "verification": "false",
 | 
					        "verification": "false",
 | 
				
			||||||
@ -181,7 +182,6 @@ async def post_boot_notice(bot):
 | 
				
			|||||||
    except Exception as e:
 | 
					    except Exception as e:
 | 
				
			||||||
        print(f"[boot_notice] wait_until_ready failed: {e}")
 | 
					        print(f"[boot_notice] wait_until_ready failed: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    for guild in bot.guilds:
 | 
					    for guild in bot.guilds:
 | 
				
			||||||
        print(f'  - {guild.name} (id: {guild.id})')
 | 
					        print(f'  - {guild.name} (id: {guild.id})')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -201,9 +201,9 @@ async def post_boot_notice(bot):
 | 
				
			|||||||
        print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
 | 
					        print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    repo_url = r.get('repo_url', '')                   # SHAI_REPO_URL
 | 
					    repo_url = r.get('repo_url', '')
 | 
				
			||||||
    branch = r.get('repo_branch', 'main') or None      # SHAI_REPO_BRANCH (optional)
 | 
					    branch = r.get('repo_branch', 'main') or None
 | 
				
			||||||
    check_time_utc = r.get('check_time_utc', '')       # SHAI_CHECK_TIME_UTC (optional)
 | 
					    check_time_utc = r.get('check_time_utc', '')
 | 
				
			||||||
    headers = _auth_headers_from_cfg(r)
 | 
					    headers = _auth_headers_from_cfg(r)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    api_base = owner = repo = None
 | 
					    api_base = owner = repo = None
 | 
				
			||||||
@ -254,7 +254,7 @@ async def post_boot_notice(bot):
 | 
				
			|||||||
    # Build + post status line
 | 
					    # Build + post status line
 | 
				
			||||||
    status_line = _format_status_line(reason, prev_ver, curr_ver)
 | 
					    status_line = _format_status_line(reason, prev_ver, curr_ver)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # NEW: If no version change (manual/scheduled), append the running version to the status line,
 | 
					    # If no version change (manual/scheduled), append the running version to the status line,
 | 
				
			||||||
    # and DO NOT post the commit message separately.
 | 
					    # and DO NOT post the commit message separately.
 | 
				
			||||||
    append_version_only = reason in ("manual", "scheduled")
 | 
					    append_version_only = reason in ("manual", "scheduled")
 | 
				
			||||||
    if append_version_only and curr_ver:
 | 
					    if append_version_only and curr_ver:
 | 
				
			||||||
 | 
				
			|||||||
@ -1,68 +1,453 @@
 | 
				
			|||||||
# modules/common/settings.py
 | 
					# modules/common/settings.py
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
from typing import Any, Dict, Iterable, Optional
 | 
					import json
 | 
				
			||||||
 | 
					import shutil
 | 
				
			||||||
 | 
					import threading
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					from urllib.parse import urlparse
 | 
				
			||||||
 | 
					from typing import Any, Dict, Iterable, Optional, List
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# Public API Exceptions
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ValidationError(Exception):
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# Helpers
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _clean(s: Optional[str]) -> str:
 | 
					def _clean(s: Optional[str]) -> str:
 | 
				
			||||||
    s = (s or "").strip()
 | 
					    s = (s or "").strip()
 | 
				
			||||||
    # strip accidental quotes Portainer sometimes adds
 | 
					 | 
				
			||||||
    if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
 | 
					    if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
 | 
				
			||||||
        s = s[1:-1].strip()
 | 
					        s = s[1:-1].strip()
 | 
				
			||||||
    return s
 | 
					    return s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _is_intish(x: Any) -> bool:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        int(str(x).strip())
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _collect_shai_env() -> Dict[str, str]:
 | 
					def _to_bool(x: Any) -> bool:
 | 
				
			||||||
    """
 | 
					    s = str(x).strip().lower()
 | 
				
			||||||
    Build a {key_without_prefix_lower: cleaned_value} mapping
 | 
					    if s in ("1", "true", "yes", "on", "y", "t"):
 | 
				
			||||||
    from all environment variables that start with SHAI_.
 | 
					        return True
 | 
				
			||||||
    """
 | 
					    if s in ("0", "false", "no", "off", "n", "f"):
 | 
				
			||||||
    out: Dict[str, str] = {}
 | 
					        return False
 | 
				
			||||||
    for k, v in os.environ.items():
 | 
					    raise ValidationError(f"Expected a boolean, got {x!r}")
 | 
				
			||||||
        if not k.startswith("SHAI_"):
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
        key = k[5:].lower()  # SHAI_MOD_CHANNEL_ID -> mod_channel_id
 | 
					 | 
				
			||||||
        out[key] = _clean(v)
 | 
					 | 
				
			||||||
    return out
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_int(x: Any) -> int:
 | 
				
			||||||
 | 
					    if _is_intish(x):
 | 
				
			||||||
 | 
					        return int(str(x).strip())
 | 
				
			||||||
 | 
					    raise ValidationError(f"Expected an integer, got {x!r}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_float(x: Any) -> float:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        return float(str(x).strip())
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        raise ValidationError(f"Expected a float, got {x!r}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_str(x: Any) -> str:
 | 
				
			||||||
 | 
					    return str(x)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_list_int(x: Any) -> List[int]:
 | 
				
			||||||
 | 
					    if isinstance(x, list):
 | 
				
			||||||
 | 
					        out = []
 | 
				
			||||||
 | 
					        for v in x:
 | 
				
			||||||
 | 
					            if not _is_intish(v):
 | 
				
			||||||
 | 
					                raise ValidationError(f"List must contain integers; got {v!r}")
 | 
				
			||||||
 | 
					            out.append(int(v))
 | 
				
			||||||
 | 
					        return out
 | 
				
			||||||
 | 
					    if isinstance(x, str):
 | 
				
			||||||
 | 
					        toks = [t.strip() for t in x.split(",") if t.strip()]
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return [int(t) for t in toks]
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            raise ValidationError(f"Could not parse list of integers from {x!r}")
 | 
				
			||||||
 | 
					    raise ValidationError(f"Expected a list of integers, got {type(x).__name__}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---- URL / Time / Date validators ----
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_url(x: Any) -> str:
 | 
				
			||||||
 | 
					    s = str(x).strip()
 | 
				
			||||||
 | 
					    if not s:
 | 
				
			||||||
 | 
					        raise ValidationError("URL cannot be empty")
 | 
				
			||||||
 | 
					    p = urlparse(s)
 | 
				
			||||||
 | 
					    if p.scheme not in ("http", "https"):
 | 
				
			||||||
 | 
					        raise ValidationError("URL must start with http:// or https://")
 | 
				
			||||||
 | 
					    if not p.netloc:
 | 
				
			||||||
 | 
					        raise ValidationError("URL missing host")
 | 
				
			||||||
 | 
					    if not re.match(r"^([A-Za-z0-9\-.]+|\d{1,3}(?:\.\d{1,3}){3}|localhost)(:\d+)?$", p.netloc):
 | 
				
			||||||
 | 
					        raise ValidationError("URL host looks invalid")
 | 
				
			||||||
 | 
					    return s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_TIME_24H_RE = re.compile(r"^(?P<h>[01]?\d|2[0-3]):(?P<m>[0-5]\d)(?::(?P<s>[0-5]\d))?$")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_time_24h(x: Any) -> str:
 | 
				
			||||||
 | 
					    s = str(x).strip()
 | 
				
			||||||
 | 
					    m = _TIME_24H_RE.match(s)
 | 
				
			||||||
 | 
					    if not m:
 | 
				
			||||||
 | 
					        raise ValidationError("Time must be HH:MM or HH:MM:SS (24-hour)")
 | 
				
			||||||
 | 
					    hh = int(m.group("h"))
 | 
				
			||||||
 | 
					    mm = int(m.group("m"))
 | 
				
			||||||
 | 
					    # canonical store as HH:MM
 | 
				
			||||||
 | 
					    return f"{hh:02d}:{mm:02d}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _expand_two_digit_year(two_digit: int, pivot_year: int) -> int:
 | 
				
			||||||
 | 
					    # Map YY -> same century as pivot_year
 | 
				
			||||||
 | 
					    base = pivot_year - (pivot_year % 100)
 | 
				
			||||||
 | 
					    return base + two_digit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_date_ymd(x: Any, *, pivot_year: int | None = None) -> str:
 | 
				
			||||||
 | 
					    s = str(x).strip()
 | 
				
			||||||
 | 
					    if not s:
 | 
				
			||||||
 | 
					        raise ValidationError("Date cannot be empty")
 | 
				
			||||||
 | 
					    m = re.match(r"^(?P<y>\d{2}|\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})$", s)
 | 
				
			||||||
 | 
					    if not m:
 | 
				
			||||||
 | 
					        raise ValidationError("Expected date format YYYY-MM-DD or YY-MM-DD")
 | 
				
			||||||
 | 
					    y = m.group("y")
 | 
				
			||||||
 | 
					    month = int(m.group("m"))
 | 
				
			||||||
 | 
					    day = int(m.group("d"))
 | 
				
			||||||
 | 
					    if len(y) == 2:
 | 
				
			||||||
 | 
					        yy = int(y)
 | 
				
			||||||
 | 
					        if pivot_year is not None:
 | 
				
			||||||
 | 
					            year = _expand_two_digit_year(yy, pivot_year)
 | 
				
			||||||
 | 
					            dt = datetime(year, month, day)
 | 
				
			||||||
 | 
					            return dt.strftime("%Y-%m-%d")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            dt = datetime.strptime(s, "%y-%m-%d")
 | 
				
			||||||
 | 
					            return dt.strftime("%Y-%m-%d")
 | 
				
			||||||
 | 
					    year = int(y)
 | 
				
			||||||
 | 
					    dt = datetime(year, month, day)
 | 
				
			||||||
 | 
					    return dt.strftime("%Y-%m-%d")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_date_dmy(x: Any, *, pivot_year: int | None = None) -> str:
 | 
				
			||||||
 | 
					    s = str(x).strip()
 | 
				
			||||||
 | 
					    if not s:
 | 
				
			||||||
 | 
					        raise ValidationError("Date cannot be empty")
 | 
				
			||||||
 | 
					    m = re.match(r"^(?P<d>\d{1,2})-(?P<m>\d{1,2})-(?P<y>\d{2}|\d{4})$", s)
 | 
				
			||||||
 | 
					    if not m:
 | 
				
			||||||
 | 
					        raise ValidationError("Expected date format DD-MM-YYYY or DD-MM-YY")
 | 
				
			||||||
 | 
					    day = int(m.group("d"))
 | 
				
			||||||
 | 
					    month = int(m.group("m"))
 | 
				
			||||||
 | 
					    y = m.group("y")
 | 
				
			||||||
 | 
					    if len(y) == 2:
 | 
				
			||||||
 | 
					        yy = int(y)
 | 
				
			||||||
 | 
					        if pivot_year is not None:
 | 
				
			||||||
 | 
					            year = _expand_two_digit_year(yy, pivot_year)
 | 
				
			||||||
 | 
					            dt = datetime(year, month, day)
 | 
				
			||||||
 | 
					            return dt.strftime("%d-%m-%Y")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            dt = datetime.strptime(s, "%d-%m-%y")
 | 
				
			||||||
 | 
					            return dt.strftime("%d-%m-%Y")
 | 
				
			||||||
 | 
					    year = int(y)
 | 
				
			||||||
 | 
					    dt = datetime(year, month, day)
 | 
				
			||||||
 | 
					    return dt.strftime("%d-%m-%Y")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# Schema (non-sensitive, front-end editable)
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
 | 
				
			||||||
 | 
					    # Channels (IDs)
 | 
				
			||||||
 | 
					    "mod_channel_id":            {"type": "int",   "default": 0, "nonzero": True, "desc": "Moderation command channel."},
 | 
				
			||||||
 | 
					    "modlog_channel_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "ModLog channel."},
 | 
				
			||||||
 | 
					    "pirates_list_channel_id":   {"type": "int",   "default": 0, "nonzero": True, "desc": "Pirates list channel."},
 | 
				
			||||||
 | 
					    "dd_channel_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Deep Desert updates channel."},
 | 
				
			||||||
 | 
					    "report_channel_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Reports/approvals channel."},
 | 
				
			||||||
 | 
					    "userslist_channel_id":      {"type": "int",   "default": 0, "nonzero": True, "desc": "Users list channel."},
 | 
				
			||||||
 | 
					    "trigger_channel_id":        {"type": "int",   "default": 0, "nonzero": True, "desc": "Trigger channel for Auto VC."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Roles (IDs)
 | 
				
			||||||
 | 
					    "rules_role_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."},
 | 
				
			||||||
 | 
					    "moderator_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Moderator role ID."},
 | 
				
			||||||
 | 
					    "intel_mod_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Intel mod role ID."},
 | 
				
			||||||
 | 
					    "full_access_role_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Full Access role ID."},
 | 
				
			||||||
 | 
					    "field_mod_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Field mod role ID."},
 | 
				
			||||||
 | 
					    "engagement_role_id":        {"type": "int",   "default": 0, "nonzero": True, "desc": "Engagement role ID."},
 | 
				
			||||||
 | 
					    "admin_role_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Admin role ID."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Message IDs
 | 
				
			||||||
 | 
					    "rules_message_id":          {"type": "int",   "default": 0, "nonzero": True, "desc": "Rules message ID."},
 | 
				
			||||||
 | 
					    "engagement_message_id":     {"type": "int",   "default": 0, "nonzero": True, "desc": "Engagement message ID."},
 | 
				
			||||||
 | 
					    "nickname_message_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Nickname message ID."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Emojis (IDs)
 | 
				
			||||||
 | 
					    "emoji_carrier_crawler_id":  {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: carrier/crawler."},
 | 
				
			||||||
 | 
					    "emoji_melange_id":          {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: melange."},
 | 
				
			||||||
 | 
					    "emoji_sand_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: sand."},
 | 
				
			||||||
 | 
					    "emoji_harvester_crew":      {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: harvester crew"},
 | 
				
			||||||
 | 
					    "emoji_escort_crew":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: escort crew"},
 | 
				
			||||||
 | 
					    "emoji_fedaykin":            {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: fedaykin - kill squad"},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Auto-VC
 | 
				
			||||||
 | 
					    "auto_vc_category_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Category to host Auto-VCs."},
 | 
				
			||||||
 | 
					    "auto_vc_cleanup_delay":     {"type": "int",   "default": 30, "nonzero": True, "desc": "Seconds before empty Auto-VCs are cleaned up."},
 | 
				
			||||||
 | 
					    "vc_name_prefix":            {"type": "str",   "default": "DD Crew", "desc": "Auto-VC name prefix."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Pirates / threat model
 | 
				
			||||||
 | 
					    "threat_group_threshold":         {"type": "int",   "default": 3,    "desc": "Threshold for group classification."},
 | 
				
			||||||
 | 
					    "threat_min_samples_for_stats":   {"type": "int",   "default": 3,    "desc": "Min samples for stats."},
 | 
				
			||||||
 | 
					    "threat_w_destruction":           {"type": "float", "default": 0.40, "desc": "Weight: destruction."},
 | 
				
			||||||
 | 
					    "threat_w_group":                 {"type": "float", "default": 0.20, "desc": "Weight: group."},
 | 
				
			||||||
 | 
					    "threat_w_kill":                  {"type": "float", "default": 0.30, "desc": "Weight: kill."},
 | 
				
			||||||
 | 
					    "threat_w_skill":                 {"type": "float", "default": 0.10, "desc": "Weight: skill."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # SpicePay
 | 
				
			||||||
 | 
					    "spicepay_base_weight":      {"type": "float", "default": 25.0, "desc": "Base weight."},
 | 
				
			||||||
 | 
					    "spicepay_carrier_bonus":    {"type": "float", "default": 12.5, "desc": "Carrier bonus."},
 | 
				
			||||||
 | 
					    "spicepay_crawler_bonus":    {"type": "float", "default": 12.5, "desc": "Crawler bonus."},
 | 
				
			||||||
 | 
					    "spicepay_lsr_cut_percent":  {"type": "float", "default": 10.0, "desc": "SR cut percent."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Jobs / loops
 | 
				
			||||||
 | 
					    "user_cards_cron_enabled":   {"type": "bool",  "default": True,  "desc": "Enable user-cards cron."},
 | 
				
			||||||
 | 
					    "nick_nudge_loop_enabled":   {"type": "bool",  "default": False, "desc": "Enable nick-nudge loop."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Deep Desert fetcher
 | 
				
			||||||
 | 
					    "dd_fetcher":                {"type": "str",   "default": "playwright", "allowed": ["playwright","requests"], "desc": "Fetcher backend."},
 | 
				
			||||||
 | 
					    "dd_pw_timeout_ms":          {"type": "int",   "default": 60000, "desc": "Playwright timeout (ms)."},
 | 
				
			||||||
 | 
					    "dd_pw_wait_ms":             {"type": "int",   "default": 0,     "desc": "Extra wait after navigation (ms)."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Repo (non-secret)
 | 
				
			||||||
 | 
					    "repo_url":                  {"type": "url",
 | 
				
			||||||
 | 
					                                  "default": "https://git.rolfsvaag.no/frarol96/shaiwatcher",
 | 
				
			||||||
 | 
					                                  "desc": "Repository URL."},
 | 
				
			||||||
 | 
					    "repo_branch":               {"type": "str",   "default": "main", "desc": "Repository branch."},
 | 
				
			||||||
 | 
					    "repo_rss":                  {"type": "url",
 | 
				
			||||||
 | 
					                                  "default": "https://git.rolfsvaag.no/frarol96/shaiwatcher.rss",
 | 
				
			||||||
 | 
					                                  "desc": "Repository RSS feed."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Admin allow-list for /shaiadmin (besides owner)
 | 
				
			||||||
 | 
					    "admin_user_ids":            {"type": "list[int]", "default": [], "desc": "User IDs allowed to use /shaiadmin."},
 | 
				
			||||||
 | 
					    "admin_role_ids":            {"type": "list[int]", "default": [], "desc": "Role IDs allowed to use /shaiadmin."},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Misc
 | 
				
			||||||
 | 
					    "check_time_utc":            {"type": "time_24h", "default": "03:00",   "desc": "Daily check time (UTC HH:MM)"},
 | 
				
			||||||
 | 
					    "ignore_test_level":         {"type": "int",      "default": 0,         "desc": "Test-level ignore flag."},
 | 
				
			||||||
 | 
					    "lang":                      {"type": "str",      "default": "C.UTF-8", "desc": "Locale (if referenced)."},
 | 
				
			||||||
 | 
					    # Examples of date keys you may enable later:
 | 
				
			||||||
 | 
					    # "feature_window_start": {"type": "date_ymd", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Start date (YYYY-MM-DD or YY-MM-DD)."},
 | 
				
			||||||
 | 
					    # "event_date_dmy":       {"type": "date_dmy", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Event date (DD-MM-YYYY or DD-MM-YY)."},
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# Env — ONLY the allowed set (plus legacy HOME_GUILD_ID alias)
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _allowed_env_map() -> Dict[str, str]:
 | 
				
			||||||
 | 
					    env: Dict[str, str] = {}
 | 
				
			||||||
 | 
					    if os.getenv("DISCORD_TOKEN"):
 | 
				
			||||||
 | 
					        env["discord_token"] = _clean(os.getenv("DISCORD_TOKEN"))
 | 
				
			||||||
 | 
					    data_file = os.getenv("DATA_FILE")
 | 
				
			||||||
 | 
					    if data_file:
 | 
				
			||||||
 | 
					        env["data_file"] = _clean(data_file)
 | 
				
			||||||
 | 
					    if os.getenv("DOCS_HOST_IP"):
 | 
				
			||||||
 | 
					        env["docs_host_ip"] = _clean(os.getenv("DOCS_HOST_IP"))
 | 
				
			||||||
 | 
					    if os.getenv("DOCS_HOST_PORT"):
 | 
				
			||||||
 | 
					        env["docs_host_port"] = _clean(os.getenv("DOCS_HOST_PORT"))
 | 
				
			||||||
 | 
					    if os.getenv("HOME_GUILD_ID"):
 | 
				
			||||||
 | 
					        env["home_guild_id"] = _clean(os.getenv("HOME_GUILD_ID"))
 | 
				
			||||||
 | 
					    if os.getenv("REPO_AHTOKEN"):
 | 
				
			||||||
 | 
					        env["repo_ahtoken"] = _clean(os.getenv("REPO_AHTOKEN"))
 | 
				
			||||||
 | 
					    return env
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# On-disk store + globals
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_SETTINGS_LOCK = threading.Lock()
 | 
				
			||||||
 | 
					_FILE_MAP: Dict[str, Any] = {}
 | 
				
			||||||
 | 
					_ENV_MAP: Dict[str, str] = {}
 | 
				
			||||||
 | 
					_SETTINGS_FILE: Optional[str] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def settings_path() -> str:
 | 
				
			||||||
 | 
					    """Place settings.json next to DATA_FILE if available; otherwise default to ./data/settings.json."""
 | 
				
			||||||
 | 
					    data_file = os.getenv("DATA_FILE")
 | 
				
			||||||
 | 
					    if data_file:
 | 
				
			||||||
 | 
					        base = os.path.dirname(data_file) or "."
 | 
				
			||||||
 | 
					        return os.path.join(base, "settings.json")
 | 
				
			||||||
 | 
					    return "./data/settings.json"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _ensure_loaded():
 | 
				
			||||||
 | 
					    global _SETTINGS_FILE, _FILE_MAP, _ENV_MAP
 | 
				
			||||||
 | 
					    with _SETTINGS_LOCK:
 | 
				
			||||||
 | 
					        if _SETTINGS_FILE is not None:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        _SETTINGS_FILE = settings_path()
 | 
				
			||||||
 | 
					        _ENV_MAP = _allowed_env_map()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if os.path.exists(_SETTINGS_FILE):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					                    _FILE_MAP = json.load(f) or {}
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                _FILE_MAP = {}
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            _FILE_MAP = {}
 | 
				
			||||||
 | 
					            _save_locked()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        changed = False
 | 
				
			||||||
 | 
					        for key, meta in SETTINGS_SCHEMA.items():
 | 
				
			||||||
 | 
					            if key not in _FILE_MAP:
 | 
				
			||||||
 | 
					                _FILE_MAP[key] = meta.get("default")
 | 
				
			||||||
 | 
					                changed = True
 | 
				
			||||||
 | 
					        if changed:
 | 
				
			||||||
 | 
					            _save_locked()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _save_locked():
 | 
				
			||||||
 | 
					    global _SETTINGS_FILE, _FILE_MAP
 | 
				
			||||||
 | 
					    path = _SETTINGS_FILE or settings_path()
 | 
				
			||||||
 | 
					    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
 | 
				
			||||||
 | 
					    tmp = path + ".tmp"
 | 
				
			||||||
 | 
					    with open(tmp, "w", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					        json.dump(_FILE_MAP, f, indent=2, ensure_ascii=False)
 | 
				
			||||||
 | 
					    if os.path.exists(path):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            shutil.copy2(path, path + ".bak")
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					    os.replace(tmp, path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def settings_get_all() -> Dict[str, Any]:
 | 
				
			||||||
 | 
					    _ensure_loaded()
 | 
				
			||||||
 | 
					    with _SETTINGS_LOCK:
 | 
				
			||||||
 | 
					        return dict(_FILE_MAP)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _cast_value(name: str, raw: Any, *, enforce_nonzero: bool = True) -> Any:
 | 
				
			||||||
 | 
					    meta = SETTINGS_SCHEMA.get(name)
 | 
				
			||||||
 | 
					    if not meta:
 | 
				
			||||||
 | 
					        raise ValidationError(f"Unknown setting: {name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    allow_empty = bool(meta.get("allow_empty", False))
 | 
				
			||||||
 | 
					    t = meta.get("type")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if t in ("str", "url", "time_24h", "date_ymd", "date_dmy"):
 | 
				
			||||||
 | 
					        s = str(raw).strip()
 | 
				
			||||||
 | 
					        if s == "" and allow_empty:
 | 
				
			||||||
 | 
					            val = ""
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            if t == "str":
 | 
				
			||||||
 | 
					                val = _to_str(raw)
 | 
				
			||||||
 | 
					            elif t == "url":
 | 
				
			||||||
 | 
					                val = _to_url(raw)
 | 
				
			||||||
 | 
					            elif t == "time_24h":
 | 
				
			||||||
 | 
					                val = _to_time_24h(raw)
 | 
				
			||||||
 | 
					            elif t == "date_ymd":
 | 
				
			||||||
 | 
					                val = _to_date_ymd(raw, pivot_year=meta.get("pivot_year"))
 | 
				
			||||||
 | 
					            elif t == "date_dmy":
 | 
				
			||||||
 | 
					                val = _to_date_dmy(raw, pivot_year=meta.get("pivot_year"))
 | 
				
			||||||
 | 
					    elif t == "bool":
 | 
				
			||||||
 | 
					        val = _to_bool(raw)
 | 
				
			||||||
 | 
					    elif t == "int":
 | 
				
			||||||
 | 
					        val = _to_int(raw)
 | 
				
			||||||
 | 
					    elif t == "float":
 | 
				
			||||||
 | 
					        val = _to_float(raw)
 | 
				
			||||||
 | 
					    elif t == "list[int]":
 | 
				
			||||||
 | 
					        val = _to_list_int(raw)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        raise ValidationError(f"Unsupported type for {name}: {t}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # enum constraint (always enforced)
 | 
				
			||||||
 | 
					    if "allowed" in meta:
 | 
				
			||||||
 | 
					        allowed = meta["allowed"]
 | 
				
			||||||
 | 
					        if val not in allowed:
 | 
				
			||||||
 | 
					            raise ValidationError(f"`{name}` must be one of {allowed}, got {val!r}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # nonzero constraint (skippable for bulk uploads)
 | 
				
			||||||
 | 
					    if enforce_nonzero and meta.get("nonzero") and isinstance(val, int) and val == 0:
 | 
				
			||||||
 | 
					        raise ValidationError(f"`{name}` must be a non-zero integer.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return val
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def settings_set(name: str, raw_value: Any) -> bool:
 | 
				
			||||||
 | 
					    _ensure_loaded()
 | 
				
			||||||
 | 
					    with _SETTINGS_LOCK:
 | 
				
			||||||
 | 
					        name = name.lower().strip()
 | 
				
			||||||
 | 
					        if name not in SETTINGS_SCHEMA:
 | 
				
			||||||
 | 
					            raise ValidationError(f"Unknown setting: {name}")
 | 
				
			||||||
 | 
					        new_val = _cast_value(name, raw_value)
 | 
				
			||||||
 | 
					        old_val = _FILE_MAP.get(name, SETTINGS_SCHEMA[name].get("default"))
 | 
				
			||||||
 | 
					        if old_val == new_val:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        _FILE_MAP[name] = new_val
 | 
				
			||||||
 | 
					        _save_locked()
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def settings_reset(name: str) -> None:
 | 
				
			||||||
 | 
					    _ensure_loaded()
 | 
				
			||||||
 | 
					    with _SETTINGS_LOCK:
 | 
				
			||||||
 | 
					        name = name.lower().strip()
 | 
				
			||||||
 | 
					        if name not in SETTINGS_SCHEMA:
 | 
				
			||||||
 | 
					            raise ValidationError(f"Unknown setting: {name}")
 | 
				
			||||||
 | 
					        _FILE_MAP[name] = SETTINGS_SCHEMA[name].get("default")
 | 
				
			||||||
 | 
					        _save_locked()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def settings_import_bulk(obj: Dict[str, Any]) -> List[str]:
 | 
				
			||||||
 | 
					    _ensure_loaded()
 | 
				
			||||||
 | 
					    if not isinstance(obj, dict):
 | 
				
			||||||
 | 
					        raise ValidationError("Uploaded JSON must be an object/dict at the top level.")
 | 
				
			||||||
 | 
					    new_map: Dict[str, Any] = dict(_FILE_MAP)
 | 
				
			||||||
 | 
					    changed: List[str] = []
 | 
				
			||||||
 | 
					    for k, v in obj.items():
 | 
				
			||||||
 | 
					        if k not in SETTINGS_SCHEMA:
 | 
				
			||||||
 | 
					            raise ValidationError(f"Unknown setting in upload: {k}")
 | 
				
			||||||
 | 
					        # Allow 0 for keys marked nonzero during bulk import (treating as 'unset' sentinel)
 | 
				
			||||||
 | 
					        new_val = _cast_value(k, v, enforce_nonzero=False)
 | 
				
			||||||
 | 
					        if new_map.get(k) != new_val:
 | 
				
			||||||
 | 
					            new_map[k] = new_val
 | 
				
			||||||
 | 
					            changed.append(k)
 | 
				
			||||||
 | 
					    with _SETTINGS_LOCK:
 | 
				
			||||||
 | 
					        if changed:
 | 
				
			||||||
 | 
					            _FILE_MAP.update({k: new_map[k] for k in changed})
 | 
				
			||||||
 | 
					            _save_locked()
 | 
				
			||||||
 | 
					    return changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					# Unified read view (keeps cfg(bot) contract)
 | 
				
			||||||
 | 
					# =========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConfigView:
 | 
					class ConfigView:
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Unified config view.
 | 
					    Reads:
 | 
				
			||||||
    - Primary: SHAI_* envs (prefix removed, lowercased keys)
 | 
					      - Schema-managed keys from settings.json
 | 
				
			||||||
    - Secondary: bot.config['DEFAULT'] (if present)
 | 
					      - Env: discord_token, data_file, docs_host_ip, docs_host_port, home_guild_id
 | 
				
			||||||
    - Helpers: get/int/bool/float/list
 | 
					      - Fallback to bot.config['DEFAULT'] for anything else (legacy)
 | 
				
			||||||
    - Can mirror values back into os.environ as SHAI_* (opt-in)
 | 
					    Helpers: get/int/bool/float/list, to_dict()
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    def __init__(self, bot=None, *, mirror_to_env: bool = False):
 | 
					    def __init__(self, bot=None):
 | 
				
			||||||
        self._env_map = _collect_shai_env()
 | 
					        _ensure_loaded()
 | 
				
			||||||
 | 
					        self._env_map = dict(_ENV_MAP)
 | 
				
			||||||
        # Optional: also look into bot.config['DEFAULT'] as a fallback
 | 
					 | 
				
			||||||
        self._default: Dict[str, Any] = {}
 | 
					 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            self._default = (getattr(bot, "config", {}) or {}).get("DEFAULT", {}) or {}
 | 
					            self._default = (getattr(bot, "config", {}) or {}).get("DEFAULT", {}) or {}
 | 
				
			||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
            self._default = {}
 | 
					            self._default = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if mirror_to_env:
 | 
					    def _effective_map(self) -> Dict[str, str]:
 | 
				
			||||||
            # Ensure os.environ has SHAI_* for everything we know (don’t clobber existing non-empty)
 | 
					        merged: Dict[str, str] = {}
 | 
				
			||||||
            for k, v in self._env_map.items():
 | 
					        # defaults first
 | 
				
			||||||
                env_key = f"SHAI_{k.upper()}"
 | 
					        for k in getattr(self._default, "keys", lambda: [])():
 | 
				
			||||||
                if not os.environ.get(env_key):
 | 
					            merged[k] = _clean(str(self._default.get(k, "")))
 | 
				
			||||||
                    os.environ[env_key] = v
 | 
					        # env overlay
 | 
				
			||||||
 | 
					        for k, v in self._env_map.items():
 | 
				
			||||||
 | 
					            merged[k] = _clean(v)
 | 
				
			||||||
 | 
					        # schema values overlay defaults
 | 
				
			||||||
 | 
					        for k, meta in SETTINGS_SCHEMA.items():
 | 
				
			||||||
 | 
					            v = _FILE_MAP.get(k, meta.get("default"))
 | 
				
			||||||
 | 
					            if isinstance(v, (list, dict)):
 | 
				
			||||||
 | 
					                merged[k] = json.dumps(v, ensure_ascii=False)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                merged[k] = _clean(str(v))
 | 
				
			||||||
 | 
					        return merged
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---- core accessors ----
 | 
					 | 
				
			||||||
    def get(self, key: str, default: str = "") -> str:
 | 
					    def get(self, key: str, default: str = "") -> str:
 | 
				
			||||||
        key = key.lower()
 | 
					        m = self._effective_map()
 | 
				
			||||||
        if key in self._env_map:
 | 
					        v = _clean(m.get(key.lower(), ""))
 | 
				
			||||||
            v = _clean(self._env_map[key])
 | 
					 | 
				
			||||||
            return v if v != "" else default
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Fallback to DEFAULT mapping (ConfigParser-like or our shim)
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            v = self._default.get(key, "")
 | 
					 | 
				
			||||||
        except Exception:
 | 
					 | 
				
			||||||
            v = ""
 | 
					 | 
				
			||||||
        v = _clean(str(v))
 | 
					 | 
				
			||||||
        return v if v != "" else default
 | 
					        return v if v != "" else default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def int(self, key: str, default: int = 0) -> int:
 | 
					    def int(self, key: str, default: int = 0) -> int:
 | 
				
			||||||
@ -97,23 +482,9 @@ class ConfigView:
 | 
				
			|||||||
        parts = [p.strip() for p in s.split(sep)]
 | 
					        parts = [p.strip() for p in s.split(sep)]
 | 
				
			||||||
        return [p for p in parts if p]
 | 
					        return [p for p in parts if p]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # expose the resolved map if you ever want to dump it for debug
 | 
					 | 
				
			||||||
    def to_dict(self) -> Dict[str, str]:
 | 
					    def to_dict(self) -> Dict[str, str]:
 | 
				
			||||||
        d = dict(self._env_map)
 | 
					        return dict(self._effective_map())
 | 
				
			||||||
        # Include defaults that aren’t already in env_map
 | 
					 | 
				
			||||||
        for k in getattr(self._default, "keys", lambda: [])():
 | 
					 | 
				
			||||||
            d.setdefault(k, _clean(str(self._default.get(k, ""))))
 | 
					 | 
				
			||||||
        return d
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def cfg(bot=None, *, mirror_to_env: bool = False) -> ConfigView:
 | 
					def cfg(bot=None) -> ConfigView:
 | 
				
			||||||
    """
 | 
					    return ConfigView(bot)
 | 
				
			||||||
    Usage in cogs:
 | 
					 | 
				
			||||||
        r = cfg(bot)
 | 
					 | 
				
			||||||
        trigger_id = r.int('trigger_channel_id', 0)
 | 
					 | 
				
			||||||
        prefix = r.get('vc_name_prefix', 'Room')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    If you want to also ensure SHAI_* are present in os.environ at runtime:
 | 
					 | 
				
			||||||
        r = cfg(bot, mirror_to_env=True)
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    return ConfigView(bot, mirror_to_env=mirror_to_env)
 | 
					 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user