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