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/
 | 
			
		||||
							
								
								
									
										127
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						@ -1,127 +0,0 @@
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Required
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
DISCORD_TOKEN=<paste your bot token>
 | 
			
		||||
 | 
			
		||||
# Git repo to run (wrapper clones nightly and on boot)
 | 
			
		||||
REPO_URL=<https://git.example.com/you/shaiwatcher.git>
 | 
			
		||||
REPO_BRANCH=main
 | 
			
		||||
# If your repo is private, you can use a token; leave empty if not used
 | 
			
		||||
REPO_TOKEN=
 | 
			
		||||
 | 
			
		||||
# Wrapper update time (UTC) & test bump policy
 | 
			
		||||
CHECK_TIME_UTC=03:00
 | 
			
		||||
IGNORE_TEST_LEVEL=1           # 1 = ignore updates that change only the -T test suffix
 | 
			
		||||
 | 
			
		||||
# Optional commit subject feed used by your boot notice
 | 
			
		||||
SHAI_REPO_RSS=<https://git.example.com/you/shaiwatcher.rss>
 | 
			
		||||
 | 
			
		||||
# Where the bot stores data inside the container (don’t change)
 | 
			
		||||
SHAI_DATA_FILE=/data/data.json
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Volumes (external, never wiped by stack re-deploys)
 | 
			
		||||
# Create once:  docker volume create shaiwatcher_data && docker volume create shaiwatcher_cache
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_VOL_DATA=shaiwatcher_data
 | 
			
		||||
SHAI_VOL_CACHE=shaiwatcher_cache
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Playwright / Headless browser (for DD scraping)
 | 
			
		||||
# Build with: docker compose build --build-arg WITH_PLAYWRIGHT=1
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
WITH_PLAYWRIGHT=1             # 1 to bake Chromium+Playwright into the image
 | 
			
		||||
SHAI_DD_CHANNEL_ID=<channel id for DD message>
 | 
			
		||||
SHAI_DD_FETCHER=playwright    # playwright|aiohttp  (playwright recommended)
 | 
			
		||||
SHAI_DD_PW_TIMEOUT_MS=60000   # nav timeout
 | 
			
		||||
SHAI_DD_PW_WAIT=              # domcontentloaded|load|networkidle (empty = default domcontentloaded)
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Slash command scope (your bot already honors this)
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_SLASH_GUILD_ONLY=true
 | 
			
		||||
SHAI_HOME_GUILD_ID=<home guild id>
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Channels
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_MOD_CHANNEL_ID=<id>
 | 
			
		||||
SHAI_MODLOG_CHANNEL_ID=<id>
 | 
			
		||||
SHAI_USERSLIST_CHANNEL_ID=<id>
 | 
			
		||||
SHAI_REPORT_CHANNEL_ID=<id>
 | 
			
		||||
SHAI_PIRATES_LIST_CHANNEL_ID=<id>
 | 
			
		||||
SHAI_TRIGGER_CHANNEL_ID=<id>          # Auto-VC
 | 
			
		||||
SHAI_AUTO_VC_CATEGORY_ID=<id>
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Reaction messages
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_RULES_MESSAGE_ID=<id>
 | 
			
		||||
SHAI_ENGAGEMENT_MESSAGE_ID=<id>
 | 
			
		||||
SHAI_NICKNAME_MESSAGE_ID=<id>
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Roles
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_RULES_ROLE_ID=<id>
 | 
			
		||||
SHAI_ENGAGEMENT_ROLE_ID=<id>
 | 
			
		||||
SHAI_FULL_ACCESS_ROLE_ID=<id>
 | 
			
		||||
SHAI_ADMIN_ROLE_ID=<id>
 | 
			
		||||
SHAI_FIELD_MOD_ROLE_ID=<id>
 | 
			
		||||
SHAI_INTEL_MOD_ROLE_ID=<id>
 | 
			
		||||
SHAI_MODERATOR_ROLE_ID=<id>
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Auto-VC
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_VC_NAME_PREFIX=DD Crew
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Toggles
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_NICK_NUDGE_LOOP_ENABLED=false
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Emojis (IDs)
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_EMOJI_MELANGE_ID=<id>
 | 
			
		||||
SHAI_EMOJI_SAND_ID=<id>
 | 
			
		||||
SHAI_EMOJI_CARRIER_CRAWLER_ID=<id>
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Docs site (optional)
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
SHAI_DOCS_HOST=0.0.0.0
 | 
			
		||||
SHAI_DOCS_PORT=8910
 | 
			
		||||
SHAI_DOCS_TITLE=ShaiWatcher Commands
 | 
			
		||||
SHAI_DOCS_SUPPORT_URL=
 | 
			
		||||
SHAI_DOCS_SUPPORT_LABEL=Buy me a ☕
 | 
			
		||||
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
# Wrapper knobs (optional)
 | 
			
		||||
# ─────────────────────────────────────────────────────────────
 | 
			
		||||
PIP_INSTALL_REQUIREMENTS=1
 | 
			
		||||
WRAPPER_STOP_TIMEOUT=25
 | 
			
		||||
 | 
			
		||||
# Keep Docker base locale happy
 | 
			
		||||
LANG=C.UTF-8
 | 
			
		||||
							
								
								
									
										21
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@ -1,21 +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
 | 
			
		||||
NOTES.md
 | 
			
		||||
sanity/
 | 
			
		||||
.offline_data.json
 | 
			
		||||
dev/.env.production
 | 
			
		||||
dev/portainer_config.png
 | 
			
		||||
 | 
			
		||||
# Tools
 | 
			
		||||
wrapper/
 | 
			
		||||
wrapper/tools/
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.7 MiB  | 
| 
		 Before Width: | Height: | Size: 52 KiB  | 
| 
		 Before Width: | Height: | Size: 417 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB  | 
@ -1,568 +0,0 @@
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
 | 
			
		||||
 | 
			
		||||
<!-- Favicons (put files in /assets/docs/) -->
 | 
			
		||||
<link rel="icon" type="image/png" href="/assets/docs/favicon.png">
 | 
			
		||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/docs/favicon-32x32.png">
 | 
			
		||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/docs/favicon-16x16.png">
 | 
			
		||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/docs/apple-touch-icon.png">
 | 
			
		||||
<link rel="manifest" href="/assets/docs/site.webmanifest">
 | 
			
		||||
 | 
			
		||||
<title>__TITLE__</title>
 | 
			
		||||
<style>
 | 
			
		||||
  :root { --bg:#0b0f14; --panel:#121922; --muted:#6b7280; --fg:#e5e7eb; --accent:#60a5fa; --sticky-top: 8px; --header-h: 56px; }
 | 
			
		||||
  * { box-sizing:border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; }
 | 
			
		||||
  body { margin:0; background:var(--bg); color:var(--fg); }
 | 
			
		||||
 | 
			
		||||
  /* Header: 2/3 title, 1/3 actions */
 | 
			
		||||
  header { padding:16px 20px; background:#0f172a; border-bottom:1px solid #223; position:sticky; top:0; z-index:3; }
 | 
			
		||||
  .hdr { display:flex; align-items:center; gap:12px; }
 | 
			
		||||
  .hdr .title { flex: 2 1 66%; font-size:20px; font-weight:600; }
 | 
			
		||||
  .hdr .actions { flex: 1 1 34%; display:flex; justify-content:flex-end; gap:8px; }
 | 
			
		||||
 | 
			
		||||
  main { max-width:1200px; margin:20px auto; padding:0 16px 40px; }
 | 
			
		||||
  .row { display:flex; gap:16px; flex-wrap:wrap; }
 | 
			
		||||
  .col { flex:1 1 560px; min-width:320px; }
 | 
			
		||||
  .panel { background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; }
 | 
			
		||||
 | 
			
		||||
  .toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
 | 
			
		||||
  .search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
 | 
			
		||||
 | 
			
		||||
  .list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
 | 
			
		||||
  .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
 | 
			
		||||
  .name { font-weight:600; display:flex; align-items:center; gap:8px; }
 | 
			
		||||
  .meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
 | 
			
		||||
  .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
 | 
			
		||||
  .pill.mod { border-color:#ef4444; color:#fecaca; }
 | 
			
		||||
  .pill.admin { border-color:#a78bfa; color:#e9d5ff; }
 | 
			
		||||
  .pill.slash { border-color:#60a5fa; }
 | 
			
		||||
  .pill.prefix { border-color:#f59e0b; }
 | 
			
		||||
  .pill.hybrid { border-color:#34d399; }
 | 
			
		||||
 | 
			
		||||
  /* Usage block + copy button */
 | 
			
		||||
  .usage{
 | 
			
		||||
    --lh:1.6;                   /* line-height used to size the button */
 | 
			
		||||
    line-height:var(--lh);
 | 
			
		||||
    position:relative;
 | 
			
		||||
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
 | 
			
		||||
    font-size:12px;
 | 
			
		||||
    background:#0a1220;
 | 
			
		||||
    border:1px dashed #1f2937;
 | 
			
		||||
    padding:6px 40px 6px 8px;    /* right padding to make room for the button */
 | 
			
		||||
    border-radius:6px; margin-top:6px; word-break:break-word;
 | 
			
		||||
  }
 | 
			
		||||
  .usage > .copybtn{
 | 
			
		||||
    position:absolute; right:0; top:0;
 | 
			
		||||
    height: calc(var(--lh) * 1em + 12px);  /* one text line + vertical padding */
 | 
			
		||||
    width:36px;
 | 
			
		||||
    display:flex; align-items:center; justify-content:center;
 | 
			
		||||
    background:#374151; border:0; color:#e5e7eb; border-radius:0 6px 0 6px;
 | 
			
		||||
    cursor:pointer;
 | 
			
		||||
  }
 | 
			
		||||
  .usage > .copybtn:hover{ background:#4b5563; }
 | 
			
		||||
  @media (max-width: 900px){ .usage > .copybtn{ width:auto; padding:0 10px; } }
 | 
			
		||||
 | 
			
		||||
  .help { margin-top:6px; color:#cbd5e1; }
 | 
			
		||||
  .btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
 | 
			
		||||
  .btn:hover { background:#0f172a; }
 | 
			
		||||
  .btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
 | 
			
		||||
  .btn-row { display:flex; gap:8px; align-items:center; }
 | 
			
		||||
 | 
			
		||||
  .detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
 | 
			
		||||
 | 
			
		||||
  /* Footer */
 | 
			
		||||
  footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
 | 
			
		||||
  footer .line { margin:4px 0; }
 | 
			
		||||
  footer a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted #334155; }
 | 
			
		||||
  footer a:hover { text-decoration: underline; }
 | 
			
		||||
 | 
			
		||||
  /* Discord drawer (right-side sheet) */
 | 
			
		||||
  .drawer{ position:fixed; top:var(--header-h); right:0; width:clamp(360px,33vw,420px);
 | 
			
		||||
           height:calc(100vh - var(--header-h) - 16px); background:var(--panel); border-left:1px solid #1f2937;
 | 
			
		||||
           box-shadow:-16px 0 40px rgba(0,0,0,.45); transform:translateX(100%); transition:transform .25s ease;
 | 
			
		||||
           z-index:6; border-top-left-radius:12px; border-bottom-left-radius:12px; overflow:hidden; }
 | 
			
		||||
  .drawer.open{ transform:translateX(0); }
 | 
			
		||||
  .drawer-close{ position:absolute; top:8px; right:8px; z-index:1; }
 | 
			
		||||
  .drawer-backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:5; }
 | 
			
		||||
  .drawer-backdrop.open{ display:block; }
 | 
			
		||||
 | 
			
		||||
  /* Blur veil under toolbar */
 | 
			
		||||
  .veil{ position:sticky; top:var(--header-h); height:16px; pointer-events:none; z-index:2;
 | 
			
		||||
         backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
 | 
			
		||||
         mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
			
		||||
         -webkit-mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); }
 | 
			
		||||
 | 
			
		||||
  /* Mobile details as sheet */
 | 
			
		||||
  @media (max-width: 900px){
 | 
			
		||||
    .row{ display:block; }
 | 
			
		||||
    .detailsbox{ position:fixed; right:0; top:var(--header-h); width:96vw; max-width:720px;
 | 
			
		||||
                 height:calc(100vh - var(--header-h) - 16px); overflow:auto; transform:translateX(100%);
 | 
			
		||||
                 box-shadow:-12px 0 30px rgba(0,0,0,.4); z-index:4; }
 | 
			
		||||
    .detailsbox.open{ transform:translateX(0); }
 | 
			
		||||
    #backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:3; }
 | 
			
		||||
    #backdrop.open{ display:block; }
 | 
			
		||||
    #list.blur{ filter:blur(2px); }
 | 
			
		||||
    #backdrop::before{ content:"‹"; position:absolute; left:10px; top:50%; transform:translateY(-50%);
 | 
			
		||||
                       font-size:48px; line-height:1; color:#e5e7eb; opacity:.8; }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* Anchored copy popover + backdrop */
 | 
			
		||||
  #copyBackdrop{ position:fixed; inset:0; display:none; z-index:20; background:transparent; }
 | 
			
		||||
  #copyBackdrop.open{ display:block; }
 | 
			
		||||
  #copyPopover{ position:fixed; display:none; z-index:21; background:var(--panel); border:1px solid #1f2937;
 | 
			
		||||
                border-radius:10px; padding:10px; min-width: min(420px, 90vw);
 | 
			
		||||
                box-shadow:0 18px 50px rgba(0,0,0,.45); }
 | 
			
		||||
  #copyPopover.open{ display:block; }
 | 
			
		||||
  #copyPopover .title{ font-weight:600; margin-bottom:6px; }
 | 
			
		||||
  #copyPopover pre{ margin:6px 0; white-space:pre-wrap; word-break:break-word; background:#0a1220; border:1px dashed #1f2937; padding:8px; border-radius:6px; }
 | 
			
		||||
 | 
			
		||||
  /* Tiny toast */
 | 
			
		||||
  #toast{ position:fixed; left:50%; bottom:28px; transform:translateX(-50%); background:#111827; color:#e5e7eb;
 | 
			
		||||
          border:1px solid #1f2937; padding:6px 10px; border-radius:999px; font-size:12px; opacity:0; pointer-events:none; z-index:40; transition:opacity .18s ease; }
 | 
			
		||||
  #toast.show{ opacity:1; }
 | 
			
		||||
 | 
			
		||||
  /* Bottom sheet (full command details) */
 | 
			
		||||
  #fullDetailsBackdrop{
 | 
			
		||||
    position:fixed; inset:var(--header-h) 0 0 0; display:none; z-index:12;
 | 
			
		||||
    background:rgba(0,0,0,.35);
 | 
			
		||||
  }
 | 
			
		||||
  #fullDetailsBackdrop.open{ display:block; }
 | 
			
		||||
 | 
			
		||||
  #fullDetailsSheet{
 | 
			
		||||
    position:fixed; left:50%; transform:translate(-50%, 100%);
 | 
			
		||||
    bottom:0; width:min(1200px, 94vw); max-height:75vh; overflow:auto;
 | 
			
		||||
    background:var(--panel); border:1px solid #1f2937;
 | 
			
		||||
    border-radius:12px 12px 0 0; box-shadow:0 18px 50px rgba(0,0,0,.45);
 | 
			
		||||
    z-index:13; transition:transform .25s ease;
 | 
			
		||||
  }
 | 
			
		||||
  #fullDetailsSheet.open{ transform:translate(-50%, 0); }
 | 
			
		||||
 | 
			
		||||
  #fullDetailsHead{
 | 
			
		||||
    position:sticky; top:0; background:rgba(0,0,0,.35);
 | 
			
		||||
    backdrop-filter: blur(6px);
 | 
			
		||||
    -webkit-backdrop-filter: blur(6px);
 | 
			
		||||
    display:flex; align-items:center; gap:8px;
 | 
			
		||||
    padding:10px 12px; border-bottom:1px solid #1f2937;
 | 
			
		||||
  }
 | 
			
		||||
  #fullDetailsTitle{ font-weight:600; }
 | 
			
		||||
  #fullDetailsBody{ padding:12px; }
 | 
			
		||||
 | 
			
		||||
  /* Small inline flag in footer */
 | 
			
		||||
  .flag-emoji{
 | 
			
		||||
    height: 1em;
 | 
			
		||||
    width: auto;
 | 
			
		||||
    vertical-align: -0.18em;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    border-radius: 2px;
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
<header>
 | 
			
		||||
  <div class="hdr">
 | 
			
		||||
    <div class="title">__TITLE__</div>
 | 
			
		||||
    <div class="actions">
 | 
			
		||||
      <button id="openDiscord" class="btn btn-icon" title="Open Discord">💬</button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</header>
 | 
			
		||||
 | 
			
		||||
<main>
 | 
			
		||||
  <!-- Toolbar -->
 | 
			
		||||
  <div class="panel toolbar" id="toolbar">
 | 
			
		||||
    <input id="q" class="search" placeholder="Search name/description…">
 | 
			
		||||
    <div style="margin-top:8px; font-size:12px; color:var(--muted)">
 | 
			
		||||
      Sections: <a href="#user">User</a> · <a href="#moderator">Moderator</a> · <a href="#all">All</a>
 | 
			
		||||
      <span id="counts" style="margin-left:10px"></span>
 | 
			
		||||
      <span id="alerts" style="margin-left:10px; color:#fbbf24"></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="veil" id="veil"></div>
 | 
			
		||||
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <div class="col"><div id="list" class="list"></div></div>
 | 
			
		||||
    <div class="col"><div class="panel detailsbox" id="details"></div></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <footer id="footer">
 | 
			
		||||
    <div class="line" id="copyright"></div>
 | 
			
		||||
    <div class="line" id="statusline">Uptime: — · Version: v—</div>
 | 
			
		||||
    <div class="line" id="coffee" style="display:__SUPPORT_VIS__">
 | 
			
		||||
      <a id="supportLink" href="__SUPPORT_URL__" target="_blank" rel="noopener noreferrer">__SUPPORT_LABEL__</a>
 | 
			
		||||
    </div>
 | 
			
		||||
  </footer>
 | 
			
		||||
 | 
			
		||||
  <!-- Mobile details backdrop -->
 | 
			
		||||
  <div id="backdrop"></div>
 | 
			
		||||
 | 
			
		||||
  <!-- Discord drawer -->
 | 
			
		||||
  <div id="discordBackdrop" class="drawer-backdrop"></div>
 | 
			
		||||
  <div id="discordSheet" class="drawer">
 | 
			
		||||
    <button class="btn btn-icon drawer-close" title="Close">✕</button>
 | 
			
		||||
    <iframe id="discordWidget"
 | 
			
		||||
      src="https://discord.com/widget?id=1396826999095427253&theme=dark"
 | 
			
		||||
      allowtransparency="true" frameborder="0"
 | 
			
		||||
      sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
 | 
			
		||||
      style="width:100%;height:100%;border:0"></iframe>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Anchored copy popover -->
 | 
			
		||||
  <div id="copyBackdrop"></div>
 | 
			
		||||
  <div id="copyPopover" role="dialog" aria-modal="true">
 | 
			
		||||
    <div class="title">Copy to clipboard</div>
 | 
			
		||||
    <pre id="copyText">—</pre>
 | 
			
		||||
    <div class="btn-row">
 | 
			
		||||
      <button id="copyAction" class="btn">Copy</button>
 | 
			
		||||
      <button id="copyClose" class="btn">Close</button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Bottom full-details sheet -->
 | 
			
		||||
  <div id="fullDetailsBackdrop"></div>
 | 
			
		||||
  <div id="fullDetailsSheet" role="dialog" aria-modal="true" aria-labelledby="fullDetailsTitle">
 | 
			
		||||
    <div id="fullDetailsHead">
 | 
			
		||||
      <div id="fullDetailsTitle">Command details</div>
 | 
			
		||||
      <span style="flex:1"></span>
 | 
			
		||||
      <button id="fullDetailsClose" class="btn btn-icon" title="Close">✕</button>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="fullDetailsBody"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Tiny toast -->
 | 
			
		||||
  <div id="toast" aria-live="polite">Copied!</div>
 | 
			
		||||
</main>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
/* Sticky offsets */
 | 
			
		||||
function computeStickyTop(){
 | 
			
		||||
  const header=document.querySelector('header');
 | 
			
		||||
  const toolbar=document.getElementById('toolbar');
 | 
			
		||||
  const headerH=header?header.offsetHeight:0;
 | 
			
		||||
  document.documentElement.style.setProperty('--header-h', headerH+'px');
 | 
			
		||||
  const toolbarH=toolbar?toolbar.offsetHeight:0;
 | 
			
		||||
  const stickyTop=headerH+toolbarH+8;
 | 
			
		||||
  document.documentElement.style.setProperty('--sticky-top', stickyTop+'px');
 | 
			
		||||
  const veil=document.getElementById('veil');
 | 
			
		||||
  if(veil){ veil.style.top=headerH+'px'; veil.style.height='16px'; }
 | 
			
		||||
}
 | 
			
		||||
addEventListener('resize', computeStickyTop);
 | 
			
		||||
addEventListener('load', computeStickyTop);
 | 
			
		||||
 | 
			
		||||
/* Error surface */
 | 
			
		||||
addEventListener('error', e=>{ const el=document.getElementById('alerts'); if(el) el.textContent='JS error: '+(e?.message||''); });
 | 
			
		||||
addEventListener('unhandledrejection', e=>{ const el=document.getElementById('alerts'); const msg=e&&(e.reason&&(e.reason.message||e.reason)||e); if(el) el.textContent='Promise error: '+msg; });
 | 
			
		||||
 | 
			
		||||
/* Mini MD (fallback when only .md exists) */
 | 
			
		||||
function renderMD(src){ if(!src) return ""; let s=String(src).replace(/\r\n/g,"\n");
 | 
			
		||||
 s=s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
 | 
			
		||||
 s=s.replace(/```([\s\S]*?)```/g,(m,p1)=>"<pre><code>"+p1+"</code></pre>");
 | 
			
		||||
 s=s.replace(/^###\s+(.*)$/gm,"<h3>$1</h3>");
 | 
			
		||||
 s=s.replace(/^##\s+(.*)$/gm,"<h2>$1</h2>");
 | 
			
		||||
 s=s.replace(/^#\s+(.*)$/gm,"<h1>$1</h1>");
 | 
			
		||||
 s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
 | 
			
		||||
 s=s.replace(/\*(.+?)\*/g,"<em>$1</em>");
 | 
			
		||||
 s=s.replace(/`([^`]+?)`/g,"<code>$1</code>");
 | 
			
		||||
 s=s.replace(/^(\s*)-\s+(.+)$/gm,"$1<li>$2</li>");
 | 
			
		||||
 s=s.replace(/(<li>.*<\/li>\n?)+/g,m=>"<ul>"+m+"</ul>");
 | 
			
		||||
 s=s.replace(/^(?!<h\d|<ul>|<pre>|<li>)([^\n][^\n]*)$/gm,"<p>$1</p>");
 | 
			
		||||
 return s; }
 | 
			
		||||
 | 
			
		||||
/* Link helpers */
 | 
			
		||||
function rowAnchor(r){ const cog=(r.cog||'nocog').toLowerCase();
 | 
			
		||||
 let base=(r.name||'').toLowerCase().replace(/^\//,'').replace(/\s+/g,'-').replace(/\//g,'-'); return `${cog}-${base}`; }
 | 
			
		||||
function getFilterFromHash(){ const h=(location.hash||'#user').slice(1);
 | 
			
		||||
 const parts=h.split('&').map(s=>s.trim()); const filt=parts.find(p=>p==='user'||p==='moderator'||p==='all')||'user';
 | 
			
		||||
 let cmd=null; const cmdPart=parts.find(p=>p.startsWith('cmd=')); if(cmdPart) cmd=cmdPart.slice(4);
 | 
			
		||||
 const sp=new URLSearchParams(location.search); const qcmd=sp.get('cmd'); if(!cmd&&qcmd) cmd=qcmd; return {filter:filt, cmd}; }
 | 
			
		||||
function buildLink(anchor){ const url=new URL(location.href); url.searchParams.set('cmd',anchor); url.hash=location.hash||'#user'; return url.toString(); }
 | 
			
		||||
function replaceURLFor(anchor){ try{ const url=new URL(location.href); url.searchParams.set('cmd',anchor); history.replaceState(null,'',url.toString()); }catch{} }
 | 
			
		||||
 | 
			
		||||
/* Copy helpers */
 | 
			
		||||
async function copyText(s){ try{ await navigator.clipboard.writeText(s); return true; }catch{ return false; } }
 | 
			
		||||
function showToast(msg='Copied!'){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'), 1100); }
 | 
			
		||||
 | 
			
		||||
const copyBackdrop=document.getElementById('copyBackdrop');
 | 
			
		||||
const copyPopover=document.getElementById('copyPopover');
 | 
			
		||||
const copyTextEl=document.getElementById('copyText');
 | 
			
		||||
const copyAction=document.getElementById('copyAction');
 | 
			
		||||
const copyClose=document.getElementById('copyClose');
 | 
			
		||||
 | 
			
		||||
function closeCopyPopover(){ copyBackdrop.classList.remove('open'); copyPopover.classList.remove('open'); }
 | 
			
		||||
copyBackdrop.addEventListener('click', closeCopyPopover);
 | 
			
		||||
copyClose.addEventListener('click', closeCopyPopover);
 | 
			
		||||
 | 
			
		||||
function openCopyPopover(text, anchorEl){
 | 
			
		||||
  copyTextEl.textContent=text;
 | 
			
		||||
  copyAction.onclick=async ()=>{ const ok=await copyText(text); if(ok){ closeCopyPopover(); showToast(); } };
 | 
			
		||||
 | 
			
		||||
  // Position: prefer above; keep inside viewport
 | 
			
		||||
  const r=anchorEl.getBoundingClientRect();
 | 
			
		||||
  const pad=8, vw=innerWidth, vh=innerHeight;
 | 
			
		||||
  copyPopover.style.visibility='hidden'; copyPopover.classList.add('open'); // measure
 | 
			
		||||
  const pw=copyPopover.offsetWidth, ph=copyPopover.offsetHeight;
 | 
			
		||||
  let left = Math.min(Math.max(pad, r.right - pw), vw - pw - pad);
 | 
			
		||||
  let top  = r.top - ph - pad;
 | 
			
		||||
  if(top < (pad + 4)) top = Math.min(vh - ph - pad, r.bottom + pad);
 | 
			
		||||
  copyPopover.style.left = `${left}px`;
 | 
			
		||||
  copyPopover.style.top  = `${top}px`;
 | 
			
		||||
  copyPopover.style.visibility='visible';
 | 
			
		||||
  copyBackdrop.classList.add('open');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Bottom sheet */
 | 
			
		||||
const fdb = document.getElementById('fullDetailsBackdrop');
 | 
			
		||||
const fds = document.getElementById('fullDetailsSheet');
 | 
			
		||||
const fdbtn = document.getElementById('fullDetailsClose');
 | 
			
		||||
const fdbody = document.getElementById('fullDetailsBody');
 | 
			
		||||
const fdtitle = document.getElementById('fullDetailsTitle');
 | 
			
		||||
function openFullDetails(title, html){
 | 
			
		||||
  fdtitle.textContent = title || 'Command details';
 | 
			
		||||
  fdbody.innerHTML = html || '<p>No extra details.</p>';
 | 
			
		||||
  fdb.classList.add('open');
 | 
			
		||||
  fds.classList.add('open');
 | 
			
		||||
}
 | 
			
		||||
function closeFullDetails(){ fdb.classList.remove('open'); fds.classList.remove('open'); }
 | 
			
		||||
fdb?.addEventListener('click', closeFullDetails);
 | 
			
		||||
fdbtn?.addEventListener('click', closeFullDetails);
 | 
			
		||||
 | 
			
		||||
/* App */
 | 
			
		||||
(function(){
 | 
			
		||||
  if(!location.hash) location.hash='#user';
 | 
			
		||||
  let data=(window.__DATA__||null);
 | 
			
		||||
 | 
			
		||||
  const listEl=document.getElementById('list');
 | 
			
		||||
  const qEl=document.getElementById('q');
 | 
			
		||||
  const countsEl=document.getElementById('counts');
 | 
			
		||||
  const detailsEl=document.getElementById('details');
 | 
			
		||||
  const backdrop=document.getElementById('backdrop');
 | 
			
		||||
 | 
			
		||||
  function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
 | 
			
		||||
  function helpSansMod(r){ return (r.help||'').replace(/^\s*\[(MOD|ADMIN)\]\s*/i,''); }
 | 
			
		||||
  function moduleSansPrefix(r){ const m=r.module||''; return m.replace(/^modules?\./,'').replace(/^discord\.ext\./,''); }
 | 
			
		||||
 | 
			
		||||
  async function shareFor(r){
 | 
			
		||||
    const anchor=rowAnchor(r);
 | 
			
		||||
    const url=buildLink(anchor);
 | 
			
		||||
    const ok=await copyText(url);
 | 
			
		||||
    if(ok) showToast('Link copied!');
 | 
			
		||||
    else openCopyPopover(url, document.querySelector(`#card-${anchor} [data-share]`) || document.getElementById('shareDetails') || document.body);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function usageBlockHTML(text){
 | 
			
		||||
    if(!text) return '';
 | 
			
		||||
    const esc=String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
 | 
			
		||||
    return `<div class="usage">${esc}<button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button></div>`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function wireUsageCopy(container){
 | 
			
		||||
    container.querySelectorAll('[data-copy]').forEach(btn=>{
 | 
			
		||||
      btn.addEventListener('click', async ev=>{
 | 
			
		||||
        ev.stopPropagation();
 | 
			
		||||
        const t=btn.getAttribute('data-copy').replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
 | 
			
		||||
        const ok=await copyText(t);
 | 
			
		||||
        if(ok) showToast();
 | 
			
		||||
        else openCopyPopover(t, btn);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function openDetails(r){
 | 
			
		||||
    const anchor=rowAnchor(r);
 | 
			
		||||
    replaceURLFor(anchor);
 | 
			
		||||
    let html=`
 | 
			
		||||
      <div class="name" style="margin-bottom:6px">
 | 
			
		||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
			
		||||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
			
		||||
        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
			
		||||
        <span>${shownName(r)}</span>
 | 
			
		||||
        <span style="flex:1"></span>
 | 
			
		||||
        <div class="btn-row">
 | 
			
		||||
          <button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
 | 
			
		||||
          <button class="btn" id="closeDetails" style="display:none">Close</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="meta">
 | 
			
		||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
			
		||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
			
		||||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      ${usageBlockHTML(r.usage_prefix)}
 | 
			
		||||
      ${usageBlockHTML(r.usage_slash)}
 | 
			
		||||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    // Brief/details HTML (or MD fallback)
 | 
			
		||||
    if (r.brief_html) {
 | 
			
		||||
      html += `<div class="md" style="margin-top:10px">${r.brief_html}</div>`;
 | 
			
		||||
    } else if (r.details_html) {
 | 
			
		||||
      html += `<div class="md" style="margin-top:10px">${r.details_html}</div>`;
 | 
			
		||||
    } else if (r.details_md) {
 | 
			
		||||
      html += `<div class="md" style="margin-top:10px">${renderMD(r.details_md)}</div>`;
 | 
			
		||||
    } else {
 | 
			
		||||
      html += `<div class="md" style="margin-top:10px"><p style="color:#9ca3af;font-size:14px;margin:0 0 6px 0">No custom documentation yet.</p></div>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // "Open full details" when we have long content
 | 
			
		||||
    const hasFull = !!(r.details_html || r.details_md);
 | 
			
		||||
    if (hasFull) {
 | 
			
		||||
      html += `<div class="btn-row" style="margin-top:10px">
 | 
			
		||||
                 <button id="openFullDetails" class="btn">Open full details</button>
 | 
			
		||||
               </div>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    detailsEl.innerHTML=html;
 | 
			
		||||
 | 
			
		||||
    const shareBtn=document.getElementById('shareDetails');
 | 
			
		||||
    if(shareBtn) shareBtn.onclick=(e)=>{ e.stopPropagation(); shareFor(r); };
 | 
			
		||||
    const fullBtn=document.getElementById('openFullDetails');
 | 
			
		||||
    if(fullBtn){
 | 
			
		||||
      fullBtn.onclick=(e)=>{
 | 
			
		||||
        e.stopPropagation();
 | 
			
		||||
        const longHtml = r.details_html ? r.details_html : renderMD(r.details_md || '');
 | 
			
		||||
        openFullDetails(shownName(r), longHtml);
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    wireUsageCopy(detailsEl);
 | 
			
		||||
 | 
			
		||||
    // Mobile sheet open
 | 
			
		||||
    if (matchMedia('(max-width: 900px)').matches){
 | 
			
		||||
      detailsEl.classList.add('open'); listEl.classList.add('blur'); backdrop.classList.add('open');
 | 
			
		||||
      const closeBtn=document.getElementById('closeDetails'); if(closeBtn) closeBtn.style.display='inline-block';
 | 
			
		||||
      backdrop.onclick=closeDetails; if(closeBtn) closeBtn.onclick=closeDetails;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  function closeDetails(){ detailsEl.classList.remove('open'); listEl.classList.remove('blur'); backdrop.classList.remove('open'); }
 | 
			
		||||
 | 
			
		||||
  function card(r){
 | 
			
		||||
    const c=document.createElement('div'); const anchor=rowAnchor(r);
 | 
			
		||||
    c.className='card'; c.id='card-'+anchor; c.dataset.anchor=anchor;
 | 
			
		||||
    c.dataset.search=[shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase();
 | 
			
		||||
 | 
			
		||||
    const usageHTML=(r.type==='hybrid') ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}` : `${usageBlockHTML(r.usage)}`;
 | 
			
		||||
 | 
			
		||||
    c.innerHTML=`
 | 
			
		||||
      <div class="name">
 | 
			
		||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
			
		||||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
			
		||||
        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
			
		||||
        <span>${shownName(r)}</span>
 | 
			
		||||
        <div class="btn-row">
 | 
			
		||||
          <button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
 | 
			
		||||
          <button class="btn" data-details="1">Details</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="meta">
 | 
			
		||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
			
		||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
			
		||||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      ${usageHTML}
 | 
			
		||||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
			
		||||
    `;
 | 
			
		||||
    c.querySelector('[data-details]').addEventListener('click', ev=>{ ev.stopPropagation(); openDetails(r); });
 | 
			
		||||
    c.querySelector('[data-share]').addEventListener('click', async ev=>{
 | 
			
		||||
      ev.stopPropagation();
 | 
			
		||||
      const url=buildLink(anchor);
 | 
			
		||||
      const ok=await copyText(url);
 | 
			
		||||
      if(ok) showToast('Link copied!');
 | 
			
		||||
      else openCopyPopover(url, ev.currentTarget);
 | 
			
		||||
    });
 | 
			
		||||
    c.addEventListener('click', ()=>openDetails(r));
 | 
			
		||||
    wireUsageCopy(c);
 | 
			
		||||
    return c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); }
 | 
			
		||||
 | 
			
		||||
  function applyFilter(selectAnchorIfPresent=true){
 | 
			
		||||
  if(!data) return;
 | 
			
		||||
  const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
 | 
			
		||||
  countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
 | 
			
		||||
 | 
			
		||||
  const q=(qEl.value||'').toLowerCase();
 | 
			
		||||
  const {filter, cmd}=getFilterFromHash();
 | 
			
		||||
  const src=filter==='moderator'?mods:(filter==='all'?all:users);
 | 
			
		||||
  const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
 | 
			
		||||
  render(listEl, rows);
 | 
			
		||||
 | 
			
		||||
  const isMobile = matchMedia('(max-width: 900px)').matches;
 | 
			
		||||
 | 
			
		||||
  // Selection logic:
 | 
			
		||||
  // - If URL has an anchor, prefer that (scroll to it)
 | 
			
		||||
  // - Otherwise, ONLY auto-open first row on non-mobile
 | 
			
		||||
  let sel = null;
 | 
			
		||||
 | 
			
		||||
  if (selectAnchorIfPresent && cmd) {
 | 
			
		||||
    const hit = rows.find(r => rowAnchor(r) === cmd);
 | 
			
		||||
    if (hit) sel = hit;
 | 
			
		||||
    const cardEl = document.getElementById('card-'+cmd);
 | 
			
		||||
    if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
 | 
			
		||||
  } else if (!isMobile && rows.length) {
 | 
			
		||||
    sel = rows[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Do not auto-open on mobile
 | 
			
		||||
  if (sel && !isMobile) openDetails(sel);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  async function boot(){
 | 
			
		||||
    computeStickyTop();
 | 
			
		||||
    try{
 | 
			
		||||
      async function loadData(){
 | 
			
		||||
        try{
 | 
			
		||||
          const res = await fetch('/api/commands', { cache:'no-store' });
 | 
			
		||||
          if(!res.ok) throw new Error('HTTP '+res.status);
 | 
			
		||||
          data = await res.json();
 | 
			
		||||
          return true;   // fetched fresh
 | 
			
		||||
        }catch(e){
 | 
			
		||||
          // fallback to inline bootstrap if fetch fails
 | 
			
		||||
          if(window.__DATA__){ data = window.__DATA__; return false; }
 | 
			
		||||
          throw e;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      addEventListener('hashchange', ()=>applyFilter(false));
 | 
			
		||||
      qEl.addEventListener('input', ()=>applyFilter(false));
 | 
			
		||||
 | 
			
		||||
      await loadData();        // always refresh from backend
 | 
			
		||||
      applyFilter(true);
 | 
			
		||||
    }catch{ document.getElementById('alerts').textContent='Failed to load.'; }
 | 
			
		||||
 | 
			
		||||
    // Footer
 | 
			
		||||
    const fromYear=2025, now=new Date();
 | 
			
		||||
    const flagSvg='<img class="flag-emoji" alt="NO" src="/assets/docs/no.svg">';
 | 
			
		||||
    document.getElementById('copyright').innerHTML=`© OokamiKunTV ${fromYear}–${now.getFullYear()} — Made in ${flagSvg} with ❤️`;
 | 
			
		||||
    try{
 | 
			
		||||
      const s=await fetch('/api/status',{cache:'no-store'});
 | 
			
		||||
      if(s.ok){ const js=await s.json();
 | 
			
		||||
        document.getElementById('statusline').textContent=`Uptime: ${fmtDuration(js.uptime_seconds||0)} · Version: v${js.version||'unknown'}`; }
 | 
			
		||||
    }catch{}
 | 
			
		||||
 | 
			
		||||
    // Discord drawer
 | 
			
		||||
    const ds=document.getElementById('discordSheet');
 | 
			
		||||
    const db=document.getElementById('discordBackdrop');
 | 
			
		||||
    const openBtn=document.getElementById('openDiscord');
 | 
			
		||||
    const closeBtn=ds.querySelector('.drawer-close');
 | 
			
		||||
    const openDiscord=()=>{ ds.classList.add('open'); db.classList.add('open'); };
 | 
			
		||||
    const closeDiscord=()=>{ ds.classList.remove('open'); db.classList.remove('open'); };
 | 
			
		||||
    if(openBtn) openBtn.onclick=openDiscord; if(closeBtn) closeBtn.onclick=closeDiscord; if(db) db.onclick=closeDiscord;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function fmtDuration(s){ s=Math.max(0,Math.floor(+s||0));
 | 
			
		||||
    const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
 | 
			
		||||
    const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
 | 
			
		||||
 | 
			
		||||
  setInterval(async ()=>{
 | 
			
		||||
    try{
 | 
			
		||||
      const gotFresh = await loadData();
 | 
			
		||||
      if(gotFresh) applyFilter(false);
 | 
			
		||||
    }catch{}
 | 
			
		||||
  }, 30000);
 | 
			
		||||
  boot();
 | 
			
		||||
})();
 | 
			
		||||
</script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_cleanup_now</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Immediately deletes empty Auto-VC rooms that have been idle past the configured delay, then renumbers the rest.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/avc_cleanup_now</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  Replies “Cleanup pass complete.” on success.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Scans the target category for <b>managed</b> Auto-VC rooms.</li>
 | 
			
		||||
  <li>Deletes rooms that have been <b>empty</b> for at least the configured <b>delay</b> (seconds).</li>
 | 
			
		||||
  <li>Renumbers remaining rooms to a clean sequence (<code>Prefix 1..N</code>).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>After a busy period to tidy up stale empties.</li>
 | 
			
		||||
  <li>If numbering looks off and you want a quick clean + renumber in one go.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Bot needs: <b>Manage Channels</b>, <b>Connect</b>, and <b>Move Members</b> in the target category to function smoothly.</li>
 | 
			
		||||
  <li>Safe to run anytime. The bot pauses the background sweeper while this runs to avoid conflicts.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_renumber</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Force a rename of all managed rooms to a clean numeric order without deleting anything.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/avc_renumber</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  Replies “Renumbered.” on success.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,19 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<p>Renames tracked rooms to <code>{prefix} 1</code>, <code>{prefix} 2</code>, … in creation order. No rooms are deleted.</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Numbering drifted after manual edits or channel deletions.</li>
 | 
			
		||||
  <li>You changed the name prefix and want all rooms aligned quickly.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Bot needs <b>Manage Channels</b> to rename rooms.</li>
 | 
			
		||||
  <li>Safe to run anytime; the bot sequences this against its background sweeper.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_status</code></h2>
 | 
			
		||||
<p>Shows current Auto-VC setup and the list of managed rooms.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/avc_status</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  Output includes trigger channel, target category, name prefix, cleanup delay, and each tracked room with its current state.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it shows</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Trigger</b> — the voice channel that spawns new rooms.</li>
 | 
			
		||||
  <li><b>Category</b> — where new rooms are created.</li>
 | 
			
		||||
  <li><b>Prefix</b> — base name (e.g. <code>Room</code> → <code>Room 1</code>, <code>Room 2</code>…).</li>
 | 
			
		||||
  <li><b>Delay</b> — seconds a room must stay empty before cleanup can delete it.</li>
 | 
			
		||||
  <li><b>Tracked rooms</b> — each room’s name and whether it’s empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Example output</h2>
 | 
			
		||||
<pre style="white-space:pre-wrap;margin:.25rem 0 0">
 | 
			
		||||
Auto-VC status:
 | 
			
		||||
Trigger: <#1234567890> | Category: <#2345678901> | Prefix: `Room` | Delay: 30s
 | 
			
		||||
- #1: Room 1 — 3 inside
 | 
			
		||||
- #2: Room 2 — empty | idle 12s
 | 
			
		||||
</pre>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.75rem"><small>
 | 
			
		||||
  This command is read-only and safe to run anytime.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
<h2>/dd_update</h2>
 | 
			
		||||
<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/dd_update <action> [reason]</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>
 | 
			
		||||
  <li><code>reason</code> (optional) — short note shown in the confirmation.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>Permissions</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
 | 
			
		||||
  <li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>What it does</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><em>stop</em> — pauses all checks until <code>resume</code>.</li>
 | 
			
		||||
  <li><em>resume</em> — returns to the normal weekly cycle.</li>
 | 
			
		||||
  <li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,50 +0,0 @@
 | 
			
		||||
<h1>/dd_update — Deep Desert updater controls</h1>
 | 
			
		||||
<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Usage</h2>
 | 
			
		||||
<pre>/dd_update <action> [reason]</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>
 | 
			
		||||
  <li><code>reason</code> (optional) — free text appended to the confirmation response.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Permissions</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
 | 
			
		||||
  <li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Behavior</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>
 | 
			
		||||
  <li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li>Every 5 min for the first hour</li>
 | 
			
		||||
      <li>Then every 15 min until 3 hours</li>
 | 
			
		||||
      <li>Then every 30 min until 6 hours</li>
 | 
			
		||||
      <li>Then every 1 hour until 24 hours</li>
 | 
			
		||||
      <li>Then every 3 hours</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>
 | 
			
		||||
  <li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Actions</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>
 | 
			
		||||
  <li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>
 | 
			
		||||
  <li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Channel & config</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>
 | 
			
		||||
  <li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>This command only controls the updater; it does not manually edit the posted message.</li>
 | 
			
		||||
  <li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/clear_nick_reviews</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Delete all <b>pending</b> nickname review records for this server.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/clear_nick_reviews</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
Replies with how many pending entries were removed (ephemeral).
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Removes every <b>pending</b> entry in the nickname review queue for this server.</li>
 | 
			
		||||
  <li>Does <u>not</u> touch approved/rejected history or verification flags.</li>
 | 
			
		||||
  <li>Useful when the queue got noisy or stuck after a busy period.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Result</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  Shows: <code>Cleared N pending nickname review(s).</code> (ephemeral)
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Writes a short entry to the mod log (if configured).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/recreate_nick_review</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Recreate a <b>single</b> user’s missing pending review.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/recreate_nick_review <b>user:</b> @Member</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
If they’re already verified or already have a pending review, you’ll get a short explanation instead (ephemeral).
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,25 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Checks the selected member:</li>
 | 
			
		||||
  <ul>
 | 
			
		||||
    <li>If <b>already verified</b> → no action.</li>
 | 
			
		||||
    <li>If a <b>pending review exists</b> → no action.</li>
 | 
			
		||||
    <li>Else → marks them as claimed (if needed) and opens a new pending review in the mod channel.</li>
 | 
			
		||||
  </ul>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>One user fell through the cracks and needs a review opened again.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Logs to the mod log (if configured) with who triggered it.</li>
 | 
			
		||||
  <li>Reply is ephemeral.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/recreate_nick_reviews</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Scan the server and recreate any <b>missing</b> pending reviews for users who claimed but never got a review opened.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/recreate_nick_reviews</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
Replies: <code>Recreated X review(s); skipped Y.</code> (ephemeral)
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Looks at users who <b>claimed</b> nickname compliance but are <u>not verified</u> and have <u>no pending review</u>.</li>
 | 
			
		||||
  <li>Opens a fresh review in the mod channel for each such user (one per user, no duplicates).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>After a restart/outage where some pending reviews may have gone missing.</li>
 | 
			
		||||
  <li>Whenever you see “claimed” users without an open review.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Posts a summary to the mod log (if configured).</li>
 | 
			
		||||
  <li>Only processes this server; other servers are unaffected.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,17 +0,0 @@
 | 
			
		||||
<h2>/pirate_cards_rebuild</h2>
 | 
			
		||||
<p><strong>Rebuild or update pirate cards</strong> for every known pirate in the configured channel.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/pirate_cards_rebuild</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Moderator-only</strong>.</li>
 | 
			
		||||
  <li>Run in a server channel. The reply is ephemeral.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>What it does</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Creates or updates one embed “card” per pirate in the pirates list channel.</li>
 | 
			
		||||
  <li>Each card shows Account, Threat %, grouping/destructive behavior buckets, encounters count, and last seen date.</li>
 | 
			
		||||
  <li>Card color reflects threat (unknown → gray; low → green; high → red).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,44 +0,0 @@
 | 
			
		||||
<h1>/pirate_cards_rebuild — Rebuild all pirate cards</h1>
 | 
			
		||||
<p>Runs a full pass that makes sure each verified pirate has a fresh, accurate card in the configured channel.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Access</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Moderator-only</strong>.</li>
 | 
			
		||||
  <li>Must be used in a server channel (not DMs). The confirmation is sent <em>ephemerally</em>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>What it updates</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Card fields</strong>: Account name, Threat %, “In groups” bucket, “Destructive” bucket, total encounters, and last encounter date (UTC).</li>
 | 
			
		||||
  <li><strong>Colors</strong>: Unknown samples → dark gray. Otherwise a green→red gradient based on Threat %.</li>
 | 
			
		||||
  <li><strong>Safety</strong>: User-provided names are escaped and mentions are neutralized (no pings).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Behavior</h2>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li>For each pirate in the internal list:
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li>If their card already exists, it edits the embed in place.</li>
 | 
			
		||||
      <li>If it can’t find the message, it posts a new card and records the message ID.</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li>Works serially per guild to avoid race conditions.</li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h2>Output</h2>
 | 
			
		||||
<p>Ephemeral summary, e.g.:</p>
 | 
			
		||||
<pre>Rebuilt/updated 27 pirate cards.</pre>
 | 
			
		||||
 | 
			
		||||
<h2>Setup notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>
 | 
			
		||||
  <li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>
 | 
			
		||||
  <li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>When to run it</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>After importing or migrating data.</li>
 | 
			
		||||
  <li>After a batch of new pirates is approved.</li>
 | 
			
		||||
  <li>Any time the cards look out of date.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/edit_pirate</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
 | 
			
		||||
<p>Edit an existing pirate entry. Use this when a player changes nickname or account tag.</p>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Current account</b> — the exact existing account in the list (<code>Name#12345</code>).</li>
 | 
			
		||||
  <li><b>New in-game nickname</b> — optional.</li>
 | 
			
		||||
  <li><b>New account</b> — optional, must be <code>Name#12345</code>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.75rem">/edit_pirate</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  You must provide <b>at least one</b> of: new nickname or new account.<br>
 | 
			
		||||
  The account format is strict: <code>#</code> + <b>five digits</b>. Duplicate accounts are blocked.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  Updates a pirate record that’s already approved. Typical cases: the player renamed their character,
 | 
			
		||||
  or moved to a new account tag.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Current account</b> — must match exactly what’s in the list (e.g., <code>SomeUser#12345</code>).</li>
 | 
			
		||||
  <li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>
 | 
			
		||||
  <li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, it’s rejected.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Rules & feedback</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>You must change at least one thing (nickname or account).</li>
 | 
			
		||||
  <li>On success you’ll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>
 | 
			
		||||
  <li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/encounter</code></h2>
 | 
			
		||||
 | 
			
		||||
<p>Log a pirate encounter so threat scores stay fresh. Takes ~10 seconds.</p>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Pirate</b> — account (<code>Name#12345</code>) or exact character name.<br>
 | 
			
		||||
      <small>Account is best. Character name must be unambiguous.</small></li>
 | 
			
		||||
  <li><b>Group size</b> — integer ≥ 1 (how many pirates in that group, including them).</li>
 | 
			
		||||
  <li><b>Kills</b> — integer ≥ 0 (0 if none/unknown).</li>
 | 
			
		||||
  <li><b>Destructive?</b> — yes/no (e.g., base or ornithopter destroyed).</li>
 | 
			
		||||
  <li><b>Perceived Skill</b> — 0–5 (0 = unknown).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>
 | 
			
		||||
 | 
			
		||||
<div style="margin-top:.75rem">
 | 
			
		||||
  <div class="usage">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>
 | 
			
		||||
  <div class="usage" style="margin-top:.4rem">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.75rem"><small>After you submit, you’ll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>
 | 
			
		||||
@ -1,75 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  <code>/encounter</code> adds a single encounter to the pirate’s history. These entries feed the
 | 
			
		||||
  threat score so the pirate list reflects how dangerous someone actually is right now.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Pirate (name or account)</b><br>
 | 
			
		||||
      Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><b>Group size</b><br>
 | 
			
		||||
      Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><b>Kills</b><br>
 | 
			
		||||
      Integer <b>≥ 0</b>. Use 0 for none/unknown.
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><b>Destructive?</b><br>
 | 
			
		||||
      <code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><b>Perceived Skill</b><br>
 | 
			
		||||
      <b>0–5</b>. Use 0 if you’re unsure. 5 = cracked aim/sweaty movement, 1–2 = casual.
 | 
			
		||||
  </li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin:.6rem 0 0">
 | 
			
		||||
  /encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Validation & messages</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If the account format is wrong, you’ll be told what to fix (<code>Name#12345</code> ending with five digits).</li>
 | 
			
		||||
  <li>Character names must be an exact match. If multiple pirates share that name, you’ll be asked to use the account.</li>
 | 
			
		||||
  <li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>
 | 
			
		||||
  <li>You’ll see a private <b>“Encounter recorded”</b> confirmation on success.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">How threat is calculated (short)</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  Each encounter contributes to a weighted score (0–100):
 | 
			
		||||
</p>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Kills</b>: higher if the pirate gets kills.</li>
 | 
			
		||||
  <li><b>Destructive</b>: higher if they destroy stuff.</li>
 | 
			
		||||
  <li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>
 | 
			
		||||
  <li><b>Skill</b>: your 0–5 rating normalized and averaged across encounters.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
<p>
 | 
			
		||||
  The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,
 | 
			
		||||
  the bot recomputes the pirate’s <b>threat level</b> and <b>encounter count</b>, then refreshes the list.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Tips</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>
 | 
			
		||||
  <li>If you can’t tell skill, leave it at 0. That’s fine.</li>
 | 
			
		||||
  <li>Group size includes the pirate you’re reporting.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Common errors</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>
 | 
			
		||||
  <li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>
 | 
			
		||||
  <li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/encounters_migrate_ids</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
 | 
			
		||||
<p>
 | 
			
		||||
  Converts old encounter records stored by <b>character name</b> to the
 | 
			
		||||
  canonical <b>account</b> (<code>Name#12345</code>).
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/encounters_migrate_ids</div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  Useful after adding pirates where past encounters referenced only nicknames.
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,27 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  Scans stored encounters and rewrites any identifier that is a <b>character name</b> to the correct
 | 
			
		||||
  <b>account</b> for that pirate. Keeps data consistent for threat calculations.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">How matching works</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If the encounter already uses an account (<code>#</code> + five digits), it’s counted as “already accounts”.</li>
 | 
			
		||||
  <li>If the character name maps to exactly one pirate, it’s updated to that pirate’s account.</li>
 | 
			
		||||
  <li>If there’s no pirate with that name, it’s counted as “not found”.</li>
 | 
			
		||||
  <li>If multiple pirates share the same nickname, it’s “ambiguous” and skipped.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Result</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Replies with a compact summary (updated / already / ambiguous / not found).</li>
 | 
			
		||||
  <li>Refreshes the public pirate list afterwards.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  <b>Moderator-only.</b>
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,9 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/remove_pirate</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
 | 
			
		||||
<p>Remove a pirate from the approved list by <b>account</b>.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/remove_pirate account_name: <code>Name#12345</code></div>
 | 
			
		||||
 | 
			
		||||
<p style="margin-top:.6rem"><small>
 | 
			
		||||
  Account format must be <code>Name#12345</code>. If the account isn’t found, you’ll get “Pirate not found.”
 | 
			
		||||
</small></p>
 | 
			
		||||
@ -1,19 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<p>Deletes the matching pirate record from the approved list.</p>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Parameters</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>account_name</b> — exact account tag (<code>Name#12345</code>).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Behavior & messages</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>If no match: “Pirate not found.”</li>
 | 
			
		||||
  <li>On success: “Removed.” (ephemeral via slash).</li>
 | 
			
		||||
  <li>Mod-log note is posted (“🗑️ Removed pirate …”) and the public list refreshes.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
<h3 style="margin:0 0 4px 0;">What it does</h3>
 | 
			
		||||
<p style="margin:0 0 8px 0;">
 | 
			
		||||
  Sends a player to the <strong>Pirate Review Queue</strong>. Mods get a card with ✅/❌.
 | 
			
		||||
  If you include a Discord media link, it will preview for them.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<h4 style="margin:10px 0 4px 0;">Quick use — <code>/report</code></h4>
 | 
			
		||||
<ul style="margin:0 0 8px 18px;">
 | 
			
		||||
  <li><strong>In-game nickname</strong> – e.g. <code>SandStalker</code></li>
 | 
			
		||||
  <li><strong>Account</strong> – must be <code>Name#12345</code> (five digits)</li>
 | 
			
		||||
  <li><strong>Proof</strong> (optional, recommended) – direct Discord CDN link
 | 
			
		||||
    (<code>cdn.discordapp.com</code> / <code>media.discordapp.net</code>)</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h4 style="margin:10px 0 4px 0;">Limits</h4>
 | 
			
		||||
<ul style="margin:0 0 6px 18px;">
 | 
			
		||||
  <li>1 submission per user every 60s</li>
 | 
			
		||||
  <li>No duplicates (pending or already approved)</li>
 | 
			
		||||
  <li>Non-Discord media links are blocked</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<p style="margin:6px 0 0 0; color:#9ca3af;">
 | 
			
		||||
  Tip: Images show inside the card; videos stay as a link so the inline player works.
 | 
			
		||||
</p>
 | 
			
		||||
@ -1,48 +0,0 @@
 | 
			
		||||
<h2 style="margin-top:0;">Submit a pirate report</h2>
 | 
			
		||||
<p>
 | 
			
		||||
  Use <code>/report</code> to send a player to the <strong>Pirate Review Queue</strong>.
 | 
			
		||||
  Mods see a compact card with your info and can approve or reject with one click.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<h3>Form fields</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>In-game nickname</strong><br>
 | 
			
		||||
      The name they use in game. Example: <code>SandStalker</code></li>
 | 
			
		||||
  <li><strong>Account (Name#12345)</strong><br>
 | 
			
		||||
      Must end with <code>#</code> + <strong>five digits</strong>. Example: <code>SomeUser#12345</code><br>
 | 
			
		||||
      If you only know the nickname, try to grab the account too – it prevents ambiguity.</li>
 | 
			
		||||
  <li><strong>Proof (Discord media URL)</strong> – optional but highly encouraged<br>
 | 
			
		||||
      Use a direct Discord CDN link so it previews: <code>https://cdn.discordapp.com/…</code> or
 | 
			
		||||
      <code>https://media.discordapp.net/…</code><br>
 | 
			
		||||
      Allowed: <em>png, jpg, jpeg, gif, webp, mp4, webm, mov</em>.<br>
 | 
			
		||||
      How to get it: open the media in Discord → “Open in browser” → copy the address.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>What happens next</h3>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li>You get a short “thanks” message in the channel.</li>
 | 
			
		||||
  <li>Mods receive an embed with ✅/❌. If you added an image, it shows inside the card.
 | 
			
		||||
      If it’s a video, the URL is kept above the card so the inline player works.</li>
 | 
			
		||||
  <li>A “Jump to message” button lets mods see your original context quickly.</li>
 | 
			
		||||
  <li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.
 | 
			
		||||
      Your small ack message in the channel is edited to reflect the result.</li>
 | 
			
		||||
  <li>If approved, the player is added to the pirate list (threat level starts at 0).</li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h3>Rules & common errors</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits – no spaces at the end.</li>
 | 
			
		||||
  <li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>
 | 
			
		||||
  <li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>
 | 
			
		||||
  <li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
<p style="color:#9ca3af">
 | 
			
		||||
  Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<h3>Related actions</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>/encounter</code> – log a new encounter with a known pirate (updates threat level over time).</li>
 | 
			
		||||
  <li><code>/edit_pirate</code> (mods) – update a pirate’s nickname/account.</li>
 | 
			
		||||
  <li><code>remove_pirate</code> (mods, hybrid) – remove an entry.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,9 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/pirates_list_refresh</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Rebuild the compact pirates list in the configured channel. Use this after adding/removing pirates or when encounter stats change.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/pirates_list_refresh</div>
 | 
			
		||||
 | 
			
		||||
<ul style="margin:.6rem 0 0 1rem">
 | 
			
		||||
  <li>Replies: <code>Pirates list refreshed.</code> (ephemeral)</li>
 | 
			
		||||
  <li>Runs per-guild cooldown: 10s</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,55 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Builds a compact, alphabetized list of verified pirates and posts it in the configured list channel.</li>
 | 
			
		||||
  <li>Entries are chunked to stay under Discord’s 2000-character limit; old chunks are edited or deleted as needed.</li>
 | 
			
		||||
  <li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Entry format</h2>
 | 
			
		||||
<pre style="margin:.5rem 0;white-space:pre-wrap">
 | 
			
		||||
- <b>Character</b> (<i>Account#12345</i>) [Threat%]
 | 
			
		||||
  - In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: <t:UNIX:R>
 | 
			
		||||
</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>
 | 
			
		||||
  <li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>
 | 
			
		||||
  <li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>After a new pirate is approved or removed.</li>
 | 
			
		||||
  <li>After logging encounters that meaningfully change threat or buckets.</li>
 | 
			
		||||
  <li>When the list looks stale or out of order.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Behavior & notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Sorts by character name, then account.</li>
 | 
			
		||||
  <li>If there are no pirates yet, shows a single placeholder line.</li>
 | 
			
		||||
  <li>If the list channel isn’t configured or can’t be found, nothing is posted (check your config).</li>
 | 
			
		||||
  <li>Per-guild cooldown: 10 seconds (prevents spam).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Config that affects output</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>pirates_list_channel_id</code> — where the list is posted.</li>
 | 
			
		||||
  <li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>
 | 
			
		||||
  <li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b></li>
 | 
			
		||||
  <li>Bot needs permission to read and write in the list channel.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/power restart</code> <small style="opacity:.8">[Moderator]</small></h2>
 | 
			
		||||
<p>Safely restarts the bot after logging a clear reason to the modlog.</p>
 | 
			
		||||
 | 
			
		||||
<div class="usage" style="margin-top:.5rem">/power restart reason:"Restarting to enable new permissions sync after config change"</div>
 | 
			
		||||
 | 
			
		||||
<ul style="margin:.6rem 0 0 1rem">
 | 
			
		||||
  <li>Requires a descriptive reason (see details).</li>
 | 
			
		||||
  <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>
 | 
			
		||||
  <li>Bot restarts a couple seconds after the reply.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,61 +0,0 @@
 | 
			
		||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>
 | 
			
		||||
  <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Reason requirements</h2>
 | 
			
		||||
<p>The <code>reason</code> must be specific enough to audit later:</p>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>At least <b>20 characters</b></li>
 | 
			
		||||
  <li>At least <b>4 words</b></li>
 | 
			
		||||
  <li>At least <b>3 words</b> with length ≥ 3</li>
 | 
			
		||||
  <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,
 | 
			
		||||
      <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,
 | 
			
		||||
      <code>bug</code>, <code>crash</code>, <code>crashed</code></li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3 style="margin:.75rem 0 .4rem">Examples</h3>
 | 
			
		||||
<div style="display:grid; gap:.5rem">
 | 
			
		||||
  <div>
 | 
			
		||||
    <div style="font-weight:600">✅ Good</div>
 | 
			
		||||
    <pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state."</pre>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div>
 | 
			
		||||
    <div style="font-weight:600">❌ Bad</div>
 | 
			
		||||
    <pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"update"</pre>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">What you’ll see</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Ephemeral confirmation in chat.</li>
 | 
			
		||||
  <li>Modlog post similar to:
 | 
			
		||||
    <pre style="margin:.4rem 0;white-space:pre-wrap">🔁 Bot Restart Requested
 | 
			
		||||
By: @YourName
 | 
			
		||||
When: 2025-01-01 12:34 UTC
 | 
			
		||||
Running version: vX.Y.Z
 | 
			
		||||
Reason: Reloading cogs after threat weight change…</pre>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li>Bot goes offline briefly, then comes back once the host restarts it.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Config that affects it</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>
 | 
			
		||||
  <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>
 | 
			
		||||
  <li>Bot just needs permission to send messages in the modlog channel.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
<h2>/nick_same</h2>
 | 
			
		||||
<p><strong>Nick matches my in-game name.</strong> Tells mods your current <em>server nickname</em> (or global display name if you haven’t set one) already matches your in-game name, and opens a quick review.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/nick_same</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>You’ll get an <em>ephemeral</em> “thanks” message.</li>
 | 
			
		||||
  <li>Moderators get a review with ✅ / ❌ in the mod channel.</li>
 | 
			
		||||
  <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>Tips</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Run it in the server (not in DMs).</li>
 | 
			
		||||
  <li>If you recently changed your nick, this will trigger a re-review.</li>
 | 
			
		||||
  <li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,38 +0,0 @@
 | 
			
		||||
<h1>/nick_same — Nickname claim</h1>
 | 
			
		||||
<p>Use this when your <strong>server nickname</strong> already matches your in-game character name. It opens a moderator review so you can get (or keep) access without typing anything else.</p>
 | 
			
		||||
 | 
			
		||||
<h2>What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Marks your nickname as <em>claimed</em> and opens exactly <strong>one</strong> pending review for moderators.</li>
 | 
			
		||||
  <li>You’ll see a short <em>ephemeral</em> confirmation in the channel.</li>
 | 
			
		||||
  <li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>
 | 
			
		||||
  <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>How to use</h2>
 | 
			
		||||
<pre>/nick_same</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Run it in the server, any channel where you can use slash commands.</li>
 | 
			
		||||
  <li>No extra arguments. If your display name already matches the game name, that’s enough.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Typical flow</h2>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>
 | 
			
		||||
  <li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>
 | 
			
		||||
  <li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h2>Good to know</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>
 | 
			
		||||
  <li>The command is idempotent: if a review is already pending, it won’t open duplicates.</li>
 | 
			
		||||
  <li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Troubleshooting</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>
 | 
			
		||||
  <li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>
 | 
			
		||||
  <li><strong>Mods didn’t get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,25 +0,0 @@
 | 
			
		||||
<h2>/spicepay</h2>
 | 
			
		||||
<p><strong>Open the Spice Pay wizard.</strong> Step through participants, roles, payout type (Sand or Melange), and weighting. Shows a live preview and lets you post the result.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/spicepay [participants] [force_new]</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><code>participants</code> (optional): total people, incl. owners (1–25).</li>
 | 
			
		||||
  <li><code>force_new</code> (optional): start fresh if you already have a session.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>Quick flow</h3>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li>Enter the total yield and participant count.</li>
 | 
			
		||||
  <li>Add/edit each participant (name, active %, owner roles).</li>
 | 
			
		||||
  <li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>
 | 
			
		||||
  <li>Adjust weighting factors or use a preset.</li>
 | 
			
		||||
  <li>Finish → preview → Post to channel.</li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h3>Notes</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>0% active = owner-only (gets owner perks but didn’t actively join).</li>
 | 
			
		||||
  <li>Melange: exactly one <em>Refiner</em> owner is required.</li>
 | 
			
		||||
  <li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,72 +0,0 @@
 | 
			
		||||
<h1>/spicepay — Guided payout</h1>
 | 
			
		||||
<p>Calculate a fair split for a spice run. The wizard keeps things simple and transparent for the team.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Start</h2>
 | 
			
		||||
<pre>/spicepay [participants] [force_new]</pre>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If <code>participants</code> is omitted, you set it in the first modal (1–25).</li>
 | 
			
		||||
  <li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Setup modal</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>
 | 
			
		||||
  <li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Editing participants</h2>
 | 
			
		||||
<p>For each slot:</p>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Name</strong> — free text (mention text is fine).</li>
 | 
			
		||||
  <li><strong>Active %</strong> — 0–100. <em>0% = owner-only</em> (didn’t actively join).</li>
 | 
			
		||||
  <li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Payout type</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>
 | 
			
		||||
  <li><strong>Melange</strong> — <em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Weighting (the math, simplified)</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>
 | 
			
		||||
  <li>We split the pot <em>proportionally</em> by weights (they’re normalized; only ratios matter).</li>
 | 
			
		||||
  <li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>Controls</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>
 | 
			
		||||
  <li><em>Adjust weighting factors</em> — change:
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li><strong>Base × active %</strong> (default <em>server setting</em>)</li>
 | 
			
		||||
      <li><strong>Carrier bonus</strong> ( + )</li>
 | 
			
		||||
      <li><strong>Crawler bonus</strong> ( + )</li>
 | 
			
		||||
      <li><strong>Refinery cut %</strong> (Melange only)</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><em>Weights help</em> — short explanation inline.</li>
 | 
			
		||||
  <li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>
 | 
			
		||||
  <li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>
 | 
			
		||||
  <li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Validation & limits</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Participants: 1–25.</li>
 | 
			
		||||
  <li>Active %: 0–100.</li>
 | 
			
		||||
  <li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>
 | 
			
		||||
  <li>No duplicate names (duplicates are auto-disambiguated in preview).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Posting</h2>
 | 
			
		||||
<p>The post includes:</p>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Header (Sand or Melange) with the chosen emoji.</li>
 | 
			
		||||
  <li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>
 | 
			
		||||
  <li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
<p><em>Footnote:</em> “0% = owner only”.</p>
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
<h2>/spicepay_cancel</h2>
 | 
			
		||||
<p><strong>Cancel your active Spice Pay session.</strong> Clears everything for you so you can start fresh.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/spicepay_cancel</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Removes your in-memory session (does not post anything).</li>
 | 
			
		||||
  <li>Run <code>/spicepay</code> again to start over.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,11 +0,0 @@
 | 
			
		||||
<h1>/spicepay_cancel — Cancel session</h1>
 | 
			
		||||
<p>Discards your current wizard state. Handy if you mis-entered totals or want to restructure the roster.</p>
 | 
			
		||||
 | 
			
		||||
<h2>What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Deletes your session (you + this server).</li>
 | 
			
		||||
  <li>Nothing is posted publicly.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Next steps</h2>
 | 
			
		||||
<p>Run <code>/spicepay</code> to start again. You can optionally pass <code>participants</code> or just set them in the first modal.</p>
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
<h2>/spicepay_config</h2>
 | 
			
		||||
<p><strong>Show server SpicePay weights.</strong> Read-only view of the defaults the wizard uses.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/spicepay_config</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Displays: Refinery cut %, Base × active %, Carrier bonus, Crawler bonus.</li>
 | 
			
		||||
  <li>Change via ENV/INI; restart the bot to apply.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,19 +0,0 @@
 | 
			
		||||
<h1>/spicepay_config — Server weights</h1>
 | 
			
		||||
<p>Shows the current default weighting used by the Spice Pay wizard.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Fields</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Refinery cut %</strong> (Melange only) — percent taken first and paid to the Refiner owner.</li>
 | 
			
		||||
  <li><strong>Base × active %</strong> — the core share, scaled by each player’s active participation.</li>
 | 
			
		||||
  <li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>
 | 
			
		||||
  <li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Where to change</h2>
 | 
			
		||||
<p>Configure via environment variables or your INI, then restart the bot. The wizard’s <em>Adjust weighting factors</em> lets users override per-run; those don’t change the server defaults.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Tips</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>
 | 
			
		||||
  <li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
<h2>/spicepay_resume</h2>
 | 
			
		||||
<p><strong>Reopen your active Spice Pay session.</strong> Useful if you closed the wizard message by accident.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/spicepay_resume</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If a session exists, the wizard view is shown again.</li>
 | 
			
		||||
  <li>If not, you’ll be told to run <code>/spicepay</code>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,14 +0,0 @@
 | 
			
		||||
<h1>/spicepay_resume — Resume wizard</h1>
 | 
			
		||||
<p>Brings back the current session UI (if you already started one with <code>/spicepay</code>).</p>
 | 
			
		||||
 | 
			
		||||
<h2>When to use</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>You dismissed the ephemeral wizard message and want it back.</li>
 | 
			
		||||
  <li>You’re mid-entry and don’t want to start over.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Behavior</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>
 | 
			
		||||
  <li>If none exists, you get a friendly nudge to start a new one.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
<h2>/usercards_rescan</h2>
 | 
			
		||||
<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>/usercards_rescan</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>
 | 
			
		||||
  <li>Runs in the server; reply is ephemeral with a short summary.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h3>What it does</h3>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>
 | 
			
		||||
  <li>Grants/removes the Rules & RoE roles to match reactions.</li>
 | 
			
		||||
  <li>For Nickname: opens a pending review if someone claimed but no review exists.</li>
 | 
			
		||||
  <li>Rebuilds/updates every user’s status card in the list channel.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,47 +0,0 @@
 | 
			
		||||
<h1>/usercards_rescan — Reconcile & refresh all cards</h1>
 | 
			
		||||
<p>One-shot maintenance pass that makes the server’s user cards match reality.</p>
 | 
			
		||||
 | 
			
		||||
<h2>Access</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>
 | 
			
		||||
  <li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>What it fixes</h2>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li><strong>Rules / RoE agreement</strong>
 | 
			
		||||
    <ul>
 | 
			
		||||
      <liReads reactions from your configured Rules and RoE messages.</li>
 | 
			
		||||
      <li>Adds/removes the corresponding roles so roles match the reactions.</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><strong>Nickname claim & reviews</strong>
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>
 | 
			
		||||
      <li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
  <li><strong>User cards</strong>
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>
 | 
			
		||||
      <li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>
 | 
			
		||||
      <li>Uses a stable footer marker (<code>UID:<id></code>) to find/edit the right card; cleans up duplicates.</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h2>Expected output</h2>
 | 
			
		||||
<p>The command replies (ephemeral) with counts like:</p>
 | 
			
		||||
<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>
 | 
			
		||||
 | 
			
		||||
<h2>Setup notes</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>
 | 
			
		||||
  <li>Won’t ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Tips</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Run after importing a server, restoring from backup, or after downtime.</li>
 | 
			
		||||
  <li>Large servers: this may take a moment while it walks members and edits cards. It’s safe to run again.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,95 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "PirateReportCog.report": {
 | 
			
		||||
    "details_md": "## What it does\nSubmit a player to the **Pirate Review Queue**. Mods get a compact card with the details and can approve ❇️ or reject ❌ with one click. If you include proof, it shows inline so they can decide faster.\n\n---\n\n## How to use — `/report`\nWhen you run **/report**, a small form opens:\n\n- **In-game nickname**  \n  The name they use in game. Example: `SandStalker`\n\n- **Account (Name#12345)**  \n  Must end with a `#` and **five digits**. Example: `SomeUser#12345`  \n  > If you only know the nickname, try to get the account tag too. It removes ambiguity later.\n\n- **Proof (Discord media URL — optional but strongly encouraged)**  \n  Paste a **direct Discord CDN** link so it previews nicely:\n  - Accepts: `https://cdn.discordapp.com/...` or `https://media.discordapp.net/...`\n  - Works with: **png, jpg, jpeg, gif, webp, mp4, webm, mov**\n  - How to get the link: open the image/video in Discord ➜ “Open in browser” ➜ copy the address.\n\n> **Tip:** Images appear inside the mod card. Videos are kept as a plain link above the card so the inline video player works.\n\n---\n\n## What happens after you submit\n- You get a quick **thank-you** in the channel.\n- Mods receive an embed in their review channel with ✅ and ❌.\n- There’s a **“Jump to message”** button on the mod card so they can see the original context.\n- When a mod picks ✅/❌, the card is updated to show **Approved/Rejected** (with who decided and when). The small ack message in your channel is also edited to reflect the result.\n\nIf approved, the player is added to the pirate list (threat level starts at 0 and grows with encounters).\n\n---\n\n## Rules & limits (so you don’t run into errors)\n- **Account format:** must be `Name#12345`. Five digits. No spaces at the end.\n- **Proof link:** must be a Discord media link (see above). Non-Discord links are blocked.\n- **Duplicates:** already-approved players can’t be re-reported; exact pending duplicates are blocked too.\n- **Rate limit:** one report per user every **60 seconds**.\n\nCommon messages you might see:\n- `Invalid account format` → fix to `Name#12345`.\n- `URL must be a Discord media link` → use cdn.discordapp.com or media.discordapp.net.\n- `A report for this player is already pending` → a mod is already reviewing one.\n- `This player is already in the pirate list` → no need to report again.\n\n---\n\n## Good proof examples\n- Screenshot of **names** and actions in the **event log**.\n- Short clip (10–30s) that shows **what happened** and **who did it**.\n- If the situation is complex, add a quick text summary when you submit.\n\n---\n\n## Related actions\n- **/encounter** — log a new encounter with a known pirate.  \n  Fields:\n  - *Pirate (name or account)* — `MuadDib` or `MuadDib#12345` (account preferred)\n  - *Group size* — integer ≥ 1\n  - *Kills* — integer ≥ 0 (0 = none/unknown)\n  - *Destructive?* — `yes` / `no`\n  - *Perceived Skill* — 0–5 (0 = unknown)  \n  Limits: one encounter per same pirate per reporter every **10 minutes**. These entries automatically update the pirate’s **threat level** and **encounter count**.\n\n- **/edit_pirate** *(moderators)* — update a pirate’s nickname/account.\n- **remove_pirate** *(moderators, hybrid)* — remove an approved entry.\n- **encounters_migrate_ids** *(moderators, hybrid)* — migrate old encounter identifiers.\n\n---\n\n## FAQ\n**Q: My proof link won’t accept.**  \nA: It must be a direct Discord CDN URL (ends in a known image/video extension). Open the media in browser from Discord and copy that address.\n\n**Q: I only know their nickname and it says “ambiguous”.**  \nA: Use the **account** form `Name#12345`. Nicknames can be shared.\n\n**Q: Do I have to add proof?**  \nA: Optional, but it **really** helps mods decide quickly. Screenshots are usually enough.\n",
 | 
			
		||||
    "brief_html": "<h3 style=\"margin:0 0 4px 0;\">What it does</h3>\n<p style=\"margin:0 0 8px 0;\">\n  Sends a player to the <strong>Pirate Review Queue</strong>. Mods get a card with ✅/❌.\n  If you include a Discord media link, it will preview for them.\n</p>\n\n<h4 style=\"margin:10px 0 4px 0;\">Quick use — <code>/report</code></h4>\n<ul style=\"margin:0 0 8px 18px;\">\n  <li><strong>In-game nickname</strong> – e.g. <code>SandStalker</code></li>\n  <li><strong>Account</strong> – must be <code>Name#12345</code> (five digits)</li>\n  <li><strong>Proof</strong> (optional, recommended) – direct Discord CDN link\n    (<code>cdn.discordapp.com</code> / <code>media.discordapp.net</code>)</li>\n</ul>\n\n<h4 style=\"margin:10px 0 4px 0;\">Limits</h4>\n<ul style=\"margin:0 0 6px 18px;\">\n  <li>1 submission per user every 60s</li>\n  <li>No duplicates (pending or already approved)</li>\n  <li>Non-Discord media links are blocked</li>\n</ul>\n\n<p style=\"margin:6px 0 0 0; color:#9ca3af;\">\n  Tip: Images show inside the card; videos stay as a link so the inline player works.\n</p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin-top:0;\">Submit a pirate report</h2>\n<p>\n  Use <code>/report</code> to send a player to the <strong>Pirate Review Queue</strong>.\n  Mods see a compact card with your info and can approve or reject with one click.\n</p>\n\n<h3>Form fields</h3>\n<ul>\n  <li><strong>In-game nickname</strong><br>\n      The name they use in game. Example: <code>SandStalker</code></li>\n  <li><strong>Account (Name#12345)</strong><br>\n      Must end with <code>#</code> + <strong>five digits</strong>. Example: <code>SomeUser#12345</code><br>\n      If you only know the nickname, try to grab the account too – it prevents ambiguity.</li>\n  <li><strong>Proof (Discord media URL)</strong> – optional but highly encouraged<br>\n      Use a direct Discord CDN link so it previews: <code>https://cdn.discordapp.com/…</code> or\n      <code>https://media.discordapp.net/…</code><br>\n      Allowed: <em>png, jpg, jpeg, gif, webp, mp4, webm, mov</em>.<br>\n      How to get it: open the media in Discord → “Open in browser” → copy the address.</li>\n</ul>\n\n<h3>What happens next</h3>\n<ol>\n  <li>You get a short “thanks” message in the channel.</li>\n  <li>Mods receive an embed with ✅/❌. If you added an image, it shows inside the card.\n      If it’s a video, the URL is kept above the card so the inline player works.</li>\n  <li>A “Jump to message” button lets mods see your original context quickly.</li>\n  <li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.\n      Your small ack message in the channel is edited to reflect the result.</li>\n  <li>If approved, the player is added to the pirate list (threat level starts at 0).</li>\n</ol>\n\n<h3>Rules & common errors</h3>\n<ul>\n  <li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits – no spaces at the end.</li>\n  <li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>\n  <li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>\n  <li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>\n</ul>\n<p style=\"color:#9ca3af\">\n  Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.\n</p>\n\n<h3>Related actions</h3>\n<ul>\n  <li><code>/encounter</code> – log a new encounter with a known pirate (updates threat level over time).</li>\n  <li><code>/edit_pirate</code> (mods) – update a pirate’s nickname/account.</li>\n  <li><code>remove_pirate</code> (mods, hybrid) – remove an entry.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PirateReportCog.encounter": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounter</code></h2>\n\n<p>Log a pirate encounter so threat scores stay fresh. Takes ~10 seconds.</p>\n\n<ul>\n  <li><b>Pirate</b> — account (<code>Name#12345</code>) or exact character name.<br>\n      <small>Account is best. Character name must be unambiguous.</small></li>\n  <li><b>Group size</b> — integer ≥ 1 (how many pirates in that group, including them).</li>\n  <li><b>Kills</b> — integer ≥ 0 (0 if none/unknown).</li>\n  <li><b>Destructive?</b> — yes/no (e.g., base or ornithopter destroyed).</li>\n  <li><b>Perceived Skill</b> — 0–5 (0 = unknown).</li>\n</ul>\n\n<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>\n\n<div style=\"margin-top:.75rem\">\n  <div class=\"usage\">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>\n  <div class=\"usage\" style=\"margin-top:.4rem\">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>\n</div>\n\n<p style=\"margin-top:.75rem\"><small>After you submit, you’ll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n  <code>/encounter</code> adds a single encounter to the pirate’s history. These entries feed the\n  threat score so the pirate list reflects how dangerous someone actually is right now.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n  <li><b>Pirate (name or account)</b><br>\n      Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.\n  </li>\n  <li><b>Group size</b><br>\n      Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.\n  </li>\n  <li><b>Kills</b><br>\n      Integer <b>≥ 0</b>. Use 0 for none/unknown.\n  </li>\n  <li><b>Destructive?</b><br>\n      <code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.\n  </li>\n  <li><b>Perceived Skill</b><br>\n      <b>0–5</b>. Use 0 if you’re unsure. 5 = cracked aim/sweaty movement, 1–2 = casual.\n  </li>\n</ul>\n\n<div class=\"usage\" style=\"margin:.6rem 0 0\">\n  /encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Validation & messages</h2>\n<ul>\n  <li>If the account format is wrong, you’ll be told what to fix (<code>Name#12345</code> ending with five digits).</li>\n  <li>Character names must be an exact match. If multiple pirates share that name, you’ll be asked to use the account.</li>\n  <li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>\n  <li>You’ll see a private <b>“Encounter recorded”</b> confirmation on success.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How threat is calculated (short)</h2>\n<p>\n  Each encounter contributes to a weighted score (0–100):\n</p>\n<ul>\n  <li><b>Kills</b>: higher if the pirate gets kills.</li>\n  <li><b>Destructive</b>: higher if they destroy stuff.</li>\n  <li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>\n  <li><b>Skill</b>: your 0–5 rating normalized and averaged across encounters.</li>\n</ul>\n<p>\n  The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,\n  the bot recomputes the pirate’s <b>threat level</b> and <b>encounter count</b>, then refreshes the list.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Tips</h2>\n<ul>\n  <li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>\n  <li>If you can’t tell skill, leave it at 0. That’s fine.</li>\n  <li>Group size includes the pirate you’re reporting.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Common errors</h2>\n<ul>\n  <li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>\n  <li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>\n  <li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PirateReportCog.edit_pirate": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/edit_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Edit an existing pirate entry. Use this when a player changes nickname or account tag.</p>\n\n<ul>\n  <li><b>Current account</b> — the exact existing account in the list (<code>Name#12345</code>).</li>\n  <li><b>New in-game nickname</b> — optional.</li>\n  <li><b>New account</b> — optional, must be <code>Name#12345</code>.</li>\n</ul>\n\n<div class=\"usage\" style=\"margin-top:.75rem\">/edit_pirate</div>\n\n<p style=\"margin-top:.6rem\"><small>\n  You must provide <b>at least one</b> of: new nickname or new account.<br>\n  The account format is strict: <code>#</code> + <b>five digits</b>. Duplicate accounts are blocked.\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n  Updates a pirate record that’s already approved. Typical cases: the player renamed their character,\n  or moved to a new account tag.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n  <li><b>Current account</b> — must match exactly what’s in the list (e.g., <code>SomeUser#12345</code>).</li>\n  <li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>\n  <li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, it’s rejected.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Rules & feedback</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>You must change at least one thing (nickname or account).</li>\n  <li>On success you’ll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>\n  <li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PirateReportCog.encounters_migrate_ids": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounters_migrate_ids</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>\n  Converts old encounter records stored by <b>character name</b> to the\n  canonical <b>account</b> (<code>Name#12345</code>).\n</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/encounters_migrate_ids</div>\n\n<p style=\"margin-top:.6rem\"><small>\n  Useful after adding pirates where past encounters referenced only nicknames.\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n  Scans stored encounters and rewrites any identifier that is a <b>character name</b> to the correct\n  <b>account</b> for that pirate. Keeps data consistent for threat calculations.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How matching works</h2>\n<ul>\n  <li>If the encounter already uses an account (<code>#</code> + five digits), it’s counted as “already accounts”.</li>\n  <li>If the character name maps to exactly one pirate, it’s updated to that pirate’s account.</li>\n  <li>If there’s no pirate with that name, it’s counted as “not found”.</li>\n  <li>If multiple pirates share the same nickname, it’s “ambiguous” and skipped.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<ul>\n  <li>Replies with a compact summary (updated / already / ambiguous / not found).</li>\n  <li>Refreshes the public pirate list afterwards.</li>\n</ul>\n\n<p style=\"margin-top:.6rem\"><small>\n  <b>Moderator-only.</b>\n</small></p>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PirateReportCog.remove_pirate": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/remove_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Remove a pirate from the approved list by <b>account</b>.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/remove_pirate account_name: <code>Name#12345</code></div>\n\n<p style=\"margin-top:.6rem\"><small>\n  Account format must be <code>Name#12345</code>. If the account isn’t found, you’ll get “Pirate not found.”\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Deletes the matching pirate record from the approved list.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Parameters</h2>\n<ul>\n  <li><b>account_name</b> — exact account tag (<code>Name#12345</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & messages</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>If no match: “Pirate not found.”</li>\n  <li>On success: “Removed.” (ephemeral via slash).</li>\n  <li>Mod-log note is posted (“🗑️ Removed pirate …”) and the public list refreshes.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "AutoVCCog.avc_cleanup_now": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_cleanup_now</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Immediately deletes empty Auto-VC rooms that have been idle past the configured delay, then renumbers the rest.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_cleanup_now</div>\n\n<p style=\"margin-top:.6rem\"><small>\n  Replies “Cleanup pass complete.” on success.\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Scans the target category for <b>managed</b> Auto-VC rooms.</li>\n  <li>Deletes rooms that have been <b>empty</b> for at least the configured <b>delay</b> (seconds).</li>\n  <li>Renumbers remaining rooms to a clean sequence (<code>Prefix 1..N</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n  <li>After a busy period to tidy up stale empties.</li>\n  <li>If numbering looks off and you want a quick clean + renumber in one go.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Bot needs: <b>Manage Channels</b>, <b>Connect</b>, and <b>Move Members</b> in the target category to function smoothly.</li>\n  <li>Safe to run anytime. The bot pauses the background sweeper while this runs to avoid conflicts.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "AutoVCCog.avc_renumber": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_renumber</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Force a rename of all managed rooms to a clean numeric order without deleting anything.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_renumber</div>\n\n<p style=\"margin-top:.6rem\"><small>\n  Replies “Renumbered.” on success.\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Renames tracked rooms to <code>{prefix} 1</code>, <code>{prefix} 2</code>, … in creation order. No rooms are deleted.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n  <li>Numbering drifted after manual edits or channel deletions.</li>\n  <li>You changed the name prefix and want all rooms aligned quickly.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Bot needs <b>Manage Channels</b> to rename rooms.</li>\n  <li>Safe to run anytime; the bot sequences this against its background sweeper.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "AutoVCCog.avc_status": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_status</code></h2>\n<p>Shows current Auto-VC setup and the list of managed rooms.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_status</div>\n\n<p style=\"margin-top:.6rem\"><small>\n  Output includes trigger channel, target category, name prefix, cleanup delay, and each tracked room with its current state.\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it shows</h2>\n<ul>\n  <li><b>Trigger</b> — the voice channel that spawns new rooms.</li>\n  <li><b>Category</b> — where new rooms are created.</li>\n  <li><b>Prefix</b> — base name (e.g. <code>Room</code> → <code>Room 1</code>, <code>Room 2</code>…).</li>\n  <li><b>Delay</b> — seconds a room must stay empty before cleanup can delete it.</li>\n  <li><b>Tracked rooms</b> — each room’s name and whether it’s empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Example output</h2>\n<pre style=\"white-space:pre-wrap;margin:.25rem 0 0\">\nAuto-VC status:\nTrigger: <#1234567890> | Category: <#2345678901> | Prefix: `Room` | Delay: 30s\n- #1: Room 1 — 3 inside\n- #2: Room 2 — empty | idle 12s\n</pre>\n\n<p style=\"margin-top:.75rem\"><small>\n  This command is read-only and safe to run anytime.\n</small></p>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "NickNudgeCog.clear_nick_reviews": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/clear_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Delete all <b>pending</b> nickname review records for this server.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/clear_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies with how many pending entries were removed (ephemeral).\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Removes every <b>pending</b> entry in the nickname review queue for this server.</li>\n  <li>Does <u>not</u> touch approved/rejected history or verification flags.</li>\n  <li>Useful when the queue got noisy or stuck after a busy period.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<p>\n  Shows: <code>Cleared N pending nickname review(s).</code> (ephemeral)\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Writes a short entry to the mod log (if configured).</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "NickNudgeCog.recreate_nick_review": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_review</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Recreate a <b>single</b> user’s missing pending review.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_review <b>user:</b> @Member</div>\n\n<p style=\"margin-top:.6rem\"><small>\nIf they’re already verified or already have a pending review, you’ll get a short explanation instead (ephemeral).\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Checks the selected member:</li>\n  <ul>\n    <li>If <b>already verified</b> → no action.</li>\n    <li>If a <b>pending review exists</b> → no action.</li>\n    <li>Else → marks them as claimed (if needed) and opens a new pending review in the mod channel.</li>\n  </ul>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n  <li>One user fell through the cracks and needs a review opened again.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Logs to the mod log (if configured) with who triggered it.</li>\n  <li>Reply is ephemeral.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "NickNudgeCog.recreate_nick_reviews": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Scan the server and recreate any <b>missing</b> pending reviews for users who claimed but never got a review opened.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies: <code>Recreated X review(s); skipped Y.</code> (ephemeral)\n</small></p>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Looks at users who <b>claimed</b> nickname compliance but are <u>not verified</u> and have <u>no pending review</u>.</li>\n  <li>Opens a fresh review in the mod channel for each such user (one per user, no duplicates).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n  <li>After a restart/outage where some pending reviews may have gone missing.</li>\n  <li>Whenever you see “claimed” users without an open review.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Posts a summary to the mod log (if configured).</li>\n  <li>Only processes this server; other servers are unaffected.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PiratesListCog.pirates_list_refresh": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/pirates_list_refresh</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Rebuild the compact pirates list in the configured channel. Use this after adding/removing pirates or when encounter stats change.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/pirates_list_refresh</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n  <li>Replies: <code>Pirates list refreshed.</code> (ephemeral)</li>\n  <li>Runs per-guild cooldown: 10s</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Builds a compact, alphabetized list of verified pirates and posts it in the configured list channel.</li>\n  <li>Entries are chunked to stay under Discord’s 2000-character limit; old chunks are edited or deleted as needed.</li>\n  <li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Entry format</h2>\n<pre style=\"margin:.5rem 0;white-space:pre-wrap\">\n- <b>Character</b> (<i>Account#12345</i>) [Threat%]\n  - In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: <t:UNIX:R>\n</pre>\n<ul>\n  <li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>\n  <li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>\n  <li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n  <li>After a new pirate is approved or removed.</li>\n  <li>After logging encounters that meaningfully change threat or buckets.</li>\n  <li>When the list looks stale or out of order.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & notes</h2>\n<ul>\n  <li>Sorts by character name, then account.</li>\n  <li>If there are no pirates yet, shows a single placeholder line.</li>\n  <li>If the list channel isn’t configured or can’t be found, nothing is posted (check your config).</li>\n  <li>Per-guild cooldown: 10 seconds (prevents spam).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects output</h2>\n<ul>\n  <li><code>pirates_list_channel_id</code> — where the list is posted.</li>\n  <li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>\n  <li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n  <li><b>Moderator-only.</b></li>\n  <li>Bot needs permission to read and write in the list channel.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PowerActionsCog.power_restart": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n  <li>Requires a descriptive reason (see details).</li>\n  <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n  <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n  <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n  <li>At least <b>20 characters</b></li>\n  <li>At least <b>4 words</b></li>\n  <li>At least <b>3 words</b> with length ≥ 3</li>\n  <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n      <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n      <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n  <div>\n    <div style=\"font-weight:600\">✅ Good</div>\n    <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n  </div>\n  <div>\n    <div style=\"font-weight:600\">❌ Bad</div>\n    <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n  </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What you’ll see</h2>\n<ul>\n  <li>Ephemeral confirmation in chat.</li>\n  <li>Modlog post similar to:\n    <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n  </li>\n  <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n  <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n  <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n  <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n  <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PowerActionsCog.restart": {
 | 
			
		||||
    "brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n  <li>Requires a descriptive reason (see details).</li>\n  <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n  <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n  <li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n  <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n  <li>At least <b>20 characters</b></li>\n  <li>At least <b>4 words</b></li>\n  <li>At least <b>3 words</b> with length ≥ 3</li>\n  <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n      <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n      <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n  <div>\n    <div style=\"font-weight:600\">✅ Good</div>\n    <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n  </div>\n  <div>\n    <div style=\"font-weight:600\">❌ Bad</div>\n    <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n  </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What you’ll see</h2>\n<ul>\n  <li>Ephemeral confirmation in chat.</li>\n  <li>Modlog post similar to:\n    <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n  </li>\n  <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n  <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n  <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n  <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n  <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "ReactionRoleCog.nick_same": {
 | 
			
		||||
    "brief_html": "<h2>/nick_same</h2>\n<p><strong>Nick matches my in-game name.</strong> Tells mods your current <em>server nickname</em> (or global display name if you haven’t set one) already matches your in-game name, and opens a quick review.</p>\n\n<h3>Usage</h3>\n<pre>/nick_same</pre>\n\n<ul>\n  <li>You’ll get an <em>ephemeral</em> “thanks” message.</li>\n  <li>Moderators get a review with ✅ / ❌ in the mod channel.</li>\n  <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>\n</ul>\n\n<h3>Tips</h3>\n<ul>\n  <li>Run it in the server (not in DMs).</li>\n  <li>If you recently changed your nick, this will trigger a re-review.</li>\n  <li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/nick_same — Nickname claim</h1>\n<p>Use this when your <strong>server nickname</strong> already matches your in-game character name. It opens a moderator review so you can get (or keep) access without typing anything else.</p>\n\n<h2>What it does</h2>\n<ul>\n  <li>Marks your nickname as <em>claimed</em> and opens exactly <strong>one</strong> pending review for moderators.</li>\n  <li>You’ll see a short <em>ephemeral</em> confirmation in the channel.</li>\n  <li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>\n  <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>\n</ul>\n\n<h2>How to use</h2>\n<pre>/nick_same</pre>\n<ul>\n  <li>Run it in the server, any channel where you can use slash commands.</li>\n  <li>No extra arguments. If your display name already matches the game name, that’s enough.</li>\n</ul>\n\n<h2>Typical flow</h2>\n<ol>\n  <li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>\n  <li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>\n  <li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>\n</ol>\n\n<h2>Good to know</h2>\n<ul>\n  <li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>\n  <li>The command is idempotent: if a review is already pending, it won’t open duplicates.</li>\n  <li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n  <li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>\n  <li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>\n  <li><strong>Mods didn’t get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "SpicePayCog.spicepay": {
 | 
			
		||||
    "brief_html": "<h2>/spicepay</h2>\n<p><strong>Open the Spice Pay wizard.</strong> Step through participants, roles, payout type (Sand or Melange), and weighting. Shows a live preview and lets you post the result.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n  <li><code>participants</code> (optional): total people, incl. owners (1–25).</li>\n  <li><code>force_new</code> (optional): start fresh if you already have a session.</li>\n</ul>\n\n<h3>Quick flow</h3>\n<ol>\n  <li>Enter the total yield and participant count.</li>\n  <li>Add/edit each participant (name, active %, owner roles).</li>\n  <li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>\n  <li>Adjust weighting factors or use a preset.</li>\n  <li>Finish → preview → Post to channel.</li>\n</ol>\n\n<h3>Notes</h3>\n<ul>\n  <li>0% active = owner-only (gets owner perks but didn’t actively join).</li>\n  <li>Melange: exactly one <em>Refiner</em> owner is required.</li>\n  <li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>",
 | 
			
		||||
    "details_html": "<h1>/spicepay — Guided payout</h1>\n<p>Calculate a fair split for a spice run. The wizard keeps things simple and transparent for the team.</p>\n\n<h2>Start</h2>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n  <li>If <code>participants</code> is omitted, you set it in the first modal (1–25).</li>\n  <li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>\n</ul>\n\n<h2>Setup modal</h2>\n<ul>\n  <li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>\n  <li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>\n</ul>\n\n<h2>Editing participants</h2>\n<p>For each slot:</p>\n<ul>\n  <li><strong>Name</strong> — free text (mention text is fine).</li>\n  <li><strong>Active %</strong> — 0–100. <em>0% = owner-only</em> (didn’t actively join).</li>\n  <li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>\n</ul>\n<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>\n\n<h2>Payout type</h2>\n<ul>\n  <li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>\n  <li><strong>Melange</strong> — <em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>\n</ul>\n<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>\n\n<h2>Weighting (the math, simplified)</h2>\n<ul>\n  <li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>\n  <li>We split the pot <em>proportionally</em> by weights (they’re normalized; only ratios matter).</li>\n  <li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>\n\n<h3>Controls</h3>\n<ul>\n  <li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>\n  <li><em>Adjust weighting factors</em> — change:\n    <ul>\n      <li><strong>Base × active %</strong> (default <em>server setting</em>)</li>\n      <li><strong>Carrier bonus</strong> ( + )</li>\n      <li><strong>Crawler bonus</strong> ( + )</li>\n      <li><strong>Refinery cut %</strong> (Melange only)</li>\n    </ul>\n  </li>\n  <li><em>Weights help</em> — short explanation inline.</li>\n  <li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>\n  <li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>\n  <li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>\n</ul>\n\n<h2>Validation & limits</h2>\n<ul>\n  <li>Participants: 1–25.</li>\n  <li>Active %: 0–100.</li>\n  <li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>\n  <li>No duplicate names (duplicates are auto-disambiguated in preview).</li>\n</ul>\n\n<h2>Posting</h2>\n<p>The post includes:</p>\n<ul>\n  <li>Header (Sand or Melange) with the chosen emoji.</li>\n  <li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>\n  <li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>\n</ul>\n<p><em>Footnote:</em> “0% = owner only”.</p>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "SpicePayCog.spicepay_cancel": {
 | 
			
		||||
    "brief_html": "<h2>/spicepay_cancel</h2>\n<p><strong>Cancel your active Spice Pay session.</strong> Clears everything for you so you can start fresh.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_cancel</pre>\n\n<ul>\n  <li>Removes your in-memory session (does not post anything).</li>\n  <li>Run <code>/spicepay</code> again to start over.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/spicepay_cancel — Cancel session</h1>\n<p>Discards your current wizard state. Handy if you mis-entered totals or want to restructure the roster.</p>\n\n<h2>What it does</h2>\n<ul>\n  <li>Deletes your session (you + this server).</li>\n  <li>Nothing is posted publicly.</li>\n</ul>\n\n<h2>Next steps</h2>\n<p>Run <code>/spicepay</code> to start again. You can optionally pass <code>participants</code> or just set them in the first modal.</p>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "SpicePayCog.spicepay_config": {
 | 
			
		||||
    "brief_html": "<h2>/spicepay_config</h2>\n<p><strong>Show server SpicePay weights.</strong> Read-only view of the defaults the wizard uses.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_config</pre>\n\n<ul>\n  <li>Displays: Refinery cut %, Base × active %, Carrier bonus, Crawler bonus.</li>\n  <li>Change via ENV/INI; restart the bot to apply.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/spicepay_config — Server weights</h1>\n<p>Shows the current default weighting used by the Spice Pay wizard.</p>\n\n<h2>Fields</h2>\n<ul>\n  <li><strong>Refinery cut %</strong> (Melange only) — percent taken first and paid to the Refiner owner.</li>\n  <li><strong>Base × active %</strong> — the core share, scaled by each player’s active participation.</li>\n  <li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>\n  <li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>\n</ul>\n\n<h2>Where to change</h2>\n<p>Configure via environment variables or your INI, then restart the bot. The wizard’s <em>Adjust weighting factors</em> lets users override per-run; those don’t change the server defaults.</p>\n\n<h2>Tips</h2>\n<ul>\n  <li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>\n  <li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "SpicePayCog.spicepay_resume": {
 | 
			
		||||
    "brief_html": "<h2>/spicepay_resume</h2>\n<p><strong>Reopen your active Spice Pay session.</strong> Useful if you closed the wizard message by accident.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_resume</pre>\n\n<ul>\n  <li>If a session exists, the wizard view is shown again.</li>\n  <li>If not, you’ll be told to run <code>/spicepay</code>.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/spicepay_resume — Resume wizard</h1>\n<p>Brings back the current session UI (if you already started one with <code>/spicepay</code>).</p>\n\n<h2>When to use</h2>\n<ul>\n  <li>You dismissed the ephemeral wizard message and want it back.</li>\n  <li>You’re mid-entry and don’t want to start over.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n  <li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>\n  <li>If none exists, you get a friendly nudge to start a new one.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "help": {
 | 
			
		||||
    "brief_html": "<h2>!help</h2>\n<p>Show a quick overview of commands or details about a specific command.</p>\n\n<h3>Usage</h3>\n<pre>!help\n!help spicepay\n!help pirate\n!help encounter</pre>\n\n<ul>\n  <li>Add a word to narrow down the list (e.g., <code>!help spice</code>).</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>!help — Command help & search</h1>\n<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>\n\n<h2>What it does</h2>\n<ul>\n  <li>Lists available commands you can run here.</li>\n  <li>Filters by a keyword (partial matches work).</li>\n  <li>When supported, shows usage and a short explanation for a specific command.</li>\n</ul>\n\n<h2>How to use it</h2>\n<ol>\n  <li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>\n  <li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>\n  <li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>\n</ol>\n\n<h2>Examples</h2>\n<pre>!help\n!help pirate\n!help spicepay\n!help encounter</pre>\n\n<h2>Notes & tips</h2>\n<ul>\n  <li>If you don’t get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n  <li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "PirateCardsCog.pirate_cards_rebuild": {
 | 
			
		||||
    "brief_html": "<h2>/pirate_cards_rebuild</h2>\n<p><strong>Rebuild or update pirate cards</strong> for every known pirate in the configured channel.</p>\n\n<h3>Usage</h3>\n<pre>/pirate_cards_rebuild</pre>\n\n<ul>\n  <li><strong>Moderator-only</strong>.</li>\n  <li>Run in a server channel. The reply is ephemeral.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n  <li>Creates or updates one embed “card” per pirate in the pirates list channel.</li>\n  <li>Each card shows Account, Threat %, grouping/destructive behavior buckets, encounters count, and last seen date.</li>\n  <li>Card color reflects threat (unknown → gray; low → green; high → red).</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/pirate_cards_rebuild — Rebuild all pirate cards</h1>\n<p>Runs a full pass that makes sure each verified pirate has a fresh, accurate card in the configured channel.</p>\n\n<h2>Access</h2>\n<ul>\n  <li><strong>Moderator-only</strong>.</li>\n  <li>Must be used in a server channel (not DMs). The confirmation is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it updates</h2>\n<ul>\n  <li><strong>Card fields</strong>: Account name, Threat %, “In groups” bucket, “Destructive” bucket, total encounters, and last encounter date (UTC).</li>\n  <li><strong>Colors</strong>: Unknown samples → dark gray. Otherwise a green→red gradient based on Threat %.</li>\n  <li><strong>Safety</strong>: User-provided names are escaped and mentions are neutralized (no pings).</li>\n</ul>\n\n<h2>Behavior</h2>\n<ol>\n  <li>For each pirate in the internal list:\n    <ul>\n      <li>If their card already exists, it edits the embed in place.</li>\n      <li>If it can’t find the message, it posts a new card and records the message ID.</li>\n    </ul>\n  </li>\n  <li>Works serially per guild to avoid race conditions.</li>\n</ol>\n\n<h2>Output</h2>\n<p>Ephemeral summary, e.g.:</p>\n<pre>Rebuilt/updated 27 pirate cards.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n  <li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>\n  <li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>\n  <li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>\n</ul>\n\n<h2>When to run it</h2>\n<ul>\n  <li>After importing or migrating data.</li>\n  <li>After a batch of new pirates is approved.</li>\n  <li>Any time the cards look out of date.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "UserCardsCog.usercards_rescan": {
 | 
			
		||||
    "brief_html": "<h2>/usercards_rescan</h2>\n<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>\n\n<h3>Usage</h3>\n<pre>/usercards_rescan</pre>\n\n<ul>\n  <li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>\n  <li>Runs in the server; reply is ephemeral with a short summary.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n  <li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>\n  <li>Grants/removes the Rules & RoE roles to match reactions.</li>\n  <li>For Nickname: opens a pending review if someone claimed but no review exists.</li>\n  <li>Rebuilds/updates every user’s status card in the list channel.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/usercards_rescan — Reconcile & refresh all cards</h1>\n<p>One-shot maintenance pass that makes the server’s user cards match reality.</p>\n\n<h2>Access</h2>\n<ul>\n  <li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>\n  <li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it fixes</h2>\n<ol>\n  <li><strong>Rules / RoE agreement</strong>\n    <ul>\n      <liReads reactions from your configured Rules and RoE messages.</li>\n      <li>Adds/removes the corresponding roles so roles match the reactions.</li>\n    </ul>\n  </li>\n  <li><strong>Nickname claim & reviews</strong>\n    <ul>\n      <li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>\n      <li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>\n    </ul>\n  </li>\n  <li><strong>User cards</strong>\n    <ul>\n      <li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>\n      <li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>\n      <li>Uses a stable footer marker (<code>UID:<id></code>) to find/edit the right card; cleans up duplicates.</li>\n    </ul>\n  </li>\n</ol>\n\n<h2>Expected output</h2>\n<p>The command replies (ephemeral) with counts like:</p>\n<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n  <li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>\n  <li>Won’t ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>\n</ul>\n\n<h2>Tips</h2>\n<ul>\n  <li>Run after importing a server, restoring from backup, or after downtime.</li>\n  <li>Large servers: this may take a moment while it walks members and edits cards. It’s safe to run again.</li>\n</ul>\n"
 | 
			
		||||
  },
 | 
			
		||||
  "DDLootTableCog.dd_update": {
 | 
			
		||||
    "brief_html": "<h2>/dd_update</h2>\n<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>\n\n<h3>Usage</h3>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n  <li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>\n  <li><code>reason</code> (optional) — short note shown in the confirmation.</li>\n</ul>\n\n<h3>Permissions</h3>\n<ul>\n  <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n  <li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n  <li><em>stop</em> — pauses all checks until <code>resume</code>.</li>\n  <li><em>resume</em> — returns to the normal weekly cycle.</li>\n  <li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>\n</ul>\n",
 | 
			
		||||
    "details_html": "<h1>/dd_update — Deep Desert updater controls</h1>\n<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>\n\n<h2>Usage</h2>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n  <li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>\n  <li><code>reason</code> (optional) — free text appended to the confirmation response.</li>\n</ul>\n\n<h2>Permissions</h2>\n<ul>\n  <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n  <li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n  <li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>\n  <li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:\n    <ul>\n      <li>Every 5 min for the first hour</li>\n      <li>Then every 15 min until 3 hours</li>\n      <li>Then every 30 min until 6 hours</li>\n      <li>Then every 1 hour until 24 hours</li>\n      <li>Then every 3 hours</li>\n    </ul>\n  </li>\n  <li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>\n  <li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>\n</ul>\n\n<h2>Actions</h2>\n<ul>\n  <li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>\n  <li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>\n  <li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>\n</ul>\n\n<h2>Channel & config</h2>\n<ul>\n  <li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>\n  <li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>\n</ul>\n\n<h2>Notes</h2>\n<ul>\n  <li>This command only controls the updater; it does not manually edit the posted message.</li>\n  <li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>\n</ul>\n"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
<h2>!help</h2>
 | 
			
		||||
<p>Show a quick overview of commands or details about a specific command.</p>
 | 
			
		||||
 | 
			
		||||
<h3>Usage</h3>
 | 
			
		||||
<pre>!help
 | 
			
		||||
!help spicepay
 | 
			
		||||
!help pirate
 | 
			
		||||
!help encounter</pre>
 | 
			
		||||
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Add a word to narrow down the list (e.g., <code>!help spice</code>).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
@ -1,32 +0,0 @@
 | 
			
		||||
<h1>!help — Command help & search</h1>
 | 
			
		||||
<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>
 | 
			
		||||
 | 
			
		||||
<h2>What it does</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>Lists available commands you can run here.</li>
 | 
			
		||||
  <li>Filters by a keyword (partial matches work).</li>
 | 
			
		||||
  <li>When supported, shows usage and a short explanation for a specific command.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>How to use it</h2>
 | 
			
		||||
<ol>
 | 
			
		||||
  <li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>
 | 
			
		||||
  <li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>
 | 
			
		||||
  <li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>
 | 
			
		||||
</ol>
 | 
			
		||||
 | 
			
		||||
<h2>Examples</h2>
 | 
			
		||||
<pre>!help
 | 
			
		||||
!help pirate
 | 
			
		||||
!help spicepay
 | 
			
		||||
!help encounter</pre>
 | 
			
		||||
 | 
			
		||||
<h2>Notes & tips</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li>If you don’t get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>
 | 
			
		||||
</ul>
 | 
			
		||||
 | 
			
		||||
<h2>Troubleshooting</h2>
 | 
			
		||||
<ul>
 | 
			
		||||
  <li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>
 | 
			
		||||
</ul>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 692 B  | 
| 
		 Before Width: | Height: | Size: 1.9 KiB  | 
| 
		 Before Width: | Height: | Size: 15 KiB  | 
@ -1,7 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22 16">
 | 
			
		||||
	<title>Flag of Norway</title>
 | 
			
		||||
	<rect width="22" height="16" fill="#ba0c2f"/>
 | 
			
		||||
	<path d="M0,8h22M8,0v16" stroke="#fff" stroke-width="4"/>
 | 
			
		||||
	<path d="M0,8h22M8,0v16" stroke="#00205b" stroke-width="2"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 319 B  | 
@ -1,12 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "ShaiWatcher",
 | 
			
		||||
  "short_name": "ShaiWatcher",
 | 
			
		||||
  "icons": [
 | 
			
		||||
    { "src": "/assets/docs/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
 | 
			
		||||
    { "src": "/assets/docs/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
 | 
			
		||||
  ],
 | 
			
		||||
  "theme_color": "#0f172a",
 | 
			
		||||
  "background_color": "#0b0f14",
 | 
			
		||||
  "display": "standalone",
 | 
			
		||||
  "start_url": "/"
 | 
			
		||||
}
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 49 KiB  | 
| 
		 Before Width: | Height: | Size: 19 KiB  | 
| 
		 Before Width: | Height: | Size: 53 KiB  | 
| 
		 Before Width: | Height: | Size: 16 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 20 KiB  | 
							
								
								
									
										219
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						@ -1,219 +0,0 @@
 | 
			
		||||
import os, signal, asyncio, pathlib
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from dotenv import load_dotenv
 | 
			
		||||
 | 
			
		||||
from data_manager import DataManager
 | 
			
		||||
from modules.common.settings import cfg as cfg_helper
 | 
			
		||||
from modules.common.boot_notice import post_boot_notice
 | 
			
		||||
 | 
			
		||||
# Version consists of:
 | 
			
		||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
			
		||||
VERSION = "0.5.1.2.a4"
 | 
			
		||||
 | 
			
		||||
# ---------- Env loading ----------
 | 
			
		||||
load_dotenv()
 | 
			
		||||
 | 
			
		||||
def _get_env(name: str, default: str = "") -> str:
 | 
			
		||||
    v = os.getenv(name, "")
 | 
			
		||||
    return (v or "").strip().strip('"').strip("'") or default
 | 
			
		||||
 | 
			
		||||
TOKEN = _get_env("DISCORD_TOKEN")
 | 
			
		||||
DATA_FILE = _get_env("DATA_FILE") or "./data/data.json"
 | 
			
		||||
 | 
			
		||||
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
 | 
			
		||||
print("[Config] DATA_FILE:", DATA_FILE)
 | 
			
		||||
 | 
			
		||||
# ---------- Ensure data path exists (fallback if not writable) ----------
 | 
			
		||||
data_dir = os.path.dirname(DATA_FILE) or "."
 | 
			
		||||
try:
 | 
			
		||||
    os.makedirs(data_dir, exist_ok=True)
 | 
			
		||||
except PermissionError:
 | 
			
		||||
    fallback = "./data/data.json"
 | 
			
		||||
    print(f"[Config] No permission to create '{data_dir}'. Falling back to {fallback}")
 | 
			
		||||
    DATA_FILE = fallback
 | 
			
		||||
    data_dir = os.path.dirname(DATA_FILE)
 | 
			
		||||
    os.makedirs(data_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
if not os.path.exists(DATA_FILE):
 | 
			
		||||
    with open(DATA_FILE, "w", encoding="utf-8") as f:
 | 
			
		||||
        f.write("{}")
 | 
			
		||||
 | 
			
		||||
# ---------- Discord intents ----------
 | 
			
		||||
 | 
			
		||||
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 ----------
 | 
			
		||||
 | 
			
		||||
if not TOKEN:
 | 
			
		||||
    print("[Config] WARNING: DISCORD_TOKEN is empty. The bot will fail to log in.")
 | 
			
		||||
 | 
			
		||||
bot = commands.Bot(command_prefix="!", intents=intents)
 | 
			
		||||
 | 
			
		||||
# Ensure data path exists and is seeded
 | 
			
		||||
os.makedirs(os.path.dirname(DATA_FILE) or ".", exist_ok=True)
 | 
			
		||||
if not os.path.exists(DATA_FILE):
 | 
			
		||||
    with open(DATA_FILE, "w", encoding="utf-8") as f:
 | 
			
		||||
        f.write("{}")
 | 
			
		||||
 | 
			
		||||
bot.data_manager = DataManager(DATA_FILE)
 | 
			
		||||
 | 
			
		||||
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
 | 
			
		||||
def _resolve_channel_id(c, key: str) -> int:
 | 
			
		||||
    """
 | 
			
		||||
    Resolve channel IDs from the runtime settings store (cfg), with a final
 | 
			
		||||
    fallback to legacy bot.config['DEFAULT'] if present. No SHAI_* env usage.
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        v = int(c.int(key, 0))
 | 
			
		||||
        if v:
 | 
			
		||||
            return v
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        # legacy DEFAULT mapping (ConfigParser-like or our shim)
 | 
			
		||||
        v = int(getattr(c, "get", lambda *_: 0)(key, 0))
 | 
			
		||||
        if v:
 | 
			
		||||
            return v
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    return 0
 | 
			
		||||
 | 
			
		||||
async def _guild_selfcheck(g: discord.Guild, c):
 | 
			
		||||
    problems = []
 | 
			
		||||
 | 
			
		||||
    def _need_channel(id_key, *perms):
 | 
			
		||||
        cid = _resolve_channel_id(c, id_key)
 | 
			
		||||
        if not cid:
 | 
			
		||||
            problems.append(f"Missing config key: {id_key}")
 | 
			
		||||
            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')
 | 
			
		||||
    _need_channel('dd_channel_id', 'read_messages', 'send_messages', 'read_message_history')
 | 
			
		||||
 | 
			
		||||
    if problems:
 | 
			
		||||
        print(f"[SelfCheck:{g.name}]")
 | 
			
		||||
        for p in problems:
 | 
			
		||||
            print(" -", p)
 | 
			
		||||
 | 
			
		||||
# ---------- events ----------
 | 
			
		||||
 | 
			
		||||
@bot.event
 | 
			
		||||
async def on_ready():
 | 
			
		||||
    import asyncio
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    env_cfg = cfg_helper(bot)
 | 
			
		||||
 | 
			
		||||
    # Per-guild permission sanity checks (env-aware)
 | 
			
		||||
    try:
 | 
			
		||||
        await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print("[SelfCheck] failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
    # ---------- Slash command scope & sync ----------
 | 
			
		||||
    try:
 | 
			
		||||
        # env_cfg already exists above in on_ready()
 | 
			
		||||
        gid = env_cfg.int("home_guild_id", 0)
 | 
			
		||||
 | 
			
		||||
        if gid > 0:
 | 
			
		||||
            print(f"[Slash] Mode: GUILD-ONLY → {gid}")
 | 
			
		||||
 | 
			
		||||
            guild_obj = discord.Object(id=gid)
 | 
			
		||||
 | 
			
		||||
            # Copy all currently-loaded global commands to HOME guild
 | 
			
		||||
            bot.tree.copy_global_to(guild=guild_obj)
 | 
			
		||||
            g_cmds = await bot.tree.sync(guild=guild_obj)
 | 
			
		||||
            g_names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
 | 
			
		||||
            print(f"[Slash] Synced {len(g_cmds)} commands to guild {gid}: {g_names}")
 | 
			
		||||
 | 
			
		||||
            # Clear global so only guild-scoped remain
 | 
			
		||||
            bot.tree.clear_commands(guild=None)
 | 
			
		||||
            cleared = await bot.tree.sync()  # push empty global set
 | 
			
		||||
            print(f"[Slash] Cleared global commands (now {len(cleared)}).")
 | 
			
		||||
 | 
			
		||||
            # Debug: list actual state after sync
 | 
			
		||||
            try:
 | 
			
		||||
                global_cmds = await bot.tree.fetch_commands()
 | 
			
		||||
                print(f"[Slash] Global commands ({len(global_cmds)}): {', '.join(f'/{c.name}' for c in global_cmds) or '(none)'}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("[Slash] Failed to fetch global commands:", repr(e))
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                g_cmds = await bot.tree.fetch_commands(guild=guild_obj)
 | 
			
		||||
                print(f"[Slash] Guild {gid} commands ({len(g_cmds)}): {', '.join(f'/{c.name}' for c in g_cmds) or '(none)'}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
 | 
			
		||||
        else:
 | 
			
		||||
            print("[Slash] Mode: GLOBAL (HOME_GUILD_ID not set)")
 | 
			
		||||
            global_cmds = await bot.tree.sync()
 | 
			
		||||
            names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
 | 
			
		||||
            print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
 | 
			
		||||
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print("[Slash] Sync failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
# ---------- Auto-discover extensions ----------
 | 
			
		||||
 | 
			
		||||
modules_path = pathlib.Path(__file__).parent / "modules"
 | 
			
		||||
extensions = []
 | 
			
		||||
for folder in modules_path.iterdir():
 | 
			
		||||
    if not folder.is_dir():
 | 
			
		||||
        continue
 | 
			
		||||
    # skip non-cog helpers under modules/common
 | 
			
		||||
    if folder.name == "common":
 | 
			
		||||
        continue
 | 
			
		||||
    for file in folder.glob("*.py"):
 | 
			
		||||
        if file.name == "__init__.py":
 | 
			
		||||
            continue
 | 
			
		||||
        extensions.append(f"modules.{folder.name}.{file.stem}")
 | 
			
		||||
 | 
			
		||||
def _install_signal_handlers(loop, bot_obj):
 | 
			
		||||
    def _graceful(*_):
 | 
			
		||||
        loop.create_task(bot_obj.close())
 | 
			
		||||
    for s in (signal.SIGTERM, signal.SIGINT):
 | 
			
		||||
        try:
 | 
			
		||||
            loop.add_signal_handler(s, _graceful)
 | 
			
		||||
        except NotImplementedError:
 | 
			
		||||
            pass  # Windows
 | 
			
		||||
 | 
			
		||||
async def main():
 | 
			
		||||
    print(f"[STARTUP] ShaiWatcher booting v{VERSION}")
 | 
			
		||||
    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))
 | 
			
		||||
        loop = asyncio.get_running_loop()
 | 
			
		||||
        _install_signal_handlers(loop, bot)
 | 
			
		||||
        await bot.start(TOKEN)
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    asyncio.run(main())
 | 
			
		||||
							
								
								
									
										156
									
								
								data_manager.py
									
									
									
									
									
								
							
							
						
						@ -1,156 +0,0 @@
 | 
			
		||||
# data_manager.py
 | 
			
		||||
import json
 | 
			
		||||
import threading
 | 
			
		||||
import shutil
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
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 _default_payload(self):
 | 
			
		||||
        return {
 | 
			
		||||
            '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': [],
 | 
			
		||||
            '_counters': {},         # key -> int (metrics)
 | 
			
		||||
            '_events_seen': {},      # optional (kept for other uses)
 | 
			
		||||
            '_counter_last_ts': {},  # key -> last increment unix_ts (timelock)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def _load(self):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.json_path, 'r', encoding='utf-8') as f:
 | 
			
		||||
                data = json.load(f)
 | 
			
		||||
                if not isinstance(data, dict):
 | 
			
		||||
                    raise ValueError("root is not an object")
 | 
			
		||||
                data.setdefault('_counters', {})
 | 
			
		||||
                data.setdefault('_events_seen', {})
 | 
			
		||||
                data.setdefault('_counter_last_ts', {})
 | 
			
		||||
                return data
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            default = self._default_payload()
 | 
			
		||||
            self._save(default)
 | 
			
		||||
            return default
 | 
			
		||||
        except Exception:
 | 
			
		||||
            # Backup the broken file if it exists, then start fresh
 | 
			
		||||
            try:
 | 
			
		||||
                if os.path.exists(self.json_path):
 | 
			
		||||
                    shutil.copy2(self.json_path, self.json_path + ".corrupt.bak")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            default = self._default_payload()
 | 
			
		||||
            self._save(default)
 | 
			
		||||
            return default
 | 
			
		||||
 | 
			
		||||
    def _safe_write(self, data: dict):
 | 
			
		||||
        os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
 | 
			
		||||
        tmp = self.json_path + ".tmp"
 | 
			
		||||
        with open(tmp, 'w', encoding='utf-8') as f:
 | 
			
		||||
            json.dump(data, f, indent=4, ensure_ascii=False)
 | 
			
		||||
        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):
 | 
			
		||||
        self._safe_write(data)
 | 
			
		||||
 | 
			
		||||
    # ------------- list helpers -------------
 | 
			
		||||
    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:
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            arr = self._data.get(category, [])
 | 
			
		||||
            for idx, item in enumerate(arr):
 | 
			
		||||
                if predicate(item):
 | 
			
		||||
                    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
 | 
			
		||||
 | 
			
		||||
    # ------------- counters (plain) -------------
 | 
			
		||||
    def incr_counter(self, key: str, by: int = 1) -> int:
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            c = self._data.setdefault('_counters', {})
 | 
			
		||||
            c[key] = int(c.get(key, 0)) + int(by)
 | 
			
		||||
            self._save(self._data)
 | 
			
		||||
            return c[key]
 | 
			
		||||
 | 
			
		||||
    def get_counter(self, key: str) -> int:
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            return int(self._data.get('_counters', {}).get(key, 0))
 | 
			
		||||
 | 
			
		||||
    def get_all_counters(self, prefix: str = "") -> dict[str, int]:
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            c = dict(self._data.get('_counters', {}))
 | 
			
		||||
        return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}
 | 
			
		||||
 | 
			
		||||
    # ------------- counters (timelocked) -------------
 | 
			
		||||
    def incr_counter_timelocked(self, counter_key: str, window_sec: float = 1.0) -> int | None:
 | 
			
		||||
        """
 | 
			
		||||
        Increment `counter_key` at most once per `window_sec`.
 | 
			
		||||
        Returns the new value if incremented, or None if suppressed by the timelock.
 | 
			
		||||
        """
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            last_map = self._data.setdefault('_counter_last_ts', {})
 | 
			
		||||
            last = float(last_map.get(counter_key, 0.0))
 | 
			
		||||
            if now - last < float(window_sec):
 | 
			
		||||
                # within lock window -> ignore
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            # increment and stamp
 | 
			
		||||
            counters = self._data.setdefault('_counters', {})
 | 
			
		||||
            counters[counter_key] = int(counters.get(counter_key, 0)) + 1
 | 
			
		||||
            last_map[counter_key] = now
 | 
			
		||||
 | 
			
		||||
            # opportunistic pruning for very old stamps (keeps file smaller)
 | 
			
		||||
            if len(last_map) > 5000:
 | 
			
		||||
                cutoff = now - (window_sec * 60)
 | 
			
		||||
                for k in list(last_map.keys()):
 | 
			
		||||
                    try:
 | 
			
		||||
                        if float(last_map.get(k, 0.0)) < cutoff:
 | 
			
		||||
                            last_map.pop(k, None)
 | 
			
		||||
                    except Exception:
 | 
			
		||||
                        last_map.pop(k, None)
 | 
			
		||||
 | 
			
		||||
            self._save(self._data)
 | 
			
		||||
            return counters[counter_key]
 | 
			
		||||
@ -1,148 +0,0 @@
 | 
			
		||||
# offline_preview.py
 | 
			
		||||
"""
 | 
			
		||||
ShaiWatcher offline preview (Discord-less)
 | 
			
		||||
 | 
			
		||||
Run from anywhere:
 | 
			
		||||
  # optional (forces root if your layout is odd)
 | 
			
		||||
  # export SHAI_PROJECT_ROOT=/absolute/path/to/shaiwatcher
 | 
			
		||||
  # export SHAI_DOCS_HOST=127.0.0.1
 | 
			
		||||
  # export SHAI_DOCS_PORT=8910
 | 
			
		||||
  # export SHAI_OFFLINE=1
 | 
			
		||||
  python3 offline_preview.py
 | 
			
		||||
"""
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import asyncio
 | 
			
		||||
import pathlib
 | 
			
		||||
import traceback
 | 
			
		||||
 | 
			
		||||
VERSION = "offline-preview-3"
 | 
			
		||||
 | 
			
		||||
# ---------- repo root discovery ----------
 | 
			
		||||
def _find_project_root() -> pathlib.Path:
 | 
			
		||||
    cand = []
 | 
			
		||||
    env = os.environ.get("SHAI_PROJECT_ROOT")
 | 
			
		||||
    if env:
 | 
			
		||||
        cand.append(pathlib.Path(env).resolve())
 | 
			
		||||
    here = pathlib.Path(__file__).resolve().parent
 | 
			
		||||
    cand.extend([
 | 
			
		||||
        pathlib.Path.cwd().resolve(),   # current working dir
 | 
			
		||||
        here,                           # folder containing this file
 | 
			
		||||
        here.parent,                    # one level up
 | 
			
		||||
        here.parent.parent,             # two levels up
 | 
			
		||||
    ])
 | 
			
		||||
    # Also walk upwards from CWD a few levels to be forgiving
 | 
			
		||||
    cur = pathlib.Path.cwd().resolve()
 | 
			
		||||
    for _ in range(5):
 | 
			
		||||
        cand.append(cur)
 | 
			
		||||
        cur = cur.parent
 | 
			
		||||
 | 
			
		||||
    tried = []
 | 
			
		||||
    for c in cand:
 | 
			
		||||
        tried.append(str(c))
 | 
			
		||||
        if (c / "modules").is_dir() and (c / "modules" / "common").is_dir():
 | 
			
		||||
            return c
 | 
			
		||||
 | 
			
		||||
    raise FileNotFoundError(
 | 
			
		||||
        "Could not locate project root with a 'modules/common' folder.\n"
 | 
			
		||||
        f"Tried:\n - " + "\n - ".join(tried) +
 | 
			
		||||
        "\nTip: set SHAI_PROJECT_ROOT=/absolute/path/to/repo"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
PROJECT_ROOT = _find_project_root()
 | 
			
		||||
if str(PROJECT_ROOT) not in sys.path:
 | 
			
		||||
    sys.path.insert(0, str(PROJECT_ROOT))
 | 
			
		||||
 | 
			
		||||
# ---------- now safe to import project modules ----------
 | 
			
		||||
import discord  # type: ignore
 | 
			
		||||
from discord.ext import commands  # type: ignore
 | 
			
		||||
 | 
			
		||||
# Optional: your config helper if cogs expect it to exist
 | 
			
		||||
try:
 | 
			
		||||
    from modules.common.settings import cfg as cfg_helper  # noqa: F401
 | 
			
		||||
except Exception as e:
 | 
			
		||||
    print("[OFFLINE] Warning: couldn't import cfg helper:", repr(e))
 | 
			
		||||
 | 
			
		||||
def _discover_extensions(project_root: pathlib.Path):
 | 
			
		||||
    modules_path = project_root / "modules"
 | 
			
		||||
    exts = []
 | 
			
		||||
    for folder in modules_path.iterdir():
 | 
			
		||||
        if not folder.is_dir():
 | 
			
		||||
            continue
 | 
			
		||||
        if folder.name == "common":  # match your prod loader
 | 
			
		||||
            continue
 | 
			
		||||
        for file in folder.glob("*.py"):
 | 
			
		||||
            if file.name == "__init__.py":
 | 
			
		||||
                continue
 | 
			
		||||
            exts.append(f"modules.{folder.name}.{file.stem}")
 | 
			
		||||
    return exts
 | 
			
		||||
 | 
			
		||||
async def main():
 | 
			
		||||
    print(f"[OFFLINE] ShaiWatcher offline preview v{VERSION}")
 | 
			
		||||
    print(f"[OFFLINE] Project root -> {PROJECT_ROOT}")
 | 
			
		||||
 | 
			
		||||
    # Keep intents minimal; we never connect anyway
 | 
			
		||||
    intents = discord.Intents.none()
 | 
			
		||||
    intents.guilds = True
 | 
			
		||||
 | 
			
		||||
    bot = commands.Bot(command_prefix="!", intents=intents)
 | 
			
		||||
 | 
			
		||||
    # Mark environment as offline for any cogs that check it
 | 
			
		||||
    os.environ.setdefault("SHAI_OFFLINE", "1")
 | 
			
		||||
    # Bind docs to localhost by default while testing
 | 
			
		||||
    os.environ.setdefault("SHAI_DOCS_HOST", "127.0.0.1")
 | 
			
		||||
    os.environ.setdefault("SHAI_DOCS_PORT", "8910")
 | 
			
		||||
    os.environ.setdefault("SHAI_DOCS_TITLE", "ShaiWatcher (Offline Preview)")
 | 
			
		||||
 | 
			
		||||
    # Optional: isolate data file so we don't touch prod paths
 | 
			
		||||
    data_file = os.environ.get("SHAI_DATA", str(PROJECT_ROOT / ".offline_data.json"))
 | 
			
		||||
    try:
 | 
			
		||||
        from data_manager import DataManager  # if your project has this at root
 | 
			
		||||
        os.makedirs(os.path.dirname(data_file) or ".", exist_ok=True)
 | 
			
		||||
        if not os.path.exists(data_file):
 | 
			
		||||
            with open(data_file, "w", encoding="utf-8") as f:
 | 
			
		||||
                f.write("{}")
 | 
			
		||||
        bot.data_manager = DataManager(data_file)
 | 
			
		||||
        print(f"[OFFLINE] DATA_FILE -> {data_file}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print("[OFFLINE] DataManager unavailable/failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
    os.environ.setdefault("SHAI_OFFLINE", "1")  # before loading cogs
 | 
			
		||||
 | 
			
		||||
    # Load extensions exactly like prod
 | 
			
		||||
    failures = 0
 | 
			
		||||
    for ext in _discover_extensions(PROJECT_ROOT):
 | 
			
		||||
        try:
 | 
			
		||||
            await bot.load_extension(ext)
 | 
			
		||||
            print(f"[Modules] Loaded: {ext}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            failures += 1
 | 
			
		||||
            print(f"[Modules] Failed to load {ext}: {e}")
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
 | 
			
		||||
    if failures:
 | 
			
		||||
        print(f"[OFFLINE] Loaded with {failures} module error(s). See logs above.")
 | 
			
		||||
 | 
			
		||||
    docs = bot.get_cog("DocsSite")
 | 
			
		||||
    if docs and hasattr(docs, "force_ready"):
 | 
			
		||||
        docs.force_ready(True)
 | 
			
		||||
 | 
			
		||||
    # Make is_ready() == True so DocsSite serves immediately
 | 
			
		||||
    try:
 | 
			
		||||
        # discord.py sets this in login/READY; we emulate it
 | 
			
		||||
        if not hasattr(bot, "_ready") or bot._ready is None:  # type: ignore[attr-defined]
 | 
			
		||||
            bot._ready = asyncio.Event()  # type: ignore[attr-defined]
 | 
			
		||||
        bot._ready.set()  # type: ignore[attr-defined]
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    print("[OFFLINE] Docs: http://%s:%s/"
 | 
			
		||||
          % (os.environ.get("SHAI_DOCS_HOST", "0.0.0.0"),
 | 
			
		||||
             os.environ.get("SHAI_DOCS_PORT", "8911")))
 | 
			
		||||
    print("[OFFLINE] This runner does NOT connect to Discord.")
 | 
			
		||||
 | 
			
		||||
    # Idle forever; DocsSite runs in its own daemon thread
 | 
			
		||||
    await asyncio.Event().wait()
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    asyncio.run(main())
 | 
			
		||||
@ -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
 | 
			
		||||
							
								
								
									
										63
									
								
								mod_perms.py
									
									
									
									
									
								
							
							
						
						@ -1,63 +0,0 @@
 | 
			
		||||
# mod_perms.py
 | 
			
		||||
import re
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from modules.common.settings import cfg  # ENV-first config helper
 | 
			
		||||
 | 
			
		||||
def _parse_ids(raw: str):
 | 
			
		||||
    ids = []
 | 
			
		||||
    if not raw:
 | 
			
		||||
        return ids
 | 
			
		||||
    for tok in re.split(r'[,\s]+', raw.strip()):
 | 
			
		||||
        if not tok:
 | 
			
		||||
            continue
 | 
			
		||||
        try:
 | 
			
		||||
            ids.append(int(tok))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
    return ids
 | 
			
		||||
 | 
			
		||||
def get_mod_role_ids(bot: commands.Bot):
 | 
			
		||||
    # Read from ENV/INI via helper; allow comma-separated lists in any field
 | 
			
		||||
    reader = cfg(bot)
 | 
			
		||||
    keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
 | 
			
		||||
    collected = []
 | 
			
		||||
    for k in keys:
 | 
			
		||||
        collected.extend(_parse_ids(reader.get(k, "")))
 | 
			
		||||
    # dedupe while preserving order
 | 
			
		||||
    seen = set()
 | 
			
		||||
    unique = []
 | 
			
		||||
    for i in collected:
 | 
			
		||||
        if i not in seen:
 | 
			
		||||
            seen.add(i)
 | 
			
		||||
            unique.append(i)
 | 
			
		||||
    return unique
 | 
			
		||||
 | 
			
		||||
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,168 +0,0 @@
 | 
			
		||||
# modules/admin/shaiadmin.py
 | 
			
		||||
import io
 | 
			
		||||
import json
 | 
			
		||||
from typing import Any, Dict, List
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import (
 | 
			
		||||
    cfg, SETTINGS_SCHEMA, settings_path, settings_get_all,
 | 
			
		||||
    settings_set, settings_reset, settings_import_bulk, ValidationError,
 | 
			
		||||
)
 | 
			
		||||
from mod_perms import is_moderator_member  # keep if you want mods as managers
 | 
			
		||||
 | 
			
		||||
def _user_has_role_ids(member: discord.Member, role_ids: List[int]) -> bool:
 | 
			
		||||
    if not isinstance(member, discord.Member) or not role_ids:
 | 
			
		||||
        return False
 | 
			
		||||
    rset = set(role_ids)
 | 
			
		||||
    return any(r.id in rset for r in member.roles)
 | 
			
		||||
 | 
			
		||||
async def _is_owner(bot: commands.Bot, user: discord.abc.User) -> bool:
 | 
			
		||||
    try:
 | 
			
		||||
        return await bot.is_owner(user)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
def _get_admin_lists(bot: commands.Bot) -> Dict[str, List[int]]:
 | 
			
		||||
    r = cfg(bot)
 | 
			
		||||
    users, roles = [], []
 | 
			
		||||
    try:
 | 
			
		||||
        users = json.loads(r.get("admin_user_ids", "[]"))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        users = []
 | 
			
		||||
    try:
 | 
			
		||||
        roles = json.loads(r.get("admin_role_ids", "[]"))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        roles = []
 | 
			
		||||
    return {"users": users, "roles": roles}
 | 
			
		||||
 | 
			
		||||
async def _check_admin(inter: discord.Interaction) -> bool:
 | 
			
		||||
    bot: commands.Bot = inter.client  # type: ignore
 | 
			
		||||
    user = inter.user
 | 
			
		||||
    if await _is_owner(bot, user):
 | 
			
		||||
        return True
 | 
			
		||||
    if isinstance(user, discord.Member):
 | 
			
		||||
        lists = _get_admin_lists(bot)
 | 
			
		||||
        if user.id in set(lists["users"]):
 | 
			
		||||
            return True
 | 
			
		||||
        if _user_has_role_ids(user, lists["roles"]):
 | 
			
		||||
            return True
 | 
			
		||||
        if is_moderator_member(user, bot):  # optional; remove if not desired
 | 
			
		||||
            return True
 | 
			
		||||
    if not inter.response.is_done():
 | 
			
		||||
        await inter.response.send_message("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
 | 
			
		||||
    else:
 | 
			
		||||
        await inter.followup.send("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShaiAdminCog(commands.Cog):
 | 
			
		||||
    """Runtime settings administration (file-backed)."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
 | 
			
		||||
    # Guild-only group; prefix description with [ADMIN]
 | 
			
		||||
    shaiadmin = app_commands.Group(
 | 
			
		||||
        name="shaiadmin",
 | 
			
		||||
        description="[ADMIN] Owner/approved-only settings manager.",
 | 
			
		||||
        guild_only=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # ---- bound coroutine for autocomplete ----
 | 
			
		||||
    async def ac_setting_keys(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]:
 | 
			
		||||
        cur = (current or "").lower()
 | 
			
		||||
        keys = [k for k in sorted(SETTINGS_SCHEMA.keys()) if cur in k]
 | 
			
		||||
        return [app_commands.Choice(name=k, value=k) for k in keys[:25]]
 | 
			
		||||
 | 
			
		||||
    # /shaiadmin set
 | 
			
		||||
    @shaiadmin.command(name="set", description="[ADMIN] Set a setting (validated, persisted, applied).")
 | 
			
		||||
    @app_commands.describe(setting_name="Which setting to change", value="New value (type depends on setting)")
 | 
			
		||||
    @app_commands.autocomplete(setting_name=ac_setting_keys)
 | 
			
		||||
    async def set_value(self, inter: discord.Interaction, setting_name: str, value: str):
 | 
			
		||||
        if not await _check_admin(inter):
 | 
			
		||||
            return
 | 
			
		||||
        await inter.response.defer(ephemeral=True, thinking=True)
 | 
			
		||||
        setting_name = setting_name.lower().strip()
 | 
			
		||||
        try:
 | 
			
		||||
            changed = settings_set(setting_name, value)
 | 
			
		||||
            await inter.followup.send(
 | 
			
		||||
                f"✅ `{setting_name}` updated and applied." if changed else "ℹ️ No change.",
 | 
			
		||||
                ephemeral=True,
 | 
			
		||||
            )
 | 
			
		||||
        except ValidationError as ve:
 | 
			
		||||
            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            await inter.followup.send(f"❌ Failed to set `{setting_name}`: {e!r}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    # /shaiadmin unset
 | 
			
		||||
    @shaiadmin.command(name="unset", description="[ADMIN] Reset/unset a setting to its default.")
 | 
			
		||||
    @app_commands.describe(setting_name="Which setting to reset")
 | 
			
		||||
    @app_commands.autocomplete(setting_name=ac_setting_keys)
 | 
			
		||||
    async def unset_value(self, inter: discord.Interaction, setting_name: str):
 | 
			
		||||
        if not await _check_admin(inter):
 | 
			
		||||
            return
 | 
			
		||||
        await inter.response.defer(ephemeral=True, thinking=True)
 | 
			
		||||
        setting_name = setting_name.lower().strip()
 | 
			
		||||
        try:
 | 
			
		||||
            settings_reset(setting_name)
 | 
			
		||||
            await inter.followup.send(f"✅ `{setting_name}` reset to default and applied.", ephemeral=True)
 | 
			
		||||
        except ValidationError as ve:
 | 
			
		||||
            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            await inter.followup.send(f"❌ Failed to reset `{setting_name}`: {e!r}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    # /shaiadmin settings (download/upload)
 | 
			
		||||
    settings = app_commands.Group(
 | 
			
		||||
        name="settings",
 | 
			
		||||
        description="[ADMIN] Download or upload the full settings JSON.",
 | 
			
		||||
        parent=shaiadmin,
 | 
			
		||||
        guild_only=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @settings.command(name="download", description="[ADMIN] Download the current settings.json")
 | 
			
		||||
    async def download(self, inter: discord.Interaction):
 | 
			
		||||
        if not await _check_admin(inter):
 | 
			
		||||
            return
 | 
			
		||||
        await inter.response.defer(ephemeral=True, thinking=True)
 | 
			
		||||
        data = settings_get_all()
 | 
			
		||||
        buf = io.BytesIO(json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8"))
 | 
			
		||||
        buf.seek(0)
 | 
			
		||||
        await inter.followup.send(
 | 
			
		||||
            content=f"📦 Current settings from `{settings_path()}`",
 | 
			
		||||
            file=discord.File(buf, filename="settings.json"),
 | 
			
		||||
            ephemeral=True,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @settings.command(name="upload", description="[ADMIN] Upload and apply a settings.json")
 | 
			
		||||
    @app_commands.describe(file="A JSON file exported by /shaiadmin settings download (or matching the schema).")
 | 
			
		||||
    async def upload(self, inter: discord.Interaction, file: discord.Attachment):
 | 
			
		||||
        if not await _check_admin(inter):
 | 
			
		||||
            return
 | 
			
		||||
        await inter.response.defer(ephemeral=True, thinking=True)
 | 
			
		||||
        if not file or not file.filename.lower().endswith(".json"):
 | 
			
		||||
            await inter.followup.send("Please attach a `.json` file.", ephemeral=True)
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            raw = await file.read()
 | 
			
		||||
            obj = json.loads(raw.decode("utf-8"))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            await inter.followup.send("❌ Invalid JSON file.", ephemeral=True)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            changed_keys = settings_import_bulk(obj)
 | 
			
		||||
            await inter.followup.send(
 | 
			
		||||
                f"✅ Uploaded and applied `{len(changed_keys)}` keys: {', '.join(sorted(changed_keys))}."
 | 
			
		||||
                if changed_keys else "ℹ️ No changes detected.",
 | 
			
		||||
                ephemeral=True,
 | 
			
		||||
            )
 | 
			
		||||
        except ValidationError as ve:
 | 
			
		||||
            await inter.followup.send(f"❌ {ve}", ephemeral=True)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            await inter.followup.send(f"❌ Upload failed: {e!r}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    await bot.add_cog(ShaiAdminCog(bot))
 | 
			
		||||
@ -1,281 +0,0 @@
 | 
			
		||||
# modules/auto_vc/auto_vc.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from modules.common.settings import cfg  # ENV-first config helper
 | 
			
		||||
from mod_perms import require_mod_ctx, require_mod_interaction  # <- use project mod perms
 | 
			
		||||
 | 
			
		||||
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   -> [MOD] run a cleanup/renumber pass now
 | 
			
		||||
      /avc_renumber      -> [MOD] renumber without deleting
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
 | 
			
		||||
        # Config (ENV/INI via helper; safe defaults)
 | 
			
		||||
        self.trigger_id = r.int('trigger_channel_id', 0)
 | 
			
		||||
        self.category_id = r.int('auto_vc_category_id', 0)
 | 
			
		||||
        self.prefix = r.get('vc_name_prefix', 'Room')
 | 
			
		||||
        self.delay = r.int('auto_vc_cleanup_delay', 30)
 | 
			
		||||
        self.modlog_channel_id = r.int('modlog_channel_id', 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()
 | 
			
		||||
        self._ops_lock = asyncio.Lock()           # serialize admin ops vs sweeper
 | 
			
		||||
 | 
			
		||||
        # 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."""
 | 
			
		||||
        if not self.category_id:
 | 
			
		||||
            return
 | 
			
		||||
        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:
 | 
			
		||||
                # Serialize with admin ops
 | 
			
		||||
                async with self._ops_lock:
 | 
			
		||||
                    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
 | 
			
		||||
        if not self.category_id:
 | 
			
		||||
            await self._log(guild, "⚠️ auto_vc_category_id not configured; cannot create rooms.")
 | 
			
		||||
            return
 | 
			
		||||
        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 self.trigger_id and 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 and self.category_id:
 | 
			
		||||
            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 -------------
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="avc_status", description="Show Auto-VC status for this guild")
 | 
			
		||||
    async def avc_status(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        g = interaction.guild
 | 
			
		||||
        recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
 | 
			
		||||
        lines = [
 | 
			
		||||
            f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | 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 interaction.response.send_message(msg)
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)")
 | 
			
		||||
    async def avc_cleanup_now(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
        async with self._ops_lock:
 | 
			
		||||
            await self._cleanup_pass(interaction.guild)
 | 
			
		||||
        await self._log(interaction.guild, f"🧹 Cleanup pass invoked by {interaction.user.mention}")
 | 
			
		||||
        await interaction.followup.send("Cleanup pass complete.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms")
 | 
			
		||||
    async def avc_renumber(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
        async with self._ops_lock:
 | 
			
		||||
            await self._renumber(interaction.guild)
 | 
			
		||||
        await self._log(interaction.guild, f"🔢 Renumber invoked by {interaction.user.mention}")
 | 
			
		||||
        await interaction.followup.send("Renumbered.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(AutoVCCog(bot))
 | 
			
		||||
@ -1,296 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from urllib.parse import urlparse, urlencode
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
import aiohttp
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
# ---------- Version helpers ----------
 | 
			
		||||
 | 
			
		||||
_VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b')
 | 
			
		||||
 | 
			
		||||
def _extract_version(subject: str) -> str | None:
 | 
			
		||||
    if not subject:
 | 
			
		||||
        return None
 | 
			
		||||
    m = _VERSION_RE.search(subject)
 | 
			
		||||
    return m.group(0) if m else None
 | 
			
		||||
 | 
			
		||||
def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
 | 
			
		||||
    if not full_message:
 | 
			
		||||
        return None, None
 | 
			
		||||
    lines = [ln.rstrip() for ln in full_message.splitlines()]
 | 
			
		||||
    subject = None
 | 
			
		||||
    i = 0
 | 
			
		||||
    while i < len(lines) and subject is None:
 | 
			
		||||
        if lines[i].strip():
 | 
			
		||||
            subject = lines[i].strip()
 | 
			
		||||
        i += 1
 | 
			
		||||
    body = '\n'.join(lines[i:]).strip() if i < len(lines) else ''
 | 
			
		||||
    return subject or None, (body or None)
 | 
			
		||||
 | 
			
		||||
def _cmp_versions(a: str | None, b: str | None) -> int:
 | 
			
		||||
    """Compare 1.2.3.4.a2 style; if either missing, treat as equal (0)."""
 | 
			
		||||
    if not a or not b:
 | 
			
		||||
        return 0
 | 
			
		||||
    pa, pb = a.split('.'), b.split('.')
 | 
			
		||||
    while len(pa) < 5: pa.append('0')
 | 
			
		||||
    while len(pb) < 5: pb.append('0')
 | 
			
		||||
 | 
			
		||||
    def key(x: str):
 | 
			
		||||
        if x.isdigit():
 | 
			
		||||
            return (int(x), '', 1)
 | 
			
		||||
        m = re.match(r'(\d+)(.*)', x)
 | 
			
		||||
        if m:
 | 
			
		||||
            return (int(m.group(1)), m.group(2), 2)
 | 
			
		||||
        return (0, x, 3)
 | 
			
		||||
 | 
			
		||||
    for xa, xb in zip(pa, pb):
 | 
			
		||||
        ka, kb = key(xa), key(xb)
 | 
			
		||||
        if ka[0] != kb[0]:
 | 
			
		||||
            return 1 if ka[0] > kb[0] else -1
 | 
			
		||||
        if ka[2] != kb[2]:
 | 
			
		||||
            return 1 if ka[2] < kb[2] else -1
 | 
			
		||||
        if ka[1] != kb[1]:
 | 
			
		||||
            return 1 if ka[1] > kb[1] else -1
 | 
			
		||||
    return 0
 | 
			
		||||
 | 
			
		||||
# ---------- Gitea helpers ----------
 | 
			
		||||
 | 
			
		||||
def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
 | 
			
		||||
    """
 | 
			
		||||
    From https://host/owner/repo(.git) -> (api_base, owner, repo)
 | 
			
		||||
    api_base = https://host/api/v1
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        pr = urlparse(repo_url.strip().rstrip('/'))
 | 
			
		||||
        parts = [p for p in pr.path.split('/') if p]
 | 
			
		||||
        if len(parts) >= 2:
 | 
			
		||||
            owner, repo = parts[0], parts[1]
 | 
			
		||||
            if repo.endswith('.git'):
 | 
			
		||||
                repo = repo[:-4]
 | 
			
		||||
            api_base = f"{pr.scheme}://{pr.netloc}/api/v1"
 | 
			
		||||
            return api_base, owner, repo
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return None, None, None
 | 
			
		||||
 | 
			
		||||
def _auth_headers_from_cfg(r):
 | 
			
		||||
    """
 | 
			
		||||
    Build Authorization header using repo auth tokens.
 | 
			
		||||
    - Preferred: cfg('repo_ahtoken') (comes from settings.json or env REPO_AHTOKEN)
 | 
			
		||||
    - Fallbacks: GITEA_TOKEN / GITEA_USER envs (non-SHAI)
 | 
			
		||||
    """
 | 
			
		||||
    ahtoken = r.get('repo_ahtoken', '').strip()  # REPO_AHTOKEN via settings/env
 | 
			
		||||
    if ahtoken:
 | 
			
		||||
        if not ahtoken.lower().startswith('token '):
 | 
			
		||||
            ahtoken = f"token {ahtoken}"
 | 
			
		||||
        return {"Authorization": ahtoken}
 | 
			
		||||
 | 
			
		||||
    tok = os.getenv("GITEA_TOKEN", "").strip()
 | 
			
		||||
    usr = os.getenv("GITEA_USER", "").strip()
 | 
			
		||||
    if tok and usr:
 | 
			
		||||
        import base64
 | 
			
		||||
        b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
 | 
			
		||||
        return {"Authorization": f"Basic {b64}"}
 | 
			
		||||
    if tok:
 | 
			
		||||
        return {"Authorization": f"token {tok}"}
 | 
			
		||||
    return {}
 | 
			
		||||
 | 
			
		||||
async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
 | 
			
		||||
    import aiohttp
 | 
			
		||||
    timeout = aiohttp.ClientTimeout(total=timeout_sec)
 | 
			
		||||
    async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
 | 
			
		||||
        async with sess.get(url) as resp:
 | 
			
		||||
            ctype = resp.headers.get("Content-Type", "")
 | 
			
		||||
            if resp.status != 200:
 | 
			
		||||
                text = await resp.text()
 | 
			
		||||
                raise RuntimeError(f"Gitea GET {url} -> {resp.status} ({ctype}): {text[:200]}")
 | 
			
		||||
            if "application/json" not in ctype:
 | 
			
		||||
                text = await resp.text()
 | 
			
		||||
                raise RuntimeError(f"Gitea GET {url} non-JSON {ctype}: {text[:200]}")
 | 
			
		||||
            return await resp.json()
 | 
			
		||||
 | 
			
		||||
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str | None,
 | 
			
		||||
                               headers: dict) -> tuple[str | None, str | None, str | None]:
 | 
			
		||||
    """
 | 
			
		||||
    Returns (sha, subject, body) for latest commit using list-commits:
 | 
			
		||||
    /api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
 | 
			
		||||
    If branch is falsy, omit 'sha' to use server default.
 | 
			
		||||
    """
 | 
			
		||||
    from urllib.parse import urlencode
 | 
			
		||||
    params = {
 | 
			
		||||
        "stat": "false",
 | 
			
		||||
        "verification": "false",
 | 
			
		||||
        "files": "false",
 | 
			
		||||
        "limit": "1",
 | 
			
		||||
    }
 | 
			
		||||
    if branch:
 | 
			
		||||
        params["sha"] = branch
 | 
			
		||||
    url = f"{api_base}/repos/{owner}/{repo}/commits?{urlencode(params)}"
 | 
			
		||||
 | 
			
		||||
    data = await _http_json(url, headers)
 | 
			
		||||
    if not isinstance(data, list) or not data:
 | 
			
		||||
        raise RuntimeError("Commits list empty or invalid")
 | 
			
		||||
 | 
			
		||||
    latest = data[0]
 | 
			
		||||
    sha = latest.get("sha") or latest.get("id")
 | 
			
		||||
    message = ""
 | 
			
		||||
    commit_obj = latest.get("commit") or {}
 | 
			
		||||
    if isinstance(commit_obj, dict):
 | 
			
		||||
        message = commit_obj.get("message") or ""
 | 
			
		||||
 | 
			
		||||
    subject, body = _split_subject_body(message or "")
 | 
			
		||||
    return sha, (subject or ""), (body or "")
 | 
			
		||||
 | 
			
		||||
# ---------- Boot reason inference ----------
 | 
			
		||||
 | 
			
		||||
def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool:
 | 
			
		||||
    if not hhmm_utc:
 | 
			
		||||
        return False
 | 
			
		||||
    try:
 | 
			
		||||
        hh, mm = [int(x) for x in hhmm_utc.strip().split(':', 1)]
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
    sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0)
 | 
			
		||||
    return abs((now_utc - sched).total_seconds()) <= window_min * 60
 | 
			
		||||
 | 
			
		||||
def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str:
 | 
			
		||||
    if kind == "updated":
 | 
			
		||||
        return f"✅ Updated from **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
 | 
			
		||||
    if kind == "scheduled":
 | 
			
		||||
        return "🕒 Scheduled restart executed"
 | 
			
		||||
    if kind == "manual":
 | 
			
		||||
        return "🟢 Manual restart detected"
 | 
			
		||||
    if kind == "rollback":
 | 
			
		||||
        return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
 | 
			
		||||
    return "🟢 Bot started"
 | 
			
		||||
 | 
			
		||||
# ---------- Main entry ----------
 | 
			
		||||
 | 
			
		||||
async def post_boot_notice(bot):
 | 
			
		||||
    """
 | 
			
		||||
    Always posts a startup status to the modlog channel.
 | 
			
		||||
    - If version changed (update or rollback): post status + full commit message.
 | 
			
		||||
    - If NO version change (manual/scheduled): post status ONLY, but append the running version to that status.
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        await bot.wait_until_ready()
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print(f"[boot_notice] wait_until_ready failed: {e}")
 | 
			
		||||
 | 
			
		||||
    for guild in bot.guilds:
 | 
			
		||||
        print(f'  - {guild.name} (id: {guild.id})')
 | 
			
		||||
 | 
			
		||||
    r = cfg(bot)
 | 
			
		||||
    modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
			
		||||
    if not modlog_channel_id:
 | 
			
		||||
        print("[boot_notice] modlog_channel_id not configured; skipping.")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    ch = bot.get_channel(modlog_channel_id)
 | 
			
		||||
    if not ch:
 | 
			
		||||
        for g in bot.guilds:
 | 
			
		||||
            ch = g.get_channel(modlog_channel_id)
 | 
			
		||||
            if ch:
 | 
			
		||||
                break
 | 
			
		||||
    if not ch:
 | 
			
		||||
        print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    repo_url = r.get('repo_url', '')
 | 
			
		||||
    branch = r.get('repo_branch', 'main') or None
 | 
			
		||||
    check_time_utc = r.get('check_time_utc', '')
 | 
			
		||||
    headers = _auth_headers_from_cfg(r)
 | 
			
		||||
 | 
			
		||||
    api_base = owner = repo = None
 | 
			
		||||
    if repo_url:
 | 
			
		||||
        api_base, owner, repo = _parse_repo_url(repo_url)
 | 
			
		||||
        if not all([api_base, owner, repo]):
 | 
			
		||||
            print(f"[boot_notice] failed to parse repo_url={repo_url!r}")
 | 
			
		||||
    else:
 | 
			
		||||
        print("[boot_notice] repo_url missing; commit lookup skipped.")
 | 
			
		||||
 | 
			
		||||
    # State
 | 
			
		||||
    dm = getattr(bot, "data_manager", None)
 | 
			
		||||
    if not dm:
 | 
			
		||||
        print("[boot_notice] data_manager missing on bot; cannot persist state.")
 | 
			
		||||
        return
 | 
			
		||||
    prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
 | 
			
		||||
    prev_sha = prev.get('last_sha') or None
 | 
			
		||||
    prev_ver = prev.get('last_version') or None
 | 
			
		||||
 | 
			
		||||
    # Fetch latest commit
 | 
			
		||||
    sha = subject = body = None
 | 
			
		||||
    if api_base and owner and repo:
 | 
			
		||||
        try:
 | 
			
		||||
            sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, headers)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print(f"[boot_notice] fetch latest commit failed: {e}")
 | 
			
		||||
    else:
 | 
			
		||||
        print("[boot_notice] repo parsing failed; commit lookup skipped.")
 | 
			
		||||
 | 
			
		||||
    curr_ver = _extract_version(subject) if subject else None
 | 
			
		||||
 | 
			
		||||
    # Decide reason
 | 
			
		||||
    now_utc = datetime.now(timezone.utc)
 | 
			
		||||
    if prev_ver and curr_ver:
 | 
			
		||||
        cmpv = _cmp_versions(prev_ver, curr_ver)
 | 
			
		||||
        if cmpv < 0:
 | 
			
		||||
            reason, ping_owner = "updated", False
 | 
			
		||||
        elif cmpv > 0:
 | 
			
		||||
            reason, ping_owner = "rollback", True
 | 
			
		||||
        else:
 | 
			
		||||
            reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
 | 
			
		||||
    else:
 | 
			
		||||
        if prev_sha and sha and prev_sha != sha:
 | 
			
		||||
            reason, ping_owner = "updated", False
 | 
			
		||||
        else:
 | 
			
		||||
            reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
 | 
			
		||||
 | 
			
		||||
    # Build + post status line
 | 
			
		||||
    status_line = _format_status_line(reason, prev_ver, curr_ver)
 | 
			
		||||
 | 
			
		||||
    # If no version change (manual/scheduled), append the running version to the status line,
 | 
			
		||||
    # and DO NOT post the commit message separately.
 | 
			
		||||
    append_version_only = reason in ("manual", "scheduled")
 | 
			
		||||
    if append_version_only and curr_ver:
 | 
			
		||||
        status_line = f"{status_line} — running **{curr_ver}**"
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        allowed = discord.AllowedMentions(
 | 
			
		||||
            everyone=False,
 | 
			
		||||
            users=True if (ping_owner and ch.guild and ch.guild.owner_id) else False,
 | 
			
		||||
            roles=False,
 | 
			
		||||
            replied_user=False
 | 
			
		||||
        )
 | 
			
		||||
        if ping_owner and ch.guild and ch.guild.owner_id:
 | 
			
		||||
            status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
 | 
			
		||||
        await ch.send(status_line, allowed_mentions=allowed)
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print(f"[boot_notice] failed to send status line: {e}")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Only post commit message if version CHANGED (updated or rollback)
 | 
			
		||||
    if not append_version_only:
 | 
			
		||||
        try:
 | 
			
		||||
            title = (curr_ver or subject or "Latest commit").strip()
 | 
			
		||||
            if title or body:
 | 
			
		||||
                commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
 | 
			
		||||
                await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print(f"[boot_notice] failed to send commit message: {e}")
 | 
			
		||||
 | 
			
		||||
    # Persist state
 | 
			
		||||
    try:
 | 
			
		||||
        dm.add('boot_state', {
 | 
			
		||||
            'last_sha': sha,
 | 
			
		||||
            'last_version': curr_ver,
 | 
			
		||||
            'last_subject': subject,
 | 
			
		||||
            'last_boot_ts': time.time(),
 | 
			
		||||
        })
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print(f"[boot_notice] failed to persist boot_state: {e}")
 | 
			
		||||
@ -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,495 +0,0 @@
 | 
			
		||||
# modules/common/settings.py
 | 
			
		||||
import os
 | 
			
		||||
import json
 | 
			
		||||
import shutil
 | 
			
		||||
import threading
 | 
			
		||||
import re
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from typing import Any, Dict, Iterable, Optional, List
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# Public API Exceptions
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
class ValidationError(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# Helpers
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
def _clean(s: Optional[str]) -> str:
 | 
			
		||||
    s = (s or "").strip()
 | 
			
		||||
    if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
 | 
			
		||||
        s = s[1:-1].strip()
 | 
			
		||||
    return s
 | 
			
		||||
 | 
			
		||||
def _is_intish(x: Any) -> bool:
 | 
			
		||||
    try:
 | 
			
		||||
        int(str(x).strip())
 | 
			
		||||
        return True
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
def _to_bool(x: Any) -> bool:
 | 
			
		||||
    s = str(x).strip().lower()
 | 
			
		||||
    if s in ("1", "true", "yes", "on", "y", "t"):
 | 
			
		||||
        return True
 | 
			
		||||
    if s in ("0", "false", "no", "off", "n", "f"):
 | 
			
		||||
        return False
 | 
			
		||||
    raise ValidationError(f"Expected a boolean, got {x!r}")
 | 
			
		||||
 | 
			
		||||
def _to_int(x: Any) -> int:
 | 
			
		||||
    if _is_intish(x):
 | 
			
		||||
        return int(str(x).strip())
 | 
			
		||||
    raise ValidationError(f"Expected an integer, got {x!r}")
 | 
			
		||||
 | 
			
		||||
def _to_float(x: Any) -> float:
 | 
			
		||||
    try:
 | 
			
		||||
        return float(str(x).strip())
 | 
			
		||||
    except Exception:
 | 
			
		||||
        raise ValidationError(f"Expected a float, got {x!r}")
 | 
			
		||||
 | 
			
		||||
def _to_str(x: Any) -> str:
 | 
			
		||||
    return str(x)
 | 
			
		||||
 | 
			
		||||
def _to_list_int(x: Any) -> List[int]:
 | 
			
		||||
    if isinstance(x, list):
 | 
			
		||||
        out = []
 | 
			
		||||
        for v in x:
 | 
			
		||||
            if not _is_intish(v):
 | 
			
		||||
                raise ValidationError(f"List must contain integers; got {v!r}")
 | 
			
		||||
            out.append(int(v))
 | 
			
		||||
        return out
 | 
			
		||||
    if isinstance(x, str):
 | 
			
		||||
        toks = [t.strip() for t in x.split(",") if t.strip()]
 | 
			
		||||
        try:
 | 
			
		||||
            return [int(t) for t in toks]
 | 
			
		||||
        except Exception:
 | 
			
		||||
            raise ValidationError(f"Could not parse list of integers from {x!r}")
 | 
			
		||||
    raise ValidationError(f"Expected a list of integers, got {type(x).__name__}")
 | 
			
		||||
 | 
			
		||||
# ---- URL / Time / Date validators ----
 | 
			
		||||
 | 
			
		||||
def _to_url(x: Any) -> str:
 | 
			
		||||
    s = str(x).strip()
 | 
			
		||||
    if not s:
 | 
			
		||||
        raise ValidationError("URL cannot be empty")
 | 
			
		||||
    p = urlparse(s)
 | 
			
		||||
    if p.scheme not in ("http", "https"):
 | 
			
		||||
        raise ValidationError("URL must start with http:// or https://")
 | 
			
		||||
    if not p.netloc:
 | 
			
		||||
        raise ValidationError("URL missing host")
 | 
			
		||||
    if not re.match(r"^([A-Za-z0-9\-.]+|\d{1,3}(?:\.\d{1,3}){3}|localhost)(:\d+)?$", p.netloc):
 | 
			
		||||
        raise ValidationError("URL host looks invalid")
 | 
			
		||||
    return s
 | 
			
		||||
 | 
			
		||||
_TIME_24H_RE = re.compile(r"^(?P<h>[01]?\d|2[0-3]):(?P<m>[0-5]\d)(?::(?P<s>[0-5]\d))?$")
 | 
			
		||||
 | 
			
		||||
def _to_time_24h(x: Any) -> str:
 | 
			
		||||
    s = str(x).strip()
 | 
			
		||||
    m = _TIME_24H_RE.match(s)
 | 
			
		||||
    if not m:
 | 
			
		||||
        raise ValidationError("Time must be HH:MM or HH:MM:SS (24-hour)")
 | 
			
		||||
    hh = int(m.group("h"))
 | 
			
		||||
    mm = int(m.group("m"))
 | 
			
		||||
    # canonical store as HH:MM
 | 
			
		||||
    return f"{hh:02d}:{mm:02d}"
 | 
			
		||||
 | 
			
		||||
def _expand_two_digit_year(two_digit: int, pivot_year: int) -> int:
 | 
			
		||||
    # Map YY -> same century as pivot_year
 | 
			
		||||
    base = pivot_year - (pivot_year % 100)
 | 
			
		||||
    return base + two_digit
 | 
			
		||||
 | 
			
		||||
def _to_date_ymd(x: Any, *, pivot_year: int | None = None) -> str:
 | 
			
		||||
    s = str(x).strip()
 | 
			
		||||
    if not s:
 | 
			
		||||
        raise ValidationError("Date cannot be empty")
 | 
			
		||||
    m = re.match(r"^(?P<y>\d{2}|\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})$", s)
 | 
			
		||||
    if not m:
 | 
			
		||||
        raise ValidationError("Expected date format YYYY-MM-DD or YY-MM-DD")
 | 
			
		||||
    y = m.group("y")
 | 
			
		||||
    month = int(m.group("m"))
 | 
			
		||||
    day = int(m.group("d"))
 | 
			
		||||
    if len(y) == 2:
 | 
			
		||||
        yy = int(y)
 | 
			
		||||
        if pivot_year is not None:
 | 
			
		||||
            year = _expand_two_digit_year(yy, pivot_year)
 | 
			
		||||
            dt = datetime(year, month, day)
 | 
			
		||||
            return dt.strftime("%Y-%m-%d")
 | 
			
		||||
        else:
 | 
			
		||||
            dt = datetime.strptime(s, "%y-%m-%d")
 | 
			
		||||
            return dt.strftime("%Y-%m-%d")
 | 
			
		||||
    year = int(y)
 | 
			
		||||
    dt = datetime(year, month, day)
 | 
			
		||||
    return dt.strftime("%Y-%m-%d")
 | 
			
		||||
 | 
			
		||||
def _to_date_dmy(x: Any, *, pivot_year: int | None = None) -> str:
 | 
			
		||||
    s = str(x).strip()
 | 
			
		||||
    if not s:
 | 
			
		||||
        raise ValidationError("Date cannot be empty")
 | 
			
		||||
    m = re.match(r"^(?P<d>\d{1,2})-(?P<m>\d{1,2})-(?P<y>\d{2}|\d{4})$", s)
 | 
			
		||||
    if not m:
 | 
			
		||||
        raise ValidationError("Expected date format DD-MM-YYYY or DD-MM-YY")
 | 
			
		||||
    day = int(m.group("d"))
 | 
			
		||||
    month = int(m.group("m"))
 | 
			
		||||
    y = m.group("y")
 | 
			
		||||
    if len(y) == 2:
 | 
			
		||||
        yy = int(y)
 | 
			
		||||
        if pivot_year is not None:
 | 
			
		||||
            year = _expand_two_digit_year(yy, pivot_year)
 | 
			
		||||
            dt = datetime(year, month, day)
 | 
			
		||||
            return dt.strftime("%d-%m-%Y")
 | 
			
		||||
        else:
 | 
			
		||||
            dt = datetime.strptime(s, "%d-%m-%y")
 | 
			
		||||
            return dt.strftime("%d-%m-%Y")
 | 
			
		||||
    year = int(y)
 | 
			
		||||
    dt = datetime(year, month, day)
 | 
			
		||||
    return dt.strftime("%d-%m-%Y")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# Schema (non-sensitive, front-end editable)
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
 | 
			
		||||
    # Channels (IDs)
 | 
			
		||||
    "mod_channel_id":            {"type": "int",   "default": 0, "nonzero": True, "desc": "Moderation command channel."},
 | 
			
		||||
    "modlog_channel_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "ModLog channel."},
 | 
			
		||||
    "pirates_list_channel_id":   {"type": "int",   "default": 0, "nonzero": True, "desc": "Pirates list channel."},
 | 
			
		||||
    "dd_channel_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Deep Desert updates channel."},
 | 
			
		||||
    "report_channel_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Reports/approvals channel."},
 | 
			
		||||
    "userslist_channel_id":      {"type": "int",   "default": 0, "nonzero": True, "desc": "Users list channel."},
 | 
			
		||||
    "trigger_channel_id":        {"type": "int",   "default": 0, "nonzero": True, "desc": "Trigger channel for Auto VC."},
 | 
			
		||||
    "crew_roles_message_id":     {"type": "int",   "default": 0, "nonzero": True, "desc": "Message ID for crew reaction roles hub."},
 | 
			
		||||
 | 
			
		||||
    # Roles (IDs)
 | 
			
		||||
    "rules_role_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."},
 | 
			
		||||
    "moderator_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Moderator role ID."},
 | 
			
		||||
    "intel_mod_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Intel mod role ID."},
 | 
			
		||||
    "full_access_role_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Full Access role ID."},
 | 
			
		||||
    "field_mod_role_id":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Field mod role ID."},
 | 
			
		||||
    "engagement_role_id":        {"type": "int",   "default": 0, "nonzero": True, "desc": "Engagement role ID."},
 | 
			
		||||
    "admin_role_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Admin role ID."},
 | 
			
		||||
    # Role IDs for crew groups
 | 
			
		||||
    "role_harvest_crew_id":      {"type": "int",   "default": 0, "nonzero": True, "desc": "Role: Harvest Crew"},
 | 
			
		||||
    "role_escort_crew_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Role: Escort Crew"},
 | 
			
		||||
    "role_fedaykin_id":          {"type": "int",   "default": 0, "nonzero": True, "desc": "Role: Fedaykin"},
 | 
			
		||||
 | 
			
		||||
    # Message IDs
 | 
			
		||||
    "rules_message_id":          {"type": "int",   "default": 0, "nonzero": True, "desc": "Rules message ID."},
 | 
			
		||||
    "engagement_message_id":     {"type": "int",   "default": 0, "nonzero": True, "desc": "Engagement message ID."},
 | 
			
		||||
    "nickname_message_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Nickname message ID."},
 | 
			
		||||
 | 
			
		||||
    # Emojis (IDs)
 | 
			
		||||
    "emoji_carrier_crawler_id":  {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: carrier/crawler."},
 | 
			
		||||
    "emoji_melange_id":          {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: melange."},
 | 
			
		||||
    "emoji_sand_id":             {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: sand."},
 | 
			
		||||
    "emoji_harvester_crew":      {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: harvester crew"},
 | 
			
		||||
    "emoji_escort_crew":         {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: escort crew"},
 | 
			
		||||
    "emoji_fedaykin":            {"type": "int",   "default": 0, "nonzero": True, "desc": "Custom emoji: fedaykin - kill squad"},
 | 
			
		||||
 | 
			
		||||
    # Auto-VC
 | 
			
		||||
    "auto_vc_category_id":       {"type": "int",   "default": 0, "nonzero": True, "desc": "Category to host Auto-VCs."},
 | 
			
		||||
    "auto_vc_cleanup_delay":     {"type": "int",   "default": 30, "nonzero": True, "desc": "Seconds before empty Auto-VCs are cleaned up."},
 | 
			
		||||
    "vc_name_prefix":            {"type": "str",   "default": "DD Crew", "desc": "Auto-VC name prefix."},
 | 
			
		||||
 | 
			
		||||
    # Pirates / threat model
 | 
			
		||||
    "threat_group_threshold":         {"type": "int",   "default": 3,    "desc": "Threshold for group classification."},
 | 
			
		||||
    "threat_min_samples_for_stats":   {"type": "int",   "default": 3,    "desc": "Min samples for stats."},
 | 
			
		||||
    "threat_w_destruction":           {"type": "float", "default": 0.40, "desc": "Weight: destruction."},
 | 
			
		||||
    "threat_w_group":                 {"type": "float", "default": 0.20, "desc": "Weight: group."},
 | 
			
		||||
    "threat_w_kill":                  {"type": "float", "default": 0.30, "desc": "Weight: kill."},
 | 
			
		||||
    "threat_w_skill":                 {"type": "float", "default": 0.10, "desc": "Weight: skill."},
 | 
			
		||||
 | 
			
		||||
    # SpicePay
 | 
			
		||||
    "spicepay_base_weight":      {"type": "float", "default": 25.0, "desc": "Base weight."},
 | 
			
		||||
    "spicepay_carrier_bonus":    {"type": "float", "default": 12.5, "desc": "Carrier bonus."},
 | 
			
		||||
    "spicepay_crawler_bonus":    {"type": "float", "default": 12.5, "desc": "Crawler bonus."},
 | 
			
		||||
    "spicepay_lsr_cut_percent":  {"type": "float", "default": 10.0, "desc": "SR cut percent."},
 | 
			
		||||
 | 
			
		||||
    # Jobs / loops
 | 
			
		||||
    "user_cards_cron_enabled":   {"type": "bool",  "default": True,  "desc": "Enable user-cards cron."},
 | 
			
		||||
    "nick_nudge_loop_enabled":   {"type": "bool",  "default": False, "desc": "Enable nick-nudge loop."},
 | 
			
		||||
 | 
			
		||||
    # Deep Desert fetcher
 | 
			
		||||
    "dd_fetcher":                {"type": "str",   "default": "playwright", "allowed": ["playwright","requests"], "desc": "Fetcher backend."},
 | 
			
		||||
    "dd_pw_timeout_ms":          {"type": "int",   "default": 60000, "desc": "Playwright timeout (ms)."},
 | 
			
		||||
    "dd_pw_wait_ms":             {"type": "int",   "default": 0,     "desc": "Extra wait after navigation (ms)."},
 | 
			
		||||
 | 
			
		||||
    # Repo (non-secret)
 | 
			
		||||
    "repo_url":                  {"type": "url",
 | 
			
		||||
                                  "default": "https://git.rolfsvaag.no/frarol96/shaiwatcher",
 | 
			
		||||
                                  "desc": "Repository URL."},
 | 
			
		||||
    "repo_branch":               {"type": "str",   "default": "main", "desc": "Repository branch."},
 | 
			
		||||
    "repo_rss":                  {"type": "url",
 | 
			
		||||
                                  "default": "https://git.rolfsvaag.no/frarol96/shaiwatcher.rss",
 | 
			
		||||
                                  "desc": "Repository RSS feed."},
 | 
			
		||||
 | 
			
		||||
    # Admin allow-list for /shaiadmin (besides owner)
 | 
			
		||||
    "admin_user_ids":            {"type": "list[int]", "default": [], "desc": "User IDs allowed to use /shaiadmin."},
 | 
			
		||||
    "admin_role_ids":            {"type": "list[int]", "default": [], "desc": "Role IDs allowed to use /shaiadmin."},
 | 
			
		||||
 | 
			
		||||
    # Misc
 | 
			
		||||
    "check_time_utc":            {"type": "time_24h", "default": "03:00",   "desc": "Daily check time (UTC HH:MM)"},
 | 
			
		||||
    "ignore_test_level":         {"type": "int",      "default": 0,         "desc": "Test-level ignore flag."},
 | 
			
		||||
    "lang":                      {"type": "str",      "default": "C.UTF-8", "desc": "Locale (if referenced)."},
 | 
			
		||||
    # Examples of date keys you may enable later:
 | 
			
		||||
    # "feature_window_start": {"type": "date_ymd", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Start date (YYYY-MM-DD or YY-MM-DD)."},
 | 
			
		||||
    # "event_date_dmy":       {"type": "date_dmy", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Event date (DD-MM-YYYY or DD-MM-YY)."},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# Env — ONLY the allowed set (plus legacy HOME_GUILD_ID alias)
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
def _allowed_env_map() -> Dict[str, str]:
 | 
			
		||||
    env: Dict[str, str] = {}
 | 
			
		||||
    if os.getenv("DISCORD_TOKEN"):
 | 
			
		||||
        env["discord_token"] = _clean(os.getenv("DISCORD_TOKEN"))
 | 
			
		||||
    data_file = os.getenv("DATA_FILE")
 | 
			
		||||
    if data_file:
 | 
			
		||||
        env["data_file"] = _clean(data_file)
 | 
			
		||||
    if os.getenv("DOCS_HOST_IP"):
 | 
			
		||||
        env["docs_host_ip"] = _clean(os.getenv("DOCS_HOST_IP"))
 | 
			
		||||
    if os.getenv("DOCS_HOST_PORT"):
 | 
			
		||||
        env["docs_host_port"] = _clean(os.getenv("DOCS_HOST_PORT"))
 | 
			
		||||
    if os.getenv("HOME_GUILD_ID"):
 | 
			
		||||
        env["home_guild_id"] = _clean(os.getenv("HOME_GUILD_ID"))
 | 
			
		||||
    if os.getenv("REPO_AHTOKEN"):
 | 
			
		||||
        env["repo_ahtoken"] = _clean(os.getenv("REPO_AHTOKEN"))
 | 
			
		||||
    return env
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# On-disk store + globals
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
_SETTINGS_LOCK = threading.Lock()
 | 
			
		||||
_FILE_MAP: Dict[str, Any] = {}
 | 
			
		||||
_ENV_MAP: Dict[str, str] = {}
 | 
			
		||||
_SETTINGS_FILE: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
def settings_path() -> str:
 | 
			
		||||
    """Place settings.json next to DATA_FILE if available; otherwise default to ./data/settings.json."""
 | 
			
		||||
    data_file = os.getenv("DATA_FILE")
 | 
			
		||||
    if data_file:
 | 
			
		||||
        base = os.path.dirname(data_file) or "."
 | 
			
		||||
        return os.path.join(base, "settings.json")
 | 
			
		||||
    return "./data/settings.json"
 | 
			
		||||
 | 
			
		||||
def _ensure_loaded():
 | 
			
		||||
    global _SETTINGS_FILE, _FILE_MAP, _ENV_MAP
 | 
			
		||||
    with _SETTINGS_LOCK:
 | 
			
		||||
        if _SETTINGS_FILE is not None:
 | 
			
		||||
            return
 | 
			
		||||
        _SETTINGS_FILE = settings_path()
 | 
			
		||||
        _ENV_MAP = _allowed_env_map()
 | 
			
		||||
 | 
			
		||||
        if os.path.exists(_SETTINGS_FILE):
 | 
			
		||||
            try:
 | 
			
		||||
                with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
 | 
			
		||||
                    _FILE_MAP = json.load(f) or {}
 | 
			
		||||
            except Exception:
 | 
			
		||||
                _FILE_MAP = {}
 | 
			
		||||
        else:
 | 
			
		||||
            _FILE_MAP = {}
 | 
			
		||||
            _save_locked()
 | 
			
		||||
 | 
			
		||||
        changed = False
 | 
			
		||||
        for key, meta in SETTINGS_SCHEMA.items():
 | 
			
		||||
            if key not in _FILE_MAP:
 | 
			
		||||
                _FILE_MAP[key] = meta.get("default")
 | 
			
		||||
                changed = True
 | 
			
		||||
        if changed:
 | 
			
		||||
            _save_locked()
 | 
			
		||||
 | 
			
		||||
def _save_locked():
 | 
			
		||||
    global _SETTINGS_FILE, _FILE_MAP
 | 
			
		||||
    path = _SETTINGS_FILE or settings_path()
 | 
			
		||||
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
 | 
			
		||||
    tmp = path + ".tmp"
 | 
			
		||||
    with open(tmp, "w", encoding="utf-8") as f:
 | 
			
		||||
        json.dump(_FILE_MAP, f, indent=2, ensure_ascii=False)
 | 
			
		||||
    if os.path.exists(path):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(path, path + ".bak")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
    os.replace(tmp, path)
 | 
			
		||||
 | 
			
		||||
def settings_get_all() -> Dict[str, Any]:
 | 
			
		||||
    _ensure_loaded()
 | 
			
		||||
    with _SETTINGS_LOCK:
 | 
			
		||||
        return dict(_FILE_MAP)
 | 
			
		||||
 | 
			
		||||
def _cast_value(name: str, raw: Any, *, enforce_nonzero: bool = True) -> Any:
 | 
			
		||||
    meta = SETTINGS_SCHEMA.get(name)
 | 
			
		||||
    if not meta:
 | 
			
		||||
        raise ValidationError(f"Unknown setting: {name}")
 | 
			
		||||
 | 
			
		||||
    allow_empty = bool(meta.get("allow_empty", False))
 | 
			
		||||
    t = meta.get("type")
 | 
			
		||||
 | 
			
		||||
    if t in ("str", "url", "time_24h", "date_ymd", "date_dmy"):
 | 
			
		||||
        s = str(raw).strip()
 | 
			
		||||
        if s == "" and allow_empty:
 | 
			
		||||
            val = ""
 | 
			
		||||
        else:
 | 
			
		||||
            if t == "str":
 | 
			
		||||
                val = _to_str(raw)
 | 
			
		||||
            elif t == "url":
 | 
			
		||||
                val = _to_url(raw)
 | 
			
		||||
            elif t == "time_24h":
 | 
			
		||||
                val = _to_time_24h(raw)
 | 
			
		||||
            elif t == "date_ymd":
 | 
			
		||||
                val = _to_date_ymd(raw, pivot_year=meta.get("pivot_year"))
 | 
			
		||||
            elif t == "date_dmy":
 | 
			
		||||
                val = _to_date_dmy(raw, pivot_year=meta.get("pivot_year"))
 | 
			
		||||
    elif t == "bool":
 | 
			
		||||
        val = _to_bool(raw)
 | 
			
		||||
    elif t == "int":
 | 
			
		||||
        val = _to_int(raw)
 | 
			
		||||
    elif t == "float":
 | 
			
		||||
        val = _to_float(raw)
 | 
			
		||||
    elif t == "list[int]":
 | 
			
		||||
        val = _to_list_int(raw)
 | 
			
		||||
    else:
 | 
			
		||||
        raise ValidationError(f"Unsupported type for {name}: {t}")
 | 
			
		||||
 | 
			
		||||
    # enum constraint (always enforced)
 | 
			
		||||
    if "allowed" in meta:
 | 
			
		||||
        allowed = meta["allowed"]
 | 
			
		||||
        if val not in allowed:
 | 
			
		||||
            raise ValidationError(f"`{name}` must be one of {allowed}, got {val!r}")
 | 
			
		||||
 | 
			
		||||
    # nonzero constraint (skippable for bulk uploads)
 | 
			
		||||
    if enforce_nonzero and meta.get("nonzero") and isinstance(val, int) and val == 0:
 | 
			
		||||
        raise ValidationError(f"`{name}` must be a non-zero integer.")
 | 
			
		||||
 | 
			
		||||
    return val
 | 
			
		||||
 | 
			
		||||
def settings_set(name: str, raw_value: Any) -> bool:
 | 
			
		||||
    _ensure_loaded()
 | 
			
		||||
    with _SETTINGS_LOCK:
 | 
			
		||||
        name = name.lower().strip()
 | 
			
		||||
        if name not in SETTINGS_SCHEMA:
 | 
			
		||||
            raise ValidationError(f"Unknown setting: {name}")
 | 
			
		||||
        new_val = _cast_value(name, raw_value)
 | 
			
		||||
        old_val = _FILE_MAP.get(name, SETTINGS_SCHEMA[name].get("default"))
 | 
			
		||||
        if old_val == new_val:
 | 
			
		||||
            return False
 | 
			
		||||
        _FILE_MAP[name] = new_val
 | 
			
		||||
        _save_locked()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
def settings_reset(name: str) -> None:
 | 
			
		||||
    _ensure_loaded()
 | 
			
		||||
    with _SETTINGS_LOCK:
 | 
			
		||||
        name = name.lower().strip()
 | 
			
		||||
        if name not in SETTINGS_SCHEMA:
 | 
			
		||||
            raise ValidationError(f"Unknown setting: {name}")
 | 
			
		||||
        _FILE_MAP[name] = SETTINGS_SCHEMA[name].get("default")
 | 
			
		||||
        _save_locked()
 | 
			
		||||
 | 
			
		||||
def settings_import_bulk(obj: Dict[str, Any]) -> List[str]:
 | 
			
		||||
    _ensure_loaded()
 | 
			
		||||
    if not isinstance(obj, dict):
 | 
			
		||||
        raise ValidationError("Uploaded JSON must be an object/dict at the top level.")
 | 
			
		||||
    new_map: Dict[str, Any] = dict(_FILE_MAP)
 | 
			
		||||
    changed: List[str] = []
 | 
			
		||||
    for k, v in obj.items():
 | 
			
		||||
        if k not in SETTINGS_SCHEMA:
 | 
			
		||||
            raise ValidationError(f"Unknown setting in upload: {k}")
 | 
			
		||||
        # Allow 0 for keys marked nonzero during bulk import (treating as 'unset' sentinel)
 | 
			
		||||
        new_val = _cast_value(k, v, enforce_nonzero=False)
 | 
			
		||||
        if new_map.get(k) != new_val:
 | 
			
		||||
            new_map[k] = new_val
 | 
			
		||||
            changed.append(k)
 | 
			
		||||
    with _SETTINGS_LOCK:
 | 
			
		||||
        if changed:
 | 
			
		||||
            _FILE_MAP.update({k: new_map[k] for k in changed})
 | 
			
		||||
            _save_locked()
 | 
			
		||||
    return changed
 | 
			
		||||
 | 
			
		||||
# =========================
 | 
			
		||||
# Unified read view (keeps cfg(bot) contract)
 | 
			
		||||
# =========================
 | 
			
		||||
 | 
			
		||||
class ConfigView:
 | 
			
		||||
    """
 | 
			
		||||
    Reads:
 | 
			
		||||
      - Schema-managed keys from settings.json
 | 
			
		||||
      - Env: discord_token, data_file, docs_host_ip, docs_host_port, home_guild_id
 | 
			
		||||
      - Fallback to bot.config['DEFAULT'] for anything else (legacy)
 | 
			
		||||
    Helpers: get/int/bool/float/list, to_dict()
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, bot=None):
 | 
			
		||||
        _ensure_loaded()
 | 
			
		||||
        self._env_map = dict(_ENV_MAP)
 | 
			
		||||
        try:
 | 
			
		||||
            self._default = (getattr(bot, "config", {}) or {}).get("DEFAULT", {}) or {}
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self._default = {}
 | 
			
		||||
 | 
			
		||||
    def _effective_map(self) -> Dict[str, str]:
 | 
			
		||||
        merged: Dict[str, str] = {}
 | 
			
		||||
        # defaults first
 | 
			
		||||
        for k in getattr(self._default, "keys", lambda: [])():
 | 
			
		||||
            merged[k] = _clean(str(self._default.get(k, "")))
 | 
			
		||||
        # env overlay
 | 
			
		||||
        for k, v in self._env_map.items():
 | 
			
		||||
            merged[k] = _clean(v)
 | 
			
		||||
        # schema values overlay defaults
 | 
			
		||||
        for k, meta in SETTINGS_SCHEMA.items():
 | 
			
		||||
            v = _FILE_MAP.get(k, meta.get("default"))
 | 
			
		||||
            if isinstance(v, (list, dict)):
 | 
			
		||||
                merged[k] = json.dumps(v, ensure_ascii=False)
 | 
			
		||||
            else:
 | 
			
		||||
                merged[k] = _clean(str(v))
 | 
			
		||||
        return merged
 | 
			
		||||
 | 
			
		||||
    def get(self, key: str, default: str = "") -> str:
 | 
			
		||||
        m = self._effective_map()
 | 
			
		||||
        v = _clean(m.get(key.lower(), ""))
 | 
			
		||||
        return v if v != "" else default
 | 
			
		||||
 | 
			
		||||
    def int(self, key: str, default: int = 0) -> int:
 | 
			
		||||
        s = self.get(key, "")
 | 
			
		||||
        try:
 | 
			
		||||
            return int(s)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return default
 | 
			
		||||
 | 
			
		||||
    def float(self, key: str, default: float = 0.0) -> float:
 | 
			
		||||
        s = self.get(key, "")
 | 
			
		||||
        try:
 | 
			
		||||
            return float(s)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return default
 | 
			
		||||
 | 
			
		||||
    def bool(self, key: str, default: bool = False) -> bool:
 | 
			
		||||
        s = self.get(key, "")
 | 
			
		||||
        if s == "":
 | 
			
		||||
            return default
 | 
			
		||||
        s = s.lower()
 | 
			
		||||
        if s in ("1", "true", "yes", "on", "y", "t"):
 | 
			
		||||
            return True
 | 
			
		||||
        if s in ("0", "false", "no", "off", "n", "f"):
 | 
			
		||||
            return False
 | 
			
		||||
        return default
 | 
			
		||||
 | 
			
		||||
    def list(self, key: str, default: Optional[Iterable[str]] = None, sep: str = ",") -> Iterable[str]:
 | 
			
		||||
        s = self.get(key, "")
 | 
			
		||||
        if s == "":
 | 
			
		||||
            return list(default or [])
 | 
			
		||||
        parts = [p.strip() for p in s.split(sep)]
 | 
			
		||||
        return [p for p in parts if p]
 | 
			
		||||
 | 
			
		||||
    def to_dict(self) -> Dict[str, str]:
 | 
			
		||||
        return dict(self._effective_map())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cfg(bot=None) -> ConfigView:
 | 
			
		||||
    return ConfigView(bot)
 | 
			
		||||
@ -1,237 +0,0 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import io
 | 
			
		||||
import os
 | 
			
		||||
import json
 | 
			
		||||
import time
 | 
			
		||||
import shutil
 | 
			
		||||
import asyncio
 | 
			
		||||
from typing import Optional, Literal
 | 
			
		||||
 | 
			
		||||
import aiohttp
 | 
			
		||||
import discord
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
MAX_UPLOAD_BYTES = 8 * 1024 * 1024  # 8 MiB hard cap for safety
 | 
			
		||||
OWNER_HINT = "This command is restricted to the **server owner** (or bot owner)."
 | 
			
		||||
 | 
			
		||||
def _now_stamp() -> str:
 | 
			
		||||
    return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
 | 
			
		||||
 | 
			
		||||
class DataAdmin(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    [ADMIN] Backup/restore the bot data file.
 | 
			
		||||
    Owner-only: guild owner or application (bot) owner.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        self._app_owner_id: Optional[int] = None
 | 
			
		||||
 | 
			
		||||
    # --- permission helper ---
 | 
			
		||||
    async def _is_owner(self, interaction: discord.Interaction) -> bool:
 | 
			
		||||
        uid = interaction.user.id
 | 
			
		||||
        # cache application owner id
 | 
			
		||||
        if self._app_owner_id is None:
 | 
			
		||||
            try:
 | 
			
		||||
                info = await self.bot.application_info()
 | 
			
		||||
                if info and info.owner:
 | 
			
		||||
                    self._app_owner_id = info.owner.id
 | 
			
		||||
            except Exception:
 | 
			
		||||
                self._app_owner_id = None
 | 
			
		||||
 | 
			
		||||
        guild_owner_id = getattr(getattr(interaction, "guild", None), "owner_id", None)
 | 
			
		||||
        if guild_owner_id and uid == guild_owner_id:
 | 
			
		||||
            return True
 | 
			
		||||
        if self._app_owner_id and uid == self._app_owner_id:
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    # --- helpers ---
 | 
			
		||||
    def _dm_path(self) -> str:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        if not dm or not getattr(dm, "json_path", None):
 | 
			
		||||
            raise RuntimeError("DataManager/json_path unavailable")
 | 
			
		||||
        return dm.json_path
 | 
			
		||||
 | 
			
		||||
    def _merge_with_defaults(self, incoming: dict) -> dict:
 | 
			
		||||
        """
 | 
			
		||||
        Ensure required keys exist; preserve unknown keys.
 | 
			
		||||
        """
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        if not dm:
 | 
			
		||||
            raise RuntimeError("DataManager unavailable")
 | 
			
		||||
 | 
			
		||||
        # Create a minimal default schema by calling _default_payload if present,
 | 
			
		||||
        # otherwise fall back to a thin set.
 | 
			
		||||
        try:
 | 
			
		||||
            defaults = dm._default_payload()  # type: ignore[attr-defined]
 | 
			
		||||
        except Exception:
 | 
			
		||||
            defaults = {
 | 
			
		||||
                "_counters": {},
 | 
			
		||||
                "_events_seen": {},
 | 
			
		||||
                "_counter_last_ts": {},
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        merged = dict(incoming)
 | 
			
		||||
        for k, v in defaults.items():
 | 
			
		||||
            merged.setdefault(k, v if not isinstance(v, list) else list(v))
 | 
			
		||||
        return merged
 | 
			
		||||
 | 
			
		||||
    async def _download_attachment_bytes(self, att: discord.Attachment) -> bytes:
 | 
			
		||||
        if att.size > MAX_UPLOAD_BYTES:
 | 
			
		||||
            raise ValueError(f"Attachment too large ({att.size} bytes)")
 | 
			
		||||
        return await att.read()
 | 
			
		||||
 | 
			
		||||
    async def _download_url_bytes(self, url: str) -> bytes:
 | 
			
		||||
        timeout = aiohttp.ClientTimeout(total=25, sock_connect=10, sock_read=15)
 | 
			
		||||
        headers = {
 | 
			
		||||
            "User-Agent": "ShaiWatcher/backup-restore (+https://example.invalid)"
 | 
			
		||||
        }
 | 
			
		||||
        async with aiohttp.ClientSession(timeout=timeout) as sess:
 | 
			
		||||
            async with sess.get(url, headers=headers, allow_redirects=True) as resp:
 | 
			
		||||
                if resp.status >= 400:
 | 
			
		||||
                    raise RuntimeError(f"HTTP {resp.status}")
 | 
			
		||||
                data = await resp.read()
 | 
			
		||||
                if len(data) > MAX_UPLOAD_BYTES:
 | 
			
		||||
                    raise ValueError(f"Downloaded file too large ({len(data)} bytes)")
 | 
			
		||||
                return data
 | 
			
		||||
 | 
			
		||||
    def _atomic_replace(self, new_payload: dict) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Replace DataManager payload atomically, with a timestamped backup.
 | 
			
		||||
        """
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        if not dm:
 | 
			
		||||
            raise RuntimeError("DataManager unavailable")
 | 
			
		||||
 | 
			
		||||
        src_path = self._dm_path()
 | 
			
		||||
        bak_path = f"{src_path}.manual.{_now_stamp()}.bak"
 | 
			
		||||
 | 
			
		||||
        with dm.lock:
 | 
			
		||||
            # backup current file if exists
 | 
			
		||||
            try:
 | 
			
		||||
                if os.path.exists(src_path):
 | 
			
		||||
                    shutil.copy2(src_path, bak_path)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            # write new file and update in-memory view
 | 
			
		||||
            dm._data = self._merge_with_defaults(new_payload)  # type: ignore[attr-defined]
 | 
			
		||||
            dm._save(dm._data)  # type: ignore[attr-defined]
 | 
			
		||||
 | 
			
		||||
    # --- slash command ---
 | 
			
		||||
    @app_commands.command(
 | 
			
		||||
        name="data",
 | 
			
		||||
        description="[ADMIN] Download or upload the bot data file (owner-only)"
 | 
			
		||||
    )
 | 
			
		||||
    @app_commands.describe(
 | 
			
		||||
        action="Choose 'download' to get the current file, or 'upload' to restore from JSON",
 | 
			
		||||
        attachment="Optional JSON attachment (used for 'upload')",
 | 
			
		||||
        url="Optional direct URL to a JSON file (used for 'upload')"
 | 
			
		||||
    )
 | 
			
		||||
    async def data_cmd(
 | 
			
		||||
        self,
 | 
			
		||||
        interaction: discord.Interaction,
 | 
			
		||||
        action: Literal["download", "upload"],
 | 
			
		||||
        attachment: Optional[discord.Attachment] = None,
 | 
			
		||||
        url: Optional[str] = None,
 | 
			
		||||
    ):
 | 
			
		||||
        # perms
 | 
			
		||||
        if not await self._is_owner(interaction):
 | 
			
		||||
            return await interaction.response.send_message(OWNER_HINT, ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # ensure dm available
 | 
			
		||||
        try:
 | 
			
		||||
            dm_path = self._dm_path()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                f"DataManager unavailable: {e}", ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # dispatch
 | 
			
		||||
        if action == "download":
 | 
			
		||||
            await interaction.response.defer(ephemeral=True, thinking=False)
 | 
			
		||||
            try:
 | 
			
		||||
                # Read raw file bytes to guarantee exact copy
 | 
			
		||||
                with open(dm_path, "rb") as f:
 | 
			
		||||
                    data = f.read()
 | 
			
		||||
                file = discord.File(io.BytesIO(data), filename="data.json")
 | 
			
		||||
                await interaction.followup.send(
 | 
			
		||||
                    content="Here is the current data file.",
 | 
			
		||||
                    file=file,
 | 
			
		||||
                    ephemeral=True,
 | 
			
		||||
                )
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                await interaction.followup.send(
 | 
			
		||||
                    f"Failed to read data file: {e}", ephemeral=True
 | 
			
		||||
                )
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # upload
 | 
			
		||||
        # must provide exactly one source
 | 
			
		||||
        sources = [s for s in (attachment, url) if s]
 | 
			
		||||
        if len(sources) != 1:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                "For `upload`, provide **exactly one** of: `attachment` **or** `url`.",
 | 
			
		||||
                ephemeral=True,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True, thinking=True)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            if attachment:
 | 
			
		||||
                raw = await self._download_attachment_bytes(attachment)
 | 
			
		||||
            else:
 | 
			
		||||
                assert url is not None
 | 
			
		||||
                raw = await self._download_url_bytes(url)
 | 
			
		||||
 | 
			
		||||
            # decode → JSON
 | 
			
		||||
            try:
 | 
			
		||||
                text = raw.decode("utf-8")
 | 
			
		||||
            except UnicodeDecodeError:
 | 
			
		||||
                return await interaction.followup.send(
 | 
			
		||||
                    "The file/URL is not valid UTF-8 text.", ephemeral=True
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                payload = json.loads(text)
 | 
			
		||||
            except json.JSONDecodeError as e:
 | 
			
		||||
                return await interaction.followup.send(
 | 
			
		||||
                    f"Invalid JSON: {e}", ephemeral=True
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            if not isinstance(payload, dict):
 | 
			
		||||
                return await interaction.followup.send(
 | 
			
		||||
                    "Top-level JSON must be an **object** (not an array/string).",
 | 
			
		||||
                    ephemeral=True,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            # final size sanity (after parse)
 | 
			
		||||
            encoded_size = len(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
 | 
			
		||||
            if encoded_size > MAX_UPLOAD_BYTES:
 | 
			
		||||
                return await interaction.followup.send(
 | 
			
		||||
                    f"Refusing to import unusually large JSON ({encoded_size} bytes).",
 | 
			
		||||
                    ephemeral=True,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            # write & backup
 | 
			
		||||
            self._atomic_replace(payload)
 | 
			
		||||
 | 
			
		||||
            # tiny summary
 | 
			
		||||
            top_keys = sorted(list(payload.keys()))
 | 
			
		||||
            shown = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
 | 
			
		||||
            await interaction.followup.send(
 | 
			
		||||
                f"✅ Imported data and wrote a timestamped backup of the previous file.\n"
 | 
			
		||||
                f"Path: `{dm_path}`\n"
 | 
			
		||||
                f"Top-level keys ({len(top_keys)}): {shown}",
 | 
			
		||||
                ephemeral=True,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            await interaction.followup.send(f"Import failed: {e}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    await bot.add_cog(DataAdmin(bot))
 | 
			
		||||
@ -1,781 +0,0 @@
 | 
			
		||||
# modules/dd/dd_loot_table.py
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
import hashlib
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from datetime import datetime, timedelta, timezone
 | 
			
		||||
from typing import Any, Dict, List, Optional, Tuple, Literal
 | 
			
		||||
 | 
			
		||||
import aiohttp
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
DD_FALLBACK_CHANNEL = 1404764793377652807
 | 
			
		||||
DD_URL = "https://dune.gaming.tools/deep-desert"
 | 
			
		||||
OWNER_ID = 203190147582394369  # for error notices
 | 
			
		||||
 | 
			
		||||
def _log(*a): print("[DD]", *a)
 | 
			
		||||
def _utcnow() -> datetime: return datetime.now(timezone.utc)
 | 
			
		||||
 | 
			
		||||
def _this_week_anchor(now: Optional[datetime] = None) -> datetime:
 | 
			
		||||
    if now is None: now = _utcnow()
 | 
			
		||||
    target_wd = 1  # Tue
 | 
			
		||||
    cur_wd = now.weekday()
 | 
			
		||||
    delta_days = (cur_wd - target_wd) % 7
 | 
			
		||||
    anchor_date = (now - timedelta(days=delta_days)).date()
 | 
			
		||||
    anchor_dt = datetime(anchor_date.year, anchor_date.month, anchor_date.day, 3, 0, 0, tzinfo=timezone.utc)
 | 
			
		||||
    if now < anchor_dt: anchor_dt -= timedelta(days=7)
 | 
			
		||||
    return anchor_dt
 | 
			
		||||
 | 
			
		||||
def _next_week_anchor(after: Optional[datetime] = None) -> datetime:
 | 
			
		||||
    return _this_week_anchor(after) + timedelta(days=7)
 | 
			
		||||
 | 
			
		||||
def _backoff_delay_secs(waiting_since: float, now_ts: float) -> int:
 | 
			
		||||
    waited = max(0.0, now_ts - waiting_since)
 | 
			
		||||
    if waited < 3600: return 5 * 60
 | 
			
		||||
    if waited < 3 * 3600: return 15 * 60
 | 
			
		||||
    if waited < 6 * 3600: return 30 * 60
 | 
			
		||||
    if waited < 24 * 3600: return 60 * 60
 | 
			
		||||
    return 3 * 60 * 60
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class DDState:
 | 
			
		||||
    channel_id: int
 | 
			
		||||
    message_id: Optional[int]
 | 
			
		||||
    disabled: bool
 | 
			
		||||
 | 
			
		||||
    # hashes
 | 
			
		||||
    last_hash: str            # current cycle
 | 
			
		||||
    prev_hash: str            # previous cycle
 | 
			
		||||
    last_post_hash: str       # hash of the message content currently posted
 | 
			
		||||
 | 
			
		||||
    week_anchor_ts: int
 | 
			
		||||
    last_success_ts: int
 | 
			
		||||
    waiting_since_ts: int
 | 
			
		||||
    last_attempt_ts: int
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_dm(cls, dm) -> "DDState":
 | 
			
		||||
        rows = dm.get("dd_state")
 | 
			
		||||
        row = rows[0] if rows else {}
 | 
			
		||||
        env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
 | 
			
		||||
        env_cid = int(env_raw) if env_raw.isdigit() else 0
 | 
			
		||||
        try:
 | 
			
		||||
            stored_cid = int(row.get("channel_id") or 0)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            stored_cid = 0
 | 
			
		||||
        chosen_cid = env_cid or stored_cid or DD_FALLBACK_CHANNEL
 | 
			
		||||
        return cls(
 | 
			
		||||
            channel_id=chosen_cid,
 | 
			
		||||
            message_id=row.get("message_id"),
 | 
			
		||||
            disabled=bool(row.get("disabled", False)),
 | 
			
		||||
            last_hash=str(row.get("last_hash", "")),
 | 
			
		||||
            prev_hash=str(row.get("prev_hash", "")),
 | 
			
		||||
            last_post_hash=str(row.get("last_post_hash", "")),
 | 
			
		||||
            week_anchor_ts=int(row.get("week_anchor_ts", 0)),
 | 
			
		||||
            last_success_ts=int(row.get("last_success_ts", 0)),
 | 
			
		||||
            waiting_since_ts=int(row.get("waiting_since_ts", 0)),
 | 
			
		||||
            last_attempt_ts=int(row.get("last_attempt_ts", 0)),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def to_row(self) -> Dict[str, Any]:
 | 
			
		||||
        return {
 | 
			
		||||
            "channel_id": self.channel_id,
 | 
			
		||||
            "message_id": self.message_id,
 | 
			
		||||
            "disabled": self.disabled,
 | 
			
		||||
            "last_hash": self.last_hash,
 | 
			
		||||
            "prev_hash": self.prev_hash,
 | 
			
		||||
            "last_post_hash": self.last_post_hash,
 | 
			
		||||
            "week_anchor_ts": self.week_anchor_ts,
 | 
			
		||||
            "last_success_ts": self.last_success_ts,
 | 
			
		||||
            "waiting_since_ts": self.waiting_since_ts,
 | 
			
		||||
            "last_attempt_ts": self.last_attempt_ts,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
# ---------- parsing ----------
 | 
			
		||||
 | 
			
		||||
_USER_AGENT = (
 | 
			
		||||
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
 | 
			
		||||
    "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
DETAILS_BLOCK_RE = re.compile(r"<details[^>]*>.*?</details>", re.I | re.S)
 | 
			
		||||
NAME_SPAN_RE = re.compile(r"<summary[^>]*>.*?<span[^>]*>(?P<name>[^<]+)</span>.*?</summary>", re.I | re.S)
 | 
			
		||||
ROW_RE = re.compile(
 | 
			
		||||
    r'<div[^>]*class="[^"]*flex[^"]*items-center[^"]*gap-2[^"]*"[^>]*>\s*'
 | 
			
		||||
    r'<div[^>]*class="[^"]*w-8[^"]*text-center[^"]*"[^>]*>\s*(?P<grid>[A-Z]\d+)\s*</div>\s*'
 | 
			
		||||
    r'<div[^>]*>\s*(?P<loc>[^<]+?)\s*</div>.*?'
 | 
			
		||||
    r'<div[^>]*class="[^"]*ml-auto[^"]*"[^>]*>.*?'
 | 
			
		||||
    r'<div[^>]*class="[^"]*w-10[^"]*text-center[^"]*"[^>]*>\s*(?P<amt>[^<]+?)\s*</div>\s*'
 | 
			
		||||
    r'<div[^>]*>\s*(?P<chance>~?\d+%|\d+\.\d+%)\s*</div>.*?'
 | 
			
		||||
    r'</div>\s*</div>',
 | 
			
		||||
    re.I | re.S,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
def _parse_dd_html(html: str) -> List[Dict[str, str]]:
 | 
			
		||||
    results: List[Dict[str, str]] = []
 | 
			
		||||
    for dmatch in DETAILS_BLOCK_RE.finditer(html or ""):
 | 
			
		||||
        block = dmatch.group(0)
 | 
			
		||||
        nmatch = NAME_SPAN_RE.search(block)
 | 
			
		||||
        if not nmatch: continue
 | 
			
		||||
        name = " ".join(nmatch.group("name").split())
 | 
			
		||||
        for rmatch in ROW_RE.finditer(block):
 | 
			
		||||
            grid = " ".join(rmatch.group("grid").split())
 | 
			
		||||
            loc = " ".join(rmatch.group("loc").split())
 | 
			
		||||
            amt = " ".join(rmatch.group("amt").split())
 | 
			
		||||
            chance = " ".join(rmatch.group("chance").split())
 | 
			
		||||
            results.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
 | 
			
		||||
    return results
 | 
			
		||||
 | 
			
		||||
def _hash_text(s: str) -> str:
 | 
			
		||||
    return hashlib.sha1(s.encode("utf-8")).hexdigest()
 | 
			
		||||
 | 
			
		||||
def _hash_records(rows) -> str:
 | 
			
		||||
    rows = _sanitize_rows(rows)
 | 
			
		||||
    m = hashlib.sha256()
 | 
			
		||||
    for r in rows:
 | 
			
		||||
        m.update(f"{r['name']}|{r['grid']}|{r['loc']}|{r['amount']}|{r['chance']}\n".encode("utf-8"))
 | 
			
		||||
    return m.hexdigest()
 | 
			
		||||
 | 
			
		||||
# ---------- formatters ----------
 | 
			
		||||
 | 
			
		||||
def _as_str(v) -> str:
 | 
			
		||||
    """Coerce any value (incl. lists/tuples) to a compact string."""
 | 
			
		||||
    if isinstance(v, str):
 | 
			
		||||
        return v
 | 
			
		||||
    if isinstance(v, (list, tuple, set)):
 | 
			
		||||
        try:
 | 
			
		||||
            return ", ".join(map(str, v))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return str(v)
 | 
			
		||||
    return str(v)
 | 
			
		||||
 | 
			
		||||
def _sanitize_rows(rows):
 | 
			
		||||
    """Return rows with all fields as trimmed strings; safe for hashing/formatting."""
 | 
			
		||||
    out = []
 | 
			
		||||
    for r in rows or []:
 | 
			
		||||
        out.append({
 | 
			
		||||
            "name":   _as_str(r.get("name", "")).strip(),
 | 
			
		||||
            "grid":   _as_str(r.get("grid", "")).strip().upper(),
 | 
			
		||||
            "loc":    _as_str(r.get("loc", "")).strip(),
 | 
			
		||||
            "amount": _as_str(r.get("amount", "")).strip().replace("–", "-"),
 | 
			
		||||
            "chance": _as_str(r.get("chance", "")).strip().replace(" ", ""),
 | 
			
		||||
        })
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
def _abbr_loc(loc: str) -> str:
 | 
			
		||||
    """Shorten common locations to save characters."""
 | 
			
		||||
    m = {
 | 
			
		||||
        "Imperial Testing Station": "Imp. Testing Station",
 | 
			
		||||
        "Large Shipwreck": "L. Shipwreck",
 | 
			
		||||
        "Small Shipwreck": "S. Shipwreck",
 | 
			
		||||
    }
 | 
			
		||||
    return m.get(loc.strip(), loc.strip())
 | 
			
		||||
 | 
			
		||||
def _grid_sort_key(g: str):
 | 
			
		||||
    """Sort grids like A1, A2, B10 naturally."""
 | 
			
		||||
    g = g.strip().upper()
 | 
			
		||||
    if not g:
 | 
			
		||||
        return ("Z", 999)
 | 
			
		||||
    letter, num = g[0], g[1:]
 | 
			
		||||
    try:
 | 
			
		||||
        n = int(num)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        n = 999
 | 
			
		||||
    return (letter, n)
 | 
			
		||||
 | 
			
		||||
def _fit_discord_message(lines: list[str], header: str, budget: int = 1900) -> str:
 | 
			
		||||
    """Join lines under budget with a truncation notice if needed."""
 | 
			
		||||
    out = [header]
 | 
			
		||||
    total = len(header) + 1
 | 
			
		||||
    dropped = 0
 | 
			
		||||
    for ln in lines:
 | 
			
		||||
        ln_len = len(ln) + 1
 | 
			
		||||
        if total + ln_len > budget:
 | 
			
		||||
            dropped += 1
 | 
			
		||||
            continue
 | 
			
		||||
        out.append(ln)
 | 
			
		||||
        total += ln_len
 | 
			
		||||
    if dropped:
 | 
			
		||||
        out.append(f"... _(truncated {dropped} lines)_")
 | 
			
		||||
    return "\n".join(out)
 | 
			
		||||
 | 
			
		||||
def _fmt_waiting(anchor_dt: datetime) -> str:
 | 
			
		||||
    when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
 | 
			
		||||
    return ("**Deep Desert — Weekly Uniques**\n"
 | 
			
		||||
            f"_Reset detected (week starting **{when}**)._\n"
 | 
			
		||||
            "Waiting for the new loot table to appear...\n"
 | 
			
		||||
            "This message will update automatically once the new data is available.")
 | 
			
		||||
 | 
			
		||||
def _fmt_error(anchor_dt: datetime, note: str) -> str:
 | 
			
		||||
    when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
 | 
			
		||||
    return ("**Deep Desert — Weekly Uniques**\n"
 | 
			
		||||
            f"_Week starting **{when}**._\n"
 | 
			
		||||
            f"⚠️ {note}\n"
 | 
			
		||||
            f"<@{OWNER_ID}> will investigate.")
 | 
			
		||||
 | 
			
		||||
def _fmt_rows(rows, anchor_dt: datetime) -> str:
 | 
			
		||||
    from collections import OrderedDict
 | 
			
		||||
 | 
			
		||||
    rows = _sanitize_rows(rows)
 | 
			
		||||
 | 
			
		||||
    def _abbr_loc(loc: str) -> str:
 | 
			
		||||
        m = {
 | 
			
		||||
            "Imperial Testing Station": "Imp. Testing Station",
 | 
			
		||||
            "Large Shipwreck": "L. Shipwreck",
 | 
			
		||||
            "Small Shipwreck": "S. Shipwreck",
 | 
			
		||||
        }
 | 
			
		||||
        return m.get(loc, loc)
 | 
			
		||||
 | 
			
		||||
    def _grid_sort_key(g: str):
 | 
			
		||||
        g = (g or "").upper()
 | 
			
		||||
        if not g: return ("Z", 999)
 | 
			
		||||
        letter, num = g[0], g[1:]
 | 
			
		||||
        try: n = int(num)
 | 
			
		||||
        except: n = 999
 | 
			
		||||
        return (letter, n)
 | 
			
		||||
 | 
			
		||||
    # item -> location -> (amount, chance) -> [grids]
 | 
			
		||||
    grouped: "OrderedDict[str, OrderedDict[str, Dict[Tuple[str, str], List[str]]]]" = OrderedDict()
 | 
			
		||||
    for r in sorted(rows, key=lambda x: (x["name"], _abbr_loc(x["loc"]), _grid_sort_key(x["grid"]))):
 | 
			
		||||
        item, loc, grid, amt, ch = r["name"], _abbr_loc(r["loc"]), r["grid"], r["amount"], r["chance"]
 | 
			
		||||
        grouped.setdefault(item, OrderedDict()).setdefault(loc, {}).setdefault((amt, ch), []).append(grid)
 | 
			
		||||
 | 
			
		||||
    lines = []
 | 
			
		||||
    for item, loc_map in grouped.items():
 | 
			
		||||
        lines.append(f"- **{item}**")
 | 
			
		||||
        for loc, by_ac in loc_map.items():
 | 
			
		||||
            lines.append(f"  - {loc}")
 | 
			
		||||
 | 
			
		||||
            def _sort_ac(k):
 | 
			
		||||
                amt, ch = k
 | 
			
		||||
                try:
 | 
			
		||||
                    chv = float(ch.lstrip("~").rstrip("%"))
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    chv = -1.0
 | 
			
		||||
                return (-chv, amt)
 | 
			
		||||
 | 
			
		||||
            for (amt, ch), grids in sorted(by_ac.items(), key=_sort_ac):
 | 
			
		||||
                gstr = ", ".join(sorted(set(grids), key=_grid_sort_key))
 | 
			
		||||
                lines.append(f"    - {gstr} - {amt} ({ch})")
 | 
			
		||||
 | 
			
		||||
    when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
 | 
			
		||||
    header = f"**Deep Desert — Weekly Uniques**  _(week starting **{when}**)_"
 | 
			
		||||
    return _fit_discord_message(lines, header, budget=1900)
 | 
			
		||||
 | 
			
		||||
# ---------- HTTP fetchers ----------
 | 
			
		||||
 | 
			
		||||
async def _fetch_via_aiohttp(session: aiohttp.ClientSession, url: str) -> str:
 | 
			
		||||
    headers = {
 | 
			
		||||
        "User-Agent": _USER_AGENT,
 | 
			
		||||
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
 | 
			
		||||
        "Accept-Language": "en-US,en;q=0.9",
 | 
			
		||||
        "Cache-Control": "no-cache",
 | 
			
		||||
        "Pragma": "no-cache",
 | 
			
		||||
    }
 | 
			
		||||
    timeout = aiohttp.ClientTimeout(total=20, sock_connect=10, sock_read=10)
 | 
			
		||||
    async with session.get(url, headers=headers, allow_redirects=True, timeout=timeout) as resp:
 | 
			
		||||
        text = await resp.text()
 | 
			
		||||
        if resp.status >= 400:
 | 
			
		||||
            raise aiohttp.ClientResponseError(
 | 
			
		||||
                request_info=resp.request_info, history=resp.history,
 | 
			
		||||
                status=resp.status, message=f"HTTP {resp.status}", headers=resp.headers
 | 
			
		||||
            )
 | 
			
		||||
        return text
 | 
			
		||||
 | 
			
		||||
# ---------- Playwright (headless) ----------
 | 
			
		||||
 | 
			
		||||
class _PlaywrightPool:
 | 
			
		||||
    """Lazy, optional Playwright Chromium pool (single context)."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.apw = None
 | 
			
		||||
        self.browser = None
 | 
			
		||||
        self.context = None
 | 
			
		||||
        self.enabled = False
 | 
			
		||||
 | 
			
		||||
    async def ensure(self) -> bool:
 | 
			
		||||
        if self.enabled and self.apw and self.browser and self.context:
 | 
			
		||||
            return True
 | 
			
		||||
        try:
 | 
			
		||||
            from playwright.async_api import async_playwright  # type: ignore
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        self.apw = await async_playwright().start()
 | 
			
		||||
        # flags for container/root environments + reduce automation signals
 | 
			
		||||
        self.browser = await self.apw.chromium.launch(
 | 
			
		||||
            headless=True,
 | 
			
		||||
            args=[
 | 
			
		||||
                "--no-sandbox",
 | 
			
		||||
                "--disable-dev-shm-usage",
 | 
			
		||||
                "--disable-gpu",
 | 
			
		||||
                "--disable-blink-features=AutomationControlled",
 | 
			
		||||
            ],
 | 
			
		||||
        )
 | 
			
		||||
        self.context = await self.browser.new_context(
 | 
			
		||||
            user_agent=_USER_AGENT,
 | 
			
		||||
            locale="en-US",
 | 
			
		||||
            timezone_id="UTC",
 | 
			
		||||
            java_script_enabled=True,
 | 
			
		||||
            ignore_https_errors=True,
 | 
			
		||||
            viewport={"width": 1366, "height": 900},
 | 
			
		||||
            extra_http_headers={
 | 
			
		||||
                "Accept-Language": "en-US,en;q=0.9",
 | 
			
		||||
                "Upgrade-Insecure-Requests": "1",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        # Minimal stealth: remove webdriver and add a few common props
 | 
			
		||||
        await self.context.add_init_script("""
 | 
			
		||||
            Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
 | 
			
		||||
            Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
 | 
			
		||||
            Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
 | 
			
		||||
            Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
 | 
			
		||||
        """)
 | 
			
		||||
        self.enabled = True
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    async def close(self):
 | 
			
		||||
        try:
 | 
			
		||||
            if self.context: await self.context.close()
 | 
			
		||||
        finally:
 | 
			
		||||
            try:
 | 
			
		||||
                if self.browser: await self.browser.close()
 | 
			
		||||
            finally:
 | 
			
		||||
                try:
 | 
			
		||||
                    if self.apw: await self.apw.stop()
 | 
			
		||||
                finally:
 | 
			
		||||
                    self.apw = self.browser = self.context = None
 | 
			
		||||
                    self.enabled = False
 | 
			
		||||
 | 
			
		||||
    async def fetch(self, url: str, timeout_ms: Optional[int] = None, wait: Optional[str] = None) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Fetch fully rendered HTML with tolerant waiting against Cloudflare.
 | 
			
		||||
        Env overrides:
 | 
			
		||||
          SHAI_DD_PW_TIMEOUT_MS (default 45000)
 | 
			
		||||
          SHAI_DD_PW_WAIT = domcontentloaded|load|networkidle (default domcontentloaded)
 | 
			
		||||
        """
 | 
			
		||||
        if not await self.ensure():
 | 
			
		||||
            raise RuntimeError("playwright-unavailable")
 | 
			
		||||
 | 
			
		||||
        timeout_ms = int(os.getenv("SHAI_DD_PW_TIMEOUT_MS", "45000") or "45000") if timeout_ms is None else timeout_ms
 | 
			
		||||
        wait_mode = (os.getenv("SHAI_DD_PW_WAIT", "domcontentloaded") or "domcontentloaded").lower()
 | 
			
		||||
        if wait: wait_mode = wait
 | 
			
		||||
 | 
			
		||||
        page = await self.context.new_page()
 | 
			
		||||
 | 
			
		||||
        # Keep media traffic low but don't block fonts/CSS/JS (CF sometimes needs them)
 | 
			
		||||
        async def _route(route):
 | 
			
		||||
            rt = route.request.resource_type
 | 
			
		||||
            if rt in ("media", "video", "audio"):
 | 
			
		||||
                await route.abort()
 | 
			
		||||
            else:
 | 
			
		||||
                await route.continue_()
 | 
			
		||||
        await page.route("**/*", _route)
 | 
			
		||||
 | 
			
		||||
        # Step 1: navigate, but don't require networkidle (CF pages rarely go "idle")
 | 
			
		||||
        await page.goto(url, wait_until=wait_mode, timeout=timeout_ms)
 | 
			
		||||
 | 
			
		||||
        # Step 2: loop for CF auto-redirect and app hydration
 | 
			
		||||
        # We'll try up to ~35s total here.
 | 
			
		||||
        end_by = time.time() + max(20, timeout_ms / 1000 - 5)
 | 
			
		||||
        last_details = 0
 | 
			
		||||
        while time.time() < end_by:
 | 
			
		||||
            html = await page.content()
 | 
			
		||||
            u = page.url
 | 
			
		||||
            # If we're still on a CF challenge or "just a moment" page, give it a bit
 | 
			
		||||
            if ("cdn-cgi/challenge" in u) or ("cf-chl" in u) or ("Just a moment" in html) or ("Please wait" in html):
 | 
			
		||||
                await page.wait_for_timeout(2500)
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            # Check if our target content looks present
 | 
			
		||||
            try:
 | 
			
		||||
                count = await page.locator("details").count()
 | 
			
		||||
            except Exception:
 | 
			
		||||
                count = 0
 | 
			
		||||
            last_details = max(last_details, count)
 | 
			
		||||
            if count > 0:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
            await page.wait_for_timeout(1500)
 | 
			
		||||
 | 
			
		||||
        html = await page.content()
 | 
			
		||||
        await page.close()
 | 
			
		||||
        return html
 | 
			
		||||
 | 
			
		||||
# ---------- Cog ----------
 | 
			
		||||
 | 
			
		||||
class DDLootTableCog(commands.Cog):
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
        self.dd_url = r.get("dd_url", DD_URL)
 | 
			
		||||
        try:
 | 
			
		||||
            self.channel_id_default = int(r.get("dd_channel_id", DD_FALLBACK_CHANNEL))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            self.channel_id_default = DD_FALLBACK_CHANNEL
 | 
			
		||||
 | 
			
		||||
        self._task: Optional[asyncio.Task] = None
 | 
			
		||||
        self._session: Optional[aiohttp.ClientSession] = None
 | 
			
		||||
        self._pw = _PlaywrightPool()
 | 
			
		||||
        self._last_debug: str = ""
 | 
			
		||||
 | 
			
		||||
    async def cog_load(self):
 | 
			
		||||
        self._session = aiohttp.ClientSession()
 | 
			
		||||
        if self._task is None:
 | 
			
		||||
            self._task = asyncio.create_task(self._runner(), name="DDLootTableRunner")
 | 
			
		||||
        _log("cog loaded; runner started:", bool(self._task), "url:", self.dd_url)
 | 
			
		||||
 | 
			
		||||
    async def cog_unload(self):
 | 
			
		||||
        t, self._task = self._task, None
 | 
			
		||||
        if t: t.cancel()
 | 
			
		||||
        s, self._session = self._session, None
 | 
			
		||||
        if s: await s.close()
 | 
			
		||||
        try:
 | 
			
		||||
            await self._pw.close()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        _log("cog unloaded; runner/task closed")
 | 
			
		||||
 | 
			
		||||
    # ---- state ----
 | 
			
		||||
 | 
			
		||||
    def _load_state(self) -> DDState:
 | 
			
		||||
        st = DDState.from_dm(self.bot.data_manager)
 | 
			
		||||
        env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
 | 
			
		||||
        env_cid = int(env_raw) if env_raw.isdigit() else 0
 | 
			
		||||
        if env_cid and env_cid != st.channel_id:
 | 
			
		||||
            st.channel_id = env_cid
 | 
			
		||||
            self._save_state(st.to_row())
 | 
			
		||||
            _log(f"channel id overridden by ENV -> {env_cid}")
 | 
			
		||||
        _log(f"state loaded: ch={st.channel_id} msg={st.message_id} disabled={st.disabled}")
 | 
			
		||||
        return st
 | 
			
		||||
 | 
			
		||||
    def _save_state(self, patch: Dict[str, Any]) -> None:
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        rows = dm.get("dd_state")
 | 
			
		||||
        if not rows:
 | 
			
		||||
            dm.add("dd_state", patch); return
 | 
			
		||||
        def pred(_): return True
 | 
			
		||||
        def upd(d): d.update(patch); return d
 | 
			
		||||
        dm.update("dd_state", pred, upd)
 | 
			
		||||
 | 
			
		||||
    # ---- message helpers ----
 | 
			
		||||
 | 
			
		||||
    async def _resolve_channel(self, channel_id: int) -> Optional[discord.TextChannel]:
 | 
			
		||||
        ch = self.bot.get_channel(channel_id)
 | 
			
		||||
        if ch is None:
 | 
			
		||||
            try: ch = await self.bot.fetch_channel(channel_id)
 | 
			
		||||
            except Exception: ch = None
 | 
			
		||||
        if not isinstance(ch, discord.TextChannel): return None
 | 
			
		||||
        me = ch.guild.me
 | 
			
		||||
        if me:
 | 
			
		||||
            p = ch.permissions_for(me)
 | 
			
		||||
            if not (p.read_messages and p.send_messages):
 | 
			
		||||
                _log(f"missing perms in #{ch.name} ({ch.id})")
 | 
			
		||||
        return ch
 | 
			
		||||
 | 
			
		||||
    async def _ensure_message(self, st: DDState, content_if_create: Optional[str]) -> Optional[discord.Message]:
 | 
			
		||||
        ch = await self._resolve_channel(st.channel_id)
 | 
			
		||||
        if not ch:
 | 
			
		||||
            _log("target channel not found/invalid:", st.channel_id)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        if st.message_id:
 | 
			
		||||
            try:
 | 
			
		||||
                return await ch.fetch_message(st.message_id)
 | 
			
		||||
            except discord.NotFound:
 | 
			
		||||
                st.message_id = None
 | 
			
		||||
                self._save_state({"message_id": None})
 | 
			
		||||
            except discord.Forbidden:
 | 
			
		||||
                _log("cannot fetch message (no history); will NOT create a new one")
 | 
			
		||||
                return None
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                _log("fetch_message failed:", repr(e))
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
        if content_if_create is None:
 | 
			
		||||
            return None
 | 
			
		||||
        try:
 | 
			
		||||
            msg = await ch.send(content_if_create)
 | 
			
		||||
            st.message_id = msg.id
 | 
			
		||||
            st.last_post_hash = _hash_text(content_if_create)
 | 
			
		||||
            self._save_state({"channel_id": st.channel_id, "message_id": msg.id, "last_post_hash": st.last_post_hash})
 | 
			
		||||
            return msg
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            _log("failed to create message:", repr(e))
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    async def _set_message(self, st: DDState, content: str) -> Optional[int]:
 | 
			
		||||
        """Create-or-edit the single managed message. Returns message_id (if known) and stores last_post_hash."""
 | 
			
		||||
        msg = await self._ensure_message(st, content_if_create=content if not st.message_id else None)
 | 
			
		||||
        if not msg:
 | 
			
		||||
            return None
 | 
			
		||||
        try:
 | 
			
		||||
            await msg.edit(content=content)
 | 
			
		||||
            st.last_post_hash = _hash_text(content)
 | 
			
		||||
            self._save_state({"last_post_hash": st.last_post_hash})
 | 
			
		||||
        except discord.NotFound:
 | 
			
		||||
            st.message_id = None
 | 
			
		||||
            self._save_state({"message_id": None})
 | 
			
		||||
            msg2 = await self._ensure_message(st, content_if_create=content)
 | 
			
		||||
            if msg2:
 | 
			
		||||
                try:
 | 
			
		||||
                    await msg2.edit(content=content)
 | 
			
		||||
                    st.last_post_hash = _hash_text(content)
 | 
			
		||||
                    self._save_state({"message_id": msg2.id, "last_post_hash": st.last_post_hash})
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
        except discord.Forbidden:
 | 
			
		||||
            _log("edit forbidden; single-message mode keeps state")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            _log("edit failed:", repr(e))
 | 
			
		||||
        return st.message_id
 | 
			
		||||
    
 | 
			
		||||
    # ---- fetch orchestration ----
 | 
			
		||||
 | 
			
		||||
    async def _fetch_dd_html_any(self) -> Tuple[str, str]:
 | 
			
		||||
        """Return (html, backend_tag). Preference: env → playwright(if available) → aiohttp."""
 | 
			
		||||
        prefer = os.getenv("SHAI_DD_FETCHER", "").lower()
 | 
			
		||||
        # prefer Playwright
 | 
			
		||||
        if prefer in {"playwright", "pw", "browser"}:
 | 
			
		||||
            if await self._pw.ensure():
 | 
			
		||||
                html = await self._pw.fetch(self.dd_url)
 | 
			
		||||
                return html, "playwright"
 | 
			
		||||
        else:
 | 
			
		||||
            # opportunistic: try Playwright first if available
 | 
			
		||||
            try:
 | 
			
		||||
                if await self._pw.ensure():
 | 
			
		||||
                    html = await self._pw.fetch(self.dd_url)
 | 
			
		||||
                    return html, "playwright"
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        # fallback: aiohttp (may 403)
 | 
			
		||||
        html = await _fetch_via_aiohttp(self._session, self.dd_url)
 | 
			
		||||
        return html, "aiohttp"
 | 
			
		||||
 | 
			
		||||
    async def _attempt_fetch(self) -> Tuple[bool, List[Dict[str, str]], str]:
 | 
			
		||||
        import asyncio
 | 
			
		||||
        self._last_debug = ""
 | 
			
		||||
        if not self._session:
 | 
			
		||||
            self._last_debug = "internal: no HTTP session"
 | 
			
		||||
            return (False, [], "unable to check for new loot (will retry)")
 | 
			
		||||
        try:
 | 
			
		||||
            html, backend = await self._fetch_dd_html_any()
 | 
			
		||||
            self._last_debug = f"ok via {backend}"
 | 
			
		||||
        except aiohttp.ClientResponseError as e:
 | 
			
		||||
            self._last_debug = f"http {getattr(e,'status','?')} (aiohttp)"
 | 
			
		||||
            return (False, [], "unable to check for new loot (will retry)")
 | 
			
		||||
        except asyncio.TimeoutError:
 | 
			
		||||
            self._last_debug = "timeout"
 | 
			
		||||
            return (False, [], "unable to check for new loot (will retry)")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            self._last_debug = f"{e.__class__.__name__}: {e}"
 | 
			
		||||
            return (False, [], "unable to check for new loot (will retry)")
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            rows = _parse_dd_html(html)
 | 
			
		||||
            if not rows:
 | 
			
		||||
                self._last_debug = "parse: zero rows"
 | 
			
		||||
                return (False, [], "no loot entries detected yet (will retry)")
 | 
			
		||||
            clean = []
 | 
			
		||||
            for r in rows:
 | 
			
		||||
                name = r["name"].strip()
 | 
			
		||||
                grid = r["grid"].strip().upper()
 | 
			
		||||
                loc = r["loc"].strip()
 | 
			
		||||
                amt = r["amount"].strip().replace("–", "-")
 | 
			
		||||
                chance = r["chance"].strip().replace(" ", "")
 | 
			
		||||
                if not name or not re.match(r"^[A-Z]\d+$", grid):
 | 
			
		||||
                    continue
 | 
			
		||||
                clean.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
 | 
			
		||||
            if not clean:
 | 
			
		||||
                self._last_debug = "parse: filtered to zero rows"
 | 
			
		||||
                return (False, [], "loot data format changed (will retry)")
 | 
			
		||||
            return (True, clean, "")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            self._last_debug = f"parse error: {e.__class__.__name__}: {e}"
 | 
			
		||||
            return (False, [], "loot data parse error (will retry)")
 | 
			
		||||
 | 
			
		||||
    # ---- manual kick ----
 | 
			
		||||
 | 
			
		||||
    async def _manual_kick_once(self, st: DDState) -> str:
 | 
			
		||||
        anchor_dt = _this_week_anchor()
 | 
			
		||||
 | 
			
		||||
        # always show "waiting" briefly so users see it's been kicked
 | 
			
		||||
        mid = await self._set_message(st, _fmt_waiting(anchor_dt))
 | 
			
		||||
        if mid and not st.message_id:
 | 
			
		||||
            st.message_id = mid
 | 
			
		||||
            self._save_state(st.to_row())
 | 
			
		||||
 | 
			
		||||
        ok, rows, note = await self._attempt_fetch()
 | 
			
		||||
        if not ok or not rows:
 | 
			
		||||
            if note:
 | 
			
		||||
                await self._set_message(st, _fmt_error(anchor_dt, note))
 | 
			
		||||
            return f"Fetch failed: {note or 'unknown error'}"
 | 
			
		||||
 | 
			
		||||
        new_hash = _hash_records(rows)
 | 
			
		||||
 | 
			
		||||
        if st.prev_hash and new_hash == st.prev_hash:
 | 
			
		||||
            # still last week's data; keep waiting
 | 
			
		||||
            await self._set_message(st, _fmt_waiting(anchor_dt))
 | 
			
		||||
            return "Data unchanged from previous cycle; still waiting."
 | 
			
		||||
 | 
			
		||||
        table = _fmt_rows(rows, anchor_dt)
 | 
			
		||||
 | 
			
		||||
        if st.last_hash and new_hash == st.last_hash:
 | 
			
		||||
            # same as what we already posted this cycle → ensure table is visible
 | 
			
		||||
            await self._set_message(st, table)
 | 
			
		||||
            return "Data unchanged; table ensured."
 | 
			
		||||
 | 
			
		||||
        # fresh for this cycle
 | 
			
		||||
        st.last_hash = new_hash
 | 
			
		||||
        st.last_success_ts = int(time.time())
 | 
			
		||||
        self._save_state(st.to_row())
 | 
			
		||||
        await self._set_message(st, table)
 | 
			
		||||
        return "Posted fresh data."
 | 
			
		||||
 | 
			
		||||
    # ---- runner ----
 | 
			
		||||
 | 
			
		||||
    async def _runner(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        _log("runner loop started")
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            try:
 | 
			
		||||
                st = self._load_state()
 | 
			
		||||
                if st.disabled:
 | 
			
		||||
                    await asyncio.sleep(300); continue
 | 
			
		||||
 | 
			
		||||
                now_dt = _utcnow()
 | 
			
		||||
                this_anchor_dt = _this_week_anchor(now_dt)
 | 
			
		||||
                this_anchor_ts = int(this_anchor_dt.timestamp())
 | 
			
		||||
                next_anchor_dt = _next_week_anchor(now_dt)
 | 
			
		||||
 | 
			
		||||
                if st.week_anchor_ts != this_anchor_ts:
 | 
			
		||||
                    # roll current → prev; reset current
 | 
			
		||||
                    st.prev_hash = st.last_hash or st.prev_hash
 | 
			
		||||
                    st.last_hash = ""
 | 
			
		||||
                    st.week_anchor_ts = this_anchor_ts
 | 
			
		||||
                    st.last_success_ts = 0
 | 
			
		||||
                    st.waiting_since_ts = this_anchor_ts
 | 
			
		||||
                    st.last_attempt_ts = 0
 | 
			
		||||
                    self._save_state(st.to_row())
 | 
			
		||||
 | 
			
		||||
                    mid = await self._set_message(st, _fmt_waiting(this_anchor_dt))
 | 
			
		||||
                    if mid and not st.message_id:
 | 
			
		||||
                        st.message_id = mid
 | 
			
		||||
                        self._save_state(st.to_row())
 | 
			
		||||
                    _log("new week anchor -> waiting UPDATED (single-message)")
 | 
			
		||||
 | 
			
		||||
                if st.last_success_ts >= this_anchor_ts and st.last_success_ts < int(next_anchor_dt.timestamp()):
 | 
			
		||||
                    await asyncio.sleep(min(3600, max(60, int(next_anchor_dt.timestamp() - time.time()))))
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                if st.waiting_since_ts == 0:
 | 
			
		||||
                    st.waiting_since_ts = this_anchor_ts
 | 
			
		||||
 | 
			
		||||
                delay = _backoff_delay_secs(st.waiting_since_ts, time.time())
 | 
			
		||||
 | 
			
		||||
                if st.last_attempt_ts == 0 or (time.time() - st.last_attempt_ts) >= delay:
 | 
			
		||||
                    ok, rows, note = await self._attempt_fetch()
 | 
			
		||||
                    st.last_attempt_ts = int(time.time())
 | 
			
		||||
                    self._save_state(st.to_row())
 | 
			
		||||
 | 
			
		||||
                    if ok and rows:
 | 
			
		||||
                        new_hash = _hash_records(rows)
 | 
			
		||||
 | 
			
		||||
                        # 1) identical to last cycle → keep waiting; keep polling
 | 
			
		||||
                        if st.prev_hash and new_hash == st.prev_hash:
 | 
			
		||||
                            waiting = _fmt_waiting(this_anchor_dt)
 | 
			
		||||
                            if st.last_post_hash != _hash_text(waiting):
 | 
			
		||||
                                await self._set_message(st, waiting)
 | 
			
		||||
                            _log("data equals prev week; still waiting")
 | 
			
		||||
                            # no success_ts update; try again with backoff
 | 
			
		||||
                        else:
 | 
			
		||||
                            table = _fmt_rows(rows, this_anchor_dt)
 | 
			
		||||
 | 
			
		||||
                            # 2) same as current hash → ensure table is visible (flip off any waiting message)
 | 
			
		||||
                            if st.last_hash and new_hash == st.last_hash:
 | 
			
		||||
                                if st.last_post_hash != _hash_text(table):
 | 
			
		||||
                                    await self._set_message(st, table)
 | 
			
		||||
                                    _log("data same as already posted; ensured table visible")
 | 
			
		||||
                                # already have success this cycle; sleep a bit longer
 | 
			
		||||
                                await asyncio.sleep(900)
 | 
			
		||||
                                continue
 | 
			
		||||
 | 
			
		||||
                            # 3) fresh data for this cycle → post table, mark success
 | 
			
		||||
                            st.last_hash = new_hash
 | 
			
		||||
                            st.last_success_ts = int(time.time())
 | 
			
		||||
                            self._save_state(st.to_row())
 | 
			
		||||
                            await self._set_message(st, table)
 | 
			
		||||
                            _log("updated weekly uniques (fresh data)")
 | 
			
		||||
                            await asyncio.sleep(900)
 | 
			
		||||
                            continue
 | 
			
		||||
 | 
			
		||||
                    else:
 | 
			
		||||
                        if note:
 | 
			
		||||
                            await self._set_message(st, _fmt_error(this_anchor_dt, note))
 | 
			
		||||
                            _log("fetch failed:", note, "| debug:", self._last_debug)
 | 
			
		||||
 | 
			
		||||
                await asyncio.sleep(30)
 | 
			
		||||
 | 
			
		||||
            except asyncio.CancelledError:
 | 
			
		||||
                break
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                _log("runner error:", repr(e)); await asyncio.sleep(30)
 | 
			
		||||
        _log("runner loop stopped")
 | 
			
		||||
 | 
			
		||||
    # ---- command ----
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="dd_update", description="[MOD] Control the Deep Desert weekly loot updater")
 | 
			
		||||
    @app_commands.describe(action="stop/resume/start", reason="Optional reason")
 | 
			
		||||
    async def dd_update(self, interaction: discord.Interaction,
 | 
			
		||||
                        action: Literal["stop", "resume", "start"],
 | 
			
		||||
                        reason: Optional[str] = None):
 | 
			
		||||
        st = self._load_state()
 | 
			
		||||
        is_owner = bool(interaction.guild and interaction.user.id == getattr(interaction.guild, "owner_id", 0))
 | 
			
		||||
        if action == "start":
 | 
			
		||||
            perms_ok = is_owner
 | 
			
		||||
        else:
 | 
			
		||||
            perms = interaction.user.guild_permissions if interaction.guild else None
 | 
			
		||||
            perms_ok = bool(is_owner or (perms and perms.manage_guild))
 | 
			
		||||
        if not perms_ok:
 | 
			
		||||
            return await interaction.response.send_message("You don't have permission to do that.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        if action == "stop":
 | 
			
		||||
            st.disabled = True; self._save_state(st.to_row())
 | 
			
		||||
            msg = "DD updater stopped."; 
 | 
			
		||||
            if reason: msg += f" Reason: {reason}"
 | 
			
		||||
            return await interaction.response.send_message(msg, ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        if action == "resume":
 | 
			
		||||
            st.disabled = False; self._save_state(st.to_row())
 | 
			
		||||
            return await interaction.response.send_message("DD updater resumed.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # start (owner-only)
 | 
			
		||||
        st.disabled = False
 | 
			
		||||
        now_dt = _utcnow()
 | 
			
		||||
        st.week_anchor_ts = int(_this_week_anchor(now_dt).timestamp())
 | 
			
		||||
        st.waiting_since_ts = int(time.time())
 | 
			
		||||
        st.last_attempt_ts = 0
 | 
			
		||||
        self._save_state(st.to_row())
 | 
			
		||||
 | 
			
		||||
        ch = await self._resolve_channel(st.channel_id)
 | 
			
		||||
        if not ch:
 | 
			
		||||
            return await interaction.response.send_message(
 | 
			
		||||
                f"Manual start queued, but the target channel is invalid or missing.\n"
 | 
			
		||||
                f"Set **SHAI_DD_CHANNEL_ID** to a valid text channel ID (current: `{st.channel_id}`).",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
        status = await self._manual_kick_once(st)
 | 
			
		||||
        dbg = f" (debug: {self._last_debug})" if self._last_debug else ""
 | 
			
		||||
        await interaction.followup.send(f"Manual start triggered. {status}{dbg}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    await bot.add_cog(DDLootTableCog(bot))
 | 
			
		||||
@ -1,845 +0,0 @@
 | 
			
		||||
import json
 | 
			
		||||
import threading
 | 
			
		||||
import traceback
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
import importlib
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from typing import Any, Dict, List, Optional, Tuple
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
_START_TS = time.time()
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Safe JSON helpers
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
_PRIMITIVES = (str, int, float, bool, type(None))
 | 
			
		||||
 | 
			
		||||
def _to_primitive(obj: Any, depth: int = 0) -> Any:
 | 
			
		||||
    if depth > 6:
 | 
			
		||||
        return str(obj)
 | 
			
		||||
    if isinstance(obj, _PRIMITIVES):
 | 
			
		||||
        return obj
 | 
			
		||||
    if isinstance(obj, (list, tuple, set)):
 | 
			
		||||
        return [_to_primitive(x, depth + 1) for x in obj]
 | 
			
		||||
    if isinstance(obj, dict):
 | 
			
		||||
        out = {}
 | 
			
		||||
        for k, v in obj.items():
 | 
			
		||||
            try:
 | 
			
		||||
                out[str(k)] = _to_primitive(v, depth + 1)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                out[str(k)] = str(v)
 | 
			
		||||
        return out
 | 
			
		||||
    if hasattr(obj, "value"):
 | 
			
		||||
        try:
 | 
			
		||||
            return _to_primitive(getattr(obj, "value"), depth + 1)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
    return str(obj)
 | 
			
		||||
 | 
			
		||||
def _json_dumps_safe(payload: Any) -> bytes:
 | 
			
		||||
    return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Version / uptime helpers
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def _project_root() -> Path:
 | 
			
		||||
    return Path(__file__).resolve().parents[2]
 | 
			
		||||
 | 
			
		||||
def _read_version_from_file() -> Optional[str]:
 | 
			
		||||
    """Strictly parse VERSION from bot.py:  VERSION = "x.y.z"."""
 | 
			
		||||
    try:
 | 
			
		||||
        bot_py = _project_root() / "bot.py"
 | 
			
		||||
        if not bot_py.is_file():
 | 
			
		||||
            return None
 | 
			
		||||
        text = bot_py.read_text(encoding="utf-8", errors="ignore")
 | 
			
		||||
        m = re.search(r'^\s*VERSION\s*=\s*["\']([^"\']+)["\']', text, re.M)
 | 
			
		||||
        if m:
 | 
			
		||||
            return m.group(1)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        traceback.print_exc()
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def _get_version_from_botpy() -> Optional[str]:
 | 
			
		||||
    try:
 | 
			
		||||
        m = importlib.import_module("bot")
 | 
			
		||||
        v = getattr(m, "VERSION", None)
 | 
			
		||||
        if isinstance(v, (str, int, float)):
 | 
			
		||||
            return str(v)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def _get_boot_state(bot: commands.Bot) -> Dict[str, Any]:
 | 
			
		||||
    dm = getattr(bot, "data_manager", None)
 | 
			
		||||
    if dm and dm.get("boot_state"):
 | 
			
		||||
        try:
 | 
			
		||||
            return (dm.get("boot_state") or [{}])[-1]
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return {}
 | 
			
		||||
    return {}
 | 
			
		||||
 | 
			
		||||
def _status_payload(bot: commands.Bot) -> Dict[str, Any]:
 | 
			
		||||
    st = _get_boot_state(bot)
 | 
			
		||||
    now = time.time()
 | 
			
		||||
    boot_ts = float(st.get("last_boot_ts", 0.0)) if st else 0.0
 | 
			
		||||
    if boot_ts > 0:
 | 
			
		||||
        uptime = max(0, int(now - boot_ts))
 | 
			
		||||
    else:
 | 
			
		||||
        uptime = max(0, int(now - _START_TS))
 | 
			
		||||
 | 
			
		||||
    ver = st.get("last_version") if st else None
 | 
			
		||||
    if not ver:
 | 
			
		||||
        ver = _read_version_from_file() or _get_version_from_botpy() or os.getenv("SHAI_VERSION", "unknown")
 | 
			
		||||
    return {"uptime_seconds": uptime, "version": str(ver)}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Mod detection (heuristics)
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def _looks_like_mod_check(fn) -> bool:
 | 
			
		||||
    try:
 | 
			
		||||
        qn = getattr(fn, "__qualname__", "") or ""
 | 
			
		||||
        mod_names = (
 | 
			
		||||
            "is_moderator_member",
 | 
			
		||||
            "is_moderator_userid",
 | 
			
		||||
            "require_mod_ctx",
 | 
			
		||||
            "require_mod_interaction",
 | 
			
		||||
        )
 | 
			
		||||
        if any(m in qn for m in mod_names):
 | 
			
		||||
            return True
 | 
			
		||||
        mod = getattr(fn, "__module__", "") or ""
 | 
			
		||||
        if "mod_perms" in mod:
 | 
			
		||||
            return True
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
def _is_perm_check(fn) -> Tuple[bool, List[str]]:
 | 
			
		||||
    perms = []
 | 
			
		||||
    try:
 | 
			
		||||
        code = getattr(fn, "__code__", None)
 | 
			
		||||
        if code and code.co_freevars and getattr(fn, "__closure__", None):
 | 
			
		||||
            for cell in fn.__closure__ or []:
 | 
			
		||||
                val = getattr(cell, "cell_contents", None)
 | 
			
		||||
                if isinstance(val, dict):
 | 
			
		||||
                    for k, v in val.items():
 | 
			
		||||
                        if v:
 | 
			
		||||
                            perms.append(str(k))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return (len(perms) > 0), perms
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Introspection
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def _bot_prefix(bot: commands.Bot) -> str:
 | 
			
		||||
    p = getattr(bot, "command_prefix", "!")
 | 
			
		||||
    try:
 | 
			
		||||
        if callable(p):
 | 
			
		||||
            val = p(bot, None)
 | 
			
		||||
            if isinstance(val, (list, tuple)) and val:
 | 
			
		||||
                return str(val[0])
 | 
			
		||||
            return str(val)
 | 
			
		||||
        return str(p)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return "!"
 | 
			
		||||
 | 
			
		||||
def _is_mod_command_prefix(cmd: commands.Command) -> Tuple[bool, List[str]]:
 | 
			
		||||
    is_mod = False
 | 
			
		||||
    perms: List[str] = []
 | 
			
		||||
    try:
 | 
			
		||||
        for chk in getattr(cmd, "checks", []) or []:
 | 
			
		||||
            if _looks_like_mod_check(chk):
 | 
			
		||||
                is_mod = True
 | 
			
		||||
            p_flag, p_list = _is_perm_check(chk)
 | 
			
		||||
            if p_flag:
 | 
			
		||||
                perms.extend(p_list)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return is_mod, sorted(set(perms))
 | 
			
		||||
 | 
			
		||||
def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
 | 
			
		||||
    is_mod = False
 | 
			
		||||
    try:
 | 
			
		||||
        for chk in getattr(cmd, "checks", []) or []:
 | 
			
		||||
            if _looks_like_mod_check(chk):
 | 
			
		||||
                is_mod = True
 | 
			
		||||
        for chk in getattr(cmd, "_checks", []) or []:
 | 
			
		||||
            if _looks_like_mod_check(chk):
 | 
			
		||||
                is_mod = True
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return is_mod, []
 | 
			
		||||
 | 
			
		||||
def _command_usage_prefix(cmd: commands.Command, prefix: str) -> str:
 | 
			
		||||
    if cmd.usage:
 | 
			
		||||
        return cmd.usage
 | 
			
		||||
    try:
 | 
			
		||||
        params = []
 | 
			
		||||
        for k, p in (cmd.clean_params or {}).items():
 | 
			
		||||
            if getattr(p, "kind", None) and str(p.kind).lower().startswith("var"):
 | 
			
		||||
                params.append(f"[{k}...]")
 | 
			
		||||
            elif p.default is p.empty:
 | 
			
		||||
                params.append(f"<{k}>")
 | 
			
		||||
            else:
 | 
			
		||||
                params.append(f"[{k}]")
 | 
			
		||||
        return f"{prefix}{cmd.qualified_name}" + ((" " + " ".join(params)) if params else "")
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return f"{prefix}{cmd.name}"
 | 
			
		||||
 | 
			
		||||
def _command_usage_slash_like(cmd_name: str, options: Optional[List[Any]] = None) -> str:
 | 
			
		||||
    try:
 | 
			
		||||
        parts = [f"/{cmd_name}"]
 | 
			
		||||
        opts = []
 | 
			
		||||
        seq = options or []
 | 
			
		||||
        for opt in seq:
 | 
			
		||||
            n = getattr(opt, "name", None) or getattr(opt, "display_name", None) or "arg"
 | 
			
		||||
            req = bool(getattr(opt, "required", False))
 | 
			
		||||
            opts.append(f"<{n}>" if req else f"[{n}]")
 | 
			
		||||
        if opts:
 | 
			
		||||
            parts.append(" " + " ".join(opts))
 | 
			
		||||
        return "".join(parts)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return f"/{cmd_name}"
 | 
			
		||||
 | 
			
		||||
def _command_usage_slash(cmd: app_commands.Command) -> str:
 | 
			
		||||
    try:
 | 
			
		||||
        options = getattr(cmd, "options", None) or getattr(cmd, "parameters", None) or getattr(cmd, "_params", None)
 | 
			
		||||
        return _command_usage_slash_like((cmd.name or "").replace("/", " "), options)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return f"/{cmd.name}"
 | 
			
		||||
 | 
			
		||||
def _iter_all_app_commands(bot: commands.Bot):
 | 
			
		||||
    """Yield (scope_tag, top_level_command) including global and per-guild trees."""
 | 
			
		||||
    out = []
 | 
			
		||||
    try:
 | 
			
		||||
        for cmd in bot.tree.get_commands():
 | 
			
		||||
            out.append(("", cmd))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    for g in list(getattr(bot, "guilds", []) or []):
 | 
			
		||||
        try:
 | 
			
		||||
            cmds = bot.tree.get_commands(guild=g)
 | 
			
		||||
        except TypeError:
 | 
			
		||||
            try:
 | 
			
		||||
                cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
 | 
			
		||||
            except Exception:
 | 
			
		||||
                cmds = []
 | 
			
		||||
        except Exception:
 | 
			
		||||
            cmds = []
 | 
			
		||||
        for cmd in cmds or []:
 | 
			
		||||
            out.append((str(g.id), cmd))
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
    rows: List[Dict[str, Any]] = []
 | 
			
		||||
    seen = set()
 | 
			
		||||
    prefix = _bot_prefix(bot)
 | 
			
		||||
 | 
			
		||||
    for cmd in getattr(bot, "commands", []) or []:
 | 
			
		||||
        try:
 | 
			
		||||
            if getattr(cmd, "hidden", False):
 | 
			
		||||
                continue
 | 
			
		||||
            is_hybrid = isinstance(cmd, commands.HybridCommand)
 | 
			
		||||
            ctype = "hybrid" if is_hybrid else "prefix"
 | 
			
		||||
 | 
			
		||||
            is_mod, perms = _is_mod_command_prefix(cmd)
 | 
			
		||||
 | 
			
		||||
            usage_slash = None
 | 
			
		||||
            if is_hybrid:
 | 
			
		||||
                try:
 | 
			
		||||
                    app_cmd = getattr(cmd, "app_command", None)
 | 
			
		||||
                    if isinstance(app_cmd, app_commands.Command):
 | 
			
		||||
                        usage_slash = _command_usage_slash(app_cmd)
 | 
			
		||||
                        s_mod, _ = _is_mod_command_slash(app_cmd)
 | 
			
		||||
                        if s_mod:
 | 
			
		||||
                            is_mod = True
 | 
			
		||||
                        desc = (getattr(app_cmd, "description", "") or "")
 | 
			
		||||
                        if "[mod]" in desc.lower():
 | 
			
		||||
                            is_mod = True
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
            usage_prefix = _command_usage_prefix(cmd, prefix)
 | 
			
		||||
            qn = cmd.qualified_name  # single source of truth for counters
 | 
			
		||||
 | 
			
		||||
            row = {
 | 
			
		||||
                "type": ctype,
 | 
			
		||||
                "name": cmd.qualified_name,
 | 
			
		||||
                "display_name": cmd.qualified_name,
 | 
			
		||||
                "help": (cmd.help or "").strip(),
 | 
			
		||||
                "brief": (cmd.brief or "").strip(),
 | 
			
		||||
                "usage": usage_prefix if not is_hybrid else None,
 | 
			
		||||
                "usage_prefix": usage_prefix,
 | 
			
		||||
                "usage_slash": usage_slash,
 | 
			
		||||
                "cog": getattr(cmd.cog, "qualified_name", None) if getattr(cmd, "cog", None) else None,
 | 
			
		||||
                "module": getattr(getattr(cmd, "callback", None), "__module__", None),
 | 
			
		||||
                "moderator_only": bool(is_mod),
 | 
			
		||||
                "admin_only": False,
 | 
			
		||||
                "required_permissions": perms,
 | 
			
		||||
                "counter_key": qn,
 | 
			
		||||
                "exec_count": _cmd_counter(bot, qn),
 | 
			
		||||
            }
 | 
			
		||||
            key = ("px", row["name"])
 | 
			
		||||
            if key not in seen:
 | 
			
		||||
                rows.append(row)
 | 
			
		||||
                seen.add(key)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
            continue
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
def _walk_app_tree(node: Any, prefix: str = "") -> List[Tuple[str, app_commands.Command]]:
 | 
			
		||||
    out: List[Tuple[str, app_commands.Command]] = []
 | 
			
		||||
    if isinstance(node, app_commands.Command):
 | 
			
		||||
        out.append((f"{prefix}/{node.name}", node))
 | 
			
		||||
        return out
 | 
			
		||||
    if isinstance(node, app_commands.Group):
 | 
			
		||||
        base = f"{prefix}/{node.name}"
 | 
			
		||||
        for sub in list(getattr(node, "commands", []) or []):
 | 
			
		||||
            out.extend(_walk_app_tree(sub, base))
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
 | 
			
		||||
    d = getattr(obj, "extras", None)
 | 
			
		||||
    if not d:
 | 
			
		||||
        return None
 | 
			
		||||
    if not isinstance(d, dict):
 | 
			
		||||
        return {"value": _to_primitive(d)}
 | 
			
		||||
    return _to_primitive(d)
 | 
			
		||||
 | 
			
		||||
def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
    rows: List[Dict[str, Any]] = []
 | 
			
		||||
    collected: List[Tuple[str, app_commands.Command, str]] = []
 | 
			
		||||
    try:
 | 
			
		||||
        for scope, top in _iter_all_app_commands(bot):
 | 
			
		||||
            for path, leaf in _walk_app_tree(top, prefix=""):
 | 
			
		||||
                collected.append((scope, leaf, path))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        traceback.print_exc()
 | 
			
		||||
 | 
			
		||||
    seen_paths = set()
 | 
			
		||||
    for scope, leaf, path in collected:
 | 
			
		||||
        try:
 | 
			
		||||
            canon = path.lstrip("/")  # e.g., 'power/restart'
 | 
			
		||||
            if canon in seen_paths:
 | 
			
		||||
                continue
 | 
			
		||||
            seen_paths.add(canon)
 | 
			
		||||
 | 
			
		||||
            is_mod, perms = _is_mod_command_slash(leaf)
 | 
			
		||||
            binding = getattr(leaf, "binding", None)
 | 
			
		||||
            callback = getattr(leaf, "callback", None)
 | 
			
		||||
 | 
			
		||||
            display = canon.replace("/", " ")
 | 
			
		||||
            options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
 | 
			
		||||
            usage_full = _command_usage_slash_like(display, options)
 | 
			
		||||
 | 
			
		||||
            # Use leaf.qualified_name when available, it matches listener keys
 | 
			
		||||
            qn = getattr(leaf, "qualified_name", None) or display
 | 
			
		||||
 | 
			
		||||
            row = {
 | 
			
		||||
                "type": "slash",
 | 
			
		||||
                "name": "/" + canon,
 | 
			
		||||
                "display_name": "/" + display,
 | 
			
		||||
                "help": (getattr(leaf, "description", "") or "").strip(),
 | 
			
		||||
                "brief": "",
 | 
			
		||||
                "usage": usage_full,
 | 
			
		||||
                "usage_prefix": None,
 | 
			
		||||
                "usage_slash": usage_full,
 | 
			
		||||
                "cog": binding.__class__.__name__ if binding else None,
 | 
			
		||||
                "module": getattr(callback, "__module__", None) if callback else None,
 | 
			
		||||
                "moderator_only": bool(is_mod),
 | 
			
		||||
                "admin_only": False,
 | 
			
		||||
                "required_permissions": perms,
 | 
			
		||||
                "extras": _safe_extras(leaf),
 | 
			
		||||
                "dm_permission": getattr(leaf, "dm_permission", None),
 | 
			
		||||
                "counter_key": qn,
 | 
			
		||||
                "exec_count": _cmd_counter(bot, qn),
 | 
			
		||||
            }
 | 
			
		||||
            rows.append(row)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
            continue
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
def _cmd_counter(bot, qualified_name: str) -> int:
 | 
			
		||||
    dm = getattr(bot, "data_manager", None)
 | 
			
		||||
    return dm.get_counter(f"cmd::{qualified_name}") if dm else 0
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Details loader & master JSON
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
_DETAILS_DIR = _project_root() / "assets" / "docs" / "commands"
 | 
			
		||||
_MASTER_PATH = _DETAILS_DIR / "__commands__.json"
 | 
			
		||||
 | 
			
		||||
_DOCS_CACHE: Dict[str, Any] = {"map": {}, "sig": ""}
 | 
			
		||||
 | 
			
		||||
def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
 | 
			
		||||
    c = str(row.get("cog") or "").strip()
 | 
			
		||||
    if row.get("type") == "slash":
 | 
			
		||||
        base = str(row.get("name", "")).lstrip("/").split("/")[-1]
 | 
			
		||||
    else:
 | 
			
		||||
        base = str(row.get("name", "")).split(" ")[0]
 | 
			
		||||
    keys = []
 | 
			
		||||
    if c:
 | 
			
		||||
        keys.append(f"{c}.{base}")
 | 
			
		||||
    if row.get("type") == "slash":
 | 
			
		||||
        keys.append(str(row.get("name", "")).lstrip("/"))
 | 
			
		||||
    keys.append(base)
 | 
			
		||||
    return keys
 | 
			
		||||
 | 
			
		||||
def _scan_doc_files() -> Dict[str, Dict[str, Any]]:
 | 
			
		||||
    """
 | 
			
		||||
    Scans assets/docs/commands for:
 | 
			
		||||
      - <key>.md                        -> details_md
 | 
			
		||||
      - <key>.html                      -> details_html
 | 
			
		||||
      - <key>.brief.html                -> brief_html
 | 
			
		||||
      - <key>.details.html              -> details_html (wins over <key>.html)
 | 
			
		||||
    """
 | 
			
		||||
    out: Dict[str, Dict[str, Any]] = {}
 | 
			
		||||
    if not _DETAILS_DIR.is_dir():
 | 
			
		||||
        return out
 | 
			
		||||
 | 
			
		||||
    def _put(key: str, field: str, text: str):
 | 
			
		||||
        if key not in out:
 | 
			
		||||
            out[key] = {}
 | 
			
		||||
        out[key][field] = text
 | 
			
		||||
 | 
			
		||||
    # .md legacy
 | 
			
		||||
    for p in _DETAILS_DIR.glob("*.md"):
 | 
			
		||||
        if p.name.startswith("_"):
 | 
			
		||||
            continue
 | 
			
		||||
        try:
 | 
			
		||||
            _put(p.stem, "details_md", p.read_text(encoding="utf-8"))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
 | 
			
		||||
    # .html brief/details
 | 
			
		||||
    for p in _DETAILS_DIR.glob("*.html"):
 | 
			
		||||
        if p.name.startswith("_"):
 | 
			
		||||
            continue
 | 
			
		||||
        name = p.stem
 | 
			
		||||
        try:
 | 
			
		||||
            txt = p.read_text(encoding="utf-8")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        if name.endswith(".brief"):
 | 
			
		||||
            key = name[:-6]
 | 
			
		||||
            _put(key, "brief_html", txt)
 | 
			
		||||
        elif name.endswith(".details"):
 | 
			
		||||
            key = name[:-8]
 | 
			
		||||
            _put(key, "details_html", txt)
 | 
			
		||||
        else:
 | 
			
		||||
            _put(name, "details_html", txt)
 | 
			
		||||
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
def _load_master_json() -> Dict[str, Dict[str, Any]]:
 | 
			
		||||
    if not _MASTER_PATH.is_file():
 | 
			
		||||
        return {}
 | 
			
		||||
    try:
 | 
			
		||||
        raw_text = _MASTER_PATH.read_text(encoding="utf-8")
 | 
			
		||||
        if not raw_text.strip():
 | 
			
		||||
            return {}
 | 
			
		||||
        raw_any = json.loads(raw_text) or {}
 | 
			
		||||
        out: Dict[str, Dict[str, Any]] = {}
 | 
			
		||||
        for k, v in raw_any.items():
 | 
			
		||||
            if isinstance(v, dict):
 | 
			
		||||
                out[str(k)] = {kk: vv for kk, vv in v.items()}
 | 
			
		||||
        return out
 | 
			
		||||
    except Exception:
 | 
			
		||||
        traceback.print_exc()
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
def _write_master_json(mapping: Dict[str, Dict[str, Any]]) -> None:
 | 
			
		||||
    try:
 | 
			
		||||
        _DETAILS_DIR.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
        with _MASTER_PATH.open("w", encoding="utf-8") as f:
 | 
			
		||||
            json.dump(mapping, f, ensure_ascii=False, indent=2)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        traceback.print_exc()
 | 
			
		||||
 | 
			
		||||
def _dir_signature() -> str:
 | 
			
		||||
    parts = []
 | 
			
		||||
    if _DETAILS_DIR.is_dir():
 | 
			
		||||
        for p in sorted(_DETAILS_DIR.glob("*")):
 | 
			
		||||
            try:
 | 
			
		||||
                parts.append(f"{p.name}:{int(p.stat().st_mtime)}")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                continue
 | 
			
		||||
    try:
 | 
			
		||||
        if _MASTER_PATH.exists():
 | 
			
		||||
            parts.append(f"__master__:{int(_MASTER_PATH.stat().st_mtime)}")
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    return "|".join(parts)
 | 
			
		||||
 | 
			
		||||
def _load_external_docs() -> Dict[str, Dict[str, Any]]:
 | 
			
		||||
    sig = _dir_signature()
 | 
			
		||||
    if _DOCS_CACHE.get("sig") == sig:
 | 
			
		||||
        return _DOCS_CACHE["map"]
 | 
			
		||||
 | 
			
		||||
    master = _load_master_json()
 | 
			
		||||
    doc_map = _scan_doc_files()
 | 
			
		||||
 | 
			
		||||
    merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in master.items()}
 | 
			
		||||
    for k, v in doc_map.items():
 | 
			
		||||
        if k not in merged:
 | 
			
		||||
            merged[k] = {}
 | 
			
		||||
        for kk, vv in v.items():
 | 
			
		||||
            merged[k][kk] = vv
 | 
			
		||||
 | 
			
		||||
    _write_master_json(merged)
 | 
			
		||||
    _DOCS_CACHE["map"] = merged
 | 
			
		||||
    _DOCS_CACHE["sig"] = sig
 | 
			
		||||
    return merged
 | 
			
		||||
 | 
			
		||||
def _augment_with_external_docs(rows: List[Dict[str, Any]]) -> None:
 | 
			
		||||
    mapping = _load_external_docs()
 | 
			
		||||
    for r in rows:
 | 
			
		||||
        if not r.get("details_md") and isinstance(r.get("extras"), dict):
 | 
			
		||||
            dm = r["extras"].get("details_md")
 | 
			
		||||
            if isinstance(dm, str) and dm.strip():
 | 
			
		||||
                r["details_md"] = dm
 | 
			
		||||
        if not r.get("details_md") or not r.get("brief_html") or not r.get("details_html"):
 | 
			
		||||
            for key in _row_key_candidates(r):
 | 
			
		||||
                if key in mapping and isinstance(mapping[key], dict):
 | 
			
		||||
                    m = mapping[key]
 | 
			
		||||
                    if not r.get("details_md") and isinstance(m.get("details_md"), str):
 | 
			
		||||
                        r["details_md"] = m["details_md"]
 | 
			
		||||
                    if not r.get("brief_html") and isinstance(m.get("brief_html"), str):
 | 
			
		||||
                        r["brief_html"] = m["brief_html"]
 | 
			
		||||
                    if not r.get("details_html") and isinstance(m.get("details_html"), str):
 | 
			
		||||
                        r["details_html"] = m["details_html"]
 | 
			
		||||
                    if not r.get("doc_meta"):
 | 
			
		||||
                        meta = {kk: vv for kk, vv in m.items() if kk not in {"details_md","brief_html","details_html"}}
 | 
			
		||||
                        if meta:
 | 
			
		||||
                            r["doc_meta"] = meta
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Merge hybrids with their slash twins
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
 | 
			
		||||
    idx_by_hybrid: Dict[Tuple[str, str], int] = {}
 | 
			
		||||
    for i, r in enumerate(rows):
 | 
			
		||||
        if r.get("type") == "hybrid":
 | 
			
		||||
            cog = (r.get("cog") or "").strip()
 | 
			
		||||
            base = str(r.get("name") or "").split(" ")[-1].lower()
 | 
			
		||||
            idx_by_hybrid[(cog, base)] = i
 | 
			
		||||
 | 
			
		||||
    to_remove: List[int] = []
 | 
			
		||||
    for i, r in enumerate(rows):
 | 
			
		||||
        if r.get("type") != "slash":
 | 
			
		||||
            continue
 | 
			
		||||
        base = str(r.get("name") or "").lstrip("/").split("/")[-1].lower()
 | 
			
		||||
        cog = (r.get("cog") or "").strip()
 | 
			
		||||
        key = (cog, base)
 | 
			
		||||
        hi = idx_by_hybrid.get(key)
 | 
			
		||||
        if hi is None:
 | 
			
		||||
            continue
 | 
			
		||||
        h = rows[hi]
 | 
			
		||||
        if not h.get("usage_slash") and r.get("usage_slash"):
 | 
			
		||||
            h["usage_slash"] = r["usage_slash"]
 | 
			
		||||
        if not h.get("help") and r.get("help"):
 | 
			
		||||
            h["help"] = r["help"]
 | 
			
		||||
        if r.get("moderator_only"):
 | 
			
		||||
            h["moderator_only"] = True
 | 
			
		||||
        if r.get("admin_only"):
 | 
			
		||||
            h["admin_only"] = True
 | 
			
		||||
        if r.get("required_permissions"):
 | 
			
		||||
            h["required_permissions"] = sorted(set((h.get("required_permissions") or []) + r["required_permissions"]))
 | 
			
		||||
        if not h.get("extras") and r.get("extras"):
 | 
			
		||||
            h["extras"] = r["extras"]
 | 
			
		||||
        if r.get("details_md") and not h.get("details_md"):
 | 
			
		||||
            h["details_md"] = r["details_md"]
 | 
			
		||||
        if r.get("brief_html") and not h.get("brief_html"):
 | 
			
		||||
            h["brief_html"] = r["brief_html"]
 | 
			
		||||
        if r.get("details_html") and not h.get("details_html"):
 | 
			
		||||
            h["details_html"] = r["details_html"]
 | 
			
		||||
 | 
			
		||||
        # NEW: sum exec_count from slash twin into the hybrid row
 | 
			
		||||
        h["exec_count"] = int(h.get("exec_count", 0) or 0) + int(r.get("exec_count", 0) or 0)
 | 
			
		||||
 | 
			
		||||
        to_remove.append(i)
 | 
			
		||||
 | 
			
		||||
    for i in sorted(to_remove, reverse=True):
 | 
			
		||||
        rows.pop(i)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Schema builder
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
 | 
			
		||||
    px = _gather_prefix_and_hybrid(bot)
 | 
			
		||||
    sl = _gather_slash(bot)
 | 
			
		||||
    all_rows = px + sl
 | 
			
		||||
 | 
			
		||||
    # Mark mod-only via hints/perms/extras
 | 
			
		||||
    for row in all_rows:
 | 
			
		||||
        try:
 | 
			
		||||
            helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
 | 
			
		||||
            hl = helptext.lower()
 | 
			
		||||
            if "[mod]" in hl:
 | 
			
		||||
                row["moderator_only"] = True
 | 
			
		||||
            if "[admin]" in hl:
 | 
			
		||||
                row["admin_only"] = True
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        if row.get("required_permissions"):
 | 
			
		||||
            row["moderator_only"] = True
 | 
			
		||||
        try:
 | 
			
		||||
            ex = row.get("extras") or {}
 | 
			
		||||
            if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"mod", "moderator", "staff"}:
 | 
			
		||||
                row["moderator_only"] = True
 | 
			
		||||
            if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"admin", "administrator", "owner"}:
 | 
			
		||||
                row["admin_only"] = True
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    _augment_with_external_docs(all_rows)
 | 
			
		||||
    _merge_hybrid_slash(all_rows)
 | 
			
		||||
 | 
			
		||||
    # ✅ NEW: sort alphabetically by *command name* (display_name/name), not by cog
 | 
			
		||||
    def _sort_key(r: Dict[str, Any]) -> str:
 | 
			
		||||
        s = (r.get("display_name") or r.get("name") or "")
 | 
			
		||||
        # strip leading slash and normalize
 | 
			
		||||
        return s.lstrip("/").strip().lower()
 | 
			
		||||
 | 
			
		||||
    all_rows.sort(key=_sort_key)
 | 
			
		||||
 | 
			
		||||
    mods = [r for r in all_rows if r.get("moderator_only") or r.get("admin_only")]
 | 
			
		||||
    users = [r for r in all_rows if not (r.get("moderator_only") or r.get("admin_only"))]
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        "title": "ShaiWatcher Commands",
 | 
			
		||||
        "count": len(all_rows),
 | 
			
		||||
        "sections": {"user": users, "moderator": mods},
 | 
			
		||||
        "all": all_rows,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Static asset serving
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
def _static_root() -> Path:
 | 
			
		||||
    return _project_root() / "assets" / "docs"
 | 
			
		||||
 | 
			
		||||
def _guess_mime(p: Path) -> str:
 | 
			
		||||
    ext = p.suffix.lower()
 | 
			
		||||
    return {
 | 
			
		||||
        ".svg": "image/svg+xml; charset=utf-8",
 | 
			
		||||
        ".png": "image/png",
 | 
			
		||||
        ".jpg": "image/jpeg",
 | 
			
		||||
        ".jpeg": "image/jpeg",
 | 
			
		||||
        ".webp": "image/webp",
 | 
			
		||||
        ".gif": "image/gif",
 | 
			
		||||
        ".css": "text/css; charset=utf-8",
 | 
			
		||||
        ".js": "application/javascript; charset=utf-8",
 | 
			
		||||
        ".json": "application/json; charset=utf-8",
 | 
			
		||||
        ".md": "text/markdown; charset=utf-8",
 | 
			
		||||
        ".txt": "text/plain; charset=utf-8",
 | 
			
		||||
        ".ico": "image/x-icon",
 | 
			
		||||
        ".html": "text/html; charset=utf-8",
 | 
			
		||||
    }.get(ext, "application/octet-stream")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# HTTP + UI
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
class _DocsHandler(BaseHTTPRequestHandler):
 | 
			
		||||
    bot: commands.Bot = None
 | 
			
		||||
    title: str = "ShaiWatcher Commands"
 | 
			
		||||
    force_ready: bool = False
 | 
			
		||||
    support_url: Optional[str] = None
 | 
			
		||||
    support_label: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
    def _set(self, code=200, content_type="text/html; charset=utf-8"):
 | 
			
		||||
        self.send_response(code)
 | 
			
		||||
        self.send_header("Content-Type", content_type)
 | 
			
		||||
        self.send_header("Cache-Control", "no-store")
 | 
			
		||||
        self.end_headers()
 | 
			
		||||
 | 
			
		||||
    def log_message(self, fmt, *args):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    def _serve_file(self, fs_path: Path, cache: str = "public, max-age=86400, immutable"):
 | 
			
		||||
        mime = _guess_mime(fs_path)
 | 
			
		||||
        self.send_response(200)
 | 
			
		||||
        self.send_header("Content-Type", mime)
 | 
			
		||||
        self.send_header("Cache-Control", cache)
 | 
			
		||||
        self.end_headers()
 | 
			
		||||
        with fs_path.open("rb") as f:
 | 
			
		||||
            self.wfile.write(f.read())
 | 
			
		||||
 | 
			
		||||
    def do_GET(self):
 | 
			
		||||
        try:
 | 
			
		||||
            ready = bool(self.force_ready) or (getattr(self, "bot", None) and self.bot.is_ready())
 | 
			
		||||
            if not ready:
 | 
			
		||||
                self._set(503, "text/plain; charset=utf-8")
 | 
			
		||||
                self.wfile.write(b"warming up")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            path = urlparse(self.path).path
 | 
			
		||||
 | 
			
		||||
            # Root favicon / PWA convenience routes
 | 
			
		||||
            if path in ("/favicon.ico", "/favicon-16x16.png", "/favicon-32x32.png",
 | 
			
		||||
                        "/apple-touch-icon.png", "/site.webmanifest"):
 | 
			
		||||
                root = _static_root().resolve()
 | 
			
		||||
                name = path.lstrip("/")
 | 
			
		||||
                fs_path = (root / name).resolve()
 | 
			
		||||
                try:
 | 
			
		||||
                    if not fs_path.is_relative_to(root):
 | 
			
		||||
                        raise ValueError("outside root")
 | 
			
		||||
                except AttributeError:
 | 
			
		||||
                    if str(root) not in str(fs_path):
 | 
			
		||||
                        raise ValueError("outside root")
 | 
			
		||||
                if fs_path.is_file():
 | 
			
		||||
                    self._serve_file(fs_path)
 | 
			
		||||
                else:
 | 
			
		||||
                    self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Static assets under /assets/docs/...
 | 
			
		||||
            if path.startswith("/assets/docs/"):
 | 
			
		||||
                try:
 | 
			
		||||
                    root = _static_root().resolve()
 | 
			
		||||
                    rel = path[len("/assets/docs/"):]
 | 
			
		||||
                    fs_path = (root / rel).resolve()
 | 
			
		||||
                    try:
 | 
			
		||||
                        if not fs_path.is_relative_to(root):
 | 
			
		||||
                            raise ValueError("outside root")
 | 
			
		||||
                    except AttributeError:
 | 
			
		||||
                        if str(root) not in str(fs_path):
 | 
			
		||||
                            raise ValueError("outside root")
 | 
			
		||||
                    if fs_path.is_file():
 | 
			
		||||
                        self._serve_file(fs_path)
 | 
			
		||||
                        return
 | 
			
		||||
                    self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
 | 
			
		||||
                    return
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    traceback.print_exc()
 | 
			
		||||
                    self._set(500, "text/plain; charset=utf-8"); self.wfile.write(b"internal error")
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            if path == "/":
 | 
			
		||||
                # Load template file
 | 
			
		||||
                tpl_path = _static_root() / "cmd.html"
 | 
			
		||||
                if tpl_path.is_file():
 | 
			
		||||
                    html = tpl_path.read_text(encoding="utf-8")
 | 
			
		||||
                else:
 | 
			
		||||
                    # Fallback minimal page if template missing
 | 
			
		||||
                    html = "<!doctype html><meta charset='utf-8'><title>Docs</title><body><div id='alerts'>Missing assets/docs/cmd.html</div></body>"
 | 
			
		||||
 | 
			
		||||
                # Inject data + support placeholders
 | 
			
		||||
                try:
 | 
			
		||||
                    schema = build_command_schema(self.bot)
 | 
			
		||||
                    inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
 | 
			
		||||
                    # Insert __DATA__ just before </head>
 | 
			
		||||
                    inj = f"<script>window.__DATA__={inline};</script>"
 | 
			
		||||
                    html = html.replace("</head>", inj + "</head>")
 | 
			
		||||
                    support_url = getattr(_DocsHandler, "support_url", "") or ""
 | 
			
		||||
                    support_label = getattr(_DocsHandler, "support_label", "Buy me a ☕")
 | 
			
		||||
                    vis = "block" if support_url else "none"
 | 
			
		||||
                    html = (html.replace("__TITLE__", self.title)
 | 
			
		||||
                                .replace("__SUPPORT_URL__", support_url)
 | 
			
		||||
                                .replace("__SUPPORT_LABEL__", support_label)
 | 
			
		||||
                                .replace("__SUPPORT_VIS__", vis))
 | 
			
		||||
                    self._set()
 | 
			
		||||
                    self.wfile.write(html.encode("utf-8"))
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    traceback.print_exc()
 | 
			
		||||
                    self._set(500, "text/plain; charset=utf-8")
 | 
			
		||||
                    self.wfile.write(b"internal error")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            if path == "/api/status":
 | 
			
		||||
                payload = _status_payload(self.bot)
 | 
			
		||||
                self._set(200, "application/json; charset=utf-8")
 | 
			
		||||
                self.wfile.write(_json_dumps_safe(payload))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            if path == "/healthz":
 | 
			
		||||
                self._set(200, "text/plain; charset=utf-8"); self.wfile.write(b"ok"); return
 | 
			
		||||
 | 
			
		||||
            if path == "/api/commands":
 | 
			
		||||
                schema = build_command_schema(self.bot)
 | 
			
		||||
                payload = _json_dumps_safe(schema)
 | 
			
		||||
                self._set(200, "application/json; charset=utf-8")
 | 
			
		||||
                self.wfile.write(payload)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
            try:
 | 
			
		||||
                self._set(500, "text/plain; charset=utf-8"); self.wfile.write(b"internal error")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
def _start_server(bot: commands.Bot, host: str, port: int, title: str):
 | 
			
		||||
    _DocsHandler.bot = bot
 | 
			
		||||
    _DocsHandler.title = title
 | 
			
		||||
    _DocsHandler.support_url = getattr(bot, "docs_support_url", None)
 | 
			
		||||
    _DocsHandler.support_label = getattr(bot, "docs_support_label", None)
 | 
			
		||||
    _DocsHandler.force_ready = os.getenv("SHAI_OFFLINE", "").lower() in {"1", "true", "yes"}
 | 
			
		||||
    httpd = ThreadingHTTPServer((host, port), _DocsHandler)
 | 
			
		||||
 | 
			
		||||
    def _run():
 | 
			
		||||
        try:
 | 
			
		||||
            httpd.serve_forever(poll_interval=0.5)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
 | 
			
		||||
    t = threading.Thread(target=_run, name="DocsSite", daemon=True)
 | 
			
		||||
    t.start()
 | 
			
		||||
    print(f"[DocsSite] Listening on http://{host}:{port} (title='{title}', offline={_DocsHandler.force_ready})")
 | 
			
		||||
 | 
			
		||||
class DocsSite(commands.Cog):
 | 
			
		||||
    """Tiny Swagger-like docs site for bot commands."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
 | 
			
		||||
        self.host = r.get("docs_host", "0.0.0.0")     # SHAI_DOCS_HOST
 | 
			
		||||
        self.port = r.int("docs_port", 8910)          # SHAI_DOCS_PORT
 | 
			
		||||
        self.title = r.get("docs_title", "ShaiWatcher Commands")  # SHAI_DOCS_TITLE
 | 
			
		||||
 | 
			
		||||
        # Support link config
 | 
			
		||||
        self.support_url = r.get("docs_support_url", "https://throne.com/ookamikuntv/item/39590391-c582-4c5d-8795-fe6f1925eaae")
 | 
			
		||||
        self.support_label = r.get("docs_support_label", "Buy me a ☕")
 | 
			
		||||
 | 
			
		||||
        # Expose to handler
 | 
			
		||||
        self.bot.docs_support_url = self.support_url
 | 
			
		||||
        self.bot.docs_support_label = self.support_label
 | 
			
		||||
 | 
			
		||||
        _start_server(self.bot, self.host, self.port, self.title)
 | 
			
		||||
 | 
			
		||||
    def force_ready(self, value: bool = True):
 | 
			
		||||
        _DocsHandler.force_ready = bool(value)
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    await bot.add_cog(DocsSite(bot))
 | 
			
		||||
@ -1,533 +0,0 @@
 | 
			
		||||
# modules/nick_nudge/nick_nudge.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
from typing import Optional, Tuple
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from mod_perms import is_moderator_userid, require_mod_interaction
 | 
			
		||||
from modules.common.emoji_accept import is_accept
 | 
			
		||||
from modules.common.settings import cfg  # ENV-first config helper
 | 
			
		||||
 | 
			
		||||
CHECK = '✅'          # approved/verified
 | 
			
		||||
CROSS = '❌'          # reject / no
 | 
			
		||||
PENDING = '✔️'        # heavy check mark = pending claim
 | 
			
		||||
ACCEPT = {CHECK, '🫡'}
 | 
			
		||||
NO_MENTIONS = discord.AllowedMentions.none()
 | 
			
		||||
 | 
			
		||||
# Per-user in-process lock to prevent duplicate reviews from concurrent reactions
 | 
			
		||||
_user_locks = defaultdict(asyncio.Lock)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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):
 | 
			
		||||
    """
 | 
			
		||||
    Nickname review flow:
 | 
			
		||||
      - Atomic transition to 'pending' and open exactly ONE review.
 | 
			
		||||
      - Mods: ✅ approve -> mark verified; ❌ reject -> clear claim.
 | 
			
		||||
      - If a verified user changes their nickname, verification is revoked automatically.
 | 
			
		||||
    Data keys used in data_manager:
 | 
			
		||||
      • agreed_nickname: [user_id]
 | 
			
		||||
      • nick_claim_pending: [user_id]
 | 
			
		||||
      • nick_verified: [user_id]
 | 
			
		||||
      • nick_reviews: [{ message_id, guild_id, user_id, status, ... }]
 | 
			
		||||
      • nick_verified_name: [{ guild_id, user_id, nick, ts }]
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
 | 
			
		||||
        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
			
		||||
        self.mod_channel_id = r.int('mod_channel_id', 0)
 | 
			
		||||
 | 
			
		||||
        self.loop_enabled = r.bool('nick_nudge_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):
 | 
			
		||||
        if not self.modlog_channel_id:
 | 
			
		||||
            return
 | 
			
		||||
        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]]:
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
    # ---------- atomic entry point used by all claim sources ----------
 | 
			
		||||
 | 
			
		||||
    async def ensure_pending_and_maybe_open(self, guild: discord.Guild, member: discord.Member, source: str):
 | 
			
		||||
        """
 | 
			
		||||
        Atomically:
 | 
			
		||||
          - set pending (idempotent)
 | 
			
		||||
          - open ONE review if this is the first transition to pending
 | 
			
		||||
        Prevents duplicate reviews when multiple reactions/commands fire.
 | 
			
		||||
        """
 | 
			
		||||
        if not guild or not self.mod_channel_id or member.bot:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        newly_pending = False
 | 
			
		||||
        lock = _user_locks[member.id]
 | 
			
		||||
        async with lock:
 | 
			
		||||
            dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
            # If a pending review already exists, bail out
 | 
			
		||||
            for r in dm.get('nick_reviews'):
 | 
			
		||||
                if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending':
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Mark "agreed" and flip to pending if not already
 | 
			
		||||
            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
 | 
			
		||||
 | 
			
		||||
        if newly_pending:
 | 
			
		||||
            try:
 | 
			
		||||
                await self.start_nick_review(guild, member, source=source)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                # Roll back pending on failure so the user can try again
 | 
			
		||||
                try:
 | 
			
		||||
                    dm = self.bot.data_manager
 | 
			
		||||
                    dm.remove('nick_claim_pending', lambda x: x == member.id)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
    # ---------- public API ----------
 | 
			
		||||
 | 
			
		||||
    async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"):
 | 
			
		||||
        if not guild or not self.mod_channel_id:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        mod_ch = guild.get_channel(self.mod_channel_id)
 | 
			
		||||
        if not mod_ch:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        before_n, _ = await self._find_last_nick_change(guild, member)
 | 
			
		||||
        now_ts = int(time.time())
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.")
 | 
			
		||||
 | 
			
		||||
    # ---------- DM nudge loop ----------
 | 
			
		||||
 | 
			
		||||
    async def _nudge_loop(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        while not self.bot.is_closed():
 | 
			
		||||
            try:
 | 
			
		||||
                now_t = time.time()
 | 
			
		||||
                for guild in self.bot.guilds:
 | 
			
		||||
                    for member in guild.members:
 | 
			
		||||
                        if member.bot or not member.joined_at:
 | 
			
		||||
                            continue
 | 
			
		||||
                        if (now_t - member.joined_at.timestamp()) < 24 * 3600:
 | 
			
		||||
                            continue
 | 
			
		||||
                        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_t
 | 
			
		||||
                            })
 | 
			
		||||
                            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)
 | 
			
		||||
 | 
			
		||||
    # ---------- listeners ----------
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_member_update(self, before: discord.Member, after: discord.Member):
 | 
			
		||||
        """Revoke verification if a verified user changes their nick to anything."""
 | 
			
		||||
        if before.bot or before.guild != after.guild:
 | 
			
		||||
            return
 | 
			
		||||
        if before.nick == after.nick:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        # Only act if the user is currently verified
 | 
			
		||||
        if before.id in dm.get('nick_verified'):
 | 
			
		||||
            dm.remove('nick_verified', lambda x: x == before.id)
 | 
			
		||||
            dm.remove('nick_claim_pending', lambda x: x == before.id)
 | 
			
		||||
            try:
 | 
			
		||||
                dm.remove('nick_verified_name', lambda r: r.get('guild_id') == before.guild.id and r.get('user_id') == before.id)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            dm.add('nick_verified_name', {
 | 
			
		||||
                'guild_id': int(before.guild.id),
 | 
			
		||||
                'user_id': int(before.id),
 | 
			
		||||
                'nick': after.nick if after.nick else None,
 | 
			
		||||
                'ts': int(time.time())
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                await self._modlog(after.guild, f"⚠️ {after.mention} changed nickname; **verification revoked**. They must re-claim for a new review.")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            rr = self.bot.get_cog('ReactionRoleCog')
 | 
			
		||||
            if rr:
 | 
			
		||||
                try:
 | 
			
		||||
                    await rr.maybe_apply_full_access(after)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
            cards = self.bot.get_cog('UserCardsCog')
 | 
			
		||||
            if cards:
 | 
			
		||||
                try:
 | 
			
		||||
                    await cards.refresh_card(after)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
    @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
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                await self.ensure_pending_and_maybe_open(guild, member, source="nick_same")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
 | 
			
		||||
 | 
			
		||||
            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 or not self.mod_channel_id:
 | 
			
		||||
                return
 | 
			
		||||
            guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
            if not guild:
 | 
			
		||||
                return
 | 
			
		||||
            if not is_moderator_userid(guild, payload.user_id, self.bot):
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            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:
 | 
			
		||||
                self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1])
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            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:
 | 
			
		||||
                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))
 | 
			
		||||
 | 
			
		||||
                try:
 | 
			
		||||
                    dm.remove('nick_verified_name', lambda r: r.get('guild_id') == guild.id and r.get('user_id') == member.id)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
                dm.add('nick_verified_name', {
 | 
			
		||||
                    'guild_id': int(guild.id),
 | 
			
		||||
                    'user_id': int(member.id),
 | 
			
		||||
                    'nick': member.nick if member.nick else None,
 | 
			
		||||
                    'ts': now_ts
 | 
			
		||||
                })
 | 
			
		||||
 | 
			
		||||
                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])
 | 
			
		||||
 | 
			
		||||
                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
 | 
			
		||||
 | 
			
		||||
                await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}.")
 | 
			
		||||
 | 
			
		||||
                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:
 | 
			
		||||
                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)
 | 
			
		||||
 | 
			
		||||
                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)}.")
 | 
			
		||||
 | 
			
		||||
                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
 | 
			
		||||
 | 
			
		||||
    # ---------- Mod commands to manipulate nickname reviews ----------
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="clear_nick_reviews", description="[MOD] Delete all PENDING nickname review records for this server.")
 | 
			
		||||
    async def clear_nick_reviews(self, interaction: discord.Interaction):
 | 
			
		||||
        """Moderator-only. Clears all 'pending' entries in data_manager['nick_reviews'] for this guild."""
 | 
			
		||||
        # Must be used in a guild
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # Moderator permission check (your existing gate)
 | 
			
		||||
        if not await require_mod_interaction(interaction):
 | 
			
		||||
            return  # require_mod_interaction already responded
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # Count pending records for this guild
 | 
			
		||||
        pending = [
 | 
			
		||||
            r for r in dm.get('nick_reviews')
 | 
			
		||||
            if r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
 | 
			
		||||
        ]
 | 
			
		||||
        count = len(pending)
 | 
			
		||||
 | 
			
		||||
        # Remove pending records
 | 
			
		||||
        if count:
 | 
			
		||||
            dm.remove(
 | 
			
		||||
                'nick_reviews',
 | 
			
		||||
                lambda r: r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Modlog + ephemeral confirmation
 | 
			
		||||
        try:
 | 
			
		||||
            await self._modlog(
 | 
			
		||||
                interaction.guild,
 | 
			
		||||
                f"🧹 {interaction.user.mention} cleared **{count}** pending nickname review(s)."
 | 
			
		||||
            )
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message(
 | 
			
		||||
            f"Cleared **{count}** pending nickname review{'s' if count != 1 else ''}.",
 | 
			
		||||
            ephemeral=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="recreate_nick_reviews", description="[MOD] Scan and recreate any missing pending nickname reviews for this server.")
 | 
			
		||||
    async def recreate_nick_reviews(self, interaction: discord.Interaction):
 | 
			
		||||
        """Moderator-only bulk fixer for 'grey checkmark' users (claimed but no pending review)."""
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction):
 | 
			
		||||
            return  # already replied
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        guild = interaction.guild
 | 
			
		||||
 | 
			
		||||
        agreed = set(dm.get('agreed_nickname'))
 | 
			
		||||
        verified = set(dm.get('nick_verified'))
 | 
			
		||||
        # Build a quick lookup of existing pending reviews
 | 
			
		||||
        pending_reviews = {
 | 
			
		||||
            (r.get('guild_id'), r.get('user_id'))
 | 
			
		||||
            for r in dm.get('nick_reviews')
 | 
			
		||||
            if r.get('status') == 'pending'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        to_fix = []
 | 
			
		||||
        for uid in agreed:
 | 
			
		||||
            # Needs a review if not verified and no pending review exists
 | 
			
		||||
            if (guild.id, uid) not in pending_reviews and uid not in verified:
 | 
			
		||||
                m = guild.get_member(uid)
 | 
			
		||||
                if m and not m.bot:
 | 
			
		||||
                    to_fix.append(m)
 | 
			
		||||
 | 
			
		||||
        fixed = 0
 | 
			
		||||
        skipped = 0
 | 
			
		||||
        for member in to_fix:
 | 
			
		||||
            try:
 | 
			
		||||
                # Clear stale pending so the atomic method will transition and open a new one
 | 
			
		||||
                dm.remove('nick_claim_pending', lambda x, _uid=member.id: x == _uid)
 | 
			
		||||
                await self.ensure_pending_and_maybe_open(guild, member, source="recreate")
 | 
			
		||||
                fixed += 1
 | 
			
		||||
            except Exception:
 | 
			
		||||
                skipped += 1
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated **{fixed}** nickname review(s); skipped **{skipped}**.")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message(
 | 
			
		||||
            f"Recreated **{fixed}** review(s); skipped **{skipped}**.",
 | 
			
		||||
            ephemeral=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @app_commands.command(name="recreate_nick_review", description="[MOD] Recreate a missing pending nickname review for one user.")
 | 
			
		||||
    @app_commands.describe(user="Member to recreate review for")
 | 
			
		||||
    async def recreate_nick_review(self, interaction: discord.Interaction, user: discord.Member):
 | 
			
		||||
        """Moderator-only single-user fixer."""
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction):
 | 
			
		||||
            return  # already replied
 | 
			
		||||
 | 
			
		||||
        guild = interaction.guild
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # If already verified, nothing to do
 | 
			
		||||
        if user.id in dm.get('nick_verified'):
 | 
			
		||||
            return await interaction.response.send_message("User is already verified — no review needed.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # If a pending review already exists, nothing to do
 | 
			
		||||
        has_pending = any(
 | 
			
		||||
            r.get('guild_id') == guild.id and r.get('user_id') == user.id and r.get('status') == 'pending'
 | 
			
		||||
            for r in dm.get('nick_reviews')
 | 
			
		||||
        )
 | 
			
		||||
        if has_pending:
 | 
			
		||||
            return await interaction.response.send_message("A pending review already exists for this user.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        # If they never agreed/claimed, mark claim now so the state is consistent
 | 
			
		||||
        if user.id not in dm.get('agreed_nickname'):
 | 
			
		||||
            dm.add('agreed_nickname', int(user.id))
 | 
			
		||||
 | 
			
		||||
        # Clear stale pending flag, then open atomically
 | 
			
		||||
        dm.remove('nick_claim_pending', lambda x: x == user.id)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            await self.ensure_pending_and_maybe_open(guild, user, source="recreate")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return await interaction.response.send_message("Failed to create the review (see logs).", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated a nickname review for {user.mention}.")
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message("Recreated the nickname review for that user.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(NickNudgeCog(bot))
 | 
			
		||||
@ -1,224 +0,0 @@
 | 
			
		||||
# modules/pirate_cards/pirate_cards.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from mod_perms import require_mod_ctx, require_mod_interaction
 | 
			
		||||
from modules.common.settings import cfg  # ENV-first config helper
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PirateCardsCog(commands.Cog):
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
 | 
			
		||||
        # IDs / config (ENV -> optional INI fallback)
 | 
			
		||||
        self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
 | 
			
		||||
        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
			
		||||
 | 
			
		||||
        # thresholds / samples (with defaults)
 | 
			
		||||
        self.group_threshold = r.int('threat_group_threshold', 3)
 | 
			
		||||
        self.min_samples = r.int('threat_min_samples_for_stats', 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)
 | 
			
		||||
 | 
			
		||||
        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) --------
 | 
			
		||||
    @app_commands.command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates")
 | 
			
		||||
    async def pirate_cards_rebuild(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
        guild = interaction.guild
 | 
			
		||||
        ch = guild.get_channel(self.pirates_channel_id)
 | 
			
		||||
        if not ch:
 | 
			
		||||
            return await interaction.followup.send("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
 | 
			
		||||
 | 
			
		||||
        await interaction.followup.send(f"Rebuilt/updated {count} pirate cards.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(PirateCardsCog(bot))
 | 
			
		||||
@ -1,719 +0,0 @@
 | 
			
		||||
# modules/pirate_report/pirate_report.py
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
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}$')
 | 
			
		||||
 | 
			
		||||
DISCORD_MEDIA_HOSTS = {"cdn.discordapp.com", "media.discordapp.net"}
 | 
			
		||||
MEDIA_EXTS_IMAGE = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
 | 
			
		||||
MEDIA_EXTS_VIDEO = {".mp4", ".webm", ".mov"}
 | 
			
		||||
MEDIA_EXTS_ALL = MEDIA_EXTS_IMAGE | MEDIA_EXTS_VIDEO
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _classify_discord_media(url: str):
 | 
			
		||||
    """
 | 
			
		||||
    Return ('image'|'video', normalized_url) if valid Discord CDN media; else (None, reason).
 | 
			
		||||
    We only accept direct CDN links so media can render inline without leaving the channel.
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        u = url.strip()
 | 
			
		||||
        if not u:
 | 
			
		||||
            return (None, "Empty URL.")
 | 
			
		||||
        pr = urlparse(u)
 | 
			
		||||
        if pr.scheme not in ("http", "https"):
 | 
			
		||||
            return (None, "URL must start with http:// or https://")
 | 
			
		||||
        host = pr.netloc.lower()
 | 
			
		||||
        if host not in DISCORD_MEDIA_HOSTS:
 | 
			
		||||
            return (None, "URL must be a **Discord media** link (cdn.discordapp.com or media.discordapp.net).")
 | 
			
		||||
        path = pr.path or ""
 | 
			
		||||
        dot = path.rfind(".")
 | 
			
		||||
        if dot == -1:
 | 
			
		||||
            return (None, "URL must end with a known media file extension.")
 | 
			
		||||
        ext = path[dot:].lower()
 | 
			
		||||
        if ext not in MEDIA_EXTS_ALL:
 | 
			
		||||
            return (None, f"Unsupported media type `{ext}`. Allowed: images {sorted(MEDIA_EXTS_IMAGE)}, videos {sorted(MEDIA_EXTS_VIDEO)}.")
 | 
			
		||||
        kind = "image" if ext in MEDIA_EXTS_IMAGE else "video"
 | 
			
		||||
        return (kind, u)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return (None, "Invalid URL format.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _jump_url(guild_id: int, channel_id: int, message_id: int) -> str:
 | 
			
		||||
    return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _make_report_embed(title: str, color: discord.Color, report_dict: dict, include_status: bool = False):
 | 
			
		||||
    e = discord.Embed(title=title, color=color, timestamp=datetime.utcnow())
 | 
			
		||||
    e.add_field(name="Character", value=report_dict['character_name'], inline=False)
 | 
			
		||||
    e.add_field(name="Account", value=report_dict['account_name'], inline=False)
 | 
			
		||||
    e.add_field(name="Submitted by", value=f"<@{report_dict['submitter_id']}>", inline=False)
 | 
			
		||||
 | 
			
		||||
    proof_url = (report_dict.get('proof_url') or "").strip()
 | 
			
		||||
    proof_type = (report_dict.get('proof_type') or "").strip()
 | 
			
		||||
 | 
			
		||||
    if include_status:
 | 
			
		||||
        e.add_field(
 | 
			
		||||
            name="Status",
 | 
			
		||||
            value=report_dict.get('status_line', 'Pending'),
 | 
			
		||||
            inline=False
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # Show proof inline if it's an image; for videos we'll keep the URL in message content.
 | 
			
		||||
    if proof_url and proof_type == 'image':
 | 
			
		||||
        e.set_image(url=proof_url)
 | 
			
		||||
 | 
			
		||||
    # Add a compact proof field for quick visibility (always safe to include)
 | 
			
		||||
    e.add_field(
 | 
			
		||||
        name="Proof",
 | 
			
		||||
        value=proof_url if proof_url else "_No proof provided — strongly encouraged._",
 | 
			
		||||
        inline=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # A small hint footer
 | 
			
		||||
    e.set_footer(text="Attach proof when possible to speed up moderation.")
 | 
			
		||||
    return e
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# --------------------- Views ----------------------
 | 
			
		||||
 | 
			
		||||
class ReportJumpView(discord.ui.View):
 | 
			
		||||
    """Simple link button to jump to the user's original ack message."""
 | 
			
		||||
    def __init__(self, url: str):
 | 
			
		||||
        super().__init__(timeout=None)
 | 
			
		||||
        if url:
 | 
			
		||||
            self.add_item(discord.ui.Button(label="Jump to message", style=discord.ButtonStyle.link, url=url))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# --------------------- 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.proof_url = discord.ui.TextInput(
 | 
			
		||||
            label="Proof (Discord media URL — optional)",
 | 
			
		||||
            placeholder="Direct Discord CDN link to image/video (highly encouraged, but optional)",
 | 
			
		||||
            required=False,
 | 
			
		||||
            max_length=300
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.add_item(self.character_name)
 | 
			
		||||
        self.add_item(self.account_name)
 | 
			
		||||
        self.add_item(self.proof_url)
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
        proof_raw = (self.proof_url.value or "").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
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        proof_type = ""
 | 
			
		||||
        proof_val = ""
 | 
			
		||||
        if proof_raw:
 | 
			
		||||
            pt, pv = _classify_discord_media(proof_raw)
 | 
			
		||||
            if pt is None:
 | 
			
		||||
                return await interaction.response.send_message(f"❌ Invalid proof link: {pv}", ephemeral=True)
 | 
			
		||||
            proof_type, proof_val = pt, pv  # valid
 | 
			
		||||
 | 
			
		||||
        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 ✅/❌, including inline media if present
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        report_payload = {
 | 
			
		||||
            'character_name': char,
 | 
			
		||||
            'account_name': acct,
 | 
			
		||||
            'submitter_id': interaction.user.id,
 | 
			
		||||
            'proof_url': proof_val,
 | 
			
		||||
            'proof_type': proof_type,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Build jump button (if ack exists)
 | 
			
		||||
        view = None
 | 
			
		||||
        if ack:
 | 
			
		||||
            try:
 | 
			
		||||
                url = _jump_url(interaction.guild.id, interaction.channel.id, ack.id)
 | 
			
		||||
                view = ReportJumpView(url)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                view = None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            embed = _make_report_embed("🚩 Pirate Report", discord.Color.orange(), report_payload, include_status=False)
 | 
			
		||||
            # Video must be in message content for inline player; images live in the embed
 | 
			
		||||
            content = proof_val if proof_type == 'video' else None
 | 
			
		||||
            mod_msg = await mod_ch.send(content=content, embed=embed, view=view)
 | 
			
		||||
            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,
 | 
			
		||||
            'proof_url': proof_val,
 | 
			
		||||
            'proof_type': proof_type,
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
 | 
			
		||||
        # Channels
 | 
			
		||||
        self.mod_channel = r.int('mod_channel_id', 0)
 | 
			
		||||
        self.modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
			
		||||
 | 
			
		||||
        # Threat config
 | 
			
		||||
        self.group_threshold = r.int('threat_group_threshold', 3)
 | 
			
		||||
 | 
			
		||||
        def _f(key: str, default: float) -> float:
 | 
			
		||||
            try:
 | 
			
		||||
                return float(r.get(key, str(default)))
 | 
			
		||||
            except Exception:
 | 
			
		||||
                return default
 | 
			
		||||
 | 
			
		||||
        self.w_kill = _f('threat_w_kill', 0.35)
 | 
			
		||||
        self.w_destruction = _f('threat_w_destruction', 0.30)
 | 
			
		||||
        self.w_group = _f('threat_w_group', 0.20)
 | 
			
		||||
        self.w_skill = _f('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):
 | 
			
		||||
        if not self.modlog_channel_id:
 | 
			
		||||
            return
 | 
			
		||||
        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)
 | 
			
		||||
    @app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry")
 | 
			
		||||
    @app_commands.describe(account_name="Account name to remove")
 | 
			
		||||
    async def remove_pirate(self, interaction: discord.Interaction, account_name: str):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "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 interaction.response.send_message("Pirate not found.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
 | 
			
		||||
        await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}")
 | 
			
		||||
        await self._refresh_pirates_list(interaction.guild)
 | 
			
		||||
        await interaction.response.send_message("Removed.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
    # 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="[MOD] 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) ----
 | 
			
		||||
    @app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names")
 | 
			
		||||
    async def encounters_migrate_ids(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        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 = ambiguous = missing = 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']
 | 
			
		||||
 | 
			
		||||
            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 interaction.followup.send(
 | 
			
		||||
            "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=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        await self._refresh_pirates_list(interaction.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()
 | 
			
		||||
 | 
			
		||||
        # Build new embed + content (keep proof visible if present)
 | 
			
		||||
        status_text = f"{'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
 | 
			
		||||
        report_view = {
 | 
			
		||||
            'character_name': report['character_name'],
 | 
			
		||||
            'account_name': report['account_name'],
 | 
			
		||||
            'submitter_id': report['submitter_id'],
 | 
			
		||||
            'proof_url': report.get('proof_url', ''),
 | 
			
		||||
            'proof_type': report.get('proof_type', ''),
 | 
			
		||||
            'status_line': status_text,
 | 
			
		||||
        }
 | 
			
		||||
        color = discord.Color.green() if approved else discord.Color.red()
 | 
			
		||||
        title = "✅ Pirate Report — Approved" if approved else "❌ Pirate Report — Rejected"
 | 
			
		||||
        new_embed = _make_report_embed(title, color, report_view, include_status=True)
 | 
			
		||||
 | 
			
		||||
        # For videos, ensure the URL stays in message content so the inline player remains visible.
 | 
			
		||||
        new_content = report_view['proof_url'] if report_view.get('proof_type') == 'video' else None
 | 
			
		||||
 | 
			
		||||
        # Always (re)attach jump button if we have the ack info
 | 
			
		||||
        view = None
 | 
			
		||||
        try:
 | 
			
		||||
            if report.get('origin_channel_id') and report.get('ack_message_id'):
 | 
			
		||||
                url = _jump_url(guild.id, report['origin_channel_id'], report['ack_message_id'])
 | 
			
		||||
                view = ReportJumpView(url)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            view = None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            await msg.clear_reactions()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[pirate_report] clear reactions failed:", repr(e))
 | 
			
		||||
        try:
 | 
			
		||||
            await msg.edit(content=new_content, embed=new_embed, view=view)
 | 
			
		||||
        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):
 | 
			
		||||
    await bot.add_cog(PirateReportCog(bot))
 | 
			
		||||
@ -1,228 +0,0 @@
 | 
			
		||||
# modules/pirates_list/pirates_list.py
 | 
			
		||||
import asyncio
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
from mod_perms import require_mod_ctx, require_mod_interaction
 | 
			
		||||
from modules.common.settings import cfg as _cfg
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
 | 
			
		||||
        c = _cfg(bot)
 | 
			
		||||
        self.list_channel_id = c.int("pirates_list_channel_id")
 | 
			
		||||
        self.group_threshold = c.int("threat_group_threshold", 3)
 | 
			
		||||
        self.min_samples = c.int("threat_min_samples_for_stats", 3)
 | 
			
		||||
 | 
			
		||||
        # serialize refreshes per guild
 | 
			
		||||
        self._locks: dict[int, asyncio.Lock] = {}
 | 
			
		||||
        # 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)."""
 | 
			
		||||
        async with self._lock_for(guild.id):
 | 
			
		||||
            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 = self._no_mentions
 | 
			
		||||
 | 
			
		||||
            # ---- 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]
 | 
			
		||||
 | 
			
		||||
            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:
 | 
			
		||||
                    dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid)
 | 
			
		||||
 | 
			
		||||
            # ---- 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:
 | 
			
		||||
                    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))
 | 
			
		||||
                    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))
 | 
			
		||||
 | 
			
		||||
            # ---- send additional messages if needed ----
 | 
			
		||||
            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))
 | 
			
		||||
 | 
			
		||||
            # ---- delete extras if fewer chunks now ----
 | 
			
		||||
            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)
 | 
			
		||||
    @app_commands.command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list")
 | 
			
		||||
    @app_commands.checks.cooldown(1, 10)  # guild-scope anti-spam analogue
 | 
			
		||||
    async def pirates_list_refresh(self, interaction: discord.Interaction):
 | 
			
		||||
        if not interaction.guild:
 | 
			
		||||
            return await interaction.response.send_message("Use this in a server.", ephemeral=True)
 | 
			
		||||
        if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        await interaction.response.defer(ephemeral=True)
 | 
			
		||||
        await self.refresh_list(interaction.guild)
 | 
			
		||||
        await interaction.followup.send("Pirates list refreshed.", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    await bot.add_cog(PiratesListCog(bot))
 | 
			
		||||
@ -1,150 +0,0 @@
 | 
			
		||||
# modules/power/power.py
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
import asyncio
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
from discord import app_commands
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
from mod_perms import require_mod_interaction
 | 
			
		||||
 | 
			
		||||
# ---------------- helpers ----------------
 | 
			
		||||
 | 
			
		||||
WEAK_REASONS = {
 | 
			
		||||
    "stuck", "idk", "dont know", "don't know", "unknown", "?",
 | 
			
		||||
    "lag", "restart", "restarting", "update", "updating", "bug",
 | 
			
		||||
    "crash", "crashed"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def _now_utc_str() -> str:
 | 
			
		||||
    return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
 | 
			
		||||
 | 
			
		||||
def _reason_ok(s: str) -> tuple[bool, str | None]:
 | 
			
		||||
    """
 | 
			
		||||
    Enforce a descriptive reason:
 | 
			
		||||
      - >= 20 characters
 | 
			
		||||
      - >= 4 words
 | 
			
		||||
      - at least 3 words with length >= 3
 | 
			
		||||
      - reject trivial/weak phrases
 | 
			
		||||
    """
 | 
			
		||||
    if not s:
 | 
			
		||||
        return False, "Reason is required."
 | 
			
		||||
    s = s.strip()
 | 
			
		||||
    if len(s) < 20:
 | 
			
		||||
        return False, "Please provide a more descriptive reason (≥ 20 characters)."
 | 
			
		||||
    words = re.findall(r"[A-Za-z0-9'-]+", s)
 | 
			
		||||
    if len(words) < 4:
 | 
			
		||||
        return False, "Please include at least 4 words."
 | 
			
		||||
    if sum(len(w) >= 3 for w in words) < 3:
 | 
			
		||||
        return False, "Add more detail—use at least three meaningful words (≥ 3 letters)."
 | 
			
		||||
    low = s.lower()
 | 
			
		||||
    if low in WEAK_REASONS:
 | 
			
		||||
        return False, "Reason is too vague. Please explain what happened."
 | 
			
		||||
    return True, None
 | 
			
		||||
 | 
			
		||||
async def _send_modlog(bot: commands.Bot, guild: discord.Guild, content: str):
 | 
			
		||||
    modlog_channel_id = cfg(bot).int('modlog_channel_id', 0)
 | 
			
		||||
    if not modlog_channel_id:
 | 
			
		||||
        print("[power] modlog_channel_id not configured; skipping modlog.")
 | 
			
		||||
        return
 | 
			
		||||
    ch = guild.get_channel(modlog_channel_id) or bot.get_channel(modlog_channel_id)
 | 
			
		||||
    if ch:
 | 
			
		||||
        try:
 | 
			
		||||
            await ch.send(content, allowed_mentions=discord.AllowedMentions.none())
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[power] failed to send modlog:", repr(e))
 | 
			
		||||
    else:
 | 
			
		||||
        print(f"[power] channel id {modlog_channel_id} not found.")
 | 
			
		||||
 | 
			
		||||
def _current_version(bot: commands.Bot) -> str | None:
 | 
			
		||||
    """Best-effort: read last detected version from boot_state."""
 | 
			
		||||
    dm = getattr(bot, "data_manager", None)
 | 
			
		||||
    if not dm:
 | 
			
		||||
        return None
 | 
			
		||||
    try:
 | 
			
		||||
        st = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
 | 
			
		||||
        v = st.get('last_version')
 | 
			
		||||
        return v if v else None
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
async def _graceful_restart(bot: commands.Bot, delay: float = 2.0):
 | 
			
		||||
    # small delay so modlog flushes out to Discord
 | 
			
		||||
    await asyncio.sleep(delay)
 | 
			
		||||
    try:
 | 
			
		||||
        await bot.close()
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        print("[power] bot.close() raised:", repr(e))
 | 
			
		||||
    # Force exit so container/wrapper restarts us
 | 
			
		||||
    os._exit(0)  # noqa
 | 
			
		||||
 | 
			
		||||
# ---------------- Cog + slash group ----------------
 | 
			
		||||
 | 
			
		||||
class PowerActionsCog(commands.Cog):
 | 
			
		||||
    """Administrative power actions (mod-only)."""
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
 | 
			
		||||
    power = app_commands.Group(name="power", description="Administrative power actions (mod-only)")
 | 
			
		||||
 | 
			
		||||
    @power.command(name="restart", description="[MOD] Restart the bot. Provide a descriptive reason.")
 | 
			
		||||
    @app_commands.describe(reason="Explain why a restart is necessary (be specific).")
 | 
			
		||||
    async def restart(self, interaction: discord.Interaction, reason: str):
 | 
			
		||||
        # Mods only
 | 
			
		||||
        if not await require_mod_interaction(interaction):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        ok, err = _reason_ok(reason)
 | 
			
		||||
        if not ok:
 | 
			
		||||
            return await interaction.response.send_message(f"❌ {err}", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        await interaction.response.send_message("🔁 Restart requested — logging to modlog and restarting…", ephemeral=True)
 | 
			
		||||
 | 
			
		||||
        guild = interaction.guild
 | 
			
		||||
        stamp = _now_utc_str()
 | 
			
		||||
        ver = _current_version(interaction.client) or "unknown"
 | 
			
		||||
 | 
			
		||||
        log = (
 | 
			
		||||
            "🔁 **Bot Restart Requested**\n"
 | 
			
		||||
            f"**By:** {interaction.user.mention}\n"
 | 
			
		||||
            f"**When:** {stamp}\n"
 | 
			
		||||
            f"**Running version:** `{ver}`\n"
 | 
			
		||||
            f"**Reason:** {reason.strip()}"
 | 
			
		||||
        )
 | 
			
		||||
        if guild:
 | 
			
		||||
            await _send_modlog(interaction.client, guild, log)
 | 
			
		||||
        else:
 | 
			
		||||
            print("[power] no guild on interaction; modlog not sent.")
 | 
			
		||||
 | 
			
		||||
        await _graceful_restart(interaction.client, delay=2.0)
 | 
			
		||||
 | 
			
		||||
# ---------------- setup ----------------
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    cog = PowerActionsCog(bot)
 | 
			
		||||
    await bot.add_cog(cog)
 | 
			
		||||
 | 
			
		||||
    home_gid = cfg(bot).int('home_guild_id', 0)
 | 
			
		||||
    guild_obj = discord.Object(id=home_gid) if home_gid else None
 | 
			
		||||
 | 
			
		||||
    # remove any prior 'power' root to keep reloads idempotent
 | 
			
		||||
    def _rm(name: str):
 | 
			
		||||
        try:
 | 
			
		||||
            bot.tree.remove_command(name, guild=guild_obj)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            try:
 | 
			
		||||
                bot.tree.remove_command(name, guild=None)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
    _rm("power")
 | 
			
		||||
    if home_gid:
 | 
			
		||||
        bot.tree.add_command(cog.power, guild=guild_obj)
 | 
			
		||||
        print("[power] Registered /power group to home guild", home_gid)
 | 
			
		||||
    else:
 | 
			
		||||
        bot.tree.add_command(cog.power)
 | 
			
		||||
        print("[power] Registered /power group globally")
 | 
			
		||||
@ -1,941 +0,0 @@
 | 
			
		||||
# modules/reaction_role/reaction_role.py
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from typing import Any, Dict, Optional, Iterable, Tuple
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
 | 
			
		||||
from modules.common.emoji_accept import is_accept
 | 
			
		||||
from modules.common.settings import cfg  # dynamic settings helper
 | 
			
		||||
 | 
			
		||||
CHECKMARK = '✅'  # kept for consistency with other flows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ------------------ small helpers ------------------
 | 
			
		||||
 | 
			
		||||
def _fmt_dt(ts: float) -> str:
 | 
			
		||||
    return discord.utils.format_dt(datetime.fromtimestamp(ts, tz=timezone.utc), style="f")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _as_list(x: Optional[Iterable]) -> list:
 | 
			
		||||
    return list(x) if isinstance(x, Iterable) else []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ------------------ Fedaykin approval buttons ------------------
 | 
			
		||||
 | 
			
		||||
class _FedaykinApprovalView(discord.ui.View):
 | 
			
		||||
    """Approval buttons for a single Fedaykin request (persistent)."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, cog: "ReactionRoleCog", req: Dict[str, Any], *, timeout: Optional[float] = None):
 | 
			
		||||
        super().__init__(timeout=timeout)
 | 
			
		||||
        self.cog = cog
 | 
			
		||||
        self.req = req  # dict persisted via data_manager
 | 
			
		||||
 | 
			
		||||
    async def interaction_check(self, interaction: discord.Interaction) -> bool:
 | 
			
		||||
        # Only Field Mod or Admin may act — and only if a Head exists
 | 
			
		||||
        member = interaction.user
 | 
			
		||||
        if not isinstance(member, discord.Member):
 | 
			
		||||
            await interaction.response.send_message("Server context required.", ephemeral=True)
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        if self.cog._fedaykin_headless or not (interaction.guild and self.cog._has_fedaykin_head(interaction.guild)):
 | 
			
		||||
            await interaction.response.send_message(
 | 
			
		||||
                "No **Fedaykin Head** is currently appointed. This request remains pending until one is appointed.",
 | 
			
		||||
                ephemeral=True
 | 
			
		||||
            )
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id}
 | 
			
		||||
        if not any(r.id in allow_roles for r in getattr(member, "roles", [])):
 | 
			
		||||
            await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True)
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    async def _finish(self, interaction: discord.Interaction, status: str):
 | 
			
		||||
        await self.cog._fedaykin_decide(
 | 
			
		||||
            guild=interaction.guild,
 | 
			
		||||
            actor=interaction.user if isinstance(interaction.user, discord.Member) else None,
 | 
			
		||||
            req=self.req,
 | 
			
		||||
            status=status,
 | 
			
		||||
            edit_view=True,
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            await interaction.response.send_message(f"{status.capitalize()}.", ephemeral=True)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Approve", style=discord.ButtonStyle.success, custom_id="fdk.approve")
 | 
			
		||||
    async def _approve(self, interaction: discord.Interaction, button: discord.ui.Button):
 | 
			
		||||
        await self._finish(interaction, "approved")
 | 
			
		||||
 | 
			
		||||
    @discord.ui.button(label="Reject", style=discord.ButtonStyle.danger, custom_id="fdk.reject")
 | 
			
		||||
    async def _reject(self, interaction: discord.Interaction, button: discord.ui.Button):
 | 
			
		||||
        await self._finish(interaction, "rejected")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ------------------ Main cog ------------------
 | 
			
		||||
 | 
			
		||||
class ReactionRoleCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Original: Records agreements and manages Full Access.
 | 
			
		||||
    Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons.
 | 
			
		||||
    - No debug slash commands.
 | 
			
		||||
    - Debounced nickname review to avoid duplicates when users add multiple accept emojis.
 | 
			
		||||
    - Fedaykin role is removed when the user unreacts the Fedaykin emoji.
 | 
			
		||||
    - Settings are reloaded dynamically on each event (hot-apply without restart).
 | 
			
		||||
    - NEW: Only users with Full Access may claim/request crew roles.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
 | 
			
		||||
        # Snapshot (will be refreshed dynamically on each event)
 | 
			
		||||
        self._settings = {}
 | 
			
		||||
        self._hub_channel_id: Optional[int] = None  # cache for crew hub channel
 | 
			
		||||
 | 
			
		||||
        # Debounce / locks to prevent duplicate nickname review on bursty reactions
 | 
			
		||||
        self._nick_debounce: Dict[tuple[int, int], float] = {}  # (guild_id, user_id) -> last_ts
 | 
			
		||||
        self._nick_lock: set[tuple[int, int]] = set()           # in-flight review creations
 | 
			
		||||
 | 
			
		||||
        # Fedaykin head state (global)
 | 
			
		||||
        self._fedaykin_headless: bool = False
 | 
			
		||||
 | 
			
		||||
        # Apply initial snapshot and schedule restore
 | 
			
		||||
        self._refresh_settings(force=True)
 | 
			
		||||
        self.bot.loop.create_task(self._boot_restore())
 | 
			
		||||
 | 
			
		||||
    # ------------------ settings ------------------
 | 
			
		||||
 | 
			
		||||
    def _refresh_settings(self, *, force: bool = False):
 | 
			
		||||
        """Hot-read settings on demand; cheap and avoids restart requirement."""
 | 
			
		||||
        r = cfg(self.bot)
 | 
			
		||||
        s = {
 | 
			
		||||
            # Message IDs
 | 
			
		||||
            "rules_msg_id": r.int('rules_message_id', 0),
 | 
			
		||||
            "engage_msg_id": r.int('engagement_message_id', 0),
 | 
			
		||||
            "nick_msg_id": r.int('nickname_message_id', 0),
 | 
			
		||||
            "crew_msg_id": r.int('crew_roles_message_id', 0),
 | 
			
		||||
 | 
			
		||||
            # Role IDs
 | 
			
		||||
            "rules_role": r.int('rules_role_id', 0),
 | 
			
		||||
            "engage_role": r.int('engagement_role_id', 0),
 | 
			
		||||
            "full_access_role": r.int('full_access_role_id', 0),
 | 
			
		||||
            "role_harvest_id": r.int('role_harvest_crew_id', 0),
 | 
			
		||||
            "role_escort_id": r.int('role_escort_crew_id', 0),
 | 
			
		||||
            "role_fedaykin_id": r.int('role_fedaykin_id', 0),
 | 
			
		||||
 | 
			
		||||
            # Approver roles
 | 
			
		||||
            "field_mod_role_id": r.int('field_mod_role_id', 0),
 | 
			
		||||
            "admin_role_id": r.int('admin_role_id', 0),
 | 
			
		||||
 | 
			
		||||
            # Emojis
 | 
			
		||||
            "emoji_harvest_id": r.int('emoji_harvester_crew', 0),
 | 
			
		||||
            "emoji_escort_id": r.int('emoji_escort_crew', 0),
 | 
			
		||||
            "emoji_fedaykin_id": r.int('emoji_fedaykin', 0),
 | 
			
		||||
 | 
			
		||||
            # Channels
 | 
			
		||||
            "report_channel_id": r.int('report_channel_id', 0),
 | 
			
		||||
            "mod_channel_id": r.int('mod_channel_id', 0),
 | 
			
		||||
            "modlog_channel_id": r.int('modlog_channel_id', 0),
 | 
			
		||||
        }
 | 
			
		||||
        self._settings = s
 | 
			
		||||
 | 
			
		||||
    # handy properties (avoid tons of self._settings["x"] everywhere)
 | 
			
		||||
    @property
 | 
			
		||||
    def rules_msg_id(self) -> int: return self._settings.get("rules_msg_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def engage_msg_id(self) -> int: return self._settings.get("engage_msg_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def nick_msg_id(self) -> int: return self._settings.get("nick_msg_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def crew_msg_id(self) -> int: return self._settings.get("crew_msg_id", 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def rules_role(self) -> int: return self._settings.get("rules_role", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def engage_role(self) -> int: return self._settings.get("engage_role", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def full_access_role(self) -> int: return self._settings.get("full_access_role", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def role_harvest_id(self) -> int: return self._settings.get("role_harvest_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def role_escort_id(self) -> int: return self._settings.get("role_escort_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def role_fedaykin_id(self) -> int: return self._settings.get("role_fedaykin_id", 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def field_mod_role_id(self) -> int: return self._settings.get("field_mod_role_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def admin_role_id(self) -> int: return self._settings.get("admin_role_id", 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def emoji_harvest_id(self) -> int: return self._settings.get("emoji_harvest_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def emoji_escort_id(self) -> int: return self._settings.get("emoji_escort_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def emoji_fedaykin_id(self) -> int: return self._settings.get("emoji_fedaykin_id", 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_channel_id(self) -> int: return self._settings.get("report_channel_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def mod_channel_id(self) -> int: return self._settings.get("mod_channel_id", 0)
 | 
			
		||||
    @property
 | 
			
		||||
    def modlog_channel_id(self) -> int: return self._settings.get("modlog_channel_id", 0)
 | 
			
		||||
 | 
			
		||||
    # ------------------ boot & persistence ------------------
 | 
			
		||||
 | 
			
		||||
    async def _boot_restore(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        self._refresh_settings()
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # Ensure list key exists to be safe
 | 
			
		||||
        if not isinstance(dm.get('fedaykin_requests'), list):
 | 
			
		||||
            try:
 | 
			
		||||
                dm.add('fedaykin_requests', {"_init": True})
 | 
			
		||||
                dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        # Re-register views for any pending requests
 | 
			
		||||
        try:
 | 
			
		||||
            for req in _as_list(dm.get('fedaykin_requests')):
 | 
			
		||||
                if req.get("status") == "pending" and req.get("review_message_id") and req.get("review_channel_id"):
 | 
			
		||||
                    self.bot.add_view(_FedaykinApprovalView(self, req), message_id=int(req["review_message_id"]))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Evaluate head state at boot and act accordingly
 | 
			
		||||
        try:
 | 
			
		||||
            hg = cfg(self.bot).int('home_guild_id', 0)
 | 
			
		||||
            guild = self.bot.get_guild(hg) if hg else (self.bot.guilds[0] if self.bot.guilds else None)
 | 
			
		||||
            await self._maybe_transition_head_state(guild)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass    
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            if guild:
 | 
			
		||||
                await self._repair_orphaned_pending_cards(guild)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _save_fedaykin_request(self, req: Dict[str, Any]):
 | 
			
		||||
        """Upsert by (guild_id, user_id)."""
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        gid, uid = int(req["guild_id"]), int(req["user_id"])
 | 
			
		||||
        try:
 | 
			
		||||
            for _ in list(_as_list(dm.get('fedaykin_requests'))):
 | 
			
		||||
                dm.remove('fedaykin_requests', lambda x, gid=gid, uid=uid:
 | 
			
		||||
                          int(x.get("guild_id", 0)) == gid and int(x.get("user_id", 0)) == uid)
 | 
			
		||||
            dm.add('fedaykin_requests', dict(req))
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    # ------------------ shared helpers ------------------
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    def _has_full_initiated(self, member: discord.Member) -> bool:
 | 
			
		||||
        """User must have Full Access role to claim/request crew roles."""
 | 
			
		||||
        if not member or not isinstance(member.guild, discord.Guild):
 | 
			
		||||
            return False
 | 
			
		||||
        role = member.guild.get_role(self.full_access_role) if self.full_access_role else None
 | 
			
		||||
        return bool(role and role in member.roles)
 | 
			
		||||
 | 
			
		||||
    async def _remove_reaction_silent(self, guild: discord.Guild, channel_id: int, message_id: int,
 | 
			
		||||
                                      emoji: discord.PartialEmoji | discord.Emoji | str, member: discord.Member):
 | 
			
		||||
        """Best-effort: remove a reaction without messaging the user."""
 | 
			
		||||
        try:
 | 
			
		||||
            ch = guild.get_channel(channel_id)
 | 
			
		||||
            if not isinstance(ch, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
                return
 | 
			
		||||
            msg = await ch.fetch_message(message_id)
 | 
			
		||||
            await msg.remove_reaction(emoji, member)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        has_rules = member.id in _as_list(dm.get('agreed_rules'))
 | 
			
		||||
        has_engage = member.id in _as_list(dm.get('agreed_engagement'))
 | 
			
		||||
        has_nick_claim = member.id in _as_list(dm.get('agreed_nickname'))
 | 
			
		||||
        has_all = has_rules and has_engage and has_nick_claim
 | 
			
		||||
 | 
			
		||||
        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 _find_hub_message(self, guild: discord.Guild) -> Tuple[Optional[discord.Message], Optional[int]]:
 | 
			
		||||
        """Return (message, channel_id) for the hub message, scanning text channels if needed."""
 | 
			
		||||
        if not self.crew_msg_id:
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
        # Try cached channel
 | 
			
		||||
        if self._hub_channel_id:
 | 
			
		||||
            ch = guild.get_channel(self._hub_channel_id)
 | 
			
		||||
            if isinstance(ch, discord.TextChannel):
 | 
			
		||||
                try:
 | 
			
		||||
                    msg = await ch.fetch_message(self.crew_msg_id)
 | 
			
		||||
                    return msg, ch.id
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        # Scan
 | 
			
		||||
        for ch in guild.text_channels:
 | 
			
		||||
            try:
 | 
			
		||||
                msg = await ch.fetch_message(self.crew_msg_id)
 | 
			
		||||
                self._hub_channel_id = ch.id
 | 
			
		||||
                return msg, ch.id
 | 
			
		||||
            except (discord.NotFound, discord.Forbidden):
 | 
			
		||||
                continue
 | 
			
		||||
            except Exception:
 | 
			
		||||
                continue
 | 
			
		||||
        return None, None
 | 
			
		||||
 | 
			
		||||
    async def _edit_review_message_footer(self, req: Dict[str, Any], note: str, *, clear_view: bool = False):
 | 
			
		||||
        try:
 | 
			
		||||
            ch = self.bot.get_channel(int(req["review_channel_id"]))
 | 
			
		||||
            if isinstance(ch, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
                msg = await ch.fetch_message(int(req["review_message_id"]))
 | 
			
		||||
                emb = msg.embeds[0] if msg.embeds else discord.Embed(title="Fedaykin Request")
 | 
			
		||||
                if emb.footer and emb.footer.text:
 | 
			
		||||
                    emb.set_footer(text=emb.footer.text + "\n" + note)
 | 
			
		||||
                else:
 | 
			
		||||
                    emb.set_footer(text=note)
 | 
			
		||||
                if clear_view:
 | 
			
		||||
                    await msg.edit(embed=emb, view=None)
 | 
			
		||||
                else:
 | 
			
		||||
                    await msg.edit(embed=emb)  # leave existing components untouched
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _modlog_decision(self, guild: discord.Guild, status: str, target: discord.Member, actor: discord.Member, req: Dict[str, Any]):
 | 
			
		||||
        """Send an approval/rejection/revoke line to modlog channel."""
 | 
			
		||||
        chan = self.bot.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if not isinstance(chan, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
            return
 | 
			
		||||
        color_map = {
 | 
			
		||||
            "approved": discord.Color.green(),
 | 
			
		||||
            "rejected": discord.Color.red(),
 | 
			
		||||
            "withdrawn": discord.Color.orange(),
 | 
			
		||||
            "revoked": discord.Color.orange()
 | 
			
		||||
        }
 | 
			
		||||
        colour = color_map.get(status, discord.Color.blurple())
 | 
			
		||||
        jump = f"https://discord.com/channels/{guild.id}/{req.get('review_channel_id')}/{req.get('review_message_id')}" \
 | 
			
		||||
               if req.get('review_channel_id') and req.get('review_message_id') else ""
 | 
			
		||||
        created_ts = float(req.get("created_ts", datetime.now(timezone.utc).timestamp()))
 | 
			
		||||
        decided_ts = float(req.get("decision_ts", datetime.now(timezone.utc).timestamp()))
 | 
			
		||||
        emb = discord.Embed(
 | 
			
		||||
            title=f"Fedaykin {status.capitalize()}",
 | 
			
		||||
            description=(f"[View review card]({jump})" if jump else "No card link available."),
 | 
			
		||||
            color=colour,
 | 
			
		||||
            timestamp=datetime.fromtimestamp(decided_ts, tz=timezone.utc),
 | 
			
		||||
        )
 | 
			
		||||
        emb.add_field(name="User", value=f"{target.mention} (`{target.id}`)", inline=True)
 | 
			
		||||
        emb.add_field(name="By", value=f"{actor.mention} (`{actor.id}`)", inline=True)
 | 
			
		||||
        emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
 | 
			
		||||
        emb.add_field(name="Decision", value=_fmt_dt(decided_ts), inline=True)
 | 
			
		||||
        try:
 | 
			
		||||
            await chan.send(embed=emb)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _fedaykin_decide(self, guild: Optional[discord.Guild], actor: Optional[discord.Member], req: Dict[str, Any], status: str, *, edit_view: bool = False):
 | 
			
		||||
        """Common decision path for approvals via buttons."""
 | 
			
		||||
        if guild is None or actor is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Idempotency
 | 
			
		||||
        if req.get("status") != "pending" and status in ("approved", "rejected"):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        target = await self._get_member(guild, int(req["user_id"]))
 | 
			
		||||
        if target is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        role = guild.get_role(self.role_fedaykin_id)
 | 
			
		||||
 | 
			
		||||
        if status == "approved":
 | 
			
		||||
            if role is None:
 | 
			
		||||
                await self._edit_review_message_footer(
 | 
			
		||||
                    req, f"**Decision attempt:** Approve by {actor.mention} failed — Fedaykin role not configured."
 | 
			
		||||
                )
 | 
			
		||||
                return
 | 
			
		||||
            try:
 | 
			
		||||
                await target.add_roles(role, reason=f"Fedaykin approved by {actor} ({actor.id})")
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        elif status == "rejected":
 | 
			
		||||
            if role and role in target.roles:
 | 
			
		||||
                try:
 | 
			
		||||
                    await target.remove_roles(role, reason=f"Fedaykin rejected by {actor} ({actor.id})")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
        elif status in ("withdrawn", "revoked"):
 | 
			
		||||
            # Both withdrawn (user cancels before approval) and revoked (user unreacts after approval)
 | 
			
		||||
            if role and role in target.roles:
 | 
			
		||||
                try:
 | 
			
		||||
                    await target.remove_roles(role, reason=f"Fedaykin {status} by user")
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
 | 
			
		||||
        if status in ("approved", "rejected"):
 | 
			
		||||
            req["status"] = status
 | 
			
		||||
            req["approver_id"] = int(actor.id)
 | 
			
		||||
        else:
 | 
			
		||||
            req["status"] = status  # withdrawn / revoked don't set approver
 | 
			
		||||
        req["decision_ts"] = datetime.now(timezone.utc).timestamp()
 | 
			
		||||
        await self._save_fedaykin_request(req)
 | 
			
		||||
 | 
			
		||||
        note = f"**Decision:** {status.capitalize()} by {actor.mention if actor else 'system'} at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}"
 | 
			
		||||
        await self._edit_review_message_footer(req, note, clear_view=edit_view)
 | 
			
		||||
 | 
			
		||||
        # modlog
 | 
			
		||||
        try:
 | 
			
		||||
            await self._modlog_decision(guild, status, target, actor or guild.me, req)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool:
 | 
			
		||||
        """Post the Fedaykin approval card; return True if posted somewhere. While headless, queue pending only."""
 | 
			
		||||
        # Require Full Access to even request Fedaykin
 | 
			
		||||
        if not self._has_full_initiated(member):
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        # If headless or effectively headless (no Head members): queue silently
 | 
			
		||||
        if self._fedaykin_headless or not self._has_fedaykin_head(guild):
 | 
			
		||||
            await self._queue_pending(guild, member, reason="headless_runtime")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        created_ts = datetime.now(timezone.utc).timestamp()
 | 
			
		||||
        emb = discord.Embed(
 | 
			
		||||
            title="Fedaykin Request",
 | 
			
		||||
            description=f"{member.mention} requested **Fedaykin** via reaction.",
 | 
			
		||||
            color=discord.Color.orange(),
 | 
			
		||||
            timestamp=datetime.fromtimestamp(created_ts, tz=timezone.utc),
 | 
			
		||||
        )
 | 
			
		||||
        emb.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True)
 | 
			
		||||
        emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
 | 
			
		||||
        emb.set_footer(text=f"Hub message ID: {hub_id}")
 | 
			
		||||
 | 
			
		||||
        req = {
 | 
			
		||||
            "guild_id": guild.id,
 | 
			
		||||
            "user_id": member.id,
 | 
			
		||||
            "status": "pending",
 | 
			
		||||
            "created_ts": created_ts,
 | 
			
		||||
            "review_message_id": 0,
 | 
			
		||||
            "review_channel_id": 0,
 | 
			
		||||
        }
 | 
			
		||||
        view = _FedaykinApprovalView(self, req, timeout=None)
 | 
			
		||||
 | 
			
		||||
        # Primary: report_channel_id; fallback: mod_channel_id
 | 
			
		||||
        targets = []
 | 
			
		||||
        chan = self.bot.get_channel(self.report_channel_id)
 | 
			
		||||
        if isinstance(chan, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
            targets.append(chan)
 | 
			
		||||
        mchan = self.bot.get_channel(self.mod_channel_id)
 | 
			
		||||
        if isinstance(mchan, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
            targets.append(mchan)
 | 
			
		||||
 | 
			
		||||
        for ch in targets:
 | 
			
		||||
            try:
 | 
			
		||||
                msg = await ch.send(embed=emb, view=view)
 | 
			
		||||
                req["review_message_id"] = int(msg.id)
 | 
			
		||||
                req["review_channel_id"] = int(msg.channel.id)
 | 
			
		||||
                await self._save_fedaykin_request(req)
 | 
			
		||||
                try:
 | 
			
		||||
                    self.bot.add_view(_FedaykinApprovalView(self, req), message_id=msg.id)
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
                return True
 | 
			
		||||
            except Exception:
 | 
			
		||||
                continue
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    # ---------- Fedaykin Head detection & transitions ----------
 | 
			
		||||
 | 
			
		||||
    def _has_fedaykin_head(self, guild: discord.Guild) -> bool:
 | 
			
		||||
        """True if any member currently has the Field Mod (Fedaykin Head) role."""
 | 
			
		||||
        if not guild or not self.field_mod_role_id:
 | 
			
		||||
            return False
 | 
			
		||||
        role = guild.get_role(self.field_mod_role_id)
 | 
			
		||||
        return bool(role and role.members)
 | 
			
		||||
 | 
			
		||||
    async def _modlog_head_summary(self, guild: discord.Guild, *, head_present: bool,
 | 
			
		||||
                                   revoked: int = 0, queued: int = 0, posted: int = 0):
 | 
			
		||||
        """Brief summary in modlog on headless/head-found transitions."""
 | 
			
		||||
        chan = self.bot.get_channel(self.modlog_channel_id)
 | 
			
		||||
        if not isinstance(chan, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
            return
 | 
			
		||||
        colour = discord.Color.green() if head_present else discord.Color.orange()
 | 
			
		||||
        title = "Fedaykin Head present — queued reviews sent" if head_present else "No Fedaykin Head — roles revoked & queued"
 | 
			
		||||
        desc = (
 | 
			
		||||
            f"**Posted reviews:** {posted}" if head_present else
 | 
			
		||||
            f"**Revoked roles:** {revoked}\n**Queued reviews:** {queued}"
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            emb = discord.Embed(title=title, description=desc, color=colour,
 | 
			
		||||
                                timestamp=datetime.now(timezone.utc))
 | 
			
		||||
            await chan.send(embed=emb)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    async def _queue_pending(self, guild: discord.Guild, member: discord.Member, *, reason: str = "headless"):
 | 
			
		||||
        """Upsert a silent pending request (no card)."""
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        created_ts = datetime.now(timezone.utc).timestamp()
 | 
			
		||||
        # Preserve an existing headless pending if one already exists (don’t duplicate)
 | 
			
		||||
        existing = next((x for x in _as_list(dm.get('fedaykin_requests'))
 | 
			
		||||
                         if x.get("guild_id") == guild.id and x.get("user_id") == member.id
 | 
			
		||||
                         and x.get("status") == "pending" and int(x.get("review_message_id", 0)) == 0), None)
 | 
			
		||||
        req = {
 | 
			
		||||
            "guild_id": guild.id,
 | 
			
		||||
            "user_id": member.id,
 | 
			
		||||
            "status": "pending",
 | 
			
		||||
            "created_ts": existing.get("created_ts", created_ts) if existing else created_ts,
 | 
			
		||||
            "review_message_id": 0,
 | 
			
		||||
            "review_channel_id": 0,
 | 
			
		||||
            "reason": reason,
 | 
			
		||||
        }
 | 
			
		||||
        await self._save_fedaykin_request(req)
 | 
			
		||||
 | 
			
		||||
    async def _headless_revoke_and_queue(self, guild: discord.Guild) -> tuple[int, int]:
 | 
			
		||||
        """When headless: remove Fedaykin role from all members and queue pending reviews."""
 | 
			
		||||
        revoked = queued = 0
 | 
			
		||||
        if not guild or not self.role_fedaykin_id:
 | 
			
		||||
            return revoked, queued
 | 
			
		||||
        role = guild.get_role(self.role_fedaykin_id)
 | 
			
		||||
        if not role:
 | 
			
		||||
            return revoked, queued
 | 
			
		||||
 | 
			
		||||
        for m in list(role.members):
 | 
			
		||||
            if m.bot:
 | 
			
		||||
                continue
 | 
			
		||||
            try:
 | 
			
		||||
                await m.remove_roles(role, reason="Fedaykin headless – role temporarily revoked")
 | 
			
		||||
                revoked += 1
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
            try:
 | 
			
		||||
                await self._queue_pending(guild, m, reason="headless_boot")
 | 
			
		||||
                queued += 1
 | 
			
		||||
            except Exception:
 | 
			
		||||
                pass
 | 
			
		||||
        return revoked, queued
 | 
			
		||||
 | 
			
		||||
    async def _flush_pending_to_cards(self, guild: discord.Guild) -> int:
 | 
			
		||||
        """When a head exists: send cards for any queued pendings (review_message_id == 0)."""
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        pending = [x for x in _as_list(dm.get('fedaykin_requests'))
 | 
			
		||||
                   if x.get("guild_id") == guild.id and x.get("status") == "pending"
 | 
			
		||||
                   and int(x.get("review_message_id", 0)) == 0]
 | 
			
		||||
        posted = 0
 | 
			
		||||
        for req in pending:
 | 
			
		||||
            member = await self._get_member(guild, int(req.get("user_id", 0)))
 | 
			
		||||
            if not member:
 | 
			
		||||
                continue
 | 
			
		||||
            # This will upsert the request with live message/channel IDs
 | 
			
		||||
            ok = await self._post_fedaykin_card(guild, member, self.crew_msg_id or 0)
 | 
			
		||||
            if ok:
 | 
			
		||||
                posted += 1
 | 
			
		||||
        return posted
 | 
			
		||||
 | 
			
		||||
    async def _maybe_transition_head_state(self, guild: Optional[discord.Guild]):
 | 
			
		||||
        """Recompute head state; if it changes, perform the required mass action + log."""
 | 
			
		||||
        if guild is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Require a configured, resolvable Field Mod role to take global actions
 | 
			
		||||
        if not self.field_mod_role_id:
 | 
			
		||||
            return
 | 
			
		||||
        head_role = guild.get_role(self.field_mod_role_id)
 | 
			
		||||
        if head_role is None:
 | 
			
		||||
            # Misconfigured: do not mass-revoke; other paths will still queue/block appropriately
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        has_head_now = bool(head_role.members)
 | 
			
		||||
        if has_head_now and self._fedaykin_headless:
 | 
			
		||||
            # Head appeared — flush queued to cards
 | 
			
		||||
            self._fedaykin_headless = False
 | 
			
		||||
            posted = await self._flush_pending_to_cards(guild)
 | 
			
		||||
            await self._repair_orphaned_pending_cards(guild)
 | 
			
		||||
            await self._modlog_head_summary(guild, head_present=True, posted=posted)
 | 
			
		||||
        elif not has_head_now and not self._fedaykin_headless:
 | 
			
		||||
            # Became headless — revoke all fedaykins and queue pendings
 | 
			
		||||
            self._fedaykin_headless = True
 | 
			
		||||
            revoked, queued = await self._headless_revoke_and_queue(guild)
 | 
			
		||||
            await self._modlog_head_summary(guild, head_present=False, revoked=revoked, queued=queued)
 | 
			
		||||
 | 
			
		||||
    # inside ReactionRoleCog class
 | 
			
		||||
    async def _repair_orphaned_pending_cards(self, guild: discord.Guild) -> int:
 | 
			
		||||
        """Detect pending requests whose review message was deleted; reset them so they can be re-posted."""
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
        repaired = 0
 | 
			
		||||
        for req in list(_as_list(dm.get('fedaykin_requests'))):
 | 
			
		||||
            if int(req.get("guild_id", 0)) != guild.id or req.get("status") != "pending":
 | 
			
		||||
                continue
 | 
			
		||||
            mid = int(req.get("review_message_id", 0) or 0)
 | 
			
		||||
            cid = int(req.get("review_channel_id", 0) or 0)
 | 
			
		||||
            if not mid or not cid:
 | 
			
		||||
                continue  # already queued (no card)
 | 
			
		||||
            ch = self.bot.get_channel(cid)
 | 
			
		||||
            exists = False
 | 
			
		||||
            if isinstance(ch, (discord.TextChannel, discord.Thread)):
 | 
			
		||||
                try:
 | 
			
		||||
                    await ch.fetch_message(mid)
 | 
			
		||||
                    exists = True
 | 
			
		||||
                except (discord.NotFound, discord.Forbidden):
 | 
			
		||||
                    exists = False
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    exists = False
 | 
			
		||||
            if not exists:
 | 
			
		||||
                # reset to "queued" so normal flow can re-post
 | 
			
		||||
                req["review_message_id"] = 0
 | 
			
		||||
                req["review_channel_id"] = 0
 | 
			
		||||
                await self._save_fedaykin_request(req)
 | 
			
		||||
                repaired += 1
 | 
			
		||||
 | 
			
		||||
        # If we repaired and a head exists, immediately flush to new cards
 | 
			
		||||
        if repaired and not self._fedaykin_headless and self._has_fedaykin_head(guild):
 | 
			
		||||
            await self._flush_pending_to_cards(guild)
 | 
			
		||||
        return repaired
 | 
			
		||||
 | 
			
		||||
    # ------------------ listeners ------------------
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_member_update(self, before: discord.Member, after: discord.Member):
 | 
			
		||||
        """Detect when someone gains/loses the Fedaykin Head role and transition state if needed."""
 | 
			
		||||
        try:
 | 
			
		||||
            hg = cfg(self.bot).int('home_guild_id', 0)
 | 
			
		||||
            if after.guild.id != (hg or after.guild.id):
 | 
			
		||||
                return
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # If the role IDs aren’t configured yet, just ignore
 | 
			
		||||
        if not self.field_mod_role_id:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        had = any(r.id == self.field_mod_role_id for r in getattr(before, "roles", []))
 | 
			
		||||
        has = any(r.id == self.field_mod_role_id for r in getattr(after, "roles", []))
 | 
			
		||||
        if had == has:
 | 
			
		||||
            return  # no change on this member
 | 
			
		||||
 | 
			
		||||
        # Re-evaluate overall head presence and transition if the global state changes
 | 
			
		||||
        await self._maybe_transition_head_state(after.guild)
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        # hot-reload settings
 | 
			
		||||
        self._refresh_settings()
 | 
			
		||||
 | 
			
		||||
        if not payload.guild_id:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
 | 
			
		||||
        # Ensure head state stays accurate even if settings/users change at runtime
 | 
			
		||||
        try:
 | 
			
		||||
            hg = cfg(self.bot).int('home_guild_id', 0)
 | 
			
		||||
            base_guild = self.bot.get_guild(hg) or guild
 | 
			
		||||
            await self._maybe_transition_head_state(base_guild)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        member = await self._get_member(guild, payload.user_id)
 | 
			
		||||
        if not member or member.bot:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # ----- Original accept-based flows -----
 | 
			
		||||
        try:
 | 
			
		||||
            if is_accept(payload.emoji):
 | 
			
		||||
 | 
			
		||||
                # RULES
 | 
			
		||||
                if self.rules_msg_id and payload.message_id == self.rules_msg_id:
 | 
			
		||||
                    role = guild.get_role(self.rules_role)
 | 
			
		||||
                    if role:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.add_roles(role, reason="Agreed to rules")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    if member.id not in _as_list(dm.get('agreed_rules')):
 | 
			
		||||
                        dm.add('agreed_rules', int(member.id))
 | 
			
		||||
 | 
			
		||||
                # ENGAGEMENT
 | 
			
		||||
                elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
 | 
			
		||||
                    role = guild.get_role(self.engage_role)
 | 
			
		||||
                    if role:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.add_roles(role, reason="Agreed to engagement")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    if member.id not in _as_list(dm.get('agreed_engagement')):
 | 
			
		||||
                        dm.add('agreed_engagement', int(member.id))
 | 
			
		||||
 | 
			
		||||
                # NICKNAME (debounced + idempotent)
 | 
			
		||||
                elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
 | 
			
		||||
                    now = time.monotonic()
 | 
			
		||||
                    key = (guild.id, member.id)
 | 
			
		||||
                    last = self._nick_debounce.get(key, 0.0)
 | 
			
		||||
                    if now - last < 10.0:
 | 
			
		||||
                        return  # debounce bursty multi-emoji reacts
 | 
			
		||||
                    self._nick_debounce[key] = now
 | 
			
		||||
 | 
			
		||||
                    # If any pending review already exists for this user, do nothing
 | 
			
		||||
                    has_pending_review = any(
 | 
			
		||||
                        r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending'
 | 
			
		||||
                        for r in _as_list(dm.get('nick_reviews'))
 | 
			
		||||
                    )
 | 
			
		||||
                    if has_pending_review:
 | 
			
		||||
                        return
 | 
			
		||||
 | 
			
		||||
                    # In-flight lock to avoid concurrent duplicate openings
 | 
			
		||||
                    if key in self._nick_lock:
 | 
			
		||||
                        return
 | 
			
		||||
                    self._nick_lock.add(key)
 | 
			
		||||
 | 
			
		||||
                    try:
 | 
			
		||||
                        # Clear stale marker if no active review, then open exactly one
 | 
			
		||||
                        dm.remove('nick_claim_pending', lambda x: x == member.id)
 | 
			
		||||
                        nn = self.bot.get_cog('NickNudgeCog')
 | 
			
		||||
                        if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
 | 
			
		||||
                            try:
 | 
			
		||||
                                await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
 | 
			
		||||
                            except Exception:
 | 
			
		||||
                                pass
 | 
			
		||||
                    finally:
 | 
			
		||||
                        self._nick_lock.discard(key)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # ----- Crew roles hub (custom emoji toggles + Fedaykin request) -----
 | 
			
		||||
        try:
 | 
			
		||||
            if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
 | 
			
		||||
                # Gate: must have Full Access to claim/request crew roles
 | 
			
		||||
                if not self._has_full_initiated(member):
 | 
			
		||||
                    await self._remove_reaction_silent(guild, payload.channel_id, payload.message_id, payload.emoji, member)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                # Harvester / Escort
 | 
			
		||||
                if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
 | 
			
		||||
                    role = guild.get_role(self.role_harvest_id)
 | 
			
		||||
                    if role:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
 | 
			
		||||
                    role = guild.get_role(self.role_escort_id)
 | 
			
		||||
                    if role:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                # Fedaykin -> open approval card (only one pending allowed)
 | 
			
		||||
                if payload.emoji.id == self.emoji_fedaykin_id:
 | 
			
		||||
                    fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
 | 
			
		||||
                    if fed_role and fed_role in member.roles:
 | 
			
		||||
                        return  # already has Fedaykin
 | 
			
		||||
 | 
			
		||||
                    pending = next((x for x in _as_list(dm.get('fedaykin_requests'))
 | 
			
		||||
                                    if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"), None)
 | 
			
		||||
                    if pending:
 | 
			
		||||
                        return
 | 
			
		||||
 | 
			
		||||
                    hub_msg, _ = await self._find_hub_message(guild)
 | 
			
		||||
                    hub_id = hub_msg.id if hub_msg else self.crew_msg_id
 | 
			
		||||
                    await self._post_fedaykin_card(guild, member, hub_id)
 | 
			
		||||
                    return
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Only the original (agreements) flow affects Full Access
 | 
			
		||||
        await self.maybe_apply_full_access(member)
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
 | 
			
		||||
        # hot-reload settings
 | 
			
		||||
        self._refresh_settings()
 | 
			
		||||
 | 
			
		||||
        if not payload.guild_id:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
			
		||||
 | 
			
		||||
        # Ensure head state stays accurate even if settings/users change at runtime
 | 
			
		||||
        try:
 | 
			
		||||
            hg = cfg(self.bot).int('home_guild_id', 0)
 | 
			
		||||
            base_guild = self.bot.get_guild(hg) or guild
 | 
			
		||||
            await self._maybe_transition_head_state(base_guild)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        member = await self._get_member(guild, payload.user_id)
 | 
			
		||||
        if not member or member.bot:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dm = self.bot.data_manager
 | 
			
		||||
 | 
			
		||||
        # ----- Original accept-based flows -----
 | 
			
		||||
        try:
 | 
			
		||||
            if is_accept(payload.emoji):
 | 
			
		||||
                # RULES
 | 
			
		||||
                if self.rules_msg_id and 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 and role in member.roles:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.remove_roles(role, reason="Rules un-ticked")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
 | 
			
		||||
                # ENGAGEMENT
 | 
			
		||||
                elif self.engage_msg_id and 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 and role in member.roles:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.remove_roles(role, reason="Engagement un-ticked")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
 | 
			
		||||
                # NICKNAME: clear only if no other accept left
 | 
			
		||||
                elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
 | 
			
		||||
                    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)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # ----- Crew roles hub (custom emoji) -----
 | 
			
		||||
        try:
 | 
			
		||||
            if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
 | 
			
		||||
                # Harvester
 | 
			
		||||
                if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
 | 
			
		||||
                    role = guild.get_role(self.role_harvest_id)
 | 
			
		||||
                    if role and role in member.roles:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                # Escort
 | 
			
		||||
                if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
 | 
			
		||||
                    role = guild.get_role(self.role_escort_id)
 | 
			
		||||
                    if role and role in member.roles:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                # Fedaykin unreact -> remove role + mark request accordingly
 | 
			
		||||
                if payload.emoji.id == self.emoji_fedaykin_id:
 | 
			
		||||
                    fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
 | 
			
		||||
 | 
			
		||||
                    # Find a related request if any (prefer pending; else last approved)
 | 
			
		||||
                    req = None
 | 
			
		||||
                    pend = [x for x in _as_list(dm.get('fedaykin_requests'))
 | 
			
		||||
                            if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"]
 | 
			
		||||
                    if pend:
 | 
			
		||||
                        req = pend[-1]
 | 
			
		||||
                        req["status"] = "withdrawn"
 | 
			
		||||
                    else:
 | 
			
		||||
                        appr = [x for x in _as_list(dm.get('fedaykin_requests'))
 | 
			
		||||
                                if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "approved"]
 | 
			
		||||
                        if appr:
 | 
			
		||||
                            req = appr[-1]
 | 
			
		||||
                            req["status"] = "revoked"
 | 
			
		||||
 | 
			
		||||
                    # Always remove the role on unreact (per requirement)
 | 
			
		||||
                    if fed_role and fed_role in member.roles:
 | 
			
		||||
                        try:
 | 
			
		||||
                            await member.remove_roles(fed_role, reason="Fedaykin reaction removed")
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
 | 
			
		||||
                    if req:
 | 
			
		||||
                        req["decision_ts"] = datetime.now(timezone.utc).timestamp()
 | 
			
		||||
                        await self._save_fedaykin_request(req)
 | 
			
		||||
                        actor = guild.me  # system action
 | 
			
		||||
                        status = req["status"]
 | 
			
		||||
                        # Update card footer if we still have it
 | 
			
		||||
                        await self._edit_review_message_footer(
 | 
			
		||||
                            req,
 | 
			
		||||
                            f"**Decision:** {status.capitalize()} (user unreact) at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}",
 | 
			
		||||
                            clear_view=True,
 | 
			
		||||
                        )
 | 
			
		||||
                        # Modlog
 | 
			
		||||
                        try:
 | 
			
		||||
                            await self._modlog_decision(guild, status, member, actor, req)
 | 
			
		||||
                        except Exception:
 | 
			
		||||
                            pass
 | 
			
		||||
                    return
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Only the original (agreements) flow affects Full Access
 | 
			
		||||
        await self.maybe_apply_full_access(member)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def setup(bot):
 | 
			
		||||
    await bot.add_cog(ReactionRoleCog(bot))
 | 
			
		||||
@ -1,902 +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
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
# 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] = {}
 | 
			
		||||
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
        def _f(key, default):
 | 
			
		||||
            try:
 | 
			
		||||
                return float(r.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:
 | 
			
		||||
                v = r.get(key, "")
 | 
			
		||||
                return int(v) if v 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"
 | 
			
		||||
            "_Set via environment variables or your INI. Restart the bot after changing._"
 | 
			
		||||
        )
 | 
			
		||||
        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: commands.Bot):
 | 
			
		||||
    cog = SpicePayCog(bot)
 | 
			
		||||
    await bot.add_cog(cog)
 | 
			
		||||
@ -1,331 +0,0 @@
 | 
			
		||||
# modules/status/status_rotator.py
 | 
			
		||||
import random
 | 
			
		||||
import asyncio
 | 
			
		||||
import time
 | 
			
		||||
from datetime import datetime, timezone, timedelta
 | 
			
		||||
 | 
			
		||||
import discord
 | 
			
		||||
from discord.ext import commands, tasks
 | 
			
		||||
 | 
			
		||||
from modules.common.settings import cfg
 | 
			
		||||
 | 
			
		||||
# ============== Tunables / lists you can expand freely ==============
 | 
			
		||||
 | 
			
		||||
# Long list of expressive unicode emojis (safe for all clients)
 | 
			
		||||
EMOTES = [
 | 
			
		||||
    "❤️","🧡","💛","💚","💙","💜","🤎","🖤","🤍","✨","🌟","⭐","🎉","🎊","🔥","💫","⚡","🌈",
 | 
			
		||||
    "😄","😁","😆","😊","🙂","😉","😎","🤩","🥳","🤗","🙌","👏","👍","🤝","🫶","🙏","🫡","🤘","💪",
 | 
			
		||||
    "👀","🤔","🧐","😼","😹","😏","😌","😇","😴","🤖","👾","🧠","🫠",
 | 
			
		||||
    "🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
DUNE_PHRASES = [
 | 
			
		||||
    "Arrakis. Dune. Desert Planet.",
 | 
			
		||||
    "Shai-Hulud stirs beneath the sands.",
 | 
			
		||||
    "The spice must flow.",
 | 
			
		||||
    "Bene Gesserit whispers in the dark.",
 | 
			
		||||
    "Kwisatz Haderach foretold.",
 | 
			
		||||
    "House Atreides rises.",
 | 
			
		||||
    "House Harkonnen plots.",
 | 
			
		||||
    "Fremen walk without rhythm.",
 | 
			
		||||
    "Crysknife unsheathed.",
 | 
			
		||||
    "Sietch Tabr stands strong.",
 | 
			
		||||
    "CHOAM counts its profits.",
 | 
			
		||||
    "The Spacing Guild navigates the void.",
 | 
			
		||||
    "Water is life.",
 | 
			
		||||
    "Fear is the mind-killer.",
 | 
			
		||||
    "Gom Jabbar at the throat.",
 | 
			
		||||
    "Stillsuits conserve every drop.",
 | 
			
		||||
    "Ornithopters in the storm.",
 | 
			
		||||
    "Sardaukar march.",
 | 
			
		||||
    "Prescience veils the future.",
 | 
			
		||||
    "Fedāykin watchful in the dunes.",
 | 
			
		||||
    "Shields hum under the sun.",
 | 
			
		||||
    "Kanly declared.",
 | 
			
		||||
    "Desert winds whisper secrets.",
 | 
			
		||||
    "Muad’Dib walks the golden path.",
 | 
			
		||||
    "Sandtrout seal the desert’s fate.",
 | 
			
		||||
    "Made with ❤️ in 🇳🇴",
 | 
			
		||||
    "DD Reset: Tuesday 03:00 UTC"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
FUN_FACTS = [
 | 
			
		||||
    "Frank Herbert conceived Dune after reporting on sand dune stabilization in the Oregon coast.",
 | 
			
		||||
    "‘Muad’Dib’ is a small desert mouse whose footprints taught Paul the sandwalk.",
 | 
			
		||||
    "Melange extends life, heightens awareness, and enables prescient navigation.",
 | 
			
		||||
    "Fremen ‘walking without rhythm’ avoids attracting sandworms.",
 | 
			
		||||
    "The crysknife is made from a sandworm’s tooth and must never be sheathed unblooded.",
 | 
			
		||||
    "Spacing Guild Navigators require spice to safely fold space.",
 | 
			
		||||
    "The Litany Against Fear is recited to focus and master one’s emotions.",
 | 
			
		||||
    "Bene Gesserit use prana-bindu training to control every muscle fiber.",
 | 
			
		||||
    "Sietch means a Fremen community cave complex.",
 | 
			
		||||
    "Stillsuits can reclaim over 90% of the body’s moisture.",
 | 
			
		||||
    "The Imperial throne balances the Landsraad, CHOAM, and the Guild.",
 | 
			
		||||
    "Kanly refers to a formal vendetta between noble houses.",
 | 
			
		||||
    "Arrakis once had open water—long before the events of Dune.",
 | 
			
		||||
    "‘Shai-Hulud’ is the Fremen name for the grand sandworm.",
 | 
			
		||||
    "A lasgun-shield interaction can cause a subatomic chain reaction.",
 | 
			
		||||
    "Mentats are human computers trained to replace forbidden thinking machines.",
 | 
			
		||||
    "Duncan Idaho appears across eras via ghola rebirths.",
 | 
			
		||||
    "The Water of Life is a deadly spice exhalation—surviving it transforms the Reverend Mother.",
 | 
			
		||||
    "Fedaykin are the Fremen elite commandos sworn to their leader.",
 | 
			
		||||
    "Sandtrout are juvenile forms of the great sandworms.",
 | 
			
		||||
    "The Butlerian Jihad outlawed thinking machines, reshaping human society.",
 | 
			
		||||
    "Caladan is the Atreides ocean world before their move to Arrakis.",
 | 
			
		||||
    "The Harkonnen homeworld is Giedi Prime, an industrialized, harsh planet.",
 | 
			
		||||
    "‘He who controls the spice controls the universe.’",
 | 
			
		||||
    "The Weirding Way is a Bene Gesserit martial art emphasizing speed and economy.",
 | 
			
		||||
    "Sardaukar troops are trained from birth on the prison planet Salusa Secundus.",
 | 
			
		||||
    "Ornithopters mimic bird flight to navigate harsh desert storms.",
 | 
			
		||||
    "The Fremen call offworlders ‘water-fat’ as an insult.",
 | 
			
		||||
    "Spice blows are natural melange eruptions from the deep desert.",
 | 
			
		||||
    "A ~30 year old male weighing ~75kg will consist of around 45L of water.",
 | 
			
		||||
    "A simple interactive DD map can be found at https://dune.gaming.tools/deep-desert."
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ============== Cog implementation ==============
 | 
			
		||||
 | 
			
		||||
class StatusRotatorCog(commands.Cog):
 | 
			
		||||
    """
 | 
			
		||||
    Cycles bot presence every N minutes with randomized status entries:
 | 
			
		||||
      - Member count (excluding bots)
 | 
			
		||||
      - Pirates count
 | 
			
		||||
      - Encounters count
 | 
			
		||||
      - Random shout-out: {emoji} {display_name} (members with SHAI_FULL_ACCESS_ROLE_ID)
 | 
			
		||||
      - Count of fully initiated members (role members)
 | 
			
		||||
      - Random Dune phrase
 | 
			
		||||
      - Random Dune fun fact (#n -> fact)
 | 
			
		||||
      - Top threat pirate
 | 
			
		||||
      - Latest report (pending/most recent)
 | 
			
		||||
      - Encounters in last 24h
 | 
			
		||||
      - Uptime
 | 
			
		||||
      - Current running version
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        r = cfg(bot)
 | 
			
		||||
        self.home_gid = r.int('home_guild_id', 0)
 | 
			
		||||
        self.full_access_role_id = r.int('full_access_role_id', 0)  # SHAI_FULL_ACCESS_ROLE_ID
 | 
			
		||||
        self.interval_min = r.int('status_interval_min', 5)         # SHAI_STATUS_INTERVAL_MIN (optional)
 | 
			
		||||
        self.max_len = 120  # clip safety for PC clients (keeps presence tidy)
 | 
			
		||||
 | 
			
		||||
        # Build generator list (enable/disable here if needed)
 | 
			
		||||
        self._generators = [
 | 
			
		||||
            self._gen_members_count,
 | 
			
		||||
            self._gen_pirates_count,
 | 
			
		||||
            self._gen_encounters_count,
 | 
			
		||||
            self._gen_random_shoutout,
 | 
			
		||||
            self._gen_initiated_count,
 | 
			
		||||
            self._gen_random_phrase,
 | 
			
		||||
            self._gen_random_fun_fact,
 | 
			
		||||
            self._gen_top_threat,
 | 
			
		||||
            self._gen_latest_report,
 | 
			
		||||
            self._gen_encounters_last_24h,
 | 
			
		||||
            self._gen_uptime,
 | 
			
		||||
            self._gen_running_version,
 | 
			
		||||
        ]
 | 
			
		||||
        self._queue = []
 | 
			
		||||
 | 
			
		||||
    # ---- lifecycle ----
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_ready(self):
 | 
			
		||||
        # ensure loop interval reflects config
 | 
			
		||||
        try:
 | 
			
		||||
            if self.rotate_status.is_running():
 | 
			
		||||
                self.rotate_status.change_interval(minutes=max(1, self.interval_min))
 | 
			
		||||
            else:
 | 
			
		||||
                self.rotate_status.change_interval(minutes=max(1, self.interval_min))
 | 
			
		||||
                self.rotate_status.start()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[status] failed to start/change loop:", repr(e))
 | 
			
		||||
 | 
			
		||||
    def cog_unload(self):
 | 
			
		||||
        try:
 | 
			
		||||
            self.rotate_status.cancel()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    # ---- the loop ----
 | 
			
		||||
 | 
			
		||||
    @tasks.loop(minutes=5)
 | 
			
		||||
    async def rotate_status(self):
 | 
			
		||||
        await self.bot.wait_until_ready()
 | 
			
		||||
        guild = self._resolve_guild()
 | 
			
		||||
        if not guild:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        text = await self._next_status_text(guild)
 | 
			
		||||
        if not text:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Enforce Custom Status; if it fails for any reason, fall back to Watching.
 | 
			
		||||
        try:
 | 
			
		||||
            await self.bot.change_presence(activity=discord.CustomActivity(name=text))
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[status] custom presence failed, falling back to Watching:", repr(e))
 | 
			
		||||
            try:
 | 
			
		||||
                await self.bot.change_presence(
 | 
			
		||||
                    activity=discord.Activity(type=discord.ActivityType.watching, name=text)
 | 
			
		||||
                )
 | 
			
		||||
            except Exception as e2:
 | 
			
		||||
                print("[status] change_presence failed:", repr(e2))
 | 
			
		||||
 | 
			
		||||
    # ---- helpers ----
 | 
			
		||||
 | 
			
		||||
    def _resolve_guild(self) -> discord.Guild | None:
 | 
			
		||||
        if self.home_gid:
 | 
			
		||||
            g = self.bot.get_guild(self.home_gid)
 | 
			
		||||
            if g:
 | 
			
		||||
                return g
 | 
			
		||||
        return self.bot.guilds[0] if self.bot.guilds else None
 | 
			
		||||
 | 
			
		||||
    async def _next_status_text(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        # Try up to len(generators) times to get a non-empty status
 | 
			
		||||
        for _ in range(len(self._generators)):
 | 
			
		||||
            if not self._queue:
 | 
			
		||||
                self._queue = random.sample(self._generators, k=len(self._generators))
 | 
			
		||||
            gen = self._queue.pop(0)
 | 
			
		||||
            try:
 | 
			
		||||
                s = await gen(guild)
 | 
			
		||||
                s = self._clip(s)
 | 
			
		||||
                if s:
 | 
			
		||||
                    return s
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print(f"[status] generator {gen.__name__} failed:", repr(e))
 | 
			
		||||
                continue
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _clip(self, s: str | None) -> str | None:
 | 
			
		||||
        if not s:
 | 
			
		||||
            return None
 | 
			
		||||
        s = s.strip()
 | 
			
		||||
        return (s[: self.max_len - 1] + "…") if len(s) > self.max_len else s
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _fmt_duration(seconds: float) -> str:
 | 
			
		||||
        seconds = int(max(0, seconds))
 | 
			
		||||
        d, rem = divmod(seconds, 86400)
 | 
			
		||||
        h, rem = divmod(rem, 3600)
 | 
			
		||||
        m, _ = divmod(rem, 60)
 | 
			
		||||
        if d > 0:
 | 
			
		||||
            return f"{d}d {h}h {m}m"
 | 
			
		||||
        if h > 0:
 | 
			
		||||
            return f"{h}h {m}m"
 | 
			
		||||
        return f"{m}m"
 | 
			
		||||
 | 
			
		||||
    # ---- individual generators (originals) ----
 | 
			
		||||
 | 
			
		||||
    async def _gen_members_count(self, guild: discord.Guild) -> str:
 | 
			
		||||
        count = sum(1 for m in guild.members if not m.bot)
 | 
			
		||||
        return f"{count} server members"
 | 
			
		||||
 | 
			
		||||
    async def _gen_pirates_count(self, guild: discord.Guild) -> str:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        n = len(dm.get('pirates')) if dm else 0
 | 
			
		||||
        return f"{n} pirates reported"
 | 
			
		||||
 | 
			
		||||
    async def _gen_encounters_count(self, guild: discord.Guild) -> str:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        n = len(dm.get('encounters')) if dm else 0
 | 
			
		||||
        return f"{n} reported encounters"
 | 
			
		||||
 | 
			
		||||
    async def _gen_random_shoutout(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        if not self.full_access_role_id:
 | 
			
		||||
            return None
 | 
			
		||||
        role = guild.get_role(self.full_access_role_id)
 | 
			
		||||
        if not role:
 | 
			
		||||
            return None
 | 
			
		||||
        candidates = [m for m in role.members if not m.bot]
 | 
			
		||||
        if not candidates:
 | 
			
		||||
            return None
 | 
			
		||||
        member = random.choice(candidates)
 | 
			
		||||
        em = random.choice(EMOTES)
 | 
			
		||||
        name = member.display_name
 | 
			
		||||
        return f"{em} {name}"
 | 
			
		||||
 | 
			
		||||
    async def _gen_initiated_count(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        if not self.full_access_role_id:
 | 
			
		||||
            return None
 | 
			
		||||
        role = guild.get_role(self.full_access_role_id)
 | 
			
		||||
        if not role:
 | 
			
		||||
            return None
 | 
			
		||||
        n = sum(1 for m in role.members if not m.bot)
 | 
			
		||||
        return f"{n} fully initiated members"
 | 
			
		||||
 | 
			
		||||
    async def _gen_random_phrase(self, guild: discord.Guild) -> str:
 | 
			
		||||
        return random.choice(DUNE_PHRASES)
 | 
			
		||||
 | 
			
		||||
    async def _gen_random_fun_fact(self, guild: discord.Guild) -> str:
 | 
			
		||||
        idx = random.randrange(len(FUN_FACTS))  # 0-based
 | 
			
		||||
        num = idx + 1
 | 
			
		||||
        fact = FUN_FACTS[idx]
 | 
			
		||||
        return f"Fun fact #{num} ->\n{fact}"
 | 
			
		||||
 | 
			
		||||
    async def _gen_top_threat(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        pirates = dm.get('pirates') if dm else []
 | 
			
		||||
        if not pirates:
 | 
			
		||||
            return None
 | 
			
		||||
        # sort by threat_level desc, then encounter_count desc, then newest added
 | 
			
		||||
        pirates_sorted = sorted(
 | 
			
		||||
            pirates,
 | 
			
		||||
            key=lambda p: (
 | 
			
		||||
                int(p.get('threat_level', 0)),
 | 
			
		||||
                int(p.get('encounter_count', 0)),
 | 
			
		||||
                float(p.get('added_ts', 0.0))
 | 
			
		||||
            ),
 | 
			
		||||
            reverse=True
 | 
			
		||||
        )
 | 
			
		||||
        top = pirates_sorted[0]
 | 
			
		||||
        tl = int(top.get('threat_level', 0))
 | 
			
		||||
        name = top.get('character_name') or top.get('account_name') or "Unknown"
 | 
			
		||||
        return f"Top threat: {name} ({tl}%)"
 | 
			
		||||
 | 
			
		||||
    async def _gen_latest_report(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        reports = dm.get('reports') if dm else []
 | 
			
		||||
        if not reports:
 | 
			
		||||
            return None
 | 
			
		||||
        latest = max(reports, key=lambda r: float(r.get('ts', 0.0)))
 | 
			
		||||
        char = latest.get('character_name') or "Unknown"
 | 
			
		||||
        acct = latest.get('account_name') or ""
 | 
			
		||||
        # keep brief
 | 
			
		||||
        return f"Latest report: {char}" + (f" ({acct})" if acct else "")
 | 
			
		||||
 | 
			
		||||
    async def _gen_encounters_last_24h(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        encs = dm.get('encounters') if dm else []
 | 
			
		||||
        if not encs:
 | 
			
		||||
            return "0 encounters last 24h"
 | 
			
		||||
        since = time.time() - 86400
 | 
			
		||||
        n = sum(1 for e in encs if float(e.get('timestamp', 0.0)) >= since)
 | 
			
		||||
        return f"{n} encounters last 24h"
 | 
			
		||||
 | 
			
		||||
    async def _gen_uptime(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
 | 
			
		||||
        boot_ts = float(st.get('last_boot_ts', 0.0))
 | 
			
		||||
        if boot_ts <= 0:
 | 
			
		||||
            return None
 | 
			
		||||
        dur = self._fmt_duration(time.time() - boot_ts)
 | 
			
		||||
        return f"Uptime {dur}"
 | 
			
		||||
 | 
			
		||||
    async def _gen_running_version(self, guild: discord.Guild) -> str | None:
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
 | 
			
		||||
        ver = st.get('last_version')
 | 
			
		||||
        if not ver:
 | 
			
		||||
            return None
 | 
			
		||||
        # Occasionally include version (kept as its own generator for randomness)
 | 
			
		||||
        return f"Running v{ver}"
 | 
			
		||||
 | 
			
		||||
# ============== setup() ==============
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    cog = StatusRotatorCog(bot)
 | 
			
		||||
    await bot.add_cog(cog)
 | 
			
		||||
    print("[status] StatusRotatorCog loaded; rotating presence every",
 | 
			
		||||
          max(1, cfg(bot).int('status_interval_min', 5)), "minute(s).")
 | 
			
		||||
@ -1,36 +0,0 @@
 | 
			
		||||
# modules/usage/usage_stats.py
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
from discord.ext import commands
 | 
			
		||||
import discord
 | 
			
		||||
 | 
			
		||||
COUNTER_KEY_PREFIX = "cmd::"
 | 
			
		||||
 | 
			
		||||
def _key_from_app(cmd: discord.app_commands.Command) -> str:
 | 
			
		||||
    return f"{COUNTER_KEY_PREFIX}{getattr(cmd, 'qualified_name', None) or getattr(cmd, 'name', 'unknown')}"
 | 
			
		||||
 | 
			
		||||
class UsageStatsCog(commands.Cog):
 | 
			
		||||
    """Slash-only metrics; count once per successful app command completion."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bot: commands.Bot):
 | 
			
		||||
        self.bot = bot
 | 
			
		||||
        print("[usage] UsageStatsCog init (slash-only)")
 | 
			
		||||
 | 
			
		||||
    @commands.Cog.listener()
 | 
			
		||||
    async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
 | 
			
		||||
        dm = getattr(self.bot, "data_manager", None)
 | 
			
		||||
        if not dm:
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            key = _key_from_app(command)
 | 
			
		||||
            newv = dm.incr_counter(key, 1)
 | 
			
		||||
            print(f"[usage] app ++ {key} -> {newv}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print("[usage] app !! incr failed:", repr(e))
 | 
			
		||||
 | 
			
		||||
async def setup(bot: commands.Bot):
 | 
			
		||||
    if getattr(bot, "_usage_stats_loaded", False):
 | 
			
		||||
        print("[usage] UsageStatsCog already loaded; skipping duplicate add")
 | 
			
		||||
        return
 | 
			
		||||
    await bot.add_cog(UsageStatsCog(bot))
 | 
			
		||||
    bot._usage_stats_loaded = True
 | 
			
		||||
    print("[usage] UsageStatsCog loaded (slash-only)")
 | 
			
		||||