Compare commits
	
		
			72 Commits
		
	
	
		
			main
			...
			wrapper-ma
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3b1aa2a406 | |||
| a01478d605 | |||
| 82f4d0e75c | |||
| 13e2623bf9 | |||
| f94da4c6e1 | |||
| e7beee2462 | |||
| b9ceb71781 | |||
| 8f7be81be6 | |||
| 842261d5d5 | |||
| 0ac94d7cfe | |||
| c096c87e6a | |||
| f123084286 | |||
| dcff277d1a | |||
| 2999ec98e7 | |||
| b41162586c | |||
| 69712bacca | |||
| 7d509f5b98 | |||
| f28ea8cf9f | |||
| 39ffee3db4 | |||
| ff218c0a98 | |||
| d4f7681f30 | |||
| fb8eebb160 | |||
| a93783b0fd | |||
| 8b67073ce2 | |||
| 799a0829e8 | |||
| 729f70bab1 | |||
| 6cfd2aa6af | |||
| 41f8179b84 | |||
| 91bcf13dfb | |||
| abc2dcc2b6 | |||
| 34a4582791 | |||
| eac918e317 | |||
| d3f140c0e8 | |||
| edcf1c2ec1 | |||
| 5258b5cd9d | |||
| 8e91927057 | |||
| 1f09990bd4 | |||
| f5bc169b31 | |||
| 2e07d7ea86 | |||
| 76b4d85e81 | |||
| 051eb903a6 | |||
| 00c679d57b | |||
| df6e0000aa | |||
| 87502af744 | |||
| 79ea71af90 | |||
| 636e8d8b23 | |||
| 72a51d444c | |||
| 91c6522e66 | |||
| 4c67abeda2 | |||
| 7cd202a5c4 | |||
| a0a8c7de1a | |||
| ee03ecb15b | |||
| 30214535b4 | |||
| 87866cae65 | |||
| 0387750800 | |||
| a381c86b05 | |||
| a14472833f | |||
| e23d0ab367 | |||
| c88edf1fdd | |||
| 5b2aa79b9f | |||
| e599fde099 | |||
| 671fdeb9a9 | |||
| 2f35e022b7 | |||
| 461679f8eb | |||
| 256cb33f0c | |||
| 520fe9c9dd | |||
| 67af31de13 | |||
| 8c30453d36 | |||
| 42385d19f6 | |||
| 1a89e21ebf | |||
| 4b7cc5b67d | |||
| c389603bc2 | 
@ -1,8 +0,0 @@
 | 
			
		||||
.git
 | 
			
		||||
__pycache__/
 | 
			
		||||
*.pyc
 | 
			
		||||
.env
 | 
			
		||||
settings.conf
 | 
			
		||||
data.json
 | 
			
		||||
data.json.bak
 | 
			
		||||
example/
 | 
			
		||||
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,12 +0,0 @@
 | 
			
		||||
# Python
 | 
			
		||||
__pycache__/
 | 
			
		||||
*.pyc
 | 
			
		||||
.venv/
 | 
			
		||||
venv/
 | 
			
		||||
.env
 | 
			
		||||
 | 
			
		||||
# Local data & secrets (we’ll mount these in Docker)
 | 
			
		||||
data/
 | 
			
		||||
data.json
 | 
			
		||||
data.json.bak
 | 
			
		||||
settings*.conf
 | 
			
		||||
							
								
								
									
										204
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										204
									
								
								bot.py
									
									
									
									
									
								
							@ -1,204 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
import asyncio
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from dotenv import load_dotenv
 | 
			
		||||
from configparser import ConfigParser
 | 
			
		||||
from data_manager import DataManager
 | 
			
		||||
import pathlib
 | 
			
		||||
import os, asyncio, xml.etree.ElementTree as ET
 | 
			
		||||
import aiohttp
 | 
			
		||||
 | 
			
		||||
VERSION="0.0.9"
 | 
			
		||||
 | 
			
		||||
# ---------- Env & config loading ----------
 | 
			
		||||
 | 
			
		||||
load_dotenv()
 | 
			
		||||
 | 
			
		||||
TOKEN = os.getenv('DISCORD_TOKEN', '').strip()
 | 
			
		||||
CONFIG_PATH = os.getenv('SHAI_CONFIG', '/config/settings.conf')
 | 
			
		||||
 | 
			
		||||
config = ConfigParser()
 | 
			
		||||
read_files = config.read(CONFIG_PATH)
 | 
			
		||||
if not read_files:
 | 
			
		||||
    print(f"[Config] INFO: no config at {CONFIG_PATH} (or unreadable). Will rely on env + defaults.")
 | 
			
		||||
 | 
			
		||||
# Ensure DEFAULT section exists
 | 
			
		||||
if 'DEFAULT' not in config:
 | 
			
		||||
    config['DEFAULT'] = {}
 | 
			
		||||
 | 
			
		||||
def _overlay_env_into_config(cfg: ConfigParser):
 | 
			
		||||
    """
 | 
			
		||||
    Overlay all SHAI_* environment variables into cfg['DEFAULT'] so env wins.
 | 
			
		||||
    Also accept SHAI_DATA_FILE or SHAI_DATA for data_file.
 | 
			
		||||
    """
 | 
			
		||||
    d = cfg['DEFAULT']
 | 
			
		||||
 | 
			
		||||
    # Map SHAI_* -> lower-case keys (e.g. SHAI_MOD_CHANNEL_ID -> 'mod_channel_id')
 | 
			
		||||
    for k, v in os.environ.items():
 | 
			
		||||
        if not k.startswith('SHAI_'):
 | 
			
		||||
            continue
 | 
			
		||||
        key = k[5:].lower()  # drop 'SHAI_' prefix
 | 
			
		||||
        if key == 'data':
 | 
			
		||||
            key = 'data_file'
 | 
			
		||||
        d[key] = str(v)
 | 
			
		||||
 | 
			
		||||
    if not d.get('data_file', '').strip():
 | 
			
		||||
        d['data_file'] = '/data/data.json'
 | 
			
		||||
 | 
			
		||||
# Apply overlay so env takes precedence everywhere
 | 
			
		||||
_overlay_env_into_config(config)
 | 
			
		||||
 | 
			
		||||
# ---------- Discord intents ----------
 | 
			
		||||
 | 
			
		||||
intents = discord.Intents.default()
 | 
			
		||||
intents.guilds = True
 | 
			
		||||
intents.members = True
 | 
			
		||||
intents.message_content = True
 | 
			
		||||
intents.reactions = True
 | 
			
		||||
intents.emojis_and_stickers = True
 | 
			
		||||
intents.voice_states = True
 | 
			
		||||
 | 
			
		||||
# ---------- Bot + DataManager ----------
 | 
			
		||||
 | 
			
		||||
data_file = config['DEFAULT']['data_file']  # guaranteed present by overlay
 | 
			
		||||
if not TOKEN:
 | 
			
		||||
    print("[Config] WARNING: DISCORD_TOKEN not set (env). Bot will fail to log in.")
 | 
			
		||||
 | 
			
		||||
bot = commands.Bot(command_prefix='!', intents=intents)
 | 
			
		||||
bot.config = config
 | 
			
		||||
bot.data_manager = DataManager(data_file)
 | 
			
		||||
 | 
			
		||||
# ---------- Self-check helpers ----------
 | 
			
		||||
 | 
			
		||||
async def _guild_selfcheck(g: discord.Guild, cfg):
 | 
			
		||||
    problems = []
 | 
			
		||||
 | 
			
		||||
    def _need_channel(id_key, *perms):
 | 
			
		||||
        raw = cfg.get(id_key)
 | 
			
		||||
        if not raw:
 | 
			
		||||
            problems.append(f"Missing config key: {id_key}")
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            cid = int(raw)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            problems.append(f"Bad channel id for {id_key}: {raw}")
 | 
			
		||||
            return
 | 
			
		||||
        ch = g.get_channel(cid)
 | 
			
		||||
        if not ch:
 | 
			
		||||
            problems.append(f"Channel not found: {id_key}={cid}")
 | 
			
		||||
            return
 | 
			
		||||
        me = g.me
 | 
			
		||||
        p = ch.permissions_for(me)
 | 
			
		||||
        for perm in perms:
 | 
			
		||||
            if not getattr(p, perm, False):
 | 
			
		||||
                problems.append(f"Missing permission on #{ch.name}: {perm}")
 | 
			
		||||
 | 
			
		||||
    _need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
 | 
			
		||||
    _need_channel('modlog_channel_id', 'read_messages', 'send_messages')
 | 
			
		||||
    _need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
 | 
			
		||||
 | 
			
		||||
    if problems:
 | 
			
		||||
        print(f"[SelfCheck:{g.name}]")
 | 
			
		||||
        for p in problems:
 | 
			
		||||
            print(" -", p)
 | 
			
		||||
 | 
			
		||||
async def _fetch_latest_from_rss(url: str):
 | 
			
		||||
    try:
 | 
			
		||||
        timeout = aiohttp.ClientTimeout(total=8)
 | 
			
		||||
        async with aiohttp.ClientSession(timeout=timeout) as sess:
 | 
			
		||||
            async with sess.get(url) as resp:
 | 
			
		||||
                if resp.status != 200:
 | 
			
		||||
                    return None, None
 | 
			
		||||
                text = await resp.text()
 | 
			
		||||
        # Gitea RSS structure: <rss><channel><item>…</item></channel></rss>
 | 
			
		||||
        root = ET.fromstring(text)
 | 
			
		||||
        item = root.find('./channel/item')
 | 
			
		||||
        if item is None:
 | 
			
		||||
            return None, None
 | 
			
		||||
        title = (item.findtext('title') or '').strip()
 | 
			
		||||
        link = (item.findtext('link') or '').strip()
 | 
			
		||||
        # Try to extract short sha from link tail if it's a commit URL
 | 
			
		||||
        sha = None
 | 
			
		||||
        if '/commit/' in link:
 | 
			
		||||
            sha = link.rsplit('/commit/', 1)[-1][:7]
 | 
			
		||||
        # Many Gitea feeds put the commit subject in <title>
 | 
			
		||||
        subject = title if title else None
 | 
			
		||||
        return subject, sha
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return None, None
 | 
			
		||||
 | 
			
		||||
# ---------- boot notice ----------
 | 
			
		||||
 | 
			
		||||
async def _post_boot_notice():
 | 
			
		||||
 | 
			
		||||
    msg = f"Self-update and reboot successful! (v.{VERSION})"
 | 
			
		||||
 | 
			
		||||
    ch_id_raw = bot.config['DEFAULT'].get('modlog_channel_id', '')
 | 
			
		||||
    try:
 | 
			
		||||
        ch_id = int(ch_id_raw) if ch_id_raw else 0
 | 
			
		||||
    except Exception:
 | 
			
		||||
        ch_id = 0
 | 
			
		||||
    if not ch_id:
 | 
			
		||||
        return
 | 
			
		||||
    for g in bot.guilds:
 | 
			
		||||
        ch = g.get_channel(ch_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.send(msg)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            break
 | 
			
		||||
 | 
			
		||||
# ---------- events ----------
 | 
			
		||||
 | 
			
		||||
@bot.event
 | 
			
		||||
async def on_ready():
 | 
			
		||||
    print(f"Logged in as {bot.user} (ID: {bot.user.id})")
 | 
			
		||||
    print("[Intents] members:", bot.intents.members, "/ message_content:", bot.intents.message_content, "/ voice_states:", bot.intents.voice_states)
 | 
			
		||||
 | 
			
		||||
    await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds])
 | 
			
		||||
 | 
			
		||||
    # Slash command sync
 | 
			
		||||
    try:
 | 
			
		||||
        dev_gid = bot.config['DEFAULT'].get('dev_guild_id')
 | 
			
		||||
        if dev_gid:
 | 
			
		||||
            guild = bot.get_guild(int(dev_gid))
 | 
			
		||||
            if guild:
 | 
			
		||||
                synced = await bot.tree.sync(guild=guild)
 | 
			
		||||
                print(f"[Slash] Synced {len(synced)} commands to {guild.name}")
 | 
			
		||||
            else:
 | 
			
		||||
                synced = await bot.tree.sync()
 | 
			
		||||
                print(f"[Slash] Synced {len(synced)} commands globally (dev_guild_id not in cache)")
 | 
			
		||||
        else:
 | 
			
		||||
            synced = await bot.tree.sync()
 | 
			
		||||
            print(f"[Slash] Synced {len(synced)} commands globally")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print("[Slash] Sync failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
    # Boot notice in modlog
 | 
			
		||||
    await _post_boot_notice()
 | 
			
		||||
 | 
			
		||||
# ---------- Auto-discover extensions ----------
 | 
			
		||||
 | 
			
		||||
modules_path = pathlib.Path(__file__).parent / 'modules'
 | 
			
		||||
extensions = []
 | 
			
		||||
for folder in modules_path.iterdir():
 | 
			
		||||
    if folder.is_dir():
 | 
			
		||||
        for file in folder.glob('*.py'):
 | 
			
		||||
            if file.name == '__init__.py':
 | 
			
		||||
                continue
 | 
			
		||||
            extensions.append(f"modules.{folder.name}.{file.stem}")
 | 
			
		||||
 | 
			
		||||
async def main():
 | 
			
		||||
    async with bot:
 | 
			
		||||
        for ext in extensions:
 | 
			
		||||
            try:
 | 
			
		||||
                await bot.load_extension(ext)
 | 
			
		||||
                print(f"[Modules] Loaded: {ext}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print(f"[Modules] Failed to load {ext}:", repr(e))
 | 
			
		||||
        await bot.start(TOKEN)
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    asyncio.run(main())
 | 
			
		||||
@ -1,90 +0,0 @@
 | 
			
		||||
import json
 | 
			
		||||
import threading
 | 
			
		||||
import shutil
 | 
			
		||||
import os
 | 
			
		||||
from typing import Callable, Any
 | 
			
		||||
 | 
			
		||||
class DataManager:
 | 
			
		||||
    def __init__(self, json_path: str):
 | 
			
		||||
        self.json_path = json_path
 | 
			
		||||
        self.lock = threading.Lock()
 | 
			
		||||
        self._data = self._load()
 | 
			
		||||
 | 
			
		||||
    def _load(self):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.json_path, 'r') as f:
 | 
			
		||||
                return json.load(f)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            default = {
 | 
			
		||||
                'agreed_rules': [],
 | 
			
		||||
                'agreed_engagement': [],
 | 
			
		||||
                'agreed_nickname': [],
 | 
			
		||||
                'nick_same_confirmed': [],
 | 
			
		||||
                'nick_nudged': [],
 | 
			
		||||
                'nick_dm_map': [],
 | 
			
		||||
                'pirates': [],
 | 
			
		||||
                'modlog': [],
 | 
			
		||||
                'reports': [],
 | 
			
		||||
                'encounters': [],
 | 
			
		||||
                'vc_channels': [],
 | 
			
		||||
                'user_cards': [],
 | 
			
		||||
                'pirates_list_posts': [],
 | 
			
		||||
                'spicepay_prefs': [],
 | 
			
		||||
                'nick_verified': [],
 | 
			
		||||
                'nick_claim_pending': [],
 | 
			
		||||
                'nick_reviews': [],
 | 
			
		||||
                'rr_msg_channels': [],
 | 
			
		||||
            }
 | 
			
		||||
            self._save(default)
 | 
			
		||||
            return default
 | 
			
		||||
 | 
			
		||||
    def _safe_write(self, data: dict):
 | 
			
		||||
        # ensure parent dir exists
 | 
			
		||||
        os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
 | 
			
		||||
        tmp = self.json_path + ".tmp"
 | 
			
		||||
        with open(tmp, 'w') as f:
 | 
			
		||||
            json.dump(data, f, indent=4)
 | 
			
		||||
        # backup current file (best-effort)
 | 
			
		||||
        if os.path.exists(self.json_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(self.json_path, self.json_path + ".bak")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        os.replace(tmp, self.json_path)
 | 
			
		||||
 | 
			
		||||
    def _save(self, data: dict):
 | 
			
		||||
        # single place to write (atomic replace + rolling .bak)
 | 
			
		||||
        self._safe_write(data)
 | 
			
		||||
 | 
			
		||||
    def get(self, category: str):
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            return list(self._data.get(category, []))
 | 
			
		||||
 | 
			
		||||
    def add(self, category: str, item: Any):
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            self._data.setdefault(category, []).append(item)
 | 
			
		||||
            self._save(self._data)
 | 
			
		||||
 | 
			
		||||
    def remove(self, category: str, predicate: Callable[[Any], bool]):
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            arr = self._data.get(category, [])
 | 
			
		||||
            self._data[category] = [i for i in arr if not predicate(i)]
 | 
			
		||||
            self._save(self._data)
 | 
			
		||||
 | 
			
		||||
    def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
 | 
			
		||||
        """
 | 
			
		||||
        Atomically find one item in `category` matching predicate and update it with `updater`.
 | 
			
		||||
        Returns True if an item was updated, else False.
 | 
			
		||||
        """
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            arr = self._data.get(category, [])
 | 
			
		||||
            for idx, item in enumerate(arr):
 | 
			
		||||
                if predicate(item):
 | 
			
		||||
                    # Copy → mutate → save back
 | 
			
		||||
                    new_item = dict(item)
 | 
			
		||||
                    new_item = updater(new_item) or new_item
 | 
			
		||||
                    arr[idx] = new_item
 | 
			
		||||
                    self._data[category] = arr
 | 
			
		||||
                    self._save(self._data)
 | 
			
		||||
                    return True
 | 
			
		||||
            return False
 | 
			
		||||
@ -1,75 +0,0 @@
 | 
			
		||||
version: "3.8"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  shaiwatcher:
 | 
			
		||||
    # Build the image from your repo (Portainer Git deploy will use this)
 | 
			
		||||
    build:
 | 
			
		||||
      context: .
 | 
			
		||||
      dockerfile: dockerfile          # <- your file is named 'dockerfile'
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    # Everything configurable shows up in Portainer's env UI
 | 
			
		||||
    environment:
 | 
			
		||||
      # --- Required token (set in Portainer UI) ---
 | 
			
		||||
      DISCORD_TOKEN: ${DISCORD_TOKEN}
 | 
			
		||||
 | 
			
		||||
      # --- Paths ---
 | 
			
		||||
      SHAI_DATA_FILE: /data/data.json
 | 
			
		||||
 | 
			
		||||
      # --- Reaction gating messages ---
 | 
			
		||||
      SHAI_RULES_MESSAGE_ID: "1396831304460402738"
 | 
			
		||||
      SHAI_ENGAGEMENT_MESSAGE_ID: "1397668657143742574"
 | 
			
		||||
      SHAI_NICKNAME_MESSAGE_ID: "1403513532108247141"
 | 
			
		||||
 | 
			
		||||
      # --- Roles ---
 | 
			
		||||
      SHAI_RULES_ROLE_ID: "1403146506596253817"
 | 
			
		||||
      SHAI_ENGAGEMENT_ROLE_ID: "1403146604894224458"
 | 
			
		||||
      SHAI_FULL_ACCESS_ROLE_ID: "1403146645121667082"
 | 
			
		||||
      SHAI_ADMIN_ROLE_ID: "1402000098476425246"
 | 
			
		||||
      SHAI_FIELD_MOD_ROLE_ID: "1402001335041261681"
 | 
			
		||||
      SHAI_INTEL_MOD_ROLE_ID: "1402001000327417946"
 | 
			
		||||
      SHAI_MODERATOR_ROLE_ID: "1396828779015573598"
 | 
			
		||||
 | 
			
		||||
      # --- Channels ---
 | 
			
		||||
      SHAI_MOD_CHANNEL_ID: "1403139701522698240"
 | 
			
		||||
      SHAI_MODLOG_CHANNEL_ID: "1403146993198436627"
 | 
			
		||||
      SHAI_USERSLIST_CHANNEL_ID: "1403146908385542215"
 | 
			
		||||
      SHAI_REPORT_CHANNEL_ID: "1403147077285843034"
 | 
			
		||||
      SHAI_PIRATES_LIST_CHANNEL_ID: "1403147077285843034"
 | 
			
		||||
 | 
			
		||||
      # --- Auto-VC ---
 | 
			
		||||
      SHAI_TRIGGER_CHANNEL_ID: "1403139044174594190"
 | 
			
		||||
      SHAI_AUTO_VC_CATEGORY_ID: "1403138882958266428"
 | 
			
		||||
      SHAI_VC_NAME_PREFIX: "DD Crew "              # trailing space intentional
 | 
			
		||||
      SHAI_AUTO_VC_CLEANUP_DELAY: "30"
 | 
			
		||||
 | 
			
		||||
      # --- Threat weights ---
 | 
			
		||||
      SHAI_THREAT_W_KILL: "0.30"
 | 
			
		||||
      SHAI_THREAT_W_DESTRUCTION: "0.40"
 | 
			
		||||
      SHAI_THREAT_W_GROUP: "0.20"
 | 
			
		||||
      SHAI_THREAT_W_SKILL: "0.10"
 | 
			
		||||
      SHAI_THREAT_GROUP_THRESHOLD: "3"
 | 
			
		||||
      SHAI_THREAT_MIN_SAMPLES_FOR_STATS: "3"
 | 
			
		||||
 | 
			
		||||
      # --- Misc toggles ---
 | 
			
		||||
      SHAI_RELEASE_VERSION: "false"
 | 
			
		||||
      SHAI_NICK_NUDGE_LOOP_ENABLED: "false"
 | 
			
		||||
      SHAI_HOME_GUILD_ID: "1396826999095427253"
 | 
			
		||||
      SHAI_USER_CARDS_CRON_ENABLED: "true"
 | 
			
		||||
 | 
			
		||||
      # --- SpicePay defaults ---
 | 
			
		||||
      SHAI_SPICEPAY_LSR_CUT_PERCENT: "10"
 | 
			
		||||
      SHAI_SPICEPAY_BASE_WEIGHT: "25"
 | 
			
		||||
      SHAI_SPICEPAY_CARRIER_BONUS: "12.5"
 | 
			
		||||
      SHAI_SPICEPAY_CRAWLER_BONUS: "12.5"
 | 
			
		||||
 | 
			
		||||
      # --- Optional emojis (IDs) ---
 | 
			
		||||
      SHAI_EMOJI_MELANGE_ID: "1401965356775510210"
 | 
			
		||||
      SHAI_EMOJI_SAND_ID: "1401965308805255310"
 | 
			
		||||
      SHAI_EMOJI_CARRIER_CRAWLER_ID: "1402285453037666386"
 | 
			
		||||
 | 
			
		||||
    volumes:
 | 
			
		||||
      - shaiwatcher_data:/data    # persistent data.json lives here
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  shaiwatcher_data:
 | 
			
		||||
@ -1,30 +0,0 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
set -e
 | 
			
		||||
 | 
			
		||||
# Defaults (can be overridden)
 | 
			
		||||
: "${SHAI_CONFIG:=/config/settings.conf}"
 | 
			
		||||
: "${SHAI_DATA:=/data/data.json}"
 | 
			
		||||
 | 
			
		||||
# Seed /config/settings.conf on first run if it doesn't exist
 | 
			
		||||
if [ ! -f "$SHAI_CONFIG" ]; then
 | 
			
		||||
  mkdir -p "$(dirname "$SHAI_CONFIG")"
 | 
			
		||||
  if [ -f /app/example/settings.conf ]; then
 | 
			
		||||
    cp /app/example/settings.conf "$SHAI_CONFIG"
 | 
			
		||||
    echo "Seeded default settings to $SHAI_CONFIG"
 | 
			
		||||
  else
 | 
			
		||||
    # Fall back: generate minimal config so the app can boot
 | 
			
		||||
    cat > "$SHAI_CONFIG" <<EOF
 | 
			
		||||
[DEFAULT]
 | 
			
		||||
data_file = ${SHAI_DATA}
 | 
			
		||||
EOF
 | 
			
		||||
    echo "Generated minimal $SHAI_CONFIG"
 | 
			
		||||
  fi
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure data directory exists
 | 
			
		||||
mkdir -p "$(dirname "$SHAI_DATA")"
 | 
			
		||||
 | 
			
		||||
# Make path visible to the app (bot.py will still read the INI)
 | 
			
		||||
export SHAI_CONFIG SHAI_DATA
 | 
			
		||||
 | 
			
		||||
exec "$@"
 | 
			
		||||
							
								
								
									
										26
									
								
								dockerfile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								dockerfile
									
									
									
									
									
								
							@ -1,26 +0,0 @@
 | 
			
		||||
FROM python:3.11-slim
 | 
			
		||||
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
ENV PYTHONDONTWRITEBYTECODE=1 \
 | 
			
		||||
    PYTHONUNBUFFERED=1
 | 
			
		||||
 | 
			
		||||
# deps first
 | 
			
		||||
COPY requirements.txt .
 | 
			
		||||
RUN pip install --no-cache-dir -r requirements.txt
 | 
			
		||||
 | 
			
		||||
# app code
 | 
			
		||||
COPY . /app
 | 
			
		||||
 | 
			
		||||
# runtime dirs + seed default config path (actual seeding done in entrypoint)
 | 
			
		||||
RUN mkdir -p /config /data
 | 
			
		||||
 | 
			
		||||
# runtime env defaults (can be overridden by compose/env)
 | 
			
		||||
ENV SHAI_CONFIG=/config/settings.conf \
 | 
			
		||||
    SHAI_DATA=/data/data.json
 | 
			
		||||
 | 
			
		||||
# small, explicit entrypoint
 | 
			
		||||
COPY docker-entrypoint.sh /usr/local/bin/entrypoint
 | 
			
		||||
RUN chmod +x /usr/local/bin/entrypoint
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["entrypoint"]
 | 
			
		||||
CMD ["python","-u","/app/bot.py"]
 | 
			
		||||
@ -1,5 +0,0 @@
 | 
			
		||||
DISCORD_TOKEN={DISCORD_BOT_TOKEN}
 | 
			
		||||
DISCORD_APPLICATION_ID=
 | 
			
		||||
DISCORD_PUBLIC_KEY=
 | 
			
		||||
DISCORD_CLIENT_ID=
 | 
			
		||||
DISCORD_CLIENT_SECRET=
 | 
			
		||||
@ -1,63 +0,0 @@
 | 
			
		||||
[DEFAULT]
 | 
			
		||||
### Reaction gating messages (✅ reactions)
 | 
			
		||||
rules_message_id       = # DISCORD RULES MESSAGE ID
 | 
			
		||||
engagement_message_id  = # DISCORD EoR MESSAGE ID
 | 
			
		||||
nickname_message_id    = # DISCORD NICKNAME MESSAGE ID
 | 
			
		||||
 | 
			
		||||
### Roles
 | 
			
		||||
rules_role_id          = # RULES ACCEPTANCE ROLE ID
 | 
			
		||||
engagement_role_id     = # RoE ACCEPTANCE ROLE ID
 | 
			
		||||
full_access_role_id    = # FULL ACCESS ROLE ID
 | 
			
		||||
# Moderator roles (IDs)
 | 
			
		||||
admin_role_id = # AMIN ROLE ID
 | 
			
		||||
field_mod_role_id = # IN-GAME MOD ROLE ID
 | 
			
		||||
intel_mod_role_id = # INTELLIGENCE MOD ROLE ID
 | 
			
		||||
moderator_role_id = # REGULAR MOD ROLE ID
 | 
			
		||||
 | 
			
		||||
### Channels
 | 
			
		||||
# for approving pirate reports
 | 
			
		||||
mod_channel_id         = # MODERATOR REVIEWS CHANNEL ID
 | 
			
		||||
# for join/nickname logs
 | 
			
		||||
modlog_channel_id      = # MODLOG CHANNEL ID
 | 
			
		||||
# where user cards live
 | 
			
		||||
userslist_channel_id   = # USERSLIST CHANNEL ID
 | 
			
		||||
# optional public notifications
 | 
			
		||||
report_channel_id      = # PUBLIC REPORTS CHANNEL ID
 | 
			
		||||
# pirates list channel
 | 
			
		||||
pirates_list_channel_id = # PIRATES LIST CHANNEL ID
 | 
			
		||||
 | 
			
		||||
### Auto-VC settings
 | 
			
		||||
trigger_channel_id     = # VC TRIGGER CHANNEL ID
 | 
			
		||||
auto_vc_category_id    = # AUTO VC CHANNEL CATEGORY ID
 | 
			
		||||
# Prefix to created voice channels
 | 
			
		||||
vc_name_prefix         = DD Crew 
 | 
			
		||||
# Seconds before an abandoned voice channel is deleted
 | 
			
		||||
auto_vc_cleanup_delay  = 30
 | 
			
		||||
 | 
			
		||||
# Pirate threat calculations weights
 | 
			
		||||
threat_w_kill          = 0.30
 | 
			
		||||
threat_w_destruction   = 0.40
 | 
			
		||||
threat_w_group         = 0.20
 | 
			
		||||
threat_w_skill         = 0.10
 | 
			
		||||
threat_group_threshold = 3
 | 
			
		||||
threat_min_samples_for_stats = 3
 | 
			
		||||
 | 
			
		||||
data_file              = data/data.json
 | 
			
		||||
release_version        = false
 | 
			
		||||
nick_nudge_loop_enabled = false
 | 
			
		||||
home_guild_id          = # DISCORD SERVER/GUILD ID
 | 
			
		||||
user_cards_cron_enabled = true
 | 
			
		||||
 | 
			
		||||
# Spice Refinery Owner Processing Fee in %
 | 
			
		||||
spicepay_lsr_cut_percent = 10
 | 
			
		||||
# Default participation weight
 | 
			
		||||
spicepay_base_weight     = 25
 | 
			
		||||
# Carrier owner weight bonus
 | 
			
		||||
spicepay_carrier_bonus   = 12.5
 | 
			
		||||
# Crawler owner weight bonus
 | 
			
		||||
spicepay_crawler_bonus   = 12.5
 | 
			
		||||
 | 
			
		||||
# Optional emojis
 | 
			
		||||
emoji_melange_id = 1401965356775510210
 | 
			
		||||
emoji_sand_id = 1401965308805255310
 | 
			
		||||
emoji_carrier_crawler_id = 1402285453037666386
 | 
			
		||||
							
								
								
									
										60
									
								
								mod_perms.py
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								mod_perms.py
									
									
									
									
									
								
							@ -1,60 +0,0 @@
 | 
			
		||||
# mod_perms.py
 | 
			
		||||
import re
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
 | 
			
		||||
def _parse_ids(raw: str):
 | 
			
		||||
    ids = []
 | 
			
		||||
    if not raw:
 | 
			
		||||
        return ids
 | 
			
		||||
    for tok in re.split(r'[,\s]+', raw.strip()):
 | 
			
		||||
        try:
 | 
			
		||||
            ids.append(int(tok))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
    return ids
 | 
			
		||||
 | 
			
		||||
def get_mod_role_ids(bot: commands.Bot):
 | 
			
		||||
    cfg = bot.config['DEFAULT']
 | 
			
		||||
    # read individually; allow comma-separated in any field for flexibility
 | 
			
		||||
    keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
 | 
			
		||||
    ids = []
 | 
			
		||||
    for k in keys:
 | 
			
		||||
        raw = cfg.get(k, "")
 | 
			
		||||
        for tok in re.split(r"[,\s]+", raw.strip()):
 | 
			
		||||
            if not tok:
 | 
			
		||||
                continue
 | 
			
		||||
            try:
 | 
			
		||||
                ids.append(int(tok))
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
    return ids
 | 
			
		||||
 | 
			
		||||
def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
 | 
			
		||||
    if not isinstance(member, discord.Member):
 | 
			
		||||
        return False
 | 
			
		||||
    if member.guild_permissions.administrator:
 | 
			
		||||
        return True
 | 
			
		||||
    mod_ids = set(get_mod_role_ids(bot))
 | 
			
		||||
    return any(r.id in mod_ids for r in member.roles)
 | 
			
		||||
 | 
			
		||||
def is_moderator_userid(guild: discord.Guild, user_id: int, bot: commands.Bot) -> bool:
 | 
			
		||||
    m = guild.get_member(user_id)
 | 
			
		||||
    return is_moderator_member(m, bot) if m else False
 | 
			
		||||
 | 
			
		||||
async def require_mod_ctx(ctx: commands.Context, msg="You don’t have permission to use this."):
 | 
			
		||||
    if not is_moderator_member(ctx.author, ctx.bot):
 | 
			
		||||
        await ctx.reply(msg)
 | 
			
		||||
        return False
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
async def require_mod_interaction(interaction: discord.Interaction, msg="This command is restricted to moderators."):
 | 
			
		||||
    user = interaction.user
 | 
			
		||||
    if isinstance(user, discord.Member) and is_moderator_member(user, interaction.client):
 | 
			
		||||
        return True
 | 
			
		||||
    # Only send a response if we haven’t already
 | 
			
		||||
    if not interaction.response.is_done():
 | 
			
		||||
        await interaction.response.send_message(msg, ephemeral=True)
 | 
			
		||||
    else:
 | 
			
		||||
        await interaction.followup.send(msg, ephemeral=True)
 | 
			
		||||
    return False
 | 
			
		||||
@ -1,254 +0,0 @@
 | 
			
		||||
# modules/auto_vc/auto_vc.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
 | 
			
		||||
def now() -> float:
 | 
			
		||||
    return time.time()
 | 
			
		||||
 | 
			
		||||
class AutoVCCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Auto-VC:
 | 
			
		||||
      • When someone joins the trigger voice channel, create a new VC under the target category,
 | 
			
		||||
        name it "{prefix} N", and move the member there.
 | 
			
		||||
      • When an auto-VC is empty for `delay` seconds, delete it and renumber the remaining ones.
 | 
			
		||||
      • Only channels created by this cog are managed (tracked in data_manager['vc_channels']).
 | 
			
		||||
 | 
			
		||||
    Admin commands:
 | 
			
		||||
      /avc_status        -> show current state
 | 
			
		||||
      /avc_cleanup_now   -> run a cleanup/renumber pass now
 | 
			
		||||
      /avc_renumber      -> renumber without deleting
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
 | 
			
		||||
        # Config
 | 
			
		||||
        self.trigger_id = int(cfg['trigger_channel_id'])
 | 
			
		||||
        self.category_id = int(cfg['auto_vc_category_id'])
 | 
			
		||||
        self.prefix = cfg['vc_name_prefix']
 | 
			
		||||
        self.delay = int(cfg.get('auto_vc_cleanup_delay', 30))
 | 
			
		||||
        self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
 | 
			
		||||
 | 
			
		||||
        # State
 | 
			
		||||
        self.empty_since: dict[int, float] = {}   # channel_id -> ts when became empty
 | 
			
		||||
        self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam)
 | 
			
		||||
        self._create_lock = asyncio.Lock()
 | 
			
		||||
 | 
			
		||||
        # Background sweeper
 | 
			
		||||
        self._task = asyncio.create_task(self._sweeper())
 | 
			
		||||
 | 
			
		||||
    # ------------- utilities -------------
 | 
			
		||||
 | 
			
		||||
    def cog_unload(self):
 | 
			
		||||
        try:
 | 
			
		||||
            self._task.cancel()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    def _prefixed(self, num: int) -> str:
 | 
			
		||||
        return f"{self.prefix.strip()} {num}"
 | 
			
		||||
 | 
			
		||||
    def _vc_records(self, guild_id: int):
 | 
			
		||||
        """Return list of tracked records for this guild from persistent store."""
 | 
			
		||||
        return [r for r in self.bot.data_manager.get('vc_channels') if r.get('guild_id') == guild_id]
 | 
			
		||||
 | 
			
		||||
    def _find_record(self, guild_id: int, channel_id: int):
 | 
			
		||||
        for r in self._vc_records(guild_id):
 | 
			
		||||
            if r.get('channel_id') == channel_id:
 | 
			
		||||
                return r
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    async def _log(self, guild: discord.Guild, msg: str):
 | 
			
		||||
        if not self.modlog_channel_id:
 | 
			
		||||
            print(f"[AutoVC][{guild.name}] {msg}")
 | 
			
		||||
            return
 | 
			
		||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.send(msg)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                print(f"[AutoVC][{guild.name}] (fallback) {msg}")
 | 
			
		||||
        else:
 | 
			
		||||
            print(f"[AutoVC][{guild.name}] {msg}")
 | 
			
		||||
 | 
			
		||||
    async def _renumber(self, guild: discord.Guild):
 | 
			
		||||
        """Rename tracked channels to {prefix} 1..N in stable order (by creation_ts)."""
 | 
			
		||||
        recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0))
 | 
			
		||||
        for i, rec in enumerate(recs, start=1):
 | 
			
		||||
            ch = guild.get_channel(rec['channel_id'])
 | 
			
		||||
            if not ch:
 | 
			
		||||
                # prune dead record
 | 
			
		||||
                self.bot.data_manager.remove('vc_channels', lambda x: x.get('channel_id') == rec['channel_id'])
 | 
			
		||||
                continue
 | 
			
		||||
            desired = self._prefixed(i)
 | 
			
		||||
            if ch.name != desired:
 | 
			
		||||
                try:
 | 
			
		||||
                    await ch.edit(name=desired, reason="Auto-VC renumber")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    print("[auto_vc] rename failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
    async def _cleanup_pass(self, guild: discord.Guild):
 | 
			
		||||
        """Delete empty tracked channels that exceeded delay and renumber."""
 | 
			
		||||
        cat = guild.get_channel(self.category_id)
 | 
			
		||||
        if not cat:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        tracked_ids = {r['channel_id'] for r in self._vc_records(guild.id)}
 | 
			
		||||
        now_ts = now()
 | 
			
		||||
        to_delete: list[discord.VoiceChannel] = []
 | 
			
		||||
 | 
			
		||||
        # Mark empties & collect deletions
 | 
			
		||||
        for ch in cat.voice_channels:
 | 
			
		||||
            if ch.id not in tracked_ids:
 | 
			
		||||
                continue  # unmanaged room
 | 
			
		||||
            if len(ch.members) == 0:
 | 
			
		||||
                started = self.empty_since.get(ch.id)
 | 
			
		||||
                if started is None:
 | 
			
		||||
                    self.empty_since[ch.id] = now_ts
 | 
			
		||||
                elif now_ts - started >= self.delay:
 | 
			
		||||
                    to_delete.append(ch)
 | 
			
		||||
            else:
 | 
			
		||||
                self.empty_since.pop(ch.id, None)
 | 
			
		||||
 | 
			
		||||
        # Delete idle channels
 | 
			
		||||
        for ch in to_delete:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.delete(reason=f"Auto-VC idle > {self.delay}s")
 | 
			
		||||
                await self._log(guild, f"🗑️ Deleted idle room: `{ch.name}`")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("[auto_vc] delete failed:", repr(e))
 | 
			
		||||
            # purge record and emptiness stamp
 | 
			
		||||
            self.bot.data_manager.remove('vc_channels', lambda r, cid=ch.id: r.get('channel_id') == cid)
 | 
			
		||||
            self.empty_since.pop(ch.id, None)
 | 
			
		||||
 | 
			
		||||
        # purge records for channels that vanished by other means
 | 
			
		||||
        for rec in list(self._vc_records(guild.id)):
 | 
			
		||||
            if not guild.get_channel(rec['channel_id']):
 | 
			
		||||
                self.bot.data_manager.remove('vc_channels', lambda r, cid=rec['channel_id']: r.get('channel_id') == cid)
 | 
			
		||||
 | 
			
		||||
        if to_delete:
 | 
			
		||||
            await self._renumber(guild)
 | 
			
		||||
 | 
			
		||||
    # ------------- background worker -------------
 | 
			
		||||
 | 
			
		||||
    async def _sweeper(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            try:
 | 
			
		||||
                for guild in self.bot.guilds:
 | 
			
		||||
                    await self._cleanup_pass(guild)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("[auto_vc] sweeper loop error:", repr(e))
 | 
			
		||||
            await asyncio.sleep(30)
 | 
			
		||||
 | 
			
		||||
    # ------------- channel creation -------------
 | 
			
		||||
 | 
			
		||||
    async def _spawn_and_move(self, member: discord.Member):
 | 
			
		||||
        guild = member.guild
 | 
			
		||||
        cat = guild.get_channel(self.category_id)
 | 
			
		||||
        if not cat:
 | 
			
		||||
            await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        async with self._create_lock:
 | 
			
		||||
            # Determine next index based on *tracked* channels
 | 
			
		||||
            recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0))
 | 
			
		||||
            next_index = len(recs) + 1
 | 
			
		||||
            name = self._prefixed(next_index)
 | 
			
		||||
 | 
			
		||||
            # Create room
 | 
			
		||||
            try:
 | 
			
		||||
                new_ch = await cat.create_voice_channel(name, reason="Auto-VC spawn")
 | 
			
		||||
            except discord.Forbidden:
 | 
			
		||||
                await self._log(guild, "❌ Missing permission to create voice channels in the category.")
 | 
			
		||||
                return
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                await self._log(guild, f"❌ Failed to create voice channel: {e}")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Persist record
 | 
			
		||||
            self.bot.data_manager.add('vc_channels', {
 | 
			
		||||
                'guild_id': guild.id,
 | 
			
		||||
                'channel_id': new_ch.id,
 | 
			
		||||
                'created_ts': now()
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            # Move the user
 | 
			
		||||
            try:
 | 
			
		||||
                await member.move_to(new_ch, reason="Auto-VC move")
 | 
			
		||||
            except discord.Forbidden:
 | 
			
		||||
                await self._log(guild, "⚠️ I need **Move Members** and **Connect** permissions to move users.")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                await self._log(guild, f"⚠️ Could not move {member} into `{name}`: {e}")
 | 
			
		||||
 | 
			
		||||
            # Start its idle timer if it immediately empties
 | 
			
		||||
            if len(new_ch.members) == 0:
 | 
			
		||||
                self.empty_since[new_ch.id] = now()
 | 
			
		||||
 | 
			
		||||
    # ------------- core flow -------------
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
 | 
			
		||||
        guild = member.guild
 | 
			
		||||
 | 
			
		||||
        # Create on trigger join (with 5s per-user cooldown)
 | 
			
		||||
        if after.channel and after.channel.id == self.trigger_id:
 | 
			
		||||
            last = self._vc_cooldowns.get(member.id, 0.0)
 | 
			
		||||
            if now() - last < 5.0:
 | 
			
		||||
                return
 | 
			
		||||
            self._vc_cooldowns[member.id] = now()
 | 
			
		||||
            try:
 | 
			
		||||
                await self._spawn_and_move(member)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("[auto_vc] spawn/move failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
        # Mark empties immediately on leave
 | 
			
		||||
        if before.channel:
 | 
			
		||||
            ch = before.channel
 | 
			
		||||
            if ch.category_id == self.category_id:
 | 
			
		||||
                rec = self._find_record(guild.id, ch.id)
 | 
			
		||||
                if rec and len(ch.members) == 0:
 | 
			
		||||
                    self.empty_since[ch.id] = now()
 | 
			
		||||
 | 
			
		||||
    # ------------- admin commands -------------
 | 
			
		||||
 | 
			
		||||
    @commands.hybrid_command(name="avc_status", description="Show Auto-VC status for this guild")
 | 
			
		||||
    @commands.has_permissions(manage_guild=True)
 | 
			
		||||
    async def avc_status(self, ctx: commands.Context):
 | 
			
		||||
        g = ctx.guild
 | 
			
		||||
        recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
 | 
			
		||||
        lines = [
 | 
			
		||||
            f"Trigger: <#{self.trigger_id}> | Category: <#{self.category_id}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
 | 
			
		||||
        ]
 | 
			
		||||
        for idx, rec in enumerate(recs, start=1):
 | 
			
		||||
            ch = g.get_channel(rec['channel_id'])
 | 
			
		||||
            if not ch:
 | 
			
		||||
                state = "missing"
 | 
			
		||||
                name = f"(deleted #{idx})"
 | 
			
		||||
            else:
 | 
			
		||||
                name = ch.name
 | 
			
		||||
                state = f"{len(ch.members)} inside" if len(ch.members) else "empty"
 | 
			
		||||
            t = self.empty_since.get(rec['channel_id'])
 | 
			
		||||
            tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else ""
 | 
			
		||||
            lines.append(f"- #{idx}: {name} — {state}{tail}")
 | 
			
		||||
        msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked."
 | 
			
		||||
        await ctx.reply(msg)
 | 
			
		||||
 | 
			
		||||
    @commands.hybrid_command(name="avc_cleanup_now", description="Run an immediate cleanup pass (delete idle rooms & renumber)")
 | 
			
		||||
    @commands.has_permissions(manage_guild=True)
 | 
			
		||||
    async def avc_cleanup_now(self, ctx: commands.Context):
 | 
			
		||||
        await self._cleanup_pass(ctx.guild)
 | 
			
		||||
        await ctx.reply("Cleanup pass complete.")
 | 
			
		||||
 | 
			
		||||
    @commands.hybrid_command(name="avc_renumber", description="Force a renumber of tracked rooms")
 | 
			
		||||
    @commands.has_permissions(manage_guild=True)
 | 
			
		||||
    async def avc_renumber(self, ctx: commands.Context):
 | 
			
		||||
        await self._renumber(ctx.guild)
 | 
			
		||||
        await ctx.reply("Renumbered.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(AutoVCCog(bot))
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
# Accept/approve emoji set used across the bot.
 | 
			
		||||
# Works for both unicode and custom server emoji.
 | 
			
		||||
 | 
			
		||||
# Unicode emoji that should count as "accept"
 | 
			
		||||
ACCEPT_UNICODE = {"✅", "🫡", "❤️"}
 | 
			
		||||
 | 
			
		||||
# Custom emoji short names that should count as "accept"
 | 
			
		||||
# Add names (not the <:name:id> literal) for any server emoji you want.
 | 
			
		||||
ACCEPT_CUSTOM_NAMES = {"diverOK"}
 | 
			
		||||
 | 
			
		||||
def is_accept(emoji) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    Return True if the given emoji should count as an 'accept' reaction.
 | 
			
		||||
    Compatible with Reaction.emoji and RawReactionActionEvent.emoji.
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        # unicode path
 | 
			
		||||
        if str(emoji) in ACCEPT_UNICODE:
 | 
			
		||||
            return True
 | 
			
		||||
        # custom emoji path (has a .name)
 | 
			
		||||
        name = getattr(emoji, "name", None)
 | 
			
		||||
        return name in ACCEPT_CUSTOM_NAMES
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
@ -1,331 +0,0 @@
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
from typing import Optional, Tuple
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from mod_perms import is_moderator_userid
 | 
			
		||||
from modules.common.emoji_accept import is_accept
 | 
			
		||||
 | 
			
		||||
CHECK = '✅'          # approved/verified
 | 
			
		||||
CROSS = '❌'          # reject / no
 | 
			
		||||
PENDING = '✔️'        # heavy check mark = pending claim
 | 
			
		||||
ACCEPT = {CHECK, '🫡'} 
 | 
			
		||||
NO_MENTIONS = discord.AllowedMentions.none()
 | 
			
		||||
 | 
			
		||||
def _ts_rel(ts: Optional[float] = None) -> str:
 | 
			
		||||
    """Discord relative timestamp like <t:12345:R>."""
 | 
			
		||||
    if ts is None:
 | 
			
		||||
        ts = time.time()
 | 
			
		||||
    return f"<t:{int(ts)}:R>"
 | 
			
		||||
 | 
			
		||||
class NickNudgeCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Handles:
 | 
			
		||||
      • DM nickname nudge loop (optional; unchanged behavior)
 | 
			
		||||
      • Nickname *review* workflow for claims:
 | 
			
		||||
          - On claim (via reaction or /nick_same): create a mod review in mod_channel with ✅/❌
 | 
			
		||||
          - Mods react: ✅ -> mark verified; ❌ -> clear claim and revoke Full Access
 | 
			
		||||
      • Stores review mapping in data_manager['nick_reviews']
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
        self.modlog_channel_id = int(cfg['modlog_channel_id'])
 | 
			
		||||
        self.mod_channel_id = int(cfg['mod_channel_id'])  # same review channel as pirate reports
 | 
			
		||||
        # Optional DM nudge loop retained
 | 
			
		||||
        try:
 | 
			
		||||
            self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled')
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.loop_enabled = False
 | 
			
		||||
        self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
 | 
			
		||||
 | 
			
		||||
    # ---------- utils ----------
 | 
			
		||||
 | 
			
		||||
    def cog_unload(self):
 | 
			
		||||
        try:
 | 
			
		||||
            if self._task:
 | 
			
		||||
                self._task.cancel()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _modlog(self, guild: discord.Guild, content: str):
 | 
			
		||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.send(content, allowed_mentions=NO_MENTIONS)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
 | 
			
		||||
 | 
			
		||||
    async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]:
 | 
			
		||||
        """
 | 
			
		||||
        Best-effort: look up last nickname change via audit logs.
 | 
			
		||||
        Returns (before_nick, after_nick) or (None, None) if not found/allowed.
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update):
 | 
			
		||||
                if entry.target.id != member.id or not entry.changes:
 | 
			
		||||
                    continue
 | 
			
		||||
                before_n = getattr(entry.changes.before, 'get', lambda *_: None)('nick')
 | 
			
		||||
                after_n = getattr(entry.changes.after, 'get', lambda *_: None)('nick')
 | 
			
		||||
                if before_n is not None or after_n is not None:
 | 
			
		||||
                    return before_n, after_n
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        return None, None
 | 
			
		||||
 | 
			
		||||
    # ---------- public API (called by ReactionRole cog) ----------
 | 
			
		||||
 | 
			
		||||
    async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"):
 | 
			
		||||
        """
 | 
			
		||||
        Create (or update) a nickname review entry for this member in the mod channel.
 | 
			
		||||
        - source: "claim" or "nick_same"
 | 
			
		||||
        Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
 | 
			
		||||
        """
 | 
			
		||||
        if not guild:
 | 
			
		||||
            return
 | 
			
		||||
        mod_ch = guild.get_channel(self.mod_channel_id)
 | 
			
		||||
        if not mod_ch:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        before_n, after_n = await self._find_last_nick_change(guild, member)
 | 
			
		||||
        now_ts = int(time.time())
 | 
			
		||||
 | 
			
		||||
        # Compose review text
 | 
			
		||||
        title = "📝 **Nickname Verification Request**"
 | 
			
		||||
        who = f"User: {member.mention} (`{member.id}`)"
 | 
			
		||||
        change = f"Claimed {_ts_rel(now_ts)}"
 | 
			
		||||
        from_to = f"From: {repr(before_n) if before_n is not None else 'unknown'} → To: {repr(member.nick) if member.nick else 'None'}"
 | 
			
		||||
        method = f"Method: {'/nick_same' if source == 'nick_same' else 'reaction'}"
 | 
			
		||||
        instructions = "Moderators: react ✅ to **approve** or ❌ to **reject**."
 | 
			
		||||
        content = f"{title}\n{who}\n{from_to}\n{method}\n{change}\n\n{instructions}"
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            msg = await mod_ch.send(content, allowed_mentions=NO_MENTIONS)
 | 
			
		||||
            await msg.add_reaction(CHECK)
 | 
			
		||||
            await msg.add_reaction(CROSS)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Persist review mapping
 | 
			
		||||
        self.bot.data_manager.add('nick_reviews', {
 | 
			
		||||
            'message_id': int(msg.id),
 | 
			
		||||
            'guild_id': int(guild.id),
 | 
			
		||||
            'user_id': int(member.id),
 | 
			
		||||
            'before_nick': before_n if before_n is None or isinstance(before_n, str) else str(before_n),
 | 
			
		||||
            'claimed_nick': member.nick if member.nick else None,
 | 
			
		||||
            'status': 'pending',
 | 
			
		||||
            'source': source,
 | 
			
		||||
            'ts': now_ts
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # Log to modlog
 | 
			
		||||
        await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.")
 | 
			
		||||
 | 
			
		||||
    # ---------- DM nudge loop (unchanged) ----------
 | 
			
		||||
 | 
			
		||||
    async def _nudge_loop(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            try:
 | 
			
		||||
                now = time.time()
 | 
			
		||||
                for guild in self.bot.guilds:
 | 
			
		||||
                    for member in guild.members:
 | 
			
		||||
                        if member.bot or not member.joined_at:
 | 
			
		||||
                            continue
 | 
			
		||||
                        if (now - member.joined_at.timestamp()) < 24*3600:
 | 
			
		||||
                            continue
 | 
			
		||||
                        # If they already have a server nick OR already claimed/verified, skip nudging
 | 
			
		||||
                        dm = self.bot.data_manager
 | 
			
		||||
                        if (member.nick and member.nick.strip()):
 | 
			
		||||
                            continue
 | 
			
		||||
                        if member.id in dm.get('nick_verified') or member.id in dm.get('nick_claim_pending'):
 | 
			
		||||
                            continue
 | 
			
		||||
                        if member.id in dm.get('nick_nudged'):
 | 
			
		||||
                            continue
 | 
			
		||||
                        try:
 | 
			
		||||
                            dmchan = await member.create_dm()
 | 
			
		||||
                            msg = await dmchan.send(
 | 
			
		||||
                                "Hey! On this server we require your **server nickname** to match your in-game character name.\n\n"
 | 
			
		||||
                                "If your default Discord display name is already identical to your in-game name, react ✅ **or 🫡** below "
 | 
			
		||||
                                "or run `/nick_same`. Otherwise, please set your **server nickname** to your in-game name and react ✅ **or 🫡**."
 | 
			
		||||
                            )
 | 
			
		||||
                            await msg.add_reaction(CHECK)
 | 
			
		||||
                            self.bot.data_manager.add('nick_dm_map', {
 | 
			
		||||
                                'message_id': int(msg.id),
 | 
			
		||||
                                'user_id': int(member.id),
 | 
			
		||||
                                'guild_id': int(guild.id),
 | 
			
		||||
                                'ts': now
 | 
			
		||||
                            })
 | 
			
		||||
                            self.bot.data_manager.add('nick_nudged', int(member.id))
 | 
			
		||||
                            await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            await asyncio.sleep(1800)  # every 30 minutes
 | 
			
		||||
 | 
			
		||||
    # ---------- listeners ----------
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        # 1) Handle DM nudge confirmations (user reacts with an accept in DM)
 | 
			
		||||
        if payload.guild_id is None and is_accept(payload.emoji) and payload.user_id != self.bot.user.id:
 | 
			
		||||
            entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None)
 | 
			
		||||
            if not entry:
 | 
			
		||||
                return
 | 
			
		||||
            guild = self.bot.get_guild(entry['guild_id'])
 | 
			
		||||
            member = guild.get_member(entry['user_id']) if guild else None
 | 
			
		||||
            if not member:
 | 
			
		||||
                return
 | 
			
		||||
            # Treat as a claim: mark pending (idempotent) and open review only on first transition
 | 
			
		||||
            dm = self.bot.data_manager
 | 
			
		||||
            if member.id not in dm.get('agreed_nickname'):
 | 
			
		||||
                dm.add('agreed_nickname', int(member.id))
 | 
			
		||||
            dm.remove('nick_verified', lambda x: x == member.id)
 | 
			
		||||
            newly_pending = False
 | 
			
		||||
            if member.id not in dm.get('nick_claim_pending'):
 | 
			
		||||
                dm.add('nick_claim_pending', int(member.id))
 | 
			
		||||
                newly_pending = True
 | 
			
		||||
 | 
			
		||||
            if newly_pending:
 | 
			
		||||
                try:
 | 
			
		||||
                    await self.start_nick_review(guild, member, source="nick_same")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
            # Clean map entry
 | 
			
		||||
            self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
 | 
			
		||||
 | 
			
		||||
            # Refresh card and maybe full access (pending does NOT block full access)
 | 
			
		||||
            rr = self.bot.get_cog('ReactionRoleCog')
 | 
			
		||||
            if rr:
 | 
			
		||||
                try:
 | 
			
		||||
                    await rr.maybe_apply_full_access(member)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
            cards = self.bot.get_cog('UserCardsCog')
 | 
			
		||||
            if cards:
 | 
			
		||||
                try:
 | 
			
		||||
                    await cards.refresh_card(member)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # 2) Handle moderator review reactions in mod channel
 | 
			
		||||
        if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id:
 | 
			
		||||
            if payload.channel_id != self.mod_channel_id:
 | 
			
		||||
                return
 | 
			
		||||
            guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
            if not guild:
 | 
			
		||||
                return
 | 
			
		||||
            # Only moderators can act
 | 
			
		||||
            if not is_moderator_userid(guild, payload.user_id, self.bot):
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Is this a review message?
 | 
			
		||||
            reviews = self.bot.data_manager.get('nick_reviews')
 | 
			
		||||
            review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None)
 | 
			
		||||
            if not review or review.get('status') != 'pending':
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            member = guild.get_member(int(review['user_id']))
 | 
			
		||||
            if not member:
 | 
			
		||||
                # mark closed missing
 | 
			
		||||
                self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status':'closed_missing'}), r)[1])
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Fetch and edit the review message content (best-effort)
 | 
			
		||||
            try:
 | 
			
		||||
                ch = self.bot.get_channel(payload.channel_id)
 | 
			
		||||
                msg = await ch.fetch_message(payload.message_id)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                msg = None
 | 
			
		||||
 | 
			
		||||
            dm = self.bot.data_manager
 | 
			
		||||
            now_ts = int(time.time())
 | 
			
		||||
            approver = f"<@{payload.user_id}>"
 | 
			
		||||
 | 
			
		||||
            if str(payload.emoji) == CHECK:
 | 
			
		||||
                # Approve: mark verified, clear pending, ensure agreed flag set
 | 
			
		||||
                if member.id not in dm.get('agreed_nickname'):
 | 
			
		||||
                    dm.add('agreed_nickname', int(member.id))
 | 
			
		||||
                dm.remove('nick_claim_pending', lambda x: x == member.id)
 | 
			
		||||
                if member.id not in dm.get('nick_verified'):
 | 
			
		||||
                    dm.add('nick_verified', int(member.id))
 | 
			
		||||
 | 
			
		||||
                # Update review record
 | 
			
		||||
                dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
 | 
			
		||||
 | 
			
		||||
                # Edit the review message
 | 
			
		||||
                if msg:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await msg.clear_reactions()
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                    try:
 | 
			
		||||
                        await msg.edit(content=f"✅ **Nickname Approved** for {member.mention} by {approver} — {_ts_rel(now_ts)}")
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
                # Modlog
 | 
			
		||||
                await self._modlog(guild,
 | 
			
		||||
                    f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                # Refresh roles / card
 | 
			
		||||
                rr = self.bot.get_cog('ReactionRoleCog')
 | 
			
		||||
                if rr:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await rr.maybe_apply_full_access(member)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                cards = self.bot.get_cog('UserCardsCog')
 | 
			
		||||
                if cards:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await cards.refresh_card(member)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                # Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access
 | 
			
		||||
                dm.remove('agreed_nickname', lambda x: x == member.id)
 | 
			
		||||
                dm.remove('nick_claim_pending', lambda x: x == member.id)
 | 
			
		||||
                dm.remove('nick_verified', lambda x: x == member.id)
 | 
			
		||||
 | 
			
		||||
                # Update review record
 | 
			
		||||
                dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
 | 
			
		||||
 | 
			
		||||
                if msg:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await msg.clear_reactions()
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                    try:
 | 
			
		||||
                        await msg.edit(content=f"❌ **Nickname Rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}")
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
                await self._modlog(guild,
 | 
			
		||||
                    f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                # Refresh roles / card
 | 
			
		||||
                rr = self.bot.get_cog('ReactionRoleCog')
 | 
			
		||||
                if rr:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await rr.maybe_apply_full_access(member)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                cards = self.bot.get_cog('UserCardsCog')
 | 
			
		||||
                if cards:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await cards.refresh_card(member)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(NickNudgeCog(bot))
 | 
			
		||||
@ -1,226 +0,0 @@
 | 
			
		||||
# modules/pirate_cards/pirate_cards.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from mod_perms import require_mod_ctx  # use your configured moderator roles
 | 
			
		||||
 | 
			
		||||
class PirateCardsCog(commands.Cog):
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
        self.pirates_channel_id = int(cfg['pirates_list_channel_id'])
 | 
			
		||||
        self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
 | 
			
		||||
 | 
			
		||||
        # thresholds / samples (optional, with defaults)
 | 
			
		||||
        try:
 | 
			
		||||
            self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.group_threshold = 3
 | 
			
		||||
        try:
 | 
			
		||||
            self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3'))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.min_samples = 3
 | 
			
		||||
 | 
			
		||||
        # safe posting (don’t ping)
 | 
			
		||||
        self._no_mentions = discord.AllowedMentions.none()
 | 
			
		||||
        # serialize rebuilds per guild
 | 
			
		||||
        self._locks: dict[int, asyncio.Lock] = {}
 | 
			
		||||
 | 
			
		||||
    # -------- internals / helpers --------
 | 
			
		||||
    def _lock_for(self, guild_id: int) -> asyncio.Lock:
 | 
			
		||||
        self._locks.setdefault(guild_id, asyncio.Lock())
 | 
			
		||||
        return self._locks[guild_id]
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _esc(s: str) -> str:
 | 
			
		||||
        """Escape markdown & neutralize mentions for any user-sourced strings we show."""
 | 
			
		||||
        safe = discord.utils.escape_markdown(str(s))
 | 
			
		||||
        return safe.replace("@", "@\u200b")
 | 
			
		||||
 | 
			
		||||
    def _color_for_threat(self, threat: int, enc_count: int) -> discord.Color:
 | 
			
		||||
        """
 | 
			
		||||
        Gradient:
 | 
			
		||||
          - If too few samples: dark gray (unknown)
 | 
			
		||||
          - Else 0 -> green (0,255,0), 100 -> red (255,0,0)
 | 
			
		||||
        """
 | 
			
		||||
        if enc_count < self.min_samples:
 | 
			
		||||
            return discord.Color.dark_gray()
 | 
			
		||||
 | 
			
		||||
        t = max(0, min(100, int(threat))) / 100.0
 | 
			
		||||
        r = int(round(255 * t))
 | 
			
		||||
        g = int(round(255 * (1.0 - t)))
 | 
			
		||||
        return discord.Color.from_rgb(r, g, 0)
 | 
			
		||||
 | 
			
		||||
    def _bucket(self, rate: float, samples: int) -> str:
 | 
			
		||||
        if samples < self.min_samples:
 | 
			
		||||
            return "unknown"
 | 
			
		||||
        if rate <= 0:
 | 
			
		||||
            return "never"
 | 
			
		||||
        if rate <= 0.25:
 | 
			
		||||
            return "rarely"
 | 
			
		||||
        if rate <= 0.60:
 | 
			
		||||
            return "sometimes"
 | 
			
		||||
        if rate <= 0.85:
 | 
			
		||||
            return "often"
 | 
			
		||||
        return "always"
 | 
			
		||||
 | 
			
		||||
    def _encounters_for(self, pirate: dict):
 | 
			
		||||
        acct_l = str(pirate.get('account_name', '')).lower()
 | 
			
		||||
        char_l = str(pirate.get('character_name', '')).lower()
 | 
			
		||||
        out = []
 | 
			
		||||
        for e in self.bot.data_manager.get('encounters'):
 | 
			
		||||
            try:
 | 
			
		||||
                ident = str(e.get('identifier', '')).lower()
 | 
			
		||||
                if ident in (acct_l, char_l):
 | 
			
		||||
                    out.append(e)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                continue
 | 
			
		||||
        return out
 | 
			
		||||
 | 
			
		||||
    def _get_card_record(self, account_lower: str):
 | 
			
		||||
        for r in self.bot.data_manager.get('pirate_cards'):
 | 
			
		||||
            if r.get('account_lower') == account_lower:
 | 
			
		||||
                return r
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    async def _modlog(self, guild: discord.Guild, content: str):
 | 
			
		||||
        if not self.modlog_channel_id:
 | 
			
		||||
            return
 | 
			
		||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.send(content, allowed_mentions=self._no_mentions)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
    async def _build_embed(self, pirate: dict) -> discord.Embed:
 | 
			
		||||
        encs = self._encounters_for(pirate)
 | 
			
		||||
        total = len(encs)
 | 
			
		||||
        # guard numeric fields
 | 
			
		||||
        def _i(v, d=0):
 | 
			
		||||
            try:
 | 
			
		||||
                return int(v)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return d
 | 
			
		||||
 | 
			
		||||
        group_rate = (sum(1 for e in encs if _i(e.get('group_size'), 0) >= self.group_threshold) / total) if total else 0.0
 | 
			
		||||
        destroy_rate = (sum(1 for e in encs if bool(e.get('destruction'))) / total) if total else 0.0
 | 
			
		||||
 | 
			
		||||
        # last encountered: date only (UTC)
 | 
			
		||||
        last_date = "—"
 | 
			
		||||
        if total:
 | 
			
		||||
            try:
 | 
			
		||||
                last_ts = max(int(float(e.get('timestamp', 0))) for e in encs)
 | 
			
		||||
                last_date = datetime.utcfromtimestamp(last_ts).strftime('%Y-%m-%d')
 | 
			
		||||
            except Exception:
 | 
			
		||||
                last_date = "—"
 | 
			
		||||
 | 
			
		||||
        groups_str = self._bucket(group_rate, total)
 | 
			
		||||
        destr_str = self._bucket(destroy_rate, total)
 | 
			
		||||
 | 
			
		||||
        # threat & color
 | 
			
		||||
        threat = int(pirate.get('threat_level', 0))
 | 
			
		||||
        color = self._color_for_threat(threat, total)
 | 
			
		||||
 | 
			
		||||
        char = self._esc(pirate.get('character_name', 'Unknown'))
 | 
			
		||||
        acct = self._esc(pirate.get('account_name', 'Unknown#00000'))
 | 
			
		||||
 | 
			
		||||
        embed = discord.Embed(title=char, color=color)
 | 
			
		||||
        embed.add_field(name="Account name", value=f"*{acct}*", inline=False)
 | 
			
		||||
        embed.add_field(name="Threat", value=f"{threat}%", inline=True)
 | 
			
		||||
        embed.add_field(name="In groups", value=groups_str, inline=True)
 | 
			
		||||
        embed.add_field(name="Destructive", value=destr_str, inline=True)
 | 
			
		||||
        embed.set_footer(text=f"Encounters: {total} | Last: {last_date}")
 | 
			
		||||
        return embed
 | 
			
		||||
 | 
			
		||||
    async def refresh_card_for_account(self, guild: discord.Guild, account_name: str):
 | 
			
		||||
        """Create or update a single pirate card by account name."""
 | 
			
		||||
        acct_l = str(account_name or "").lower()
 | 
			
		||||
        pirates = self.bot.data_manager.get('pirates')
 | 
			
		||||
        p = next((x for x in pirates if str(x.get('account_name', '')).lower() == acct_l), None)
 | 
			
		||||
        if not p:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        channel = guild.get_channel(self.pirates_channel_id)
 | 
			
		||||
        if not channel:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        embed = await self._build_embed(p)
 | 
			
		||||
        rec = self._get_card_record(acct_l)
 | 
			
		||||
 | 
			
		||||
        if rec:
 | 
			
		||||
            try:
 | 
			
		||||
                msg = await channel.fetch_message(int(rec['message_id']))
 | 
			
		||||
                await msg.edit(embed=embed)
 | 
			
		||||
                return
 | 
			
		||||
            except Exception:
 | 
			
		||||
                # fall-through to recreate
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            msg = await channel.send(embed=embed, allowed_mentions=self._no_mentions)
 | 
			
		||||
            self.bot.data_manager.add('pirate_cards', {
 | 
			
		||||
                'account_lower': acct_l,
 | 
			
		||||
                'message_id': msg.id
 | 
			
		||||
            })
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def delete_card_for_account(self, guild: discord.Guild, account_name: str):
 | 
			
		||||
        """Delete a pirate card by account and remove its record."""
 | 
			
		||||
        acct_l = str(account_name or "").lower()
 | 
			
		||||
        channel = guild.get_channel(self.pirates_channel_id)
 | 
			
		||||
        rec = self._get_card_record(acct_l)
 | 
			
		||||
 | 
			
		||||
        if rec and channel:
 | 
			
		||||
            try:
 | 
			
		||||
                msg = await channel.fetch_message(int(rec['message_id']))
 | 
			
		||||
                await msg.delete()
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        # ensure record is gone regardless
 | 
			
		||||
        self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == acct_l)
 | 
			
		||||
 | 
			
		||||
    async def update_on_edit(self, guild: discord.Guild, old_account: str, new_account: str):
 | 
			
		||||
        """When an account name changes, carry over the card mapping and refresh."""
 | 
			
		||||
        old_l = str(old_account or "").lower()
 | 
			
		||||
        new_l = str(new_account or "").lower()
 | 
			
		||||
        rec = self._get_card_record(old_l)
 | 
			
		||||
        if rec:
 | 
			
		||||
            # replace mapping to new key
 | 
			
		||||
            self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == old_l)
 | 
			
		||||
            self.bot.data_manager.add('pirate_cards', {
 | 
			
		||||
                'account_lower': new_l,
 | 
			
		||||
                'message_id': rec['message_id']
 | 
			
		||||
            })
 | 
			
		||||
        await self.refresh_card_for_account(guild, new_account)
 | 
			
		||||
 | 
			
		||||
    # -------- command (mod-gated via require_mod_ctx) --------
 | 
			
		||||
    @commands.hybrid_command(name="pirate_cards_rebuild", description="Rebuild pirate cards for all known pirates")
 | 
			
		||||
    async def pirate_cards_rebuild(self, ctx: commands.Context):
 | 
			
		||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
        if not ctx.guild:
 | 
			
		||||
            return await ctx.reply("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        async with self._lock_for(ctx.guild.id):
 | 
			
		||||
            guild = ctx.guild
 | 
			
		||||
            ch = guild.get_channel(self.pirates_channel_id)
 | 
			
		||||
            if not ch:
 | 
			
		||||
                return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
            count = 0
 | 
			
		||||
            for p in self.bot.data_manager.get('pirates'):
 | 
			
		||||
                try:
 | 
			
		||||
                    await self.refresh_card_for_account(guild, p.get('account_name', ''))
 | 
			
		||||
                    count += 1
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
        is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
 | 
			
		||||
        await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(PirateCardsCog(bot))
 | 
			
		||||
@ -1,579 +0,0 @@
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from mod_perms import (
 | 
			
		||||
    is_moderator_member,
 | 
			
		||||
    is_moderator_userid,
 | 
			
		||||
    require_mod_ctx,
 | 
			
		||||
    require_mod_interaction,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
CHECK = '✅'
 | 
			
		||||
CROSS = '❌'
 | 
			
		||||
 | 
			
		||||
# --------------------- Helpers ---------------------
 | 
			
		||||
 | 
			
		||||
_ACCT_RE = re.compile(r'.+#\d{5}$')
 | 
			
		||||
 | 
			
		||||
def _acct_ok(s: str) -> bool:
 | 
			
		||||
    return bool(_ACCT_RE.fullmatch(s.strip()))
 | 
			
		||||
 | 
			
		||||
def _now_utc_str() -> str:
 | 
			
		||||
    return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
 | 
			
		||||
 | 
			
		||||
def _parse_bool(s: str) -> bool:
 | 
			
		||||
    v = s.strip().lower()
 | 
			
		||||
    if v in ('y', 'yes', 'true', 't', '1'):
 | 
			
		||||
        return True
 | 
			
		||||
    if v in ('n', 'no', 'false', 'f', '0'):
 | 
			
		||||
        return False
 | 
			
		||||
    raise ValueError("Please enter yes or no")
 | 
			
		||||
 | 
			
		||||
# --------------------- Modals ----------------------
 | 
			
		||||
 | 
			
		||||
class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
 | 
			
		||||
    def __init__(self, cog: "PirateReportCog"):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
 | 
			
		||||
        self.character_name = discord.ui.TextInput(
 | 
			
		||||
            label="In-game nickname",
 | 
			
		||||
            placeholder="e.g., SandStalker",
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
        self.account_name = discord.ui.TextInput(
 | 
			
		||||
            label="Account (Name#12345)",
 | 
			
		||||
            placeholder="Must end with # and 5 digits",
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.character_name)
 | 
			
		||||
        self.add_item(self.account_name)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        char = self.character_name.value.strip()
 | 
			
		||||
        acct = self.account_name.value.strip()
 | 
			
		||||
 | 
			
		||||
        if not _acct_ok(acct):
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "❌ Invalid account format. It must end with `#` and **five digits** (e.g. `SomeUser#12345`).",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        dm = self.cog.bot.data_manager
 | 
			
		||||
        char_l = char.lower()
 | 
			
		||||
        acct_l = acct.lower()
 | 
			
		||||
 | 
			
		||||
        # Anti-spam: 60s per reporter
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        recent = [r for r in dm.get('reports')
 | 
			
		||||
                  if r.get('submitter_id') == interaction.user.id and (now - r.get('ts', now)) < 60]
 | 
			
		||||
        if recent:
 | 
			
		||||
            return await interaction.response.send_message("⏱️ Please wait 60 seconds before submitting another report.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Already approved?
 | 
			
		||||
        if any(p['account_name'].lower() == acct_l for p in dm.get('pirates')):
 | 
			
		||||
            return await interaction.response.send_message("❌ This player is already in the pirate list.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Pending duplicate?
 | 
			
		||||
        if any(r['character_name'].lower() == char_l and r['account_name'].lower() == acct_l for r in dm.get('reports')):
 | 
			
		||||
            return await interaction.response.send_message("⏳ A report for this player is already pending moderation.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Acknowledge in channel so we can edit later when approved/rejected
 | 
			
		||||
        try:
 | 
			
		||||
            ack = await interaction.channel.send(f"{interaction.user.mention} thanks — your report was sent to moderators for review.")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] ack send failed:", repr(e))
 | 
			
		||||
            ack = None
 | 
			
		||||
 | 
			
		||||
        # Send to mod channel with ✅/❌
 | 
			
		||||
        mod_ch = interaction.guild.get_channel(self.cog.mod_channel)
 | 
			
		||||
        if not mod_ch:
 | 
			
		||||
            return await interaction.response.send_message("❌ Mod channel not configured.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            mod_msg = await mod_ch.send(
 | 
			
		||||
                f"🚩 **Pirate Report**\n"
 | 
			
		||||
                f"**Character:** {char}\n"
 | 
			
		||||
                f"**Account:** {acct}\n"
 | 
			
		||||
                f"**Submitted by:** {interaction.user.mention}"
 | 
			
		||||
            )
 | 
			
		||||
            await mod_msg.add_reaction(CHECK)
 | 
			
		||||
            await mod_msg.add_reaction(CROSS)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] mod message failed:", repr(e))
 | 
			
		||||
            return await interaction.response.send_message("❌ Failed to send to mod channel.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        dm.add('reports', {
 | 
			
		||||
            'report_id': mod_msg.id,
 | 
			
		||||
            'guild_id': interaction.guild.id,
 | 
			
		||||
            'character_name': char,
 | 
			
		||||
            'account_name': acct,
 | 
			
		||||
            'submitter_id': interaction.user.id,
 | 
			
		||||
            'origin_channel_id': interaction.channel.id if interaction.channel else 0,
 | 
			
		||||
            'ack_message_id': ack.id if ack else 0,
 | 
			
		||||
            'status': 'pending',
 | 
			
		||||
            'ts': now
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message("✅ Report submitted to moderators.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
 | 
			
		||||
    def __init__(self, cog: "PirateReportCog"):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
 | 
			
		||||
        self.old_account = discord.ui.TextInput(
 | 
			
		||||
            label="Current account (Name#12345)",
 | 
			
		||||
            placeholder="Exact current account in the list",
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
        self.new_character = discord.ui.TextInput(
 | 
			
		||||
            label="New in-game nickname (optional)",
 | 
			
		||||
            placeholder="Leave blank to keep existing",
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            required=False
 | 
			
		||||
        )
 | 
			
		||||
        self.new_account = discord.ui.TextInput(
 | 
			
		||||
            label="New account (optional, Name#12345)",
 | 
			
		||||
            placeholder="Leave blank to keep existing",
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            required=False
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.old_account)
 | 
			
		||||
        self.add_item(self.new_character)
 | 
			
		||||
        self.add_item(self.new_account)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        if not is_moderator_member(interaction.user, self.cog.bot):
 | 
			
		||||
            return await interaction.response.send_message("This action is restricted to moderators.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        old_acct = self.old_account.value.strip()
 | 
			
		||||
        new_char = (self.new_character.value or "").strip()
 | 
			
		||||
        new_acct = (self.new_account.value or "").strip()
 | 
			
		||||
 | 
			
		||||
        if not new_char and not new_acct:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "❌ Provide **at least one** of: new nickname or new account.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        dm = self.cog.bot.data_manager
 | 
			
		||||
        pirates = dm.get('pirates')
 | 
			
		||||
        rec = next((p for p in pirates if p['account_name'].lower() == old_acct.lower()), None)
 | 
			
		||||
        if not rec:
 | 
			
		||||
            return await interaction.response.send_message("❌ Pirate not found.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        if new_acct:
 | 
			
		||||
            if not _acct_ok(new_acct):
 | 
			
		||||
                return await interaction.response.send_message("❌ Invalid new account format (Name#12345).", ephemeral=True)
 | 
			
		||||
            if new_acct.lower() != old_acct.lower() and any(p['account_name'].lower() == new_acct.lower() for p in pirates):
 | 
			
		||||
                return await interaction.response.send_message("❌ Another pirate already has that account.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        before = rec.copy()
 | 
			
		||||
        dm.remove('pirates', lambda p: p['account_name'].lower() == old_acct.lower())
 | 
			
		||||
        if new_char:
 | 
			
		||||
            rec['character_name'] = new_char
 | 
			
		||||
        if new_acct:
 | 
			
		||||
            rec['account_name'] = new_acct
 | 
			
		||||
        dm.add('pirates', rec)
 | 
			
		||||
 | 
			
		||||
        await self.cog._modlog(
 | 
			
		||||
            interaction.guild,
 | 
			
		||||
            f"✏️ Edited pirate by {interaction.user.mention}: "
 | 
			
		||||
            f"{'name ' + before['character_name'] + ' → ' + rec['character_name'] if new_char else ''} "
 | 
			
		||||
            f"{'/ account ' + before['account_name'] + ' → ' + rec['account_name'] if new_acct else ''}".strip()
 | 
			
		||||
        )
 | 
			
		||||
        await interaction.response.send_message("✅ Pirate updated.", ephemeral=True)
 | 
			
		||||
        await self.cog._refresh_pirates_list(interaction.guild)
 | 
			
		||||
 | 
			
		||||
class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
 | 
			
		||||
    def __init__(self, cog: "PirateReportCog"):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
 | 
			
		||||
        self.identifier = discord.ui.TextInput(
 | 
			
		||||
            label="Pirate (name or account)",
 | 
			
		||||
            placeholder="MuadDib or MuadDib#12345",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=64
 | 
			
		||||
        )
 | 
			
		||||
        self.group_size = discord.ui.TextInput(
 | 
			
		||||
            label="Group size (including this pirate)",
 | 
			
		||||
            placeholder="How many pirates were in the group?",
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
        self.kills = discord.ui.TextInput(
 | 
			
		||||
            label="Kills (integer ≥ 0; 0 = none/unknown)",
 | 
			
		||||
            placeholder="How many did the pirate kill during the encounter",
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
        self.destruction = discord.ui.TextInput(
 | 
			
		||||
            label="Destructive? (yes/no)",
 | 
			
		||||
            placeholder="Did they destroy a base/ornithopter? yes or no",
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
        self.skill = discord.ui.TextInput(
 | 
			
		||||
            label="Perceived Skill (0–5, 0 = unknown)",
 | 
			
		||||
            placeholder="0..5",
 | 
			
		||||
            required=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.identifier)
 | 
			
		||||
        self.add_item(self.group_size)
 | 
			
		||||
        self.add_item(self.kills)
 | 
			
		||||
        self.add_item(self.destruction)
 | 
			
		||||
        self.add_item(self.skill)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        ident_raw = self.identifier.value.strip()
 | 
			
		||||
        guild = interaction.guild
 | 
			
		||||
        if guild is None:
 | 
			
		||||
            return await interaction.response.send_message("This can only be used in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Parse numeric fields
 | 
			
		||||
        try:
 | 
			
		||||
            group_size = int(self.group_size.value.strip()); assert group_size >= 1
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ **Group size** must be an integer ≥ 1.", ephemeral=True)
 | 
			
		||||
        try:
 | 
			
		||||
            kills = int(self.kills.value.strip()); assert kills >= 0
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ **Kills** must be an integer ≥ 0.", ephemeral=True)
 | 
			
		||||
        try:
 | 
			
		||||
            destruction = _parse_bool(self.destruction.value)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            return await interaction.response.send_message("❌ **Destructive** must be **yes** or **no**.", ephemeral=True)
 | 
			
		||||
        try:
 | 
			
		||||
            skill = int(self.skill.value.strip()); assert 0 <= skill <= 5
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ **Skill** must be an integer between **0** and **5**.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Resolve to a single pirate record and canonical account identifier
 | 
			
		||||
        pirate, error = self.cog.resolve_pirate(ident_raw)
 | 
			
		||||
        if error:
 | 
			
		||||
            return await interaction.response.send_message(error, ephemeral=True)
 | 
			
		||||
        if not pirate:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "❌ No such pirate registered. Ask a moderator to add them, or submit a **/report** for review.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        canonical_acct = pirate['account_name']                # <- always store account here
 | 
			
		||||
        acct_l = canonical_acct.lower()
 | 
			
		||||
        char_l = pirate['character_name'].lower()
 | 
			
		||||
 | 
			
		||||
        dm = self.cog.bot.data_manager
 | 
			
		||||
        now = time.time()
 | 
			
		||||
 | 
			
		||||
        # Rate-limit by canonical account
 | 
			
		||||
        recent = [
 | 
			
		||||
            e for e in dm.get('encounters')
 | 
			
		||||
            if e['reporter_id'] == interaction.user.id
 | 
			
		||||
            and e.get('identifier','').lower() in (acct_l, char_l)  # respect legacy
 | 
			
		||||
            and (now - e['timestamp']) < 600
 | 
			
		||||
        ]
 | 
			
		||||
        if recent:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "⏱️ You can only report the same pirate once every **10 minutes**.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Store with canonical account as identifier
 | 
			
		||||
        rec = {
 | 
			
		||||
            'identifier': canonical_acct,
 | 
			
		||||
            'reporter_id': interaction.user.id,
 | 
			
		||||
            'timestamp': now,
 | 
			
		||||
            'group_size': group_size,
 | 
			
		||||
            'kills': kills,
 | 
			
		||||
            'destruction': destruction,
 | 
			
		||||
            'skill': skill
 | 
			
		||||
        }
 | 
			
		||||
        dm.add('encounters', rec)
 | 
			
		||||
 | 
			
		||||
        # Recalculate threat (count both new account-based and legacy name-based entries)
 | 
			
		||||
        encs = [e for e in dm.get('encounters') if e.get('identifier','').lower() in (acct_l, char_l)]
 | 
			
		||||
        total = len(encs)
 | 
			
		||||
        if total:
 | 
			
		||||
            group_threshold = self.cog.group_threshold
 | 
			
		||||
            kill_rate = sum(e['kills'] > 0 for e in encs) / total
 | 
			
		||||
            group_rate = sum(e['group_size'] >= group_threshold for e in encs) / total
 | 
			
		||||
            destroy_rate = sum(bool(e['destruction']) for e in encs) / total
 | 
			
		||||
            skill_samples = [e['skill'] / 5 for e in encs if e['skill'] > 0]
 | 
			
		||||
            skill_rate = (sum(skill_samples) / len(skill_samples)) if skill_samples else 0.0
 | 
			
		||||
 | 
			
		||||
            w_k, w_d, w_g, w_s = self.cog.w_kill, self.cog.w_destruction, self.cog.w_group, self.cog.w_skill
 | 
			
		||||
            weighted = (w_k * kill_rate) + (w_d * destroy_rate) + (w_g * group_rate) + (w_s * skill_rate)
 | 
			
		||||
            threat_level = int(round(100 * min(1.0, max(0.0, weighted))))
 | 
			
		||||
 | 
			
		||||
            # Update pirate record
 | 
			
		||||
            pirates = dm.get('pirates')
 | 
			
		||||
            def match(p): return p['account_name'].lower() == acct_l
 | 
			
		||||
            rec0 = next((p for p in pirates if match(p)), None)
 | 
			
		||||
            if rec0:
 | 
			
		||||
                dm.remove('pirates', match)
 | 
			
		||||
                rec0.update({'threat_level': threat_level, 'encounter_count': total})
 | 
			
		||||
                dm.add('pirates', rec0)
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message(
 | 
			
		||||
            f"✅ Encounter recorded for **{canonical_acct}**.",
 | 
			
		||||
            ephemeral=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        await self.cog._refresh_pirates_list(interaction.guild)
 | 
			
		||||
 | 
			
		||||
# -------------- Cog: commands + listeners ---------------
 | 
			
		||||
 | 
			
		||||
class PirateReportCog(commands.Cog):
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
        self.mod_channel = int(cfg['mod_channel_id'])
 | 
			
		||||
        self.modlog_channel_id = int(cfg['modlog_channel_id'])
 | 
			
		||||
 | 
			
		||||
        # Optional threat weights (normalized elsewhere if you added them)
 | 
			
		||||
        try:
 | 
			
		||||
            self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.group_threshold = 3
 | 
			
		||||
        # Defaults if not already present in your earlier version:
 | 
			
		||||
        self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
 | 
			
		||||
        self.w_destruction = float(cfg.get('threat_w_destruction', '0.30'))
 | 
			
		||||
        self.w_group = float(cfg.get('threat_w_group', '0.20'))
 | 
			
		||||
        self.w_skill = float(cfg.get('threat_w_skill', '0.15'))
 | 
			
		||||
 | 
			
		||||
    async def _refresh_pirates_list(self, guild: discord.Guild):
 | 
			
		||||
        plist = self.bot.get_cog('PiratesListCog')
 | 
			
		||||
        if plist:
 | 
			
		||||
            await plist.refresh_list(guild)
 | 
			
		||||
 | 
			
		||||
    async def _modlog(self, guild: discord.Guild, content: str):
 | 
			
		||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try:
 | 
			
		||||
                await ch.send(content)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("[pirate_report] modlog send failed:", repr(e))
 | 
			
		||||
        self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
 | 
			
		||||
 | 
			
		||||
    # --- Resolver: name/account -> pirate record (handles ambiguity) ---
 | 
			
		||||
    def resolve_pirate(self, ident: str):
 | 
			
		||||
        """Return (pirate_record or None, error_message or None)."""
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        pirates = dm.get('pirates')
 | 
			
		||||
        s = ident.strip()
 | 
			
		||||
        s_l = s.lower()
 | 
			
		||||
        if '#' in s and _acct_ok(s):
 | 
			
		||||
            rec = next((p for p in pirates if p['account_name'].lower() == s_l), None)
 | 
			
		||||
            return (rec, None)
 | 
			
		||||
        # resolve by character; must be unambiguous
 | 
			
		||||
        matches = [p for p in pirates if p['character_name'].lower() == s_l]
 | 
			
		||||
        if not matches:
 | 
			
		||||
            return (None, None)
 | 
			
		||||
        if len(matches) > 1:
 | 
			
		||||
            return (None, "❌ That character name is **ambiguous**. Please use the account (e.g. `Name#12345`).")
 | 
			
		||||
        return (matches[0], None)
 | 
			
		||||
 | 
			
		||||
    # Remove pirate (mod-only)
 | 
			
		||||
    @commands.hybrid_command(name='remove_pirate', description='Remove an approved pirate entry')
 | 
			
		||||
    async def remove_pirate(self, ctx, account_name: str):
 | 
			
		||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
        acct_lower = account_name.strip().lower()
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
 | 
			
		||||
            return await ctx.reply("Pirate not found.")
 | 
			
		||||
        dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
 | 
			
		||||
        await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}")
 | 
			
		||||
        await self._refresh_pirates_list(ctx.guild)
 | 
			
		||||
        await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None)
 | 
			
		||||
 | 
			
		||||
    # Modal launchers
 | 
			
		||||
    @app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)")
 | 
			
		||||
    async def report(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        await interaction.response.send_modal(ReportModal(self))
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="edit_pirate", description="Edit a pirate entry (opens a form)")
 | 
			
		||||
    async def edit_pirate(self, interaction: discord.Interaction):
 | 
			
		||||
        if not await require_mod_interaction(interaction):
 | 
			
		||||
            return
 | 
			
		||||
        await interaction.response.send_modal(EditPirateModal(self))
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="encounter", description="Log a pirate encounter (opens a form)")
 | 
			
		||||
    async def encounter(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        await interaction.response.send_modal(EncounterModal(self))
 | 
			
		||||
 | 
			
		||||
    # ---- Migration: convert encounter identifiers to accounts (mod-only) ----
 | 
			
		||||
    @commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names')
 | 
			
		||||
    async def encounters_migrate_ids(self, ctx: commands.Context):
 | 
			
		||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        pirates = dm.get('pirates')
 | 
			
		||||
        by_char = {}
 | 
			
		||||
        for p in pirates:
 | 
			
		||||
            by_char.setdefault(p['character_name'].lower(), []).append(p)
 | 
			
		||||
        by_acct = {p['account_name'].lower(): p for p in pirates}
 | 
			
		||||
 | 
			
		||||
        changed = 0
 | 
			
		||||
        ambiguous = 0
 | 
			
		||||
        missing = 0
 | 
			
		||||
        already = 0
 | 
			
		||||
 | 
			
		||||
        for e in dm.get('encounters'):
 | 
			
		||||
            ident = e.get('identifier', '')
 | 
			
		||||
            if not ident:
 | 
			
		||||
                continue
 | 
			
		||||
            if '#' in ident:
 | 
			
		||||
                already += 1
 | 
			
		||||
                continue
 | 
			
		||||
            key = ident.lower()
 | 
			
		||||
            matches = by_char.get(key, [])
 | 
			
		||||
            if not matches:
 | 
			
		||||
                missing += 1
 | 
			
		||||
                continue
 | 
			
		||||
            if len(matches) > 1:
 | 
			
		||||
                ambiguous += 1
 | 
			
		||||
                continue
 | 
			
		||||
            acct = matches[0]['account_name']
 | 
			
		||||
            # update this one entry atomically
 | 
			
		||||
            def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident):
 | 
			
		||||
                return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old
 | 
			
		||||
            def upd(x, acct_new=acct):
 | 
			
		||||
                x['identifier'] = acct_new
 | 
			
		||||
                return x
 | 
			
		||||
            ok = dm.update('encounters', pred, upd)
 | 
			
		||||
            if ok:
 | 
			
		||||
                changed += 1
 | 
			
		||||
 | 
			
		||||
        await ctx.reply(
 | 
			
		||||
            f"Migration complete.\n"
 | 
			
		||||
            f"- Updated to accounts: **{changed}**\n"
 | 
			
		||||
            f"- Already accounts: **{already}**\n"
 | 
			
		||||
            f"- Ambiguous character names (skipped): **{ambiguous}**\n"
 | 
			
		||||
            f"- Not found in pirates list (skipped): **{missing}**",
 | 
			
		||||
            ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        await self._refresh_pirates_list(ctx.guild)
 | 
			
		||||
 | 
			
		||||
    # Moderator reaction handling (atomic claim)
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        if (
 | 
			
		||||
            payload.channel_id != self.mod_channel
 | 
			
		||||
            or str(payload.emoji) not in (CHECK, CROSS)
 | 
			
		||||
            or payload.user_id == self.bot.user.id
 | 
			
		||||
        ):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        channel = self.bot.get_channel(payload.channel_id)
 | 
			
		||||
        if not channel or not channel.guild:
 | 
			
		||||
            return
 | 
			
		||||
        if not is_moderator_userid(channel.guild, payload.user_id, self.bot):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            msg = await channel.fetch_message(payload.message_id)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] fetch mod msg failed:", repr(e))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # Atomically claim this report
 | 
			
		||||
        claimed = dm.update(
 | 
			
		||||
            'reports',
 | 
			
		||||
            lambda r: r.get('report_id') == msg.id and r.get('status') == 'pending',
 | 
			
		||||
            lambda r: (r.update({'status': 'deciding'}), r)[1]
 | 
			
		||||
        )
 | 
			
		||||
        if not claimed:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        report = next((r for r in dm.get('reports') if r.get('report_id') == msg.id), None)
 | 
			
		||||
        if not report:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        approved = (str(payload.emoji) == CHECK)
 | 
			
		||||
        guild = channel.guild
 | 
			
		||||
        stamp = _now_utc_str()
 | 
			
		||||
 | 
			
		||||
        header_emoji = CHECK if approved else CROSS
 | 
			
		||||
        new_content = (
 | 
			
		||||
            f"{header_emoji} **Pirate Report**\n"
 | 
			
		||||
            f"**Character:** {report['character_name']}\n"
 | 
			
		||||
            f"**Account:** {report['account_name']}\n"
 | 
			
		||||
            f"**Submitted by:** <@{report['submitter_id']}>\n\n"
 | 
			
		||||
            f"**Status:** {'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            await msg.clear_reactions()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] clear reactions failed:", repr(e))
 | 
			
		||||
        try:
 | 
			
		||||
            await msg.edit(content=new_content)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] edit mod msg failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
        if approved:
 | 
			
		||||
            acct_lower = report['account_name'].lower()
 | 
			
		||||
            if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
 | 
			
		||||
                dm.add('pirates', {
 | 
			
		||||
                    'character_name': report['character_name'],
 | 
			
		||||
                    'account_name': report['account_name'],
 | 
			
		||||
                    'submitter': str(report['submitter_id']),
 | 
			
		||||
                    'threat_level': 0,
 | 
			
		||||
                    'encounter_count': 0,
 | 
			
		||||
                    'added_ts': time.time()
 | 
			
		||||
                })
 | 
			
		||||
                await self._refresh_pirates_list(guild)
 | 
			
		||||
 | 
			
		||||
        await self._modlog(guild, f"{'✅ Approved' if approved else '❌ Rejected'} report for {report['character_name']} ({report['account_name']}) by <@{payload.user_id}>.")
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            if report.get('origin_channel_id') and report.get('ack_message_id'):
 | 
			
		||||
                ch = guild.get_channel(report['origin_channel_id'])
 | 
			
		||||
                if ch:
 | 
			
		||||
                    ack_msg = await ch.fetch_message(report['ack_message_id'])
 | 
			
		||||
                    await ack_msg.edit(content=f"{('✅ Approved' if approved else '❌ Rejected')} pirate report of {report['character_name']} ({report['account_name']}) by <@{payload.user_id}> on {stamp}.")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] edit ack msg failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
        dm.remove('reports', lambda r: r.get('report_id') == msg.id)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    cog = PirateReportCog(bot)
 | 
			
		||||
    await bot.add_cog(cog)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        home_gid = 0
 | 
			
		||||
 | 
			
		||||
    if home_gid:
 | 
			
		||||
        guild_obj = discord.Object(id=home_gid)
 | 
			
		||||
        bot.tree.add_command(cog.report, guild=guild_obj)
 | 
			
		||||
        bot.tree.add_command(cog.edit_pirate, guild=guild_obj)
 | 
			
		||||
        bot.tree.add_command(cog.encounter, guild=guild_obj)
 | 
			
		||||
    else:
 | 
			
		||||
        bot.tree.add_command(cog.report)
 | 
			
		||||
        bot.tree.add_command(cog.edit_pirate)
 | 
			
		||||
        bot.tree.add_command(cog.encounter)
 | 
			
		||||
@ -1,256 +0,0 @@
 | 
			
		||||
# modules/pirates_list/pirates_list.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from mod_perms import require_mod_ctx  # ctx-aware mod gate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PiratesListCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Maintains a compact, alphabetized pirates list in the configured channel.
 | 
			
		||||
 | 
			
		||||
    Entry format:
 | 
			
		||||
    - **{Character}** (*{Account}*) [{Threat}%]
 | 
			
		||||
      - In group: {never/rarely/...}. Destructive: {never/rarely/...}. Encounters: N. Last: <t:UNIX:R>
 | 
			
		||||
 | 
			
		||||
    Posts are chunked to stay <2000 chars and previous posts are deleted on refresh.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config["DEFAULT"]
 | 
			
		||||
        self.list_channel_id = int(cfg["pirates_list_channel_id"])
 | 
			
		||||
        try:
 | 
			
		||||
            self.group_threshold = int(cfg.get("threat_group_threshold", "3"))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.group_threshold = 3
 | 
			
		||||
        try:
 | 
			
		||||
            self.min_samples = int(cfg.get("threat_min_samples_for_stats", "3"))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.min_samples = 3
 | 
			
		||||
 | 
			
		||||
        # serialize refreshes per guild
 | 
			
		||||
        self._locks = {}
 | 
			
		||||
        self._no_mentions = discord.AllowedMentions.none()
 | 
			
		||||
 | 
			
		||||
        def _lock_for(self, guild_id: int):
 | 
			
		||||
            import asyncio
 | 
			
		||||
            self._locks.setdefault(guild_id, asyncio.Lock())
 | 
			
		||||
            return self._locks[guild_id]
 | 
			
		||||
 | 
			
		||||
        # send settings: never ping on posted content
 | 
			
		||||
        self._no_mentions = discord.AllowedMentions.none()
 | 
			
		||||
 | 
			
		||||
    # ----------------- utils -----------------
 | 
			
		||||
 | 
			
		||||
    def _lock_for(self, guild_id: int) -> asyncio.Lock:
 | 
			
		||||
        self._locks.setdefault(guild_id, asyncio.Lock())
 | 
			
		||||
        return self._locks[guild_id]
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _esc(s: str) -> str:
 | 
			
		||||
        """Escape markdown and neuter @mentions in user-provided strings."""
 | 
			
		||||
        safe = discord.utils.escape_markdown(str(s))
 | 
			
		||||
        return safe.replace("@", "@\u200b")
 | 
			
		||||
 | 
			
		||||
    def _encounters_for(self, pirate: dict):
 | 
			
		||||
        """Return encounters matching either char name or account (case-insensitive), defensively."""
 | 
			
		||||
        acct_l = str(pirate.get("account_name", "")).lower()
 | 
			
		||||
        char_l = str(pirate.get("character_name", "")).lower()
 | 
			
		||||
        out = []
 | 
			
		||||
        for e in self.bot.data_manager.get("encounters"):
 | 
			
		||||
            try:
 | 
			
		||||
                ident = str(e.get("identifier", "")).lower()
 | 
			
		||||
                if ident in (acct_l, char_l):
 | 
			
		||||
                    out.append(e)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                continue
 | 
			
		||||
        return out
 | 
			
		||||
 | 
			
		||||
    def _bucket(self, rate: float, samples: int) -> str:
 | 
			
		||||
        if samples < self.min_samples:
 | 
			
		||||
            return "unknown"
 | 
			
		||||
        if rate <= 0:
 | 
			
		||||
            return "never"
 | 
			
		||||
        if rate <= 0.25:
 | 
			
		||||
            return "rarely"
 | 
			
		||||
        if rate <= 0.60:
 | 
			
		||||
            return "sometimes"
 | 
			
		||||
        if rate <= 0.85:
 | 
			
		||||
            return "often"
 | 
			
		||||
        return "always"
 | 
			
		||||
 | 
			
		||||
    def _format_entry(self, p: dict) -> str:
 | 
			
		||||
        encs = self._encounters_for(p)
 | 
			
		||||
        total = len(encs)
 | 
			
		||||
 | 
			
		||||
        def _safe_int(v, default=0):
 | 
			
		||||
            try:
 | 
			
		||||
                return int(v)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return default
 | 
			
		||||
 | 
			
		||||
        group_rate = (
 | 
			
		||||
            sum(1 for e in encs if _safe_int(e.get("group_size"), 0) >= self.group_threshold) / total
 | 
			
		||||
            if total else 0.0
 | 
			
		||||
        )
 | 
			
		||||
        destroy_rate = (
 | 
			
		||||
            sum(1 for e in encs if bool(e.get("destruction"))) / total
 | 
			
		||||
            if total else 0.0
 | 
			
		||||
        )
 | 
			
		||||
        groups_str = self._bucket(group_rate, total)
 | 
			
		||||
        destr_str = self._bucket(destroy_rate, total)
 | 
			
		||||
 | 
			
		||||
        last_ts = None
 | 
			
		||||
        if total:
 | 
			
		||||
            try:
 | 
			
		||||
                last_ts = max(int(float(e.get("timestamp", 0))) for e in encs)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                last_ts = None
 | 
			
		||||
 | 
			
		||||
        char = self._esc(p.get("character_name", "Unknown"))
 | 
			
		||||
        acct = self._esc(p.get("account_name", "Unknown#00000"))
 | 
			
		||||
        threat = int(p.get("threat_level", 0))
 | 
			
		||||
        last_str = f"<t:{last_ts}:R>" if last_ts else "—"
 | 
			
		||||
 | 
			
		||||
        line1 = f"- **{char}** (*{acct}*) [{threat}%]"
 | 
			
		||||
        line2 = f"  - In group: {groups_str}. Destructive: {destr_str}. Encounters: {total}. Last: {last_str}"
 | 
			
		||||
        return f"{line1}\n{line2}"
 | 
			
		||||
 | 
			
		||||
    async def refresh_list(self, guild: discord.Guild):
 | 
			
		||||
        """Edit list messages in place; only send extra messages when we need more chunks (new pirates)."""
 | 
			
		||||
        # ---- serialize per guild ----
 | 
			
		||||
        lock = getattr(self, "_locks", {}).get(guild.id)
 | 
			
		||||
        if lock is None:
 | 
			
		||||
            # tiny fallback if you didn't add _lock_for()
 | 
			
		||||
            import asyncio as _asyncio
 | 
			
		||||
            if not hasattr(self, "_locks"):
 | 
			
		||||
                self._locks = {}
 | 
			
		||||
            self._locks[guild.id] = _asyncio.Lock()
 | 
			
		||||
            lock = self._locks[guild.id]
 | 
			
		||||
 | 
			
		||||
        async with lock:
 | 
			
		||||
            channel = guild.get_channel(self.list_channel_id)
 | 
			
		||||
            if not channel:
 | 
			
		||||
                print("[pirates_list] list channel not found:", self.list_channel_id)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            dm = self.bot.data_manager
 | 
			
		||||
            allow = getattr(self, "_no_mentions", discord.AllowedMentions.none())
 | 
			
		||||
 | 
			
		||||
            # ---- load & prune existing posts for this guild/channel ----
 | 
			
		||||
            records = [r for r in dm.get("pirates_list_posts")
 | 
			
		||||
                    if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
 | 
			
		||||
 | 
			
		||||
            # fetch messages (drop any that vanished)
 | 
			
		||||
            msgs, kept_records = [], []
 | 
			
		||||
            for r in records:
 | 
			
		||||
                try:
 | 
			
		||||
                    m = await channel.fetch_message(int(r["message_id"]))
 | 
			
		||||
                    msgs.append(m)
 | 
			
		||||
                    kept_records.append(r)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    # prune dead record
 | 
			
		||||
                    dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid)
 | 
			
		||||
 | 
			
		||||
            records = kept_records  # only live ones, in stored order
 | 
			
		||||
 | 
			
		||||
            # ---- build fresh, sorted contents ----
 | 
			
		||||
            pirates = sorted(
 | 
			
		||||
                dm.get("pirates"),
 | 
			
		||||
                key=lambda x: (
 | 
			
		||||
                    str(x.get("character_name", "")).lower(),
 | 
			
		||||
                    str(x.get("account_name", "")).lower()
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            # Empty state
 | 
			
		||||
            if not pirates:
 | 
			
		||||
                placeholder = "_No verified pirates yet._"
 | 
			
		||||
                if msgs:
 | 
			
		||||
                    # edit first, delete the rest
 | 
			
		||||
                    if msgs[0].content != placeholder:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await msgs[0].edit(content=placeholder, allowed_mentions=allow)
 | 
			
		||||
                        except Exception as e:
 | 
			
		||||
                            print("[pirates_list] edit placeholder failed:", repr(e))
 | 
			
		||||
                    # remove extra posts/records
 | 
			
		||||
                    for extra in msgs[1:]:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await extra.delete()
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                        dm.remove("pirates_list_posts", lambda r, mid=extra.id: r.get("message_id") == mid)
 | 
			
		||||
                else:
 | 
			
		||||
                    try:
 | 
			
		||||
                        m = await channel.send(placeholder, allowed_mentions=allow)
 | 
			
		||||
                        dm.add("pirates_list_posts", {
 | 
			
		||||
                            "guild_id": guild.id,
 | 
			
		||||
                            "channel_id": self.list_channel_id,
 | 
			
		||||
                            "message_id": m.id
 | 
			
		||||
                        })
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        print("[pirates_list] send placeholder failed:", repr(e))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Chunk into <2000 char blocks (~1900 for margin)
 | 
			
		||||
            chunks, current = [], ""
 | 
			
		||||
            for p in pirates:
 | 
			
		||||
                entry = self._format_entry(p)
 | 
			
		||||
                if len(current) + len(entry) + 1 > 1900:
 | 
			
		||||
                    chunks.append(current.rstrip())
 | 
			
		||||
                    current = entry + "\n"
 | 
			
		||||
                else:
 | 
			
		||||
                    current += entry + "\n"
 | 
			
		||||
            if current.strip():
 | 
			
		||||
                chunks.append(current.rstrip())
 | 
			
		||||
 | 
			
		||||
            # ---- edit existing messages with new content ----
 | 
			
		||||
            common = min(len(msgs), len(chunks))
 | 
			
		||||
            for i in range(common):
 | 
			
		||||
                if msgs[i].content != chunks[i]:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await msgs[i].edit(content=chunks[i], allowed_mentions=allow)
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        print("[pirates_list] edit block failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
            # ---- if we need *more* messages (usually after adding a pirate), send them ----
 | 
			
		||||
            if len(chunks) > len(msgs):
 | 
			
		||||
                for i in range(len(msgs), len(chunks)):
 | 
			
		||||
                    try:
 | 
			
		||||
                        m = await channel.send(chunks[i], allowed_mentions=allow)
 | 
			
		||||
                        dm.add("pirates_list_posts", {
 | 
			
		||||
                            "guild_id": guild.id,
 | 
			
		||||
                            "channel_id": self.list_channel_id,
 | 
			
		||||
                            "message_id": m.id
 | 
			
		||||
                        })
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        print("[pirates_list] send block failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
            # ---- if we need fewer messages (e.g., pirate removed), delete extras ----
 | 
			
		||||
            elif len(chunks) < len(msgs):
 | 
			
		||||
                extras = msgs[len(chunks):]
 | 
			
		||||
                for m in extras:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await m.delete()
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                    dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid)
 | 
			
		||||
 | 
			
		||||
    # Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh)
 | 
			
		||||
    @commands.hybrid_command(name="pirates_list_refresh", description="Rebuild the compact pirates list")
 | 
			
		||||
    @commands.cooldown(1, 10, commands.BucketType.guild)  # tiny anti-spam
 | 
			
		||||
    async def pirates_list_refresh(self, ctx: commands.Context):
 | 
			
		||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
        if not ctx.guild:
 | 
			
		||||
            return await ctx.reply("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        await self.refresh_list(ctx.guild)
 | 
			
		||||
 | 
			
		||||
        is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
 | 
			
		||||
        await ctx.reply("Pirates list refreshed.", ephemeral=is_slash)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(PiratesListCog(bot))
 | 
			
		||||
@ -1,219 +0,0 @@
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from modules.common.emoji_accept import is_accept
 | 
			
		||||
 | 
			
		||||
CHECKMARK = '✅'
 | 
			
		||||
 | 
			
		||||
class ReactionRoleCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Records agreements and manages Full Access.
 | 
			
		||||
    Nickname flow:
 | 
			
		||||
      • Add accept on nickname message -> mark agreed + pending (idempotent) and open ONE review
 | 
			
		||||
      • Remove accept on nickname message -> clear only if user has no accept reactions left
 | 
			
		||||
    Full Access: granted when Rules ✅ + RoE ✅ + Nickname *claimed* (pending or verified).
 | 
			
		||||
    Revoked when any of the three is missing.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
 | 
			
		||||
        def _i(key):
 | 
			
		||||
            try:
 | 
			
		||||
                v = cfg.get(key)
 | 
			
		||||
                return int(v) if v else 0
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return 0
 | 
			
		||||
 | 
			
		||||
        self.rules_msg_id = _i('rules_message_id')
 | 
			
		||||
        self.engage_msg_id = _i('engagement_message_id')
 | 
			
		||||
        self.nick_msg_id = _i('nickname_message_id')
 | 
			
		||||
        self.rules_role = _i('rules_role_id')
 | 
			
		||||
        self.engage_role = _i('engagement_role_id')
 | 
			
		||||
        self.full_access_role = _i('full_access_role_id')
 | 
			
		||||
 | 
			
		||||
    # ---- helpers ----
 | 
			
		||||
    def _has_rules(self, member_id: int) -> bool:
 | 
			
		||||
        return member_id in self.bot.data_manager.get('agreed_rules')
 | 
			
		||||
 | 
			
		||||
    def _has_engage(self, member_id: int) -> bool:
 | 
			
		||||
        return member_id in self.bot.data_manager.get('agreed_engagement')
 | 
			
		||||
 | 
			
		||||
    def _has_nick_claim(self, member_id: int) -> bool:
 | 
			
		||||
        """Claimed = agreed_nickname; pending/verified tracked separately."""
 | 
			
		||||
        return member_id in self.bot.data_manager.get('agreed_nickname')
 | 
			
		||||
 | 
			
		||||
    async def maybe_apply_full_access(self, member: discord.Member):
 | 
			
		||||
        """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing."""
 | 
			
		||||
        guild = member.guild
 | 
			
		||||
        role = guild.get_role(self.full_access_role) if self.full_access_role else None
 | 
			
		||||
        if not role:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id)
 | 
			
		||||
        try:
 | 
			
		||||
            if has_all and role not in member.roles:
 | 
			
		||||
                await member.add_roles(role, reason="All agreements completed (nickname may be pending)")
 | 
			
		||||
            elif not has_all and role in member.roles:
 | 
			
		||||
                await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed")
 | 
			
		||||
        except discord.Forbidden:
 | 
			
		||||
            pass
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Best-effort: refresh user card
 | 
			
		||||
        cards = self.bot.get_cog('UserCardsCog')
 | 
			
		||||
        if cards:
 | 
			
		||||
            try:
 | 
			
		||||
                await cards.refresh_card(member)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
    async def _get_member(self, guild: discord.Guild, user_id: int):
 | 
			
		||||
        if not guild:
 | 
			
		||||
            return None
 | 
			
		||||
        m = guild.get_member(user_id)
 | 
			
		||||
        if m is None:
 | 
			
		||||
            try:
 | 
			
		||||
                m = await guild.fetch_member(user_id)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return None
 | 
			
		||||
        return m
 | 
			
		||||
 | 
			
		||||
    async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool:
 | 
			
		||||
        """Return True if the user still has at least one 'accept' reaction on the message."""
 | 
			
		||||
        try:
 | 
			
		||||
            ch = guild.get_channel(channel_id)
 | 
			
		||||
            if not ch:
 | 
			
		||||
                return False
 | 
			
		||||
            msg = await ch.fetch_message(message_id)
 | 
			
		||||
            for rxn in msg.reactions:
 | 
			
		||||
                if is_accept(rxn.emoji):
 | 
			
		||||
                    async for u in rxn.users(limit=None):
 | 
			
		||||
                        if u.id == user_id:
 | 
			
		||||
                            return True
 | 
			
		||||
            return False
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    # ---- commands (hybrid = prefix + slash) ----
 | 
			
		||||
    @commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)')
 | 
			
		||||
    async def nick_same(self, ctx: commands.Context):
 | 
			
		||||
        member = ctx.author if isinstance(ctx.author, discord.Member) else None
 | 
			
		||||
        if not member or not ctx.guild:
 | 
			
		||||
            return await ctx.reply("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        if member.id not in dm.get('agreed_nickname'):
 | 
			
		||||
            dm.add('agreed_nickname', int(member.id))
 | 
			
		||||
        # Mark pending (clear verified if present)
 | 
			
		||||
        dm.remove('nick_verified', lambda x: x == member.id)
 | 
			
		||||
        newly_pending = False
 | 
			
		||||
        if member.id not in dm.get('nick_claim_pending'):
 | 
			
		||||
            dm.add('nick_claim_pending', int(member.id))
 | 
			
		||||
            newly_pending = True
 | 
			
		||||
 | 
			
		||||
        # Open/refresh a review with NickNudge (only on first transition to pending)
 | 
			
		||||
        if newly_pending:
 | 
			
		||||
            nn = self.bot.get_cog('NickNudgeCog')
 | 
			
		||||
            if nn and hasattr(nn, 'start_nick_review'):
 | 
			
		||||
                try:
 | 
			
		||||
                    await nn.start_nick_review(ctx.guild, member, source="nick_same")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        await self.maybe_apply_full_access(member)
 | 
			
		||||
        await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    # ---- listeners ----
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        if not payload.guild_id or not is_accept(payload.emoji):
 | 
			
		||||
            return
 | 
			
		||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
        member = await self._get_member(guild, payload.user_id)
 | 
			
		||||
        if not member or member.bot:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        try:
 | 
			
		||||
            if payload.message_id == self.rules_msg_id:
 | 
			
		||||
                role = guild.get_role(self.rules_role)
 | 
			
		||||
                if role:
 | 
			
		||||
                    await member.add_roles(role, reason="Agreed to rules")
 | 
			
		||||
                if member.id not in dm.get('agreed_rules'):
 | 
			
		||||
                    dm.add('agreed_rules', int(member.id))
 | 
			
		||||
 | 
			
		||||
            elif payload.message_id == self.engage_msg_id:
 | 
			
		||||
                role = guild.get_role(self.engage_role)
 | 
			
		||||
                if role:
 | 
			
		||||
                    await member.add_roles(role, reason="Agreed to engagement")
 | 
			
		||||
                if member.id not in dm.get('agreed_engagement'):
 | 
			
		||||
                    dm.add('agreed_engagement', int(member.id))
 | 
			
		||||
 | 
			
		||||
            elif payload.message_id == self.nick_msg_id:
 | 
			
		||||
                # Claim nickname via reaction -> mark agreed + pending (idempotent)
 | 
			
		||||
                newly_pending = False
 | 
			
		||||
                if member.id not in dm.get('agreed_nickname'):
 | 
			
		||||
                    dm.add('agreed_nickname', int(member.id))
 | 
			
		||||
                dm.remove('nick_verified', lambda x: x == member.id)
 | 
			
		||||
                if member.id not in dm.get('nick_claim_pending'):
 | 
			
		||||
                    dm.add('nick_claim_pending', int(member.id))
 | 
			
		||||
                    newly_pending = True
 | 
			
		||||
 | 
			
		||||
                # Only open a review when we just transitioned to pending
 | 
			
		||||
                if newly_pending:
 | 
			
		||||
                    nn = self.bot.get_cog('NickNudgeCog')
 | 
			
		||||
                    if nn and hasattr(nn, 'start_nick_review'):
 | 
			
		||||
                        try:
 | 
			
		||||
                            await nn.start_nick_review(guild, member, source="claim")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
            else:
 | 
			
		||||
                return
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        await self.maybe_apply_full_access(member)
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        if not payload.guild_id or not is_accept(payload.emoji):
 | 
			
		||||
            return
 | 
			
		||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
        member = await self._get_member(guild, payload.user_id)
 | 
			
		||||
        if not member or member.bot:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        try:
 | 
			
		||||
            if payload.message_id == self.rules_msg_id:
 | 
			
		||||
                dm.remove('agreed_rules', lambda x: x == member.id)
 | 
			
		||||
                role = guild.get_role(self.rules_role)
 | 
			
		||||
                if role:
 | 
			
		||||
                    await member.remove_roles(role, reason="Rules un-ticked")
 | 
			
		||||
 | 
			
		||||
            elif payload.message_id == self.engage_msg_id:
 | 
			
		||||
                dm.remove('agreed_engagement', lambda x: x == member.id)
 | 
			
		||||
                role = guild.get_role(self.engage_role)
 | 
			
		||||
                if role:
 | 
			
		||||
                    await member.remove_roles(role, reason="Engagement un-ticked")
 | 
			
		||||
 | 
			
		||||
            elif payload.message_id == self.nick_msg_id:
 | 
			
		||||
                # Clear only if the user has NO accept reactions left on the message
 | 
			
		||||
                still_has_accept = await self._user_has_any_accept(
 | 
			
		||||
                    guild, payload.channel_id, payload.message_id, member.id
 | 
			
		||||
                )
 | 
			
		||||
                if not still_has_accept:
 | 
			
		||||
                    dm.remove('agreed_nickname', lambda x: x == member.id)
 | 
			
		||||
                    dm.remove('nick_claim_pending', lambda x: x == member.id)
 | 
			
		||||
                    dm.remove('nick_verified', lambda x: x == member.id)
 | 
			
		||||
            else:
 | 
			
		||||
                return
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        await self.maybe_apply_full_access(member)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(ReactionRoleCog(bot))
 | 
			
		||||
@ -1,917 +0,0 @@
 | 
			
		||||
# modules/spicepay/spicepay.py
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
import asyncio
 | 
			
		||||
from typing import List, Dict, Tuple, Optional
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
# Accept both for backward compatibility; display uses "Refiner"
 | 
			
		||||
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
 | 
			
		||||
 | 
			
		||||
# ------------------------ emoji helpers ------------------------
 | 
			
		||||
 | 
			
		||||
def _emoji_str(bot: commands.Bot, emoji_id: Optional[int], fallback: str) -> str:
 | 
			
		||||
    if emoji_id:
 | 
			
		||||
        e = bot.get_emoji(int(emoji_id))
 | 
			
		||||
        if e:
 | 
			
		||||
            return str(e)
 | 
			
		||||
    return fallback
 | 
			
		||||
 | 
			
		||||
# ------------------------ core math ------------------------
 | 
			
		||||
 | 
			
		||||
def _normalize_roles(raw: str) -> List[str]:
 | 
			
		||||
    """
 | 
			
		||||
    Accepts flexible input; maps common aliases to canonical role keys.
 | 
			
		||||
    New canonical: refiner_owner (old: lsr_owner still accepted).
 | 
			
		||||
    """
 | 
			
		||||
    if not raw:
 | 
			
		||||
        return []
 | 
			
		||||
    txt = raw.strip().lower()
 | 
			
		||||
    tokens = re.split(r"[,\s]+", txt)
 | 
			
		||||
    out = set()
 | 
			
		||||
    for tok in tokens:
 | 
			
		||||
        if not tok:
 | 
			
		||||
            continue
 | 
			
		||||
        t = tok.replace("-", "").replace("_", "")
 | 
			
		||||
        if t in ("crawler", "cr", "craw", "crawlerowner"):
 | 
			
		||||
            out.add("crawler_owner")
 | 
			
		||||
        elif t in ("carrier", "ca", "car", "carrierowner"):
 | 
			
		||||
            out.add("carrier_owner")
 | 
			
		||||
        elif t in ("refiner", "refinery", "refineryowner", "refinerowner", "lsr", "lsrowner", "largespicerefinery"):
 | 
			
		||||
            out.add("refiner_owner")
 | 
			
		||||
        elif t in ("none", "no", "nil"):
 | 
			
		||||
            return []
 | 
			
		||||
    return list(out)
 | 
			
		||||
 | 
			
		||||
def calculate_spice_distribution(
 | 
			
		||||
    spice_total: int,
 | 
			
		||||
    participants: List[Dict],
 | 
			
		||||
    *,
 | 
			
		||||
    base_weight: float = 25.0,
 | 
			
		||||
    carrier_bonus: float = 12.5,
 | 
			
		||||
    crawler_bonus: float = 12.5,
 | 
			
		||||
    lsr_cut_percent: float = 10.0,   # config key name retained; UI calls it "refinery cut"
 | 
			
		||||
    lsr_required: bool = True,       # True for melange payouts
 | 
			
		||||
) -> Tuple[List[Dict], List[str]]:
 | 
			
		||||
    errors: List[str] = []
 | 
			
		||||
    result: List[Dict] = []
 | 
			
		||||
 | 
			
		||||
    if not isinstance(spice_total, int) or spice_total < 0:
 | 
			
		||||
        return [], ["Spice total must be a non-negative integer."]
 | 
			
		||||
    if base_weight < 0 or carrier_bonus < 0 or crawler_bonus < 0:
 | 
			
		||||
        return [], ["Weights must be non-negative."]
 | 
			
		||||
    if lsr_cut_percent < 0 or lsr_cut_percent > 100:
 | 
			
		||||
        return [], ["Refinery cut percent must be between 0 and 100."]
 | 
			
		||||
 | 
			
		||||
    seen_names = set()
 | 
			
		||||
    refiner_owners: List[str] = []
 | 
			
		||||
    cleaned: List[Dict] = []
 | 
			
		||||
 | 
			
		||||
    for i, p in enumerate(participants):
 | 
			
		||||
        if not isinstance(p, dict):
 | 
			
		||||
            errors.append(f"Participant at index {i} is not a dict.")
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        name = p.get('name')
 | 
			
		||||
        participation = p.get('participation', 0)
 | 
			
		||||
        roles = p.get('roles', [])
 | 
			
		||||
 | 
			
		||||
        if not name or not isinstance(name, str):
 | 
			
		||||
            errors.append(f"Participant at index {i} has an invalid or missing name.")
 | 
			
		||||
            continue
 | 
			
		||||
        name = name.strip()
 | 
			
		||||
        if name in seen_names:
 | 
			
		||||
            errors.append(f"Duplicate participant name: '{name}'.")
 | 
			
		||||
            continue
 | 
			
		||||
        seen_names.add(name)
 | 
			
		||||
 | 
			
		||||
        if isinstance(participation, str):
 | 
			
		||||
            participation = participation.strip().replace("%", "")
 | 
			
		||||
            try:
 | 
			
		||||
                participation = float(participation)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                errors.append(f"Participant '{name}' has invalid participation value.")
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
        if not isinstance(participation, (int, float)) or not (0 <= participation <= 100):
 | 
			
		||||
            errors.append(f"Participant '{name}' has invalid participation: {participation}. Must be 0–100.")
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        if isinstance(roles, str):
 | 
			
		||||
            roles = _normalize_roles(roles)
 | 
			
		||||
        if not isinstance(roles, list):
 | 
			
		||||
            errors.append(f"Participant '{name}' has invalid roles payload.")
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        invalid = [r for r in roles if r not in VALID_ROLES]
 | 
			
		||||
        if invalid:
 | 
			
		||||
            errors.append(f"Participant '{name}' has invalid roles: {invalid}.")
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        if 'refiner_owner' in roles or 'lsr_owner' in roles:
 | 
			
		||||
            refiner_owners.append(name)
 | 
			
		||||
 | 
			
		||||
        cleaned.append({'name': name, 'participation': float(participation), 'roles': roles})
 | 
			
		||||
 | 
			
		||||
    # Refiner checks depend on payout mode
 | 
			
		||||
    if lsr_required:
 | 
			
		||||
        if len(refiner_owners) > 1:
 | 
			
		||||
            errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.")
 | 
			
		||||
        elif len(refiner_owners) == 0:
 | 
			
		||||
            errors.append("No refiner owner found. Exactly one is required for melange payouts.")
 | 
			
		||||
    else:
 | 
			
		||||
        if len(refiner_owners) > 1:
 | 
			
		||||
            errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.")
 | 
			
		||||
 | 
			
		||||
    if errors:
 | 
			
		||||
        return [], errors
 | 
			
		||||
 | 
			
		||||
    refiner_owner = refiner_owners[0] if refiner_owners else None
 | 
			
		||||
    refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0
 | 
			
		||||
    distributable = spice_total - refinery_cut
 | 
			
		||||
 | 
			
		||||
    # Compute weights
 | 
			
		||||
    weights: Dict[str, float] = {}
 | 
			
		||||
    total_weight = 0.0
 | 
			
		||||
    for p in cleaned:
 | 
			
		||||
        w = float(base_weight) * (p['participation'] / 100.0)
 | 
			
		||||
        if 'carrier_owner' in p['roles']:
 | 
			
		||||
            w += float(carrier_bonus)
 | 
			
		||||
        if 'crawler_owner' in p['roles']:
 | 
			
		||||
            w += float(crawler_bonus)
 | 
			
		||||
        weights[p['name']] = w
 | 
			
		||||
        total_weight += w
 | 
			
		||||
 | 
			
		||||
    if total_weight <= 0:
 | 
			
		||||
        return [], [
 | 
			
		||||
            "Total effective weight is zero. Increase participation or weighting factors "
 | 
			
		||||
            "(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight."
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    # Proportional allocation
 | 
			
		||||
    spice_raw: Dict[str, int] = {}
 | 
			
		||||
    total_floored = 0
 | 
			
		||||
    for p in cleaned:
 | 
			
		||||
        name = p['name']
 | 
			
		||||
        w = weights.get(name, 0.0)
 | 
			
		||||
        share = int((w / total_weight) * distributable)
 | 
			
		||||
        spice_raw[name] = share
 | 
			
		||||
        total_floored += share
 | 
			
		||||
 | 
			
		||||
    remainder = distributable - total_floored
 | 
			
		||||
    if refiner_owner:
 | 
			
		||||
        spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder
 | 
			
		||||
    else:
 | 
			
		||||
        top = max(spice_raw.items(), key=lambda kv: kv[1])[0]
 | 
			
		||||
        spice_raw[top] += remainder
 | 
			
		||||
 | 
			
		||||
    result = [{
 | 
			
		||||
        'name': p['name'],
 | 
			
		||||
        'spice': int(spice_raw.get(p['name'], 0)),
 | 
			
		||||
        'participation': p['participation'],
 | 
			
		||||
        'roles': p['roles'],
 | 
			
		||||
    } for p in cleaned]
 | 
			
		||||
 | 
			
		||||
    return result, []
 | 
			
		||||
 | 
			
		||||
# ------------------------ formatting ------------------------
 | 
			
		||||
 | 
			
		||||
def _fmt_roles_short(roles: List[str]) -> str:
 | 
			
		||||
    if not roles:
 | 
			
		||||
        return "Escort"
 | 
			
		||||
    short = []
 | 
			
		||||
    for r in roles:
 | 
			
		||||
        if r in ("refiner_owner", "lsr_owner"): short.append("Refiner")
 | 
			
		||||
        elif r == "crawler_owner": short.append("Crawler")
 | 
			
		||||
        elif r == "carrier_owner": short.append("Carrier")
 | 
			
		||||
    return ",".join(short) if short else "Escort"
 | 
			
		||||
 | 
			
		||||
def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str:
 | 
			
		||||
    rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower()))
 | 
			
		||||
    any_zero = any((r.get('participation') or 0) <= 0 for r in rows)
 | 
			
		||||
 | 
			
		||||
    own_header = "Owner of/Role"
 | 
			
		||||
    name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4
 | 
			
		||||
    own_w  = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header)
 | 
			
		||||
    pay_w  = max(6, len(unit_header))
 | 
			
		||||
 | 
			
		||||
    header = f"{'Name'.ljust(name_w)}  {'Active %'.rjust(8)}  {own_header.ljust(own_w)}  {unit_header.rjust(pay_w)}"
 | 
			
		||||
    sep = "-" * len(header)
 | 
			
		||||
    lines = [header, sep]
 | 
			
		||||
 | 
			
		||||
    for r in rows:
 | 
			
		||||
        name = r['name'].ljust(name_w)
 | 
			
		||||
        pct_val = int(round(r.get('participation') or 0))
 | 
			
		||||
        pct = f"{pct_val}%"
 | 
			
		||||
        if pct_val == 0:
 | 
			
		||||
            pct += "*"  # owner-only marker
 | 
			
		||||
        pct = pct.rjust(8)
 | 
			
		||||
        owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w)
 | 
			
		||||
        amount = f"{r['spice']} {unit_suffix}"
 | 
			
		||||
        lines.append(f"{name}  {pct}  {owned}  {amount}")
 | 
			
		||||
 | 
			
		||||
    if any_zero:
 | 
			
		||||
        lines.append("")
 | 
			
		||||
        lines.append("* 0% = owner only (did not actively join the run)")
 | 
			
		||||
    return "```\n" + "\n".join(lines) + "\n```"
 | 
			
		||||
 | 
			
		||||
# -------- Modals --------
 | 
			
		||||
 | 
			
		||||
class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.pre_participants = pre_participants
 | 
			
		||||
 | 
			
		||||
        self.total_field = discord.ui.TextInput(
 | 
			
		||||
            label="Total spice yield (sand)",
 | 
			
		||||
            placeholder="e.g. 12345",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=10
 | 
			
		||||
        )
 | 
			
		||||
        self.count_field = discord.ui.TextInput(
 | 
			
		||||
            label="Participants (incl. owners)",
 | 
			
		||||
            placeholder="Total number of people (add refiner/carrier/crawler owners too)",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=45,  # allow a clearer hint
 | 
			
		||||
            default=str(pre_participants) if pre_participants is not None else None
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.total_field)
 | 
			
		||||
        self.add_item(self.count_field)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        try:
 | 
			
		||||
            total = int(self.total_field.value.strip())
 | 
			
		||||
            if total < 0:
 | 
			
		||||
                raise ValueError
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            count = int(str(self.count_field.value).strip())
 | 
			
		||||
            if not (1 <= count <= 25):
 | 
			
		||||
                raise ValueError
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        await self.cog.start_session(interaction, total, count)
 | 
			
		||||
 | 
			
		||||
class _ParticipantModal(discord.ui.Modal):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]):
 | 
			
		||||
        super().__init__(title=f"Participant {index}")
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.session_key = session_key
 | 
			
		||||
        self.index = index
 | 
			
		||||
        ex = existing or {}
 | 
			
		||||
 | 
			
		||||
        self.name = discord.ui.TextInput(
 | 
			
		||||
            label="Name",
 | 
			
		||||
            placeholder="Player name (or @mention text)",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            default=ex.get("name") if ex else None
 | 
			
		||||
        )
 | 
			
		||||
        self.part = discord.ui.TextInput(
 | 
			
		||||
            label="Active participation % (0–100)",
 | 
			
		||||
            placeholder="How much they actively joined the run (0..100; 0% = owner only)",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=45,
 | 
			
		||||
            default=str(int(ex["participation"])) if "participation" in ex else None
 | 
			
		||||
        )
 | 
			
		||||
        self.roles = discord.ui.TextInput(
 | 
			
		||||
            label="Owner of (optional)",
 | 
			
		||||
            placeholder="refiner, crawler, carrier — leave empty if escort/non-owner",
 | 
			
		||||
            required=False,
 | 
			
		||||
            max_length=64,
 | 
			
		||||
            default=",".join(ex["roles"]) if ex and ex.get("roles") else None
 | 
			
		||||
        )
 | 
			
		||||
        self.add_item(self.name)
 | 
			
		||||
        self.add_item(self.part)
 | 
			
		||||
        self.add_item(self.roles)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        name = self.name.value.strip()
 | 
			
		||||
        part_raw = str(self.part.value).strip().replace("%", "")
 | 
			
		||||
        try:
 | 
			
		||||
            participation = float(part_raw)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ Participation must be a number (0–100).", ephemeral=True)
 | 
			
		||||
        if participation < 0 or participation > 100:
 | 
			
		||||
            return await interaction.response.send_message("❌ Participation must be 0–100.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        roles = _normalize_roles(self.roles.value or "")
 | 
			
		||||
 | 
			
		||||
        idx = self.index - 1
 | 
			
		||||
        while len(sess["participants"]) < idx + 1:
 | 
			
		||||
            sess["participants"].append({})
 | 
			
		||||
        sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles}
 | 
			
		||||
 | 
			
		||||
        if sess["next_index"] == self.index and sess["next_index"] < sess["count"]:
 | 
			
		||||
            sess["next_index"] += 1
 | 
			
		||||
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", session_key):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.session_key = session_key
 | 
			
		||||
        self.amount = discord.ui.TextInput(
 | 
			
		||||
            label="Melange refinery yield (integer)",
 | 
			
		||||
            placeholder="e.g. 123",
 | 
			
		||||
            required=True,
 | 
			
		||||
            max_length=10
 | 
			
		||||
        )
 | 
			
		||||
        self.add_item(self.amount)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
        val = self.amount.value.strip()
 | 
			
		||||
        try:
 | 
			
		||||
            m = int(val)
 | 
			
		||||
            if m < 0:
 | 
			
		||||
                raise ValueError
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True)
 | 
			
		||||
        sess["melange_total"] = m
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", session_key):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.session_key = session_key
 | 
			
		||||
 | 
			
		||||
        sess = self.cog.sessions.get(session_key) or {}
 | 
			
		||||
        w = (sess.get("weights") or {})
 | 
			
		||||
        bw = w.get("base", self.cog.base_weight)
 | 
			
		||||
        cab = w.get("carrier", self.cog.carrier_bonus)
 | 
			
		||||
        crb = w.get("crawler", self.cog.crawler_bonus)
 | 
			
		||||
        lsr = w.get("lsr", self.cog.lsr_cut_percent)
 | 
			
		||||
 | 
			
		||||
        self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw))
 | 
			
		||||
        self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab))
 | 
			
		||||
        self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb))
 | 
			
		||||
        self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr))
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.base)
 | 
			
		||||
        self.add_item(self.carrier)
 | 
			
		||||
        self.add_item(self.crawler)
 | 
			
		||||
        self.add_item(self.lsr)
 | 
			
		||||
 | 
			
		||||
    async def on_submit(self, interaction: discord.Interaction):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        def _parse_nonneg(opt_str, pct=False):
 | 
			
		||||
            if not opt_str:
 | 
			
		||||
                return None
 | 
			
		||||
            s = str(opt_str).strip().replace("%", "")
 | 
			
		||||
            try:
 | 
			
		||||
                v = float(s)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return "bad"
 | 
			
		||||
            if v < 0:
 | 
			
		||||
                return "bad"
 | 
			
		||||
            if pct and (v < 0 or v > 100):
 | 
			
		||||
                return "bad"
 | 
			
		||||
            return v
 | 
			
		||||
 | 
			
		||||
        b = _parse_nonneg(self.base.value)
 | 
			
		||||
        cab = _parse_nonneg(self.carrier.value)
 | 
			
		||||
        crb = _parse_nonneg(self.crawler.value)
 | 
			
		||||
        lsr = _parse_nonneg(self.lsr.value, pct=True)
 | 
			
		||||
 | 
			
		||||
        if "bad" in (b, cab, crb, lsr):
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "❌ Invalid values. Use non-negative numbers; refinery % must be 0–100.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        w = sess.get("weights") or {}
 | 
			
		||||
        if b is not None: w["base"] = b
 | 
			
		||||
        if cab is not None: w["carrier"] = cab
 | 
			
		||||
        if crb is not None: w["crawler"] = crb
 | 
			
		||||
        if lsr is not None: w["lsr"] = lsr
 | 
			
		||||
        sess["weights"] = w
 | 
			
		||||
 | 
			
		||||
        # Soft warning for extreme values
 | 
			
		||||
        if any(v is not None and v > 1000 for v in (b, cab, crb)):
 | 
			
		||||
            try:
 | 
			
		||||
                await interaction.response.send_message(
 | 
			
		||||
                    "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
 | 
			
		||||
                    ephemeral=True
 | 
			
		||||
                )
 | 
			
		||||
            except discord.InteractionResponded:
 | 
			
		||||
                await interaction.followup.send(
 | 
			
		||||
                    "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.",
 | 
			
		||||
                    ephemeral=True
 | 
			
		||||
                )
 | 
			
		||||
            return await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
# ------------------------ Views ------------------------
 | 
			
		||||
 | 
			
		||||
class _HelpView(discord.ui.View):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", session_key: tuple):
 | 
			
		||||
        super().__init__(timeout=600)  # keep session alive
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.session_key = session_key
 | 
			
		||||
 | 
			
		||||
    async def on_timeout(self):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary)
 | 
			
		||||
    async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary)
 | 
			
		||||
    async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
 | 
			
		||||
 | 
			
		||||
class _WizardView(discord.ui.View):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", session_key: tuple):
 | 
			
		||||
        super().__init__(timeout=600)
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.session_key = session_key
 | 
			
		||||
 | 
			
		||||
    async def on_timeout(self):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0)
 | 
			
		||||
    async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
 | 
			
		||||
        idx = sess["next_index"]
 | 
			
		||||
        if idx > sess["count"]:
 | 
			
		||||
            idx = sess["count"]
 | 
			
		||||
        existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None
 | 
			
		||||
        await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing))
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0)
 | 
			
		||||
    async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
        sess["next_index"] = max(1, sess["next_index"] - 1)
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0)
 | 
			
		||||
    async def next(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
        sess["next_index"] = min(sess["count"], sess["next_index"] + 1)
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1)
 | 
			
		||||
    async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        sess = self.cog.sessions.get(self.session_key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
        sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand"
 | 
			
		||||
        if sess["payout_type"] == "melange" and not sess.get("melange_total"):
 | 
			
		||||
            return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
 | 
			
		||||
        await self.cog.render_progress(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1)
 | 
			
		||||
    async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1)
 | 
			
		||||
    async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2)
 | 
			
		||||
    async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        help_txt = (
 | 
			
		||||
            "**How the split works**\n"
 | 
			
		||||
            "• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n"
 | 
			
		||||
            "• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n"
 | 
			
		||||
            "• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n"
 | 
			
		||||
            "• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n"
 | 
			
		||||
            "• People with **0%** are treated as **owner only** (get owner bonuses/cut but didn’t actively join the run).\n"
 | 
			
		||||
            "• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n"
 | 
			
		||||
            "_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._"
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key))
 | 
			
		||||
        except discord.InteractionResponded:
 | 
			
		||||
            await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key))
 | 
			
		||||
 | 
			
		||||
    # ---------- Presets row ----------
 | 
			
		||||
    @discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3)
 | 
			
		||||
    async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.apply_preset(interaction, self.session_key, preset="owner")
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3)
 | 
			
		||||
    async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.apply_preset(interaction, self.session_key, preset="participation")
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3)
 | 
			
		||||
    async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.apply_preset(interaction, self.session_key, preset="fair")
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3)
 | 
			
		||||
    async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.apply_preset(interaction, self.session_key, preset="even")
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3)
 | 
			
		||||
    async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        await self.cog.save_user_defaults(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2)
 | 
			
		||||
    async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        await self.cog.finish_and_show(interaction, self.session_key)
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2)
 | 
			
		||||
    async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button):
 | 
			
		||||
        self.cog.sessions.pop(self.session_key, None)
 | 
			
		||||
        await interaction.response.edit_message(content="Spice pay session cancelled.", view=None)
 | 
			
		||||
 | 
			
		||||
class _StartView(discord.ui.View):
 | 
			
		||||
    def __init__(self, cog: "SpicePayCog", participants: Optional[int]):
 | 
			
		||||
        super().__init__(timeout=600)  # was 120
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.participants = participants
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary)
 | 
			
		||||
    async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.send_modal(_SetupModal(self.cog, self.participants))
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[spicepay] fallback button failed:", repr(e))
 | 
			
		||||
            await interaction.response.send_message("Still couldn’t open the modal. Do I have the right permissions here?", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
# ------------------------ Cog ------------------------
 | 
			
		||||
 | 
			
		||||
class SpicePayCog(commands.Cog):
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        self.sessions: Dict[tuple, Dict] = {}
 | 
			
		||||
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
        def _f(key, default):
 | 
			
		||||
            try:
 | 
			
		||||
                return float(cfg.get(key, str(default)))
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return float(default)
 | 
			
		||||
        self.base_weight = _f('spicepay_base_weight', 25.0)
 | 
			
		||||
        self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5)
 | 
			
		||||
        self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5)
 | 
			
		||||
        self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0)  # keep key; UI calls it refinery cut
 | 
			
		||||
 | 
			
		||||
        def _i(key):
 | 
			
		||||
            try:
 | 
			
		||||
                return int(cfg.get(key)) if cfg.get(key) else None
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return None
 | 
			
		||||
        self.emoji_sand_id = _i('emoji_sand_id')
 | 
			
		||||
        self.emoji_melange_id = _i('emoji_melange_id')
 | 
			
		||||
        self.emoji_cc_id = _i('emoji_carrier_crawler_id')
 | 
			
		||||
 | 
			
		||||
        # Session reaper (clean up sessions older than 60 minutes)
 | 
			
		||||
        self._reaper_task = asyncio.create_task(self._session_reaper())
 | 
			
		||||
 | 
			
		||||
    def cog_unload(self):
 | 
			
		||||
        try:
 | 
			
		||||
            self._reaper_task.cancel()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _session_reaper(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            now = time.time()
 | 
			
		||||
            ttl = 3600  # 60 min
 | 
			
		||||
            dead = [k for k, s in list(self.sessions.items())
 | 
			
		||||
                    if now - s.get("created_ts", now) > ttl]
 | 
			
		||||
            for k in dead:
 | 
			
		||||
                self.sessions.pop(k, None)
 | 
			
		||||
            await asyncio.sleep(300)  # sweep every 5 min
 | 
			
		||||
 | 
			
		||||
    # ----- user prefs helpers (persisted to data_file) -----
 | 
			
		||||
 | 
			
		||||
    def _get_user_prefs(self, user_id: int) -> Optional[Dict]:
 | 
			
		||||
        prefs = self.bot.data_manager.get('spicepay_prefs')
 | 
			
		||||
        for row in prefs:
 | 
			
		||||
            if row.get('user_id') == user_id:
 | 
			
		||||
                return row
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _save_user_prefs(self, user_id: int, weights: Dict[str, float]):
 | 
			
		||||
        # overwrite existing
 | 
			
		||||
        self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id)
 | 
			
		||||
        self.bot.data_manager.add('spicepay_prefs', {
 | 
			
		||||
            'user_id': user_id,
 | 
			
		||||
            'weights': {
 | 
			
		||||
                'base': float(weights.get('base', self.base_weight)),
 | 
			
		||||
                'carrier': float(weights.get('carrier', self.carrier_bonus)),
 | 
			
		||||
                'crawler': float(weights.get('crawler', self.crawler_bonus)),
 | 
			
		||||
                'lsr': float(weights.get('lsr', self.lsr_cut_percent)),
 | 
			
		||||
            },
 | 
			
		||||
            'saved_ts': time.time()
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    # ----- commands -----
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)")
 | 
			
		||||
    @app_commands.describe(
 | 
			
		||||
        participants="Total number of people involved (include owners of refiner/carrier/crawler)",
 | 
			
		||||
        force_new="Start a fresh session even if one is active"
 | 
			
		||||
    )
 | 
			
		||||
    async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False):
 | 
			
		||||
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
 | 
			
		||||
        if not force_new and key in self.sessions:
 | 
			
		||||
            return await self.render_progress(interaction, key, new_message=True)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.send_modal(_SetupModal(self, participants))
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[spicepay] send_modal failed:", repr(e))
 | 
			
		||||
            try:
 | 
			
		||||
                await interaction.response.defer(ephemeral=True, thinking=False)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            await interaction.followup.send(
 | 
			
		||||
                "Couldn’t open the setup modal automatically. Click below to start:",
 | 
			
		||||
                ephemeral=True,
 | 
			
		||||
                view=_StartView(self, participants)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session")
 | 
			
		||||
    async def spicepay_resume(self, interaction: discord.Interaction):
 | 
			
		||||
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
 | 
			
		||||
        if key in self.sessions:
 | 
			
		||||
            await self.render_progress(interaction, key, new_message=True)
 | 
			
		||||
        else:
 | 
			
		||||
            await interaction.response.send_message(
 | 
			
		||||
                "No active session found. Run **/spicepay** to start a new one.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session")
 | 
			
		||||
    async def spicepay_cancel(self, interaction: discord.Interaction):
 | 
			
		||||
        key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
 | 
			
		||||
        if key in self.sessions:
 | 
			
		||||
            self.sessions.pop(key, None)
 | 
			
		||||
            await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True)
 | 
			
		||||
        else:
 | 
			
		||||
            await interaction.response.send_message("No active spice pay session.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings")
 | 
			
		||||
    async def spicepay_config(self, interaction: discord.Interaction):
 | 
			
		||||
        txt = (
 | 
			
		||||
            "**SpicePay configuration**\n"
 | 
			
		||||
            f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n"
 | 
			
		||||
            f"- Base weight: **{self.base_weight} × active %**\n"
 | 
			
		||||
            f"- Carrier bonus: **+{self.carrier_bonus}**\n"
 | 
			
		||||
            f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
 | 
			
		||||
            "_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._"
 | 
			
		||||
        )
 | 
			
		||||
        await interaction.response.send_message(txt, ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    # ----- session helpers -----
 | 
			
		||||
 | 
			
		||||
    async def start_session(self, interaction: discord.Interaction, total: int, count: int):
 | 
			
		||||
        g = interaction.guild
 | 
			
		||||
        key = (g.id if g else 0, interaction.user.id)
 | 
			
		||||
 | 
			
		||||
        # seed weights from user prefs if present
 | 
			
		||||
        w = {}
 | 
			
		||||
        user_prefs = self._get_user_prefs(interaction.user.id)
 | 
			
		||||
        if user_prefs and isinstance(user_prefs.get('weights'), dict):
 | 
			
		||||
            w = {
 | 
			
		||||
                'base': float(user_prefs['weights'].get('base', self.base_weight)),
 | 
			
		||||
                'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)),
 | 
			
		||||
                'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)),
 | 
			
		||||
                'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)),
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        # Auto-fill initiator as participant #1 (easy to adjust)
 | 
			
		||||
        display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name
 | 
			
		||||
        participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}]
 | 
			
		||||
 | 
			
		||||
        self.sessions[key] = {
 | 
			
		||||
            "total": total,                # sand yield
 | 
			
		||||
            "count": count,
 | 
			
		||||
            "participants": participants_seed,
 | 
			
		||||
            "next_index": 1,               # stays on 1 so they can review/edit their autofill
 | 
			
		||||
            "created_ts": time.time(),
 | 
			
		||||
            "payout_type": "sand",         # 'sand' or 'melange'
 | 
			
		||||
            "melange_total": None,         # required if payout_type == 'melange'
 | 
			
		||||
            "weights": w                   # optional overrides: base, carrier, crawler, lsr(refinery)
 | 
			
		||||
        }
 | 
			
		||||
        await self.render_progress(interaction, key, new_message=True)
 | 
			
		||||
 | 
			
		||||
    async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False):
 | 
			
		||||
        sess = self.sessions.get(key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
 | 
			
		||||
        mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
 | 
			
		||||
        cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛")
 | 
			
		||||
 | 
			
		||||
        w = sess.get("weights") or {}
 | 
			
		||||
        bw = w.get("base", self.base_weight)
 | 
			
		||||
        cab = w.get("carrier", self.carrier_bonus)
 | 
			
		||||
        crb = w.get("crawler", self.crawler_bonus)
 | 
			
		||||
        lsrp = w.get("lsr", self.lsr_cut_percent)
 | 
			
		||||
 | 
			
		||||
        filled = sum(1 for p in sess["participants"] if p.get("name"))
 | 
			
		||||
        idx = sess["next_index"]
 | 
			
		||||
        total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"])))
 | 
			
		||||
 | 
			
		||||
        payout_line = f"Payout: **Sand Spice** {sand_emoji}"
 | 
			
		||||
        if sess.get("payout_type") == "melange":
 | 
			
		||||
            if sess.get("melange_total") is None:
 | 
			
		||||
                payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_"
 | 
			
		||||
            else:
 | 
			
		||||
                payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**"
 | 
			
		||||
 | 
			
		||||
        progress = (
 | 
			
		||||
            f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | "
 | 
			
		||||
            f"total active ≈ **{total_pct}%**\n"
 | 
			
		||||
            f"{payout_line}\n"
 | 
			
		||||
            f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n"
 | 
			
		||||
            f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb} — "
 | 
			
		||||
            f"normalized; only ratios matter._ {cc_emoji}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        preview_lines = []
 | 
			
		||||
        for i in range(sess["count"]):
 | 
			
		||||
            p = sess["participants"][i] if i < len(sess["participants"]) else None
 | 
			
		||||
            if p and p.get("name"):
 | 
			
		||||
                mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else ""
 | 
			
		||||
                preview_lines.append(f"{i+1}. {p['name']} — {int(p['participation'])}%{mark} — {_fmt_roles_short(p.get('roles', []))}")
 | 
			
		||||
            else:
 | 
			
		||||
                preview_lines.append(f"{i+1}. _empty_")
 | 
			
		||||
 | 
			
		||||
        content = progress + "\n" + "\n".join(preview_lines)
 | 
			
		||||
        view = _WizardView(self, key)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            if new_message:
 | 
			
		||||
                await interaction.response.send_message(content, ephemeral=True, view=view)
 | 
			
		||||
            else:
 | 
			
		||||
                await interaction.response.edit_message(content=content, view=view)
 | 
			
		||||
        except discord.InteractionResponded:
 | 
			
		||||
            await interaction.followup.send(content, ephemeral=True, view=view)
 | 
			
		||||
 | 
			
		||||
    async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str):
 | 
			
		||||
        sess = self.sessions.get(key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        if preset == "owner":
 | 
			
		||||
            w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
 | 
			
		||||
        elif preset == "participation":
 | 
			
		||||
            w = {'base': 50.0, 'carrier': 5.0,  'crawler': 5.0,  'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
 | 
			
		||||
        elif preset == "even":
 | 
			
		||||
            w = {'base': 1.0,  'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0}
 | 
			
		||||
        else:  # "fair" -> server defaults
 | 
			
		||||
            w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent}
 | 
			
		||||
 | 
			
		||||
        sess['weights'] = w
 | 
			
		||||
        await self.render_progress(interaction, key)
 | 
			
		||||
 | 
			
		||||
    async def save_user_defaults(self, interaction: discord.Interaction, key: tuple):
 | 
			
		||||
        sess = self.sessions.get(key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
        w = sess.get('weights') or {
 | 
			
		||||
            'base': self.base_weight,
 | 
			
		||||
            'carrier': self.carrier_bonus,
 | 
			
		||||
            'crawler': self.crawler_bonus,
 | 
			
		||||
            'lsr': self.lsr_cut_percent
 | 
			
		||||
        }
 | 
			
		||||
        self._save_user_prefs(interaction.user.id, w)
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True)
 | 
			
		||||
        except discord.InteractionResponded:
 | 
			
		||||
            await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    async def finish_and_show(self, interaction: discord.Interaction, key: tuple):
 | 
			
		||||
        sess = self.sessions.get(key)
 | 
			
		||||
        if not sess:
 | 
			
		||||
            return await interaction.response.send_message("Session expired.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        payout_type = sess.get("payout_type", "sand")
 | 
			
		||||
        if payout_type == "melange" and sess.get("melange_total") is None:
 | 
			
		||||
            return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        w = sess.get("weights") or {}
 | 
			
		||||
        bw = w.get("base", self.base_weight)
 | 
			
		||||
        cab = w.get("carrier", self.carrier_bonus)
 | 
			
		||||
        crb = w.get("crawler", self.crawler_bonus)
 | 
			
		||||
        lsrp = w.get("lsr", self.lsr_cut_percent)
 | 
			
		||||
 | 
			
		||||
        participants = []
 | 
			
		||||
        names_seen = set()
 | 
			
		||||
        for i in range(sess["count"]):
 | 
			
		||||
            p = sess["participants"][i] if i < len(sess["participants"]) else {}
 | 
			
		||||
            name = (p.get("name") or f"User{i+1}").strip()
 | 
			
		||||
            if name in names_seen:
 | 
			
		||||
                name = f"{name}_{i+1}"
 | 
			
		||||
            names_seen.add(name)
 | 
			
		||||
            part = p.get("participation", 0)
 | 
			
		||||
            roles = p.get("roles", [])
 | 
			
		||||
            participants.append({"name": name, "participation": part, "roles": roles})
 | 
			
		||||
 | 
			
		||||
        if payout_type == "sand":
 | 
			
		||||
            total = int(sess["total"])
 | 
			
		||||
            lsr_req = False
 | 
			
		||||
            lsr_pct = 0.0
 | 
			
		||||
            unit_header = "Sand Spice"
 | 
			
		||||
            unit_suffix = "Sand Spice"
 | 
			
		||||
            title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
 | 
			
		||||
            payout_name = "Sand Spice"
 | 
			
		||||
        else:
 | 
			
		||||
            total = int(sess["melange_total"])
 | 
			
		||||
            lsr_req = True
 | 
			
		||||
            lsr_pct = float(lsrp)
 | 
			
		||||
            unit_header = "Spice Melange"
 | 
			
		||||
            unit_suffix = "Spice Melange"
 | 
			
		||||
            title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
 | 
			
		||||
            payout_name = "Spice Melange"
 | 
			
		||||
 | 
			
		||||
        results, errors = calculate_spice_distribution(
 | 
			
		||||
            total,
 | 
			
		||||
            participants,
 | 
			
		||||
            base_weight=bw,
 | 
			
		||||
            carrier_bonus=cab,
 | 
			
		||||
            crawler_bonus=crb,
 | 
			
		||||
            lsr_cut_percent=lsr_pct,
 | 
			
		||||
            lsr_required=lsr_req
 | 
			
		||||
        )
 | 
			
		||||
        if errors:
 | 
			
		||||
            err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \
 | 
			
		||||
                       "Use **Add / Edit participant** to fix, or **Cancel**."
 | 
			
		||||
            try:
 | 
			
		||||
                await interaction.response.edit_message(content=err_text, view=_WizardView(self, key))
 | 
			
		||||
            except discord.InteractionResponded:
 | 
			
		||||
                await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        table = _format_table(results, unit_header, unit_suffix)
 | 
			
		||||
 | 
			
		||||
        class _PostView(discord.ui.View):
 | 
			
		||||
            def __init__(self, outer: "SpicePayCog"):
 | 
			
		||||
                super().__init__(timeout=600)
 | 
			
		||||
                self.outer = outer
 | 
			
		||||
 | 
			
		||||
            @discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary)
 | 
			
		||||
            async def post(self, inter: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
                header = f"**Payout: {payout_name}** {title_emoji}"
 | 
			
		||||
                settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_"
 | 
			
		||||
                if payout_type == "melange":
 | 
			
		||||
                    settings = f"_Refinery cut {lsrp}% | " + settings
 | 
			
		||||
                txt = f"{header}\n{settings}\n{table}"
 | 
			
		||||
                await inter.channel.send(txt)
 | 
			
		||||
                await inter.response.edit_message(content="Posted to channel ✅", view=None)
 | 
			
		||||
 | 
			
		||||
            @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary)
 | 
			
		||||
            async def back(self, inter: discord.Interaction, _btn: discord.ui.Button):
 | 
			
		||||
                await self.outer.render_progress(inter, key)
 | 
			
		||||
 | 
			
		||||
        content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}"
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.edit_message(content=content, view=_PostView(self))
 | 
			
		||||
        except discord.InteractionResponded:
 | 
			
		||||
            await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self))
 | 
			
		||||
 | 
			
		||||
# ------------------------ setup ------------------------
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    cog = SpicePayCog(bot)
 | 
			
		||||
    await bot.add_cog(cog)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        home_gid = 0
 | 
			
		||||
 | 
			
		||||
    if home_gid:
 | 
			
		||||
        guild_obj = discord.Object(id=home_gid)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay, guild=guild_obj)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_resume, guild=guild_obj)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_config, guild=guild_obj)
 | 
			
		||||
    else:
 | 
			
		||||
        bot.tree.add_command(cog.spicepay)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_resume)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_cancel)
 | 
			
		||||
        bot.tree.add_command(cog.spicepay_config)
 | 
			
		||||
@ -1,510 +0,0 @@
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
from typing import Optional, Set, Tuple
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from modules.common.emoji_accept import is_accept
 | 
			
		||||
 | 
			
		||||
CHECK = '✅'          # verified
 | 
			
		||||
CROSS = '❌'          # not done
 | 
			
		||||
PENDING = '✔️'        # claimed / pending review
 | 
			
		||||
ACCEPT = {CHECK, '🫡'}
 | 
			
		||||
NO_MENTIONS = discord.AllowedMentions.none()
 | 
			
		||||
 | 
			
		||||
class UserCardsCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Per-user status cards with live reconcile and offline review triggers.
 | 
			
		||||
    Title = Nickname → Display → Global → Username (adds '*' if NO server nickname)
 | 
			
		||||
    Fields: Rules / RoE = ✅/❌, Nickname = ❌ | ✔️(pending) | ✅(verified)
 | 
			
		||||
    Colors:
 | 
			
		||||
      - Green:  Rules✅ & RoE✅ & Nickname✅
 | 
			
		||||
      - Blue:   Rules✅ & RoE✅ & Nickname✔️ (pending)
 | 
			
		||||
      - Yellow: Some/partial
 | 
			
		||||
      - Red:    None
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        cfg = bot.config['DEFAULT']
 | 
			
		||||
        self.userslist_channel_id = int(cfg['userslist_channel_id'])
 | 
			
		||||
        self.modlog_channel_id = int(cfg['modlog_channel_id'])
 | 
			
		||||
        self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0)
 | 
			
		||||
 | 
			
		||||
        # reaction-role authoritative messages/roles
 | 
			
		||||
        self.rules_msg_id = int(cfg['rules_message_id'])
 | 
			
		||||
        self.engage_msg_id = int(cfg['engagement_message_id'])
 | 
			
		||||
        self.nick_msg_id = int(cfg['nickname_message_id'])
 | 
			
		||||
        self.rules_role_id = int(cfg['rules_role_id'])
 | 
			
		||||
        self.engage_role_id = int(cfg['engagement_role_id'])
 | 
			
		||||
        self.full_access_role_id = int(cfg['full_access_role_id'])
 | 
			
		||||
        self._refresh_locks = {}  # per-user locks to avoid racey double-posts
 | 
			
		||||
 | 
			
		||||
        # Optional periodic refresh (twice a day)
 | 
			
		||||
        try:
 | 
			
		||||
            self.cron_enabled = cfg.getboolean('user_cards_cron_enabled')
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.cron_enabled = False
 | 
			
		||||
 | 
			
		||||
        self._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None
 | 
			
		||||
        self._startup_task = asyncio.create_task(self._startup_reconcile())
 | 
			
		||||
 | 
			
		||||
    def cog_unload(self):
 | 
			
		||||
        for t in (self._cron_task, self._startup_task):
 | 
			
		||||
            if t:
 | 
			
		||||
                try: t.cancel()
 | 
			
		||||
                except Exception: pass
 | 
			
		||||
 | 
			
		||||
    # ---------- status helpers ----------
 | 
			
		||||
 | 
			
		||||
    def _rules_ok(self, uid: int) -> bool:
 | 
			
		||||
        return uid in self.bot.data_manager.get('agreed_rules')
 | 
			
		||||
 | 
			
		||||
    def _engage_ok(self, uid: int) -> bool:
 | 
			
		||||
        return uid in self.bot.data_manager.get('agreed_engagement')
 | 
			
		||||
 | 
			
		||||
    def _nick_status(self, uid: int) -> str:
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        if uid in dm.get('nick_verified'):
 | 
			
		||||
            return 'verified'
 | 
			
		||||
        if uid in dm.get('nick_claim_pending') or uid in dm.get('agreed_nickname'):
 | 
			
		||||
            return 'pending'
 | 
			
		||||
        return 'none'
 | 
			
		||||
 | 
			
		||||
    def _primary_name(self, m: discord.Member) -> str:
 | 
			
		||||
        has_nick = bool(m.nick and m.nick.strip())
 | 
			
		||||
        base = m.nick or m.display_name or m.global_name or m.name
 | 
			
		||||
        if not has_nick:
 | 
			
		||||
            base = f"{base} *"
 | 
			
		||||
        return base
 | 
			
		||||
 | 
			
		||||
    def _secondary_name(self, m: discord.Member) -> str:
 | 
			
		||||
        return m.global_name or m.name
 | 
			
		||||
 | 
			
		||||
    def _card_color(self, uid: int) -> discord.Color:
 | 
			
		||||
        r = self._rules_ok(uid)
 | 
			
		||||
        e = self._engage_ok(uid)
 | 
			
		||||
        n = self._nick_status(uid)
 | 
			
		||||
        if r and e and n == 'verified':
 | 
			
		||||
            return discord.Color.green()
 | 
			
		||||
        if r and e and n == 'pending':
 | 
			
		||||
            return discord.Color.blurple()
 | 
			
		||||
        if r or e or n != 'none':
 | 
			
		||||
            return discord.Color.yellow()
 | 
			
		||||
        return discord.Color.red()
 | 
			
		||||
 | 
			
		||||
    # ---------- persistence ----------
 | 
			
		||||
 | 
			
		||||
    def _get_card_record(self, user_id: int):
 | 
			
		||||
        for r in self.bot.data_manager.get('user_cards'):
 | 
			
		||||
            if r.get('user_id') == user_id:
 | 
			
		||||
                return r
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    async def _build_embed(self, member: discord.Member) -> discord.Embed:
 | 
			
		||||
        uid = member.id
 | 
			
		||||
        rules = CHECK if self._rules_ok(uid) else CROSS
 | 
			
		||||
        roe = CHECK if self._engage_ok(uid) else CROSS
 | 
			
		||||
        ns = self._nick_status(uid)
 | 
			
		||||
        if ns == 'verified':
 | 
			
		||||
            nick_val = CHECK
 | 
			
		||||
        elif ns == 'pending':
 | 
			
		||||
            nick_val = PENDING
 | 
			
		||||
        else:
 | 
			
		||||
            nick_val = CROSS
 | 
			
		||||
 | 
			
		||||
        title_left = self._primary_name(member)
 | 
			
		||||
        title_right = self._secondary_name(member)
 | 
			
		||||
        title = f"{title_left} ({title_right})"
 | 
			
		||||
 | 
			
		||||
        join_val = f"<t:{int(member.joined_at.timestamp())}:R>" if member.joined_at else "Unknown"
 | 
			
		||||
 | 
			
		||||
        embed = discord.Embed(title=title, color=self._card_color(uid))
 | 
			
		||||
        embed.add_field(name="Rules", value=rules)
 | 
			
		||||
        embed.add_field(name="RoE", value=roe)
 | 
			
		||||
        embed.add_field(name="Nickname Set", value=nick_val)
 | 
			
		||||
        embed.add_field(name="Joined", value=join_val, inline=False)
 | 
			
		||||
        if member.avatar:
 | 
			
		||||
            embed.set_thumbnail(url=member.avatar.url)
 | 
			
		||||
 | 
			
		||||
        # NEW: stable identity so we can find/edit the right card later
 | 
			
		||||
        embed.set_footer(text=f"UID:{member.id}")
 | 
			
		||||
        return embed
 | 
			
		||||
 | 
			
		||||
    async def refresh_card(self, member: discord.Member):
 | 
			
		||||
        """
 | 
			
		||||
        Idempotent refresh:
 | 
			
		||||
        1) Try to edit the tracked message.
 | 
			
		||||
        2) If not found, search the channel by footer marker and edit that.
 | 
			
		||||
        3) If still not found, post a new one, then delete any stragglers with the same marker.
 | 
			
		||||
        """
 | 
			
		||||
        if not member or not member.guild:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        async with self._lock_for(member.id):
 | 
			
		||||
            dm = self.bot.data_manager
 | 
			
		||||
            channel: discord.TextChannel = member.guild.get_channel(self.userslist_channel_id)
 | 
			
		||||
            if not channel:
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            record = self._get_card_record(member.id)
 | 
			
		||||
            embed = await self._build_embed(member)
 | 
			
		||||
 | 
			
		||||
            async def _fetch_in(ch_id: int, mid: int) -> Optional[discord.Message]:
 | 
			
		||||
                ch = member.guild.get_channel(ch_id)
 | 
			
		||||
                if not ch:
 | 
			
		||||
                    return None
 | 
			
		||||
                try:
 | 
			
		||||
                    return await ch.fetch_message(mid)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    return None
 | 
			
		||||
 | 
			
		||||
            # 1) Attempt to edit the exact tracked message (configured channel, then stored channel)
 | 
			
		||||
            msg = None
 | 
			
		||||
            stored_ch_id = int(record.get('channel_id')) if record and record.get('channel_id') else None
 | 
			
		||||
            if record and record.get('message_id'):
 | 
			
		||||
                mid = int(record['message_id'])
 | 
			
		||||
                msg = await _fetch_in(channel.id, mid)
 | 
			
		||||
                if not msg and stored_ch_id and stored_ch_id != channel.id:
 | 
			
		||||
                    msg = await _fetch_in(stored_ch_id, mid)
 | 
			
		||||
 | 
			
		||||
            # 2) If we couldn’t find by record, try to find by footer marker in channel history
 | 
			
		||||
            if not msg:
 | 
			
		||||
                msg = await self._find_existing_card(channel, member.id)
 | 
			
		||||
 | 
			
		||||
            # 3) Edit in place if found
 | 
			
		||||
            if msg:
 | 
			
		||||
                try:
 | 
			
		||||
                    await msg.edit(embed=embed, allowed_mentions=NO_MENTIONS)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
                # upsert record with the definitive location
 | 
			
		||||
                dm.remove('user_cards', lambda r: r.get('user_id') == member.id)
 | 
			
		||||
                dm.add('user_cards', {'user_id': member.id, 'message_id': msg.id, 'channel_id': msg.channel.id})
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # 4) Post fresh card
 | 
			
		||||
            new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
 | 
			
		||||
 | 
			
		||||
            # 5) Clean up any other messages that look like this user's card
 | 
			
		||||
            try:
 | 
			
		||||
                # Find any *other* occurrences with the same footer marker and delete them
 | 
			
		||||
                marker = f"UID:{member.id}"
 | 
			
		||||
                async for m in channel.history(limit=400, oldest_first=False):
 | 
			
		||||
                    if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds:
 | 
			
		||||
                        continue
 | 
			
		||||
                    foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else ""
 | 
			
		||||
                    if foot == marker:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await m.delete()
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            # 6) Persist mapping
 | 
			
		||||
            dm.remove('user_cards', lambda r: r.get('user_id') == member.id)
 | 
			
		||||
            dm.add('user_cards', {'user_id': member.id, 'message_id': new_msg.id, 'channel_id': new_msg.channel.id})
 | 
			
		||||
 | 
			
		||||
    def _lock_for(self, user_id: int) -> asyncio.Lock:
 | 
			
		||||
        lk = self._refresh_locks.get(user_id)
 | 
			
		||||
        if not lk:
 | 
			
		||||
            lk = asyncio.Lock()
 | 
			
		||||
            self._refresh_locks[user_id] = lk
 | 
			
		||||
        return lk
 | 
			
		||||
    
 | 
			
		||||
    async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]:
 | 
			
		||||
        """Search recent history for a card we posted for this user (by footer marker)."""
 | 
			
		||||
        marker = f"UID:{user_id}"
 | 
			
		||||
        try:
 | 
			
		||||
            async for m in channel.history(limit=400, oldest_first=False):
 | 
			
		||||
                if m.author.id != self.bot.user.id:
 | 
			
		||||
                    continue
 | 
			
		||||
                if not m.embeds:
 | 
			
		||||
                    continue
 | 
			
		||||
                foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else ""
 | 
			
		||||
                if foot == marker:
 | 
			
		||||
                    return m
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    async def _log(self, guild: discord.Guild, content: str):
 | 
			
		||||
        ch = guild.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if ch:
 | 
			
		||||
            try: await ch.send(content, allowed_mentions=NO_MENTIONS)
 | 
			
		||||
            except Exception: pass
 | 
			
		||||
        self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
 | 
			
		||||
 | 
			
		||||
    # ---------- RR message lookup & reactor collection ----------
 | 
			
		||||
 | 
			
		||||
    def _get_cached_msg_channel_id(self, guild_id: int, message_id: int) -> Optional[int]:
 | 
			
		||||
        for r in self.bot.data_manager.get('rr_msg_channels'):
 | 
			
		||||
            if r.get('guild_id') == guild_id and r.get('message_id') == message_id:
 | 
			
		||||
                return int(r.get('channel_id'))
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _cache_msg_channel_id(self, guild_id: int, message_id: int, channel_id: int):
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        dm.remove('rr_msg_channels', lambda r: r.get('guild_id') == guild_id and r.get('message_id') == message_id)
 | 
			
		||||
        dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)})
 | 
			
		||||
 | 
			
		||||
    async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]:
 | 
			
		||||
        ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
 | 
			
		||||
        if ch_id:
 | 
			
		||||
            ch = guild.get_channel(ch_id)
 | 
			
		||||
            if ch:
 | 
			
		||||
                try:
 | 
			
		||||
                    return await ch.fetch_message(message_id)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
        for ch in guild.text_channels:
 | 
			
		||||
            try:
 | 
			
		||||
                msg = await ch.fetch_message(message_id)
 | 
			
		||||
                self._cache_msg_channel_id(guild.id, message_id, ch.id)
 | 
			
		||||
                return msg
 | 
			
		||||
            except (discord.Forbidden, discord.NotFound, discord.HTTPException):
 | 
			
		||||
                continue
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    async def _collect_reactors(self, message: Optional[discord.Message]) -> Set[int]:
 | 
			
		||||
        ids: Set[int] = set()
 | 
			
		||||
        if not message:
 | 
			
		||||
            return ids
 | 
			
		||||
        for rxn in message.reactions:
 | 
			
		||||
            if is_accept(rxn.emoji):
 | 
			
		||||
                async for u in rxn.users(limit=None):
 | 
			
		||||
                    if not u.bot:
 | 
			
		||||
                        ids.add(u.id)
 | 
			
		||||
        return ids
 | 
			
		||||
 | 
			
		||||
    # ---------- reconcile (creates reviews for offline nickname claims) ----------
 | 
			
		||||
 | 
			
		||||
    async def _open_review_fallback(self, guild: discord.Guild, member: discord.Member, source: str):
 | 
			
		||||
        """If NickNudgeCog.start_nick_review is missing, post a simple review to mod channel."""
 | 
			
		||||
        if not self.mod_channel_id:
 | 
			
		||||
            return
 | 
			
		||||
        ch = guild.get_channel(self.mod_channel_id)
 | 
			
		||||
        if not ch:
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            await ch.send(
 | 
			
		||||
                f"📝 **Nickname claim review (fallback)**\n"
 | 
			
		||||
                f"User: {member.mention} (`{member.id}`)\n"
 | 
			
		||||
                f"Source: `{source}`\n"
 | 
			
		||||
                f"Current name: **{self._primary_name(member)}**\n"
 | 
			
		||||
                f"Please verify and handle manually.",
 | 
			
		||||
                allowed_mentions=NO_MENTIONS
 | 
			
		||||
            )
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _reconcile_agreements(self, guild: discord.Guild) -> Tuple[int, int, int, int]:
 | 
			
		||||
        """
 | 
			
		||||
        Reconcile from live reaction messages.
 | 
			
		||||
        Returns (rules_changed, engage_changed, nick_added_claims, nick_removed_claims).
 | 
			
		||||
        Also opens nickname reviews for any **unreviewed** claimers (startup/offline).
 | 
			
		||||
        """
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        rules_msg = await self._get_message_by_id(guild, self.rules_msg_id)
 | 
			
		||||
        engage_msg = await self._get_message_by_id(guild, self.engage_msg_id)
 | 
			
		||||
        nick_msg = await self._get_message_by_id(guild, self.nick_msg_id)
 | 
			
		||||
 | 
			
		||||
        new_rules = await self._collect_reactors(rules_msg)
 | 
			
		||||
        new_engage = await self._collect_reactors(engage_msg)
 | 
			
		||||
        new_nick = await self._collect_reactors(nick_msg)
 | 
			
		||||
 | 
			
		||||
        old_rules = set(dm.get('agreed_rules'))
 | 
			
		||||
        old_engage = set(dm.get('agreed_engagement'))
 | 
			
		||||
        old_nick = set(dm.get('agreed_nickname'))
 | 
			
		||||
 | 
			
		||||
        rules_added, rules_removed = new_rules - old_rules, old_rules - new_rules
 | 
			
		||||
        engage_added, engage_removed = new_engage - old_engage, old_engage - new_engage
 | 
			
		||||
        nick_added, nick_removed = new_nick - old_nick, old_nick - new_nick
 | 
			
		||||
 | 
			
		||||
        # Persist live truth for rules/engagement/nickname claim (reaction path)
 | 
			
		||||
        if old_rules != new_rules:
 | 
			
		||||
            dm.remove('agreed_rules', lambda _x: True)
 | 
			
		||||
            for uid in new_rules:
 | 
			
		||||
                dm.add('agreed_rules', int(uid))
 | 
			
		||||
 | 
			
		||||
        if old_engage != new_engage:
 | 
			
		||||
            dm.remove('agreed_engagement', lambda _x: True)
 | 
			
		||||
            for uid in new_engage:
 | 
			
		||||
                dm.add('agreed_engagement', int(uid))
 | 
			
		||||
 | 
			
		||||
        if old_nick != new_nick:
 | 
			
		||||
            dm.remove('agreed_nickname', lambda _x: True)
 | 
			
		||||
            for uid in new_nick:
 | 
			
		||||
                dm.add('agreed_nickname', int(uid))
 | 
			
		||||
 | 
			
		||||
        # Apply/repair roles for Rules/Engagement
 | 
			
		||||
        rules_role = guild.get_role(self.rules_role_id)
 | 
			
		||||
        engage_role = guild.get_role(self.engage_role_id)
 | 
			
		||||
        for uid in (rules_added | rules_removed | engage_added | engage_removed):
 | 
			
		||||
            m = guild.get_member(uid)
 | 
			
		||||
            if not m or m.bot:
 | 
			
		||||
                continue
 | 
			
		||||
            if rules_role:
 | 
			
		||||
                try:
 | 
			
		||||
                    if uid in new_rules and rules_role not in m.roles:
 | 
			
		||||
                        await m.add_roles(rules_role, reason="Reconcile: reacted to Rules ✅")
 | 
			
		||||
                    elif uid not in new_rules and rules_role in m.roles:
 | 
			
		||||
                        await m.remove_roles(rules_role, reason="Reconcile: removed Rules ✅")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
            if engage_role:
 | 
			
		||||
                try:
 | 
			
		||||
                    if uid in new_engage and engage_role not in m.roles:
 | 
			
		||||
                        await m.add_roles(engage_role, reason="Reconcile: reacted to RoE ✅")
 | 
			
		||||
                    elif uid not in new_engage and engage_role in m.roles:
 | 
			
		||||
                        await m.remove_roles(engage_role, reason="Reconcile: removed RoE ✅")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        # --- New part: open reviews for *any* unreviewed claimers (startup/offline) ---
 | 
			
		||||
        nn = self.bot.get_cog('NickNudgeCog')
 | 
			
		||||
 | 
			
		||||
        verified_set = set(dm.get('nick_verified'))
 | 
			
		||||
        pending_set = set(dm.get('nick_claim_pending'))
 | 
			
		||||
        open_review_user_ids = {
 | 
			
		||||
            int(r['user_id'])
 | 
			
		||||
            for r in dm.get('nick_reviews')
 | 
			
		||||
            if r.get('guild_id') == guild.id and r.get('status') == 'pending'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Anyone who currently claims (reacted) but isn't verified/pending/under review
 | 
			
		||||
        need_review = [
 | 
			
		||||
            uid for uid in new_nick
 | 
			
		||||
            if uid not in verified_set and uid not in pending_set and uid not in open_review_user_ids
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for uid in need_review:
 | 
			
		||||
            dm.add('nick_claim_pending', int(uid))
 | 
			
		||||
            m = guild.get_member(uid)
 | 
			
		||||
            if m and not m.bot:
 | 
			
		||||
                if nn and hasattr(nn, 'start_nick_review'):
 | 
			
		||||
                    try:
 | 
			
		||||
                        await nn.start_nick_review(guild, m, source="claim_offline")
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
                else:
 | 
			
		||||
                    await self._open_review_fallback(guild, m, source="claim_offline")
 | 
			
		||||
 | 
			
		||||
        # Anyone who *lost* their reaction -> clear pending/verified
 | 
			
		||||
        for uid in nick_removed:
 | 
			
		||||
            dm.remove('nick_claim_pending', lambda x: x == uid)
 | 
			
		||||
            dm.remove('nick_verified', lambda x: x == uid)
 | 
			
		||||
 | 
			
		||||
        # Nudge roles/full access via ReactionRoleCog for anyone impacted
 | 
			
		||||
        rr = self.bot.get_cog('ReactionRoleCog')
 | 
			
		||||
        impacted = (rules_added | rules_removed | engage_added | engage_removed |
 | 
			
		||||
                    set(need_review) | nick_removed)
 | 
			
		||||
        if rr:
 | 
			
		||||
            for uid in impacted:
 | 
			
		||||
                m = guild.get_member(uid)
 | 
			
		||||
                if m and not m.bot:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await rr.maybe_apply_full_access(m)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
        else:
 | 
			
		||||
            for uid in impacted:
 | 
			
		||||
                m = guild.get_member(uid)
 | 
			
		||||
                if m and not m.bot:
 | 
			
		||||
                    try:
 | 
			
		||||
                        await self.refresh_card(m)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
            len(rules_added) + len(rules_removed),
 | 
			
		||||
            len(engage_added) + len(engage_removed),
 | 
			
		||||
            len(nick_added),
 | 
			
		||||
            len(nick_removed),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # ---------- listeners ----------
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_member_join(self, member: discord.Member):
 | 
			
		||||
        await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})")
 | 
			
		||||
        await self.refresh_card(member)
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_member_update(self, before: discord.Member, after: discord.Member):
 | 
			
		||||
        if before.nick != after.nick or before.roles != after.roles:
 | 
			
		||||
            await self.refresh_card(after)
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_user_update(self, before: discord.User, after: discord.User):
 | 
			
		||||
        if before.global_name == after.global_name and before.name == after.name:
 | 
			
		||||
            return
 | 
			
		||||
        for g in self.bot.guilds:
 | 
			
		||||
            m = g.get_member(after.id)
 | 
			
		||||
            if m:
 | 
			
		||||
                try: await self.refresh_card(m)
 | 
			
		||||
                except Exception: pass
 | 
			
		||||
 | 
			
		||||
    # ---------- periodic + startup ----------
 | 
			
		||||
 | 
			
		||||
    async def _startup_reconcile(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        for g in list(self.bot.guilds):
 | 
			
		||||
            try:
 | 
			
		||||
                await self._reconcile_agreements(g)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        for g in list(self.bot.guilds):
 | 
			
		||||
            for m in g.members:
 | 
			
		||||
                if not m.bot:
 | 
			
		||||
                    try: await self.refresh_card(m)
 | 
			
		||||
                    except Exception: pass
 | 
			
		||||
 | 
			
		||||
    async def _periodic_refresh(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            try:
 | 
			
		||||
                for g in self.bot.guilds:
 | 
			
		||||
                    try: await self._reconcile_agreements(g)
 | 
			
		||||
                    except Exception: pass
 | 
			
		||||
                    for m in g.members:
 | 
			
		||||
                        if not m.bot:
 | 
			
		||||
                            await self.refresh_card(m)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            await asyncio.sleep(12 * 60 * 60)  # twice a day
 | 
			
		||||
 | 
			
		||||
    # ---------- mod command: rescan + live reconcile ----------
 | 
			
		||||
 | 
			
		||||
    @commands.hybrid_command(
 | 
			
		||||
        name="usercards_rescan",
 | 
			
		||||
        description="Re-check all users and refresh cards (mod-only)"
 | 
			
		||||
    )
 | 
			
		||||
    @commands.has_permissions(manage_guild=True)
 | 
			
		||||
    async def usercards_rescan(self, ctx: commands.Context):
 | 
			
		||||
        g = ctx.guild
 | 
			
		||||
        if not g:
 | 
			
		||||
            return await ctx.reply("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        rchg, echg, nadd, nrem = await self._reconcile_agreements(g)
 | 
			
		||||
 | 
			
		||||
        # Rebuild cards
 | 
			
		||||
        updated = 0
 | 
			
		||||
        for m in g.members:
 | 
			
		||||
            if not m.bot:
 | 
			
		||||
                try:
 | 
			
		||||
                    await self.refresh_card(m)
 | 
			
		||||
                    updated += 1
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        await ctx.reply(
 | 
			
		||||
            f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, "
 | 
			
		||||
            f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. "
 | 
			
		||||
            f"Refreshed cards for **{updated}** members.",
 | 
			
		||||
            ephemeral=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(UserCardsCog(bot))
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
discord.py>=2.3.2
 | 
			
		||||
python-dotenv
 | 
			
		||||
@ -1,59 +0,0 @@
 | 
			
		||||
[DEFAULT]
 | 
			
		||||
### Reaction gating messages (✅ reactions)
 | 
			
		||||
rules_message_id       = 1396831304460402738
 | 
			
		||||
engagement_message_id  = 1397668657143742574
 | 
			
		||||
nickname_message_id    = 1403513532108247141
 | 
			
		||||
 | 
			
		||||
### Roles
 | 
			
		||||
rules_role_id          = 1403146506596253817
 | 
			
		||||
engagement_role_id     = 1403146604894224458
 | 
			
		||||
full_access_role_id    = 1403146645121667082
 | 
			
		||||
# Moderator roles (IDs)
 | 
			
		||||
admin_role_id = 1402000098476425246
 | 
			
		||||
field_mod_role_id = 1402001335041261681
 | 
			
		||||
intel_mod_role_id = 1402001000327417946
 | 
			
		||||
moderator_role_id = 1396828779015573598
 | 
			
		||||
 | 
			
		||||
### Channels
 | 
			
		||||
# for approving pirate reports
 | 
			
		||||
mod_channel_id         = 1403139701522698240
 | 
			
		||||
# for join/nickname logs
 | 
			
		||||
modlog_channel_id      = 1403146993198436627
 | 
			
		||||
# where user cards live
 | 
			
		||||
userslist_channel_id   = 1403146908385542215
 | 
			
		||||
# optional public notifications
 | 
			
		||||
report_channel_id      = 1403147077285843034
 | 
			
		||||
# pirates list channel
 | 
			
		||||
pirates_list_channel_id = 1403147077285843034
 | 
			
		||||
 | 
			
		||||
### Auto-VC settings
 | 
			
		||||
trigger_channel_id     = 1403139044174594190
 | 
			
		||||
auto_vc_category_id    = 1403138882958266428
 | 
			
		||||
vc_name_prefix         = DD Crew 
 | 
			
		||||
auto_vc_cleanup_delay  = 30
 | 
			
		||||
 | 
			
		||||
threat_w_kill          = 0.30
 | 
			
		||||
threat_w_destruction   = 0.40
 | 
			
		||||
threat_w_group         = 0.20
 | 
			
		||||
threat_w_skill         = 0.10
 | 
			
		||||
threat_group_threshold = 3
 | 
			
		||||
threat_min_samples_for_stats = 3
 | 
			
		||||
 | 
			
		||||
data_file              = /data/data.json
 | 
			
		||||
release_version        = false
 | 
			
		||||
nick_nudge_loop_enabled = false
 | 
			
		||||
home_guild_id          = 1396826999095427253
 | 
			
		||||
user_cards_cron_enabled = true
 | 
			
		||||
 | 
			
		||||
# LSR Owner Processing Fee in %
 | 
			
		||||
spicepay_lsr_cut_percent = 10
 | 
			
		||||
# Default participation weight
 | 
			
		||||
spicepay_base_weight     = 25
 | 
			
		||||
# Carrier owner weight bonus
 | 
			
		||||
spicepay_carrier_bonus   = 12.5
 | 
			
		||||
# Crawler owner weight bonus
 | 
			
		||||
spicepay_crawler_bonus   = 12.5
 | 
			
		||||
# Optional emojis
 | 
			
		||||
emoji_melange_id = 1401965356775510210
 | 
			
		||||
emoji_sand_id = 1401965308805255310
 | 
			
		||||
emoji_carrier_crawler_id = 1402285453037666386
 | 
			
		||||
							
								
								
									
										4
									
								
								wrapper/.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								wrapper/.env
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
VOL_DATA=shaiwatcher_shaiwatcher_data
 | 
			
		||||
VOL_CACHE=shaiwatcher_shaiwatcher_cache
 | 
			
		||||
WITH_PLAYWRIGHT=1
 | 
			
		||||
PLAYWRIGHT_BROWSERS_PATH=/cache/pw-browsers
 | 
			
		||||
							
								
								
									
										46
									
								
								wrapper/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								wrapper/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
version: "3.9"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  shaiwatcher:
 | 
			
		||||
    build:
 | 
			
		||||
      context: .
 | 
			
		||||
      dockerfile: dockerfile
 | 
			
		||||
      args:
 | 
			
		||||
        WITH_PLAYWRIGHT: ${WITH_PLAYWRIGHT:-1}
 | 
			
		||||
    container_name: shaiwatcher
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    environment:
 | 
			
		||||
      DISCORD_TOKEN: ${DISCORD_TOKEN}
 | 
			
		||||
      HOME_GUILD_ID: ${HOME_GUILD_ID}
 | 
			
		||||
      DATA_FILE: ${DATA_FILE:-/data/data.json}
 | 
			
		||||
      DOCS_HOST_IP: ${DOCS_HOST_IP:-0.0.0.0}
 | 
			
		||||
      DOCS_HOST_PORT: ${DOCS_HOST_PORT:-8910}
 | 
			
		||||
 | 
			
		||||
      # Wrapper controls
 | 
			
		||||
      REPO_URL: ${REPO_URL:-https://git.rolfsvaag.no/frarol96/shaiwatcher.git}
 | 
			
		||||
      REPO_BRANCH: ${REPO_BRANCH:-main}
 | 
			
		||||
      REPO_AHTOKEN: ${REPO_AHTOKEN:-}
 | 
			
		||||
      CHECK_TIME_UTC: ${CHECK_TIME_UTC:-03:00}
 | 
			
		||||
      IGNORE_TEST_LEVEL: ${IGNORE_TEST_LEVEL:-1}
 | 
			
		||||
 | 
			
		||||
      # Wrapper knobs
 | 
			
		||||
      PIP_INSTALL_REQUIREMENTS: ${PIP_INSTALL_REQUIREMENTS:-1}
 | 
			
		||||
      WRAPPER_STOP_TIMEOUT: ${WRAPPER_STOP_TIMEOUT:-25}
 | 
			
		||||
 | 
			
		||||
      # Playwright & locale
 | 
			
		||||
      WITH_PLAYWRIGHT: ${WITH_PLAYWRIGHT:-1}
 | 
			
		||||
      PLAYWRIGHT_BROWSERS_PATH: ${PLAYWRIGHT_BROWSERS_PATH:-/cache/pw-browsers}
 | 
			
		||||
      LANG: ${LANG:-C.UTF-8}
 | 
			
		||||
 | 
			
		||||
    volumes:
 | 
			
		||||
      - data:/data
 | 
			
		||||
      - cache:/cache
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  data:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${VOL_DATA:-shaiwatcher_shaiwatcher_data}   # <-- exact existing name
 | 
			
		||||
  cache:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${VOL_CACHE:-shaiwatcher_shaiwatcher_cache} # <-- exact existing name
 | 
			
		||||
							
								
								
									
										53
									
								
								wrapper/docker-entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								wrapper/docker-entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,53 @@
 | 
			
		||||
#!/usr/bin/env bash
 | 
			
		||||
set -euo pipefail
 | 
			
		||||
 | 
			
		||||
# --- ENV defaults (overridden by stack/Portainer) ---
 | 
			
		||||
REPO_URL="${REPO_URL:-https://git.example.com/owner/shaiwatcher.git}"
 | 
			
		||||
REPO_BRANCH="${REPO_BRANCH:-main}"
 | 
			
		||||
 | 
			
		||||
# new names only; these are here just for echo and sanity
 | 
			
		||||
DATA_FILE="${DATA_FILE:-/data/data.json}"
 | 
			
		||||
PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/cache/pw-browsers}"
 | 
			
		||||
 | 
			
		||||
echo "[Wrapper] Repo: $REPO_URL @ $REPO_BRANCH"
 | 
			
		||||
echo "[Wrapper] DATA_FILE: $DATA_FILE"
 | 
			
		||||
echo "[Wrapper] PLAYWRIGHT_BROWSERS_PATH: $PLAYWRIGHT_BROWSERS_PATH"
 | 
			
		||||
 | 
			
		||||
mkdir -p /data /app/runtime "$PLAYWRIGHT_BROWSERS_PATH"
 | 
			
		||||
 | 
			
		||||
# --- fetch repo to /app/runtime/repo ---
 | 
			
		||||
if [ ! -d /app/runtime/repo/.git ]; then
 | 
			
		||||
  echo "[Wrapper] Cloning repo..."
 | 
			
		||||
  git clone --depth=1 -b "$REPO_BRANCH" "$REPO_URL" /app/runtime/repo
 | 
			
		||||
else
 | 
			
		||||
  echo "[Wrapper] Updating repo..."
 | 
			
		||||
  git -C /app/runtime/repo fetch origin "$REPO_BRANCH" --depth=1
 | 
			
		||||
  git -C /app/runtime/repo reset --hard "origin/$REPO_BRANCH"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
cd /app/runtime/repo
 | 
			
		||||
 | 
			
		||||
# --- Python deps (from repo) ---
 | 
			
		||||
if [ -f requirements.txt ]; then
 | 
			
		||||
  echo "[Wrapper] Installing requirements..."
 | 
			
		||||
  pip install --no-cache-dir -r requirements.txt
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# --- Playwright browsers (idempotent) ---
 | 
			
		||||
if python - <<'PY' >/dev/null 2>&1; then
 | 
			
		||||
import importlib, sys
 | 
			
		||||
sys.exit(0 if importlib.util.find_spec("playwright") else 1)
 | 
			
		||||
PY
 | 
			
		||||
then
 | 
			
		||||
  if ! find "$PLAYWRIGHT_BROWSERS_PATH" -type f -path "*/chrome-linux/chrome" -print -quit | grep -q . ; then
 | 
			
		||||
    echo "[Wrapper] Preparing Playwright runtime in $PLAYWRIGHT_BROWSERS_PATH ..."
 | 
			
		||||
    python -m playwright install-deps chromium || true
 | 
			
		||||
    python -m playwright install chromium
 | 
			
		||||
  fi
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# ensure DATA_FILE propagates into the child
 | 
			
		||||
export DATA_FILE
 | 
			
		||||
 | 
			
		||||
echo "[Wrapper] Launching bot..."
 | 
			
		||||
exec python -u bot.py
 | 
			
		||||
							
								
								
									
										30
									
								
								wrapper/dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								wrapper/dockerfile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
FROM python:3.11-slim
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
 | 
			
		||||
# Base tools
 | 
			
		||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && \
 | 
			
		||||
    rm -rf /var/lib/apt/lists/*
 | 
			
		||||
 | 
			
		||||
RUN pip install --no-cache-dir pip setuptools wheel
 | 
			
		||||
 | 
			
		||||
# Playwright OS deps (Debian runtime libs for Chromium)
 | 
			
		||||
ARG WITH_PLAYWRIGHT=1
 | 
			
		||||
RUN if [ "$WITH_PLAYWRIGHT" = "1" ]; then \
 | 
			
		||||
      set -eux; apt-get update && apt-get install -y --no-install-recommends \
 | 
			
		||||
        libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 \
 | 
			
		||||
        libx11-xcb1 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
 | 
			
		||||
        libgbm1 libgtk-3-0 libpango-1.0-0 libcairo2 libasound2 \
 | 
			
		||||
        libdrm2 libxss1 libxshmfence1 libxext6 libxi6 libxkbcommon0 \
 | 
			
		||||
        xdg-utils fonts-liberation wget \
 | 
			
		||||
        && rm -rf /var/lib/apt/lists/*; \
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
# Wrapper deps (keep playwright here even if repo requirements don't list it)
 | 
			
		||||
RUN pip install --no-cache-dir playwright
 | 
			
		||||
 | 
			
		||||
# Wrapper files
 | 
			
		||||
COPY wrapper.py /app/wrapper.py
 | 
			
		||||
COPY start.sh   /app/start.sh
 | 
			
		||||
RUN chmod +x /app/start.sh
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["/app/start.sh"]
 | 
			
		||||
							
								
								
									
										4
									
								
								wrapper/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								wrapper/requirements.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
discord.py>=2.5.2
 | 
			
		||||
python-dotenv>=1.0.1
 | 
			
		||||
aiohttp>=3.9,<4
 | 
			
		||||
playwright==1.45.0
 | 
			
		||||
							
								
								
									
										28
									
								
								wrapper/start.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								wrapper/start.sh
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
#!/usr/bin/env bash
 | 
			
		||||
set -euo pipefail
 | 
			
		||||
 | 
			
		||||
export DATA_FILE="${DATA_FILE:-/data/data.json}"
 | 
			
		||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/cache/pw-browsers}"
 | 
			
		||||
export WITH_PLAYWRIGHT="${WITH_PLAYWRIGHT:-1}"
 | 
			
		||||
 | 
			
		||||
mkdir -p "$(dirname "$DATA_FILE")" /cache "$PLAYWRIGHT_BROWSERS_PATH"
 | 
			
		||||
 | 
			
		||||
# Install browser binaries once (idempotent)
 | 
			
		||||
if [ "$WITH_PLAYWRIGHT" = "1" ]; then
 | 
			
		||||
  python - <<'PY' || true
 | 
			
		||||
import importlib, os, subprocess, sys, pathlib
 | 
			
		||||
ok = importlib.util.find_spec("playwright") is not None
 | 
			
		||||
if ok:
 | 
			
		||||
    bpath = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "/cache/pw-browsers")
 | 
			
		||||
    p = pathlib.Path(bpath)
 | 
			
		||||
    need = not any(p.glob("*chromium*"))
 | 
			
		||||
    if need:
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run([sys.executable, "-m", "playwright", "install-deps", "chromium"], check=False)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"])
 | 
			
		||||
PY
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
exec python -u /app/wrapper.py
 | 
			
		||||
							
								
								
									
										190
									
								
								wrapper/wrapper.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								wrapper/wrapper.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,190 @@
 | 
			
		||||
import os, sys, time, shutil, subprocess, json, pathlib, re, datetime
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
REPO_URL    = os.getenv("REPO_URL", "https://git.rolfsvaag.no/frarol96/shaiwatcher.git").strip()
 | 
			
		||||
REPO_BRANCH = os.getenv("REPO_BRANCH", "main").strip()
 | 
			
		||||
REPO_TOKEN  = os.getenv("REPO_AHTOKEN", os.getenv("REPO_TOKEN", "")).strip()
 | 
			
		||||
 | 
			
		||||
CHECK_TIME_UTC = os.getenv("CHECK_TIME_UTC", "03:00").strip()
 | 
			
		||||
IGNORE_TEST_LEVEL = os.getenv("IGNORE_TEST_LEVEL", "1").strip() == "1"
 | 
			
		||||
PIP_INSTALL = os.getenv("PIP_INSTALL_REQUIREMENTS", "1").strip() == "1"
 | 
			
		||||
 | 
			
		||||
CACHE_DIR = pathlib.Path("/cache/app")
 | 
			
		||||
TMP_DIR   = pathlib.Path("/cache/tmp")
 | 
			
		||||
PREV_DIR  = pathlib.Path("/cache/prev")
 | 
			
		||||
DATA_DIR  = pathlib.Path("/data")
 | 
			
		||||
 | 
			
		||||
RUN_TIMEOUT = int(os.getenv("WRAPPER_STOP_TIMEOUT", "25"))
 | 
			
		||||
ROLLBACK_MAX_FAILS = 3
 | 
			
		||||
 | 
			
		||||
def log(m): print(f"[wrapper] {m}", flush=True)
 | 
			
		||||
def run(*cmd, cwd=None, check=True): 
 | 
			
		||||
    log(f"$ {' '.join(cmd)}")
 | 
			
		||||
    return subprocess.run(cmd, cwd=cwd, check=check, text=True, capture_output=True)
 | 
			
		||||
 | 
			
		||||
def ensure_git():
 | 
			
		||||
    try: run("git","--version")
 | 
			
		||||
    except subprocess.CalledProcessError as e:
 | 
			
		||||
        log(f"git missing? {e.stderr}"); sys.exit(1)
 | 
			
		||||
 | 
			
		||||
def utc_now(): return datetime.datetime.utcnow()
 | 
			
		||||
def next_utc(hhmm: str) -> float:
 | 
			
		||||
    hh, mm = map(int, hhmm.split(":"))
 | 
			
		||||
    now = utc_now()
 | 
			
		||||
    tgt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
 | 
			
		||||
    if tgt <= now: tgt += datetime.timedelta(days=1)
 | 
			
		||||
    return (tgt - now).total_seconds()
 | 
			
		||||
 | 
			
		||||
_VER_RE = re.compile(r'^\s*VERSION\s*=\s*[\'"]([^\'"]+)[\'"]', re.M)
 | 
			
		||||
def extract_version_from(path: pathlib.Path) -> str:
 | 
			
		||||
    try:
 | 
			
		||||
        txt = path.read_text(encoding="utf-8", errors="ignore")
 | 
			
		||||
        m = _VER_RE.search(txt)
 | 
			
		||||
        return m.group(1).strip() if m else "v0.0.0.0"
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return "v0.0.0.0"
 | 
			
		||||
 | 
			
		||||
def parse_version(ver: str):
 | 
			
		||||
    test = "-T" in ver
 | 
			
		||||
    core = ver.split("-T")[0].lstrip("v")
 | 
			
		||||
    parts = [int(p or 0) for p in core.split(".")+["0","0","0","0"]][:4]
 | 
			
		||||
    return (*parts, test)
 | 
			
		||||
 | 
			
		||||
def should_update(old, new):
 | 
			
		||||
    oM,oE,oN,oP,ot = parse_version(old)
 | 
			
		||||
    nM,nE,nN,nP,nt = parse_version(new)
 | 
			
		||||
    if (oM,oE,oN,oP) != (nM,nE,nN,nP):
 | 
			
		||||
        return True
 | 
			
		||||
    return not IGNORE_TEST_LEVEL and (ot != nt)
 | 
			
		||||
 | 
			
		||||
def clone_or_fetch(target: pathlib.Path):
 | 
			
		||||
    if target.exists() and (target/".git").exists():
 | 
			
		||||
        try:
 | 
			
		||||
            run("git","fetch","--all","-p", cwd=target)
 | 
			
		||||
            run("git","reset","--hard", f"origin/{REPO_BRANCH}", cwd=target)
 | 
			
		||||
            return
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            log(f"fetch failed, recloning: {e}")
 | 
			
		||||
            shutil.rmtree(target, ignore_errors=True)
 | 
			
		||||
 | 
			
		||||
    url = REPO_URL
 | 
			
		||||
    if REPO_TOKEN and REPO_URL.startswith("https://"):
 | 
			
		||||
        url = REPO_URL.replace("https://", f"https://{REPO_TOKEN}@")
 | 
			
		||||
    run("git","clone","--branch", REPO_BRANCH, "--depth","1", url, str(target))
 | 
			
		||||
 | 
			
		||||
def prime_tmp_then_decide():
 | 
			
		||||
    TMP_DIR.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
    shutil.rmtree(TMP_DIR, ignore_errors=True)
 | 
			
		||||
    clone_or_fetch(TMP_DIR)
 | 
			
		||||
 | 
			
		||||
    new_ver = extract_version_from(TMP_DIR / "bot.py")
 | 
			
		||||
    old_ver = extract_version_from(CACHE_DIR / "bot.py") if (CACHE_DIR / "bot.py").exists() else "v0.0.0.0"
 | 
			
		||||
    do_swap = not (CACHE_DIR / "bot.py").exists() or should_update(old_ver, new_ver)
 | 
			
		||||
    why = "first fetch" if not (CACHE_DIR / "bot.py").exists() else f"update allowed: {do_swap} (old={old_ver}, new={new_ver})"
 | 
			
		||||
    return do_swap, old_ver, new_ver, why
 | 
			
		||||
 | 
			
		||||
def swap_cache_to_prev():
 | 
			
		||||
    PREV_DIR.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
    shutil.rmtree(PREV_DIR, ignore_errors=True)
 | 
			
		||||
    if CACHE_DIR.exists():
 | 
			
		||||
        shutil.move(str(CACHE_DIR), str(PREV_DIR))
 | 
			
		||||
 | 
			
		||||
def copy_tmp_to_cache():
 | 
			
		||||
    shutil.rmtree(CACHE_DIR, ignore_errors=True)
 | 
			
		||||
    shutil.copytree(TMP_DIR, CACHE_DIR, dirs_exist_ok=False)
 | 
			
		||||
 | 
			
		||||
def pip_install(cwd: pathlib.Path):
 | 
			
		||||
    req = cwd / "requirements.txt"
 | 
			
		||||
    if PIP_INSTALL and req.exists():
 | 
			
		||||
        try: run(sys.executable, "-m", "pip", "install", "-r", str(req))
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            log("pip install failed (continuing)"); log(e.stdout + "\n" + e.stderr)
 | 
			
		||||
 | 
			
		||||
def set_boot_env(status: str, old_ver: str, new_ver: str, commit: str = "", subject: str = ""):
 | 
			
		||||
    os.environ["BOOT_STATUS"]   = status
 | 
			
		||||
    os.environ["BOOT_OLDVER"]   = old_ver
 | 
			
		||||
    os.environ["BOOT_NEWVER"]   = new_ver
 | 
			
		||||
    os.environ["BUILD_COMMIT"]  = commit
 | 
			
		||||
    os.environ["BUILD_SUBJECT"] = subject
 | 
			
		||||
 | 
			
		||||
def get_head_info(cwd: pathlib.Path):
 | 
			
		||||
    try:
 | 
			
		||||
        sha = run("git","rev-parse","--short","HEAD", cwd=cwd).stdout.strip()
 | 
			
		||||
        subj = run("git","log","-1","--pretty=%s", cwd=cwd).stdout.strip()
 | 
			
		||||
        return (sha, subj)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return ("","")
 | 
			
		||||
 | 
			
		||||
def start_bot(cwd: pathlib.Path) -> subprocess.Popen:
 | 
			
		||||
    env = os.environ.copy()
 | 
			
		||||
    env.setdefault("DATA_FILE", "/data/data.json")  # new name
 | 
			
		||||
    return subprocess.Popen([sys.executable, "-u", "bot.py"], cwd=cwd, env=env)
 | 
			
		||||
 | 
			
		||||
def graceful_stop(proc: subprocess.Popen) -> bool:
 | 
			
		||||
    try:
 | 
			
		||||
        proc.terminate()
 | 
			
		||||
        try:
 | 
			
		||||
            proc.wait(timeout=RUN_TIMEOUT); return True
 | 
			
		||||
        except subprocess.TimeoutExpired:
 | 
			
		||||
            proc.kill(); proc.wait(timeout=10); return True
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
def run_loop():
 | 
			
		||||
    ensure_git()
 | 
			
		||||
    updated, old_ver, new_ver, why = prime_tmp_then_decide()
 | 
			
		||||
    if updated: log(f"updating cache: {why}"); swap_cache_to_prev(); copy_tmp_to_cache()
 | 
			
		||||
    else: log(f"no update: {why}")
 | 
			
		||||
 | 
			
		||||
    pip_install(CACHE_DIR)
 | 
			
		||||
    sha, subj = get_head_info(CACHE_DIR)
 | 
			
		||||
    set_boot_env(
 | 
			
		||||
        "Successfully fetched, cached, and booted new version" if updated else "Successfully booted from cached version",
 | 
			
		||||
        old_ver, new_ver, sha, subj
 | 
			
		||||
    )
 | 
			
		||||
    proc = start_bot(CACHE_DIR)
 | 
			
		||||
    log(f"bot started pid={proc.pid}")
 | 
			
		||||
 | 
			
		||||
    consecutive_failures = 0
 | 
			
		||||
    while True:
 | 
			
		||||
        delay = next_utc(CHECK_TIME_UTC)
 | 
			
		||||
        log(f"sleeping {int(delay)}s until {CHECK_TIME_UTC} UTC for update check")
 | 
			
		||||
        time.sleep(delay)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            upd, cur_ver, remote_ver, why = prime_tmp_then_decide()
 | 
			
		||||
            log(f"nightly check: {why}")
 | 
			
		||||
            if not upd:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            log("updating to new version at nightly window")
 | 
			
		||||
            graceful_stop(proc)
 | 
			
		||||
            swap_cache_to_prev()
 | 
			
		||||
            copy_tmp_to_cache()
 | 
			
		||||
            pip_install(CACHE_DIR)
 | 
			
		||||
            sha, subj = get_head_info(CACHE_DIR)
 | 
			
		||||
            set_boot_env("Successfully fetched, cached, and booted new version", cur_ver, remote_ver, sha, subj)
 | 
			
		||||
            proc = start_bot(CACHE_DIR)
 | 
			
		||||
            log(f"bot restarted on new version pid={proc.pid}")
 | 
			
		||||
            consecutive_failures = 0
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            log(f"nightly update failed: {e}")
 | 
			
		||||
            consecutive_failures += 1
 | 
			
		||||
            if consecutive_failures < ROLLBACK_MAX_FAILS and PREV_DIR.exists() and (PREV_DIR / "bot.py").exists():
 | 
			
		||||
                log("attempting rollback to previous cached version")
 | 
			
		||||
                if proc.poll() is None: graceful_stop(proc)
 | 
			
		||||
                shutil.rmtree(CACHE_DIR, ignore_errors=True)
 | 
			
		||||
                shutil.copytree(PREV_DIR, CACHE_DIR, dirs_exist_ok=False)
 | 
			
		||||
                pip_install(CACHE_DIR)
 | 
			
		||||
                set_boot_env("Rolled back to last known working version", "-", extract_version_from(CACHE_DIR / "bot.py"))
 | 
			
		||||
                proc = start_bot(CACHE_DIR)
 | 
			
		||||
            elif consecutive_failures >= ROLLBACK_MAX_FAILS:
 | 
			
		||||
                log("critical: failed 3 times to update/restart; entering freeze mode")
 | 
			
		||||
                try:
 | 
			
		||||
                    if proc.poll() is None: graceful_stop(proc)
 | 
			
		||||
                except Exception: pass
 | 
			
		||||
                while True: time.sleep(3600)
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    run_loop()
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user