0.4.1.0.a1

- Doc site UI improvements and fixes
  - Added Discord widget
  - Fixed some styling issues
  - Added command docs briefs and details
    - Briefs are shown in the right-hand panel
    - Details can be shown by clicking `Open full details`
  - Added ShaiWatchers' logo as the site favicon
  - Moved HTML template to static file for responsiveness improvements
This commit is contained in:
Franz Rolfsvaag 2025-08-15 06:17:52 +02:00
parent d768712b75
commit fdd336fe91
55 changed files with 1758 additions and 575 deletions

BIN
assets/docs/ShaiWatcher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

531
assets/docs/cmd.html Normal file
View File

@ -0,0 +1,531 @@
<!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.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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
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\]\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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/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>':''}
<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>`:''}
${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</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>':''}
<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>`:''}
${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</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);
let sel=rows[0];
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'}); }
if(sel) openDetails(sel);
}
async function boot(){
computeStickyTop();
try{
if(!data){ const res=await fetch('/api/commands',{cache:'no-store'}); if(!res.ok) throw new Error('HTTP '+res.status); data=await res.json(); }
addEventListener('hashchange', ()=>applyFilter(false));
qEl.addEventListener('input', ()=>applyFilter(false));
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(' '); }
boot();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,23 @@
<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>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,19 @@
<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>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,22 @@
<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 rooms name and whether its 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: &lt;#1234567890&gt; | Category: &lt;#2345678901&gt; | 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>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,21 @@
<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>

View File

@ -0,0 +1,8 @@
<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> users 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 theyre already verified or already have a pending review, youll get a short explanation instead (ephemeral).
</small></p>

View File

@ -0,0 +1,25 @@
<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>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,22 @@
<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>

View File

@ -0,0 +1,17 @@
<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>

View File

@ -0,0 +1,44 @@
<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 cant 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>

View File

@ -0,0 +1,16 @@
<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>

View File

@ -0,0 +1,24 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>
Updates a pirate record thats 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 whats 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, its 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 youll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>
<li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>
</ul>

View File

@ -0,0 +1,21 @@
<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> — 05 (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, youll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>

View File

@ -0,0 +1,75 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>
<code>/encounter</code> adds a single encounter to the pirates 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>05</b>. Use 0 if youre unsure. 5 = cracked aim/sweaty movement, 12 = 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, youll 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, youll 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>Youll 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 (0100):
</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 05 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 pirates <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 cant tell skill, leave it at 0. Thats fine.</li>
<li>Group size includes the pirate youre 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>

View File

@ -0,0 +1,12 @@
<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>

View File

@ -0,0 +1,27 @@
<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), its counted as “already accounts”.</li>
<li>If the character name maps to exactly one pirate, its updated to that pirates account.</li>
<li>If theres no pirate with that name, its counted as “not found”.</li>
<li>If multiple pirates share the same nickname, its “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>

View File

@ -0,0 +1,9 @@
<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:&nbsp;<code>Name#12345</code></div>
<p style="margin-top:.6rem"><small>
Account format must be <code>Name#12345</code>. If the account isnt found, youll get “Pirate not found.”
</small></p>

View File

@ -0,0 +1,19 @@
<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>

View File

@ -0,0 +1,24 @@
<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>

View File

@ -0,0 +1,48 @@
<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 its 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 pirates nickname/account.</li>
<li><code>remove_pirate</code> (mods, hybrid) remove an entry.</li>
</ul>

View File

@ -0,0 +1,9 @@
<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>

View File

@ -0,0 +1,55 @@
<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 Discords 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: &lt;t:UNIX:R&gt;
</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 isnt configured or cant 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>

View File

@ -0,0 +1,10 @@
<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>

View File

@ -0,0 +1,61 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Checks youre 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 youll 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>

View File

@ -0,0 +1,18 @@
<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 havent set one) already matches your in-game name, and opens a quick review.</p>
<h3>Usage</h3>
<pre>/nick_same</pre>
<ul>
<li>Youll 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>

View File

@ -0,0 +1,38 @@
<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>Youll 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, thats 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 wont 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 didnt get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>
</ul>

View File

@ -0,0 +1,25 @@
<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 (125).</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 didnt 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>

View File

@ -0,0 +1,72 @@
<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 (125).</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> — 0100. <em>0% = owner-only</em> (didnt 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 (theyre 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: 125.</li>
<li>Active %: 0100.</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>

View File

@ -0,0 +1,10 @@
<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>

View File

@ -0,0 +1,11 @@
<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>

View File

@ -0,0 +1,10 @@
<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>

View File

@ -0,0 +1,19 @@
<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 players 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 wizards <em>Adjust weighting factors</em> lets users override per-run; those dont 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>

View File

@ -0,0 +1,10 @@
<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, youll be told to run <code>/spicepay</code>.</li>
</ul>

View File

@ -0,0 +1,14 @@
<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>Youre mid-entry and dont 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>

View File

@ -0,0 +1,18 @@
<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 &amp; 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 users status card in the list channel.</li>
</ul>

View File

@ -0,0 +1,47 @@
<h1>/usercards_rescan — Reconcile & refresh all cards</h1>
<p>One-shot maintenance pass that makes the servers 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 &amp; 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:&lt;id&gt;</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>Wont 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. Its safe to run again.</li>
</ul>

View File

@ -1 +1,91 @@
{}
{
"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- Theres 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 dont 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 cant 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 (1030s) 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* — 05 (0 = unknown) \n Limits: one encounter per same pirate per reporter every **10 minutes**. These entries automatically update the pirates **threat level** and **encounter count**.\n\n- **/edit_pirate** *(moderators)* — update a pirates 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 wont 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 its 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 pirates 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> — 05 (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, youll 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 pirates 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>05</b>. Use 0 if youre unsure. 5 = cracked aim/sweaty movement, 12 = 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, youll 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, youll 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>Youll 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 (0100):\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 05 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 pirates <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 cant tell skill, leave it at 0. Thats fine.</li>\n <li>Group size includes the pirate youre 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 thats 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 whats 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, its 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 youll 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), its counted as “already accounts”.</li>\n <li>If the character name maps to exactly one pirate, its updated to that pirates account.</li>\n <li>If theres no pirate with that name, its counted as “not found”.</li>\n <li>If multiple pirates share the same nickname, its “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:&nbsp;<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 isnt found, youll 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 rooms name and whether its 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: &lt;#1234567890&gt; | Category: &lt;#2345678901&gt; | 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> users 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 theyre already verified or already have a pending review, youll 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 Discords 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: &lt;t:UNIX:R&gt;\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 isnt configured or cant 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 youre 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 youll 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 youre 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 youll 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 havent 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>Youll 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>Youll 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, thats 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 wont 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 didnt 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 (125).</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 didnt 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 (125).</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> — 0100. <em>0% = owner-only</em> (didnt 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 (theyre 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: 125.</li>\n <li>Active %: 0100.</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 players 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 wizards <em>Adjust weighting factors</em> lets users override per-run; those dont 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, youll 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>Youre mid-entry and dont 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 &amp; 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 &amp; tips</h2>\n<ul>\n <li>If you dont 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 cant 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 &amp; 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 users 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 servers 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 &amp; 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:&lt;id&gt;</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>Wont 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. Its safe to run again.</li>\n</ul>\n"
}
}

View File

@ -0,0 +1,12 @@
<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>

View File

@ -0,0 +1,32 @@
<h1>!help — Command help &amp; 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 &amp; tips</h2>
<ul>
<li>If you dont 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,12 @@
{
"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": "/"
}

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.4.0.0.a5"
VERSION = "0.4.1.0.a1"
# ---------- Env loading ----------

View File

@ -6,7 +6,7 @@ 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=8911
# export SHAI_DOCS_PORT=8910
# export SHAI_OFFLINE=1
python3 offline_preview.py
"""
@ -91,7 +91,7 @@ async def main():
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", "8911")
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
@ -137,7 +137,7 @@ async def main():
pass
print("[OFFLINE] Docs: http://%s:%s/"
% (os.environ.get("SHAI_DOCS_HOST", "127.0.0.1"),
% (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.")

View File

@ -1,4 +1,3 @@
# modules/docs_site/docs_site.py
import json
import threading
import traceback
@ -228,13 +227,11 @@ def _command_usage_slash(cmd: app_commands.Command) -> str:
def _iter_all_app_commands(bot: commands.Bot):
"""Yield (scope_tag, top_level_command) including global and per-guild trees."""
out = []
# Global
try:
for cmd in bot.tree.get_commands():
out.append(("", cmd))
except Exception:
pass
# Per-guild
for g in list(getattr(bot, "guilds", []) or []):
try:
cmds = bot.tree.get_commands(guild=g)
@ -394,18 +391,52 @@ def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
keys.append(base)
return keys
def _scan_md_files() -> Dict[str, Dict[str, Any]]:
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:
out[p.stem] = {"details_md": p.read_text(encoding="utf-8")}
_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]]:
@ -454,13 +485,14 @@ def _load_external_docs() -> Dict[str, Dict[str, Any]]:
return _DOCS_CACHE["map"]
master = _load_master_json()
md_map = _scan_md_files()
doc_map = _scan_doc_files()
merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in master.items()}
for k, v in md_map.items():
for k, v in doc_map.items():
if k not in merged:
merged[k] = {}
merged[k]["details_md"] = v.get("details_md", "")
for kk, vv in v.items():
merged[k][kk] = vv
_write_master_json(merged)
_DOCS_CACHE["map"] = merged
@ -474,16 +506,21 @@ def _augment_with_external_docs(rows: List[Dict[str, Any]]) -> None:
dm = r["extras"].get("details_md")
if isinstance(dm, str) and dm.strip():
r["details_md"] = dm
if not r.get("details_md"):
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):
dm = mapping[key].get("details_md")
if isinstance(dm, str) and dm.strip():
r["details_md"] = dm
meta = {kk: vv for kk, vv in mapping[key].items() if kk != "details_md"}
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
break
# =============================
@ -521,6 +558,10 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
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"]
to_remove.append(i)
for i in sorted(to_remove, reverse=True):
@ -536,7 +577,7 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
sl = _gather_slash(bot)
all_rows = px + sl
# Additional moderator checks & sanitization
# Mark mod-only via hints/perms/extras
for row in all_rows:
try:
helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
@ -556,20 +597,24 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
_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")]
users = [r for r in all_rows if not r.get("moderator_only")]
return {
"title": "ShaiWatcher Commands",
"count": len(all_rows),
"sections": {
"user": users,
"moderator": mods,
},
"sections": {"user": users, "moderator": mods},
"all": all_rows,
}
# =============================
# Static asset serving
# =============================
@ -591,6 +636,8 @@ def _guess_mime(p: Path) -> str:
".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")
@ -598,508 +645,12 @@ def _guess_mime(p: Path) -> str:
# HTTP + UI
# =============================
_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>__TITLE__</title>
<style>
:root { --bg:#0b0f14; --panel:#121922; --muted:#6b7280; --fg:#e5e7eb; --accent:#60a5fa; --good:#34d399; --warn:#f59e0b; --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 { padding:16px 20px; background:#0f172a; border-bottom:1px solid #223; position:sticky; top:0; z-index:3; }
header h1 { margin:0; font-size:20px; }
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.slash { border-color:#60a5fa; }
.pill.prefix { border-color:#f59e0b; }
.pill.hybrid { border-color:#34d399; }
.usage { position:relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; }
.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; }
.flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; }
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; }
/* Copy modal */
#copyModal { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; z-index: 10; }
#copyModal.open { display:flex; }
#copyModal .overlay { position:absolute; inset:0; background:rgba(0,0,0,.4); }
#copyModal .sheet { position:relative; z-index:1; min-width: min(520px, 92vw); background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; box-shadow: 0 20px 60px rgba(0,0,0,.45); }
#copyModal .close { position:absolute; right:10px; top:10px; }
/* Copy button inside usage */
.usage .copybtn { position:absolute; right:6px; top:6px; }
@media (max-width: 900px) {
.usage .copybtn { right:6px; top:6px; }
.btn-icon { width:auto; padding:4px 10px; } /* larger tap target on mobile */
}
/* Blur veil under the toolbar */
.veil {
position: sticky;
top: var(--header-h);
height: var(--veil-h, 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 sheet behavior */
@media (max-width: 900px) {
.row { display:block; }
.detailsbox {
position: fixed;
right: 0; top: var(--sticky-top);
width: 96vw; max-width: 720px;
height: calc(100vh - var(--sticky-top) - 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(--sticky-top) 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;
}
}
</style>
</head>
<body>
<header><h1>__TITLE__</h1></header>
<main>
<!-- Top 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>
<!-- Gradual blur veil under the toolbar -->
<div class="veil" id="veil"></div>
<div class="row">
<!-- Left: command list -->
<div class="col">
<div id="list" class="list"></div>
</div>
<!-- Right: details panel (sticky / mobile sheet) -->
<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>
<!-- Copy/share modal -->
<div id="copyModal" aria-hidden="true">
<div class="overlay" data-close="1"></div>
<div class="sheet">
<button class="btn btn-icon close" title="Close" data-close="1"></button>
<div id="copyText" style="word-break:break-all; font-family: ui-monospace, monospace;"></div>
<div style="margin-top:10px" class="btn-row">
<button id="copyAction" class="btn">Copy</button>
<span id="copyHint" style="font-size:12px;color:#9ca3af">Tap outside to dismiss.</span>
</div>
</div>
</div>
<div id="backdrop"></div>
</main>
<script>
function computeStickyTop() {
const header = document.querySelector('header');
const toolbar = document.getElementById('toolbar');
const headerH = header ? header.offsetHeight : 0;
const veilH = 16;
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 = veilH + 'px';
}
}
window.addEventListener('resize', computeStickyTop);
window.addEventListener('load', computeStickyTop);
window.addEventListener('error', function(e){
var list = document.getElementById('alerts');
if (list) list.textContent = 'JS error: ' + (e && e.message);
});
window.addEventListener('unhandledrejection', function(e){
var list = document.getElementById('alerts');
var msg = e && (e.reason && (e.reason.message || e.reason) || e);
if (list) list.textContent = 'Promise error: ' + msg;
});
// Minimal markdown renderer
function renderMD(src) {
if (!src) return "";
let s = String(src).replace(/\\r\\n/g, "\\n");
s = s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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;
}
// --------- Linking helpers ---------
function rowAnchor(r) {
// Stable anchor: "<cog-or-nocog>-<path with slashes -> dashes>"
const cog = (r.cog || 'nocog').toLowerCase();
let base = (r.name || '').toLowerCase();
base = base.replace(/^\\//, ''); // remove leading "/"
base = base.replace(/\\s+/g, '-'); // spaces to dash
base = base.replace(/\\//g, '-'); // slashes to dash
return `${cog}-${base}`;
}
function getFilterFromHash() {
const h = (location.hash || '#user').slice(1);
// support legacy "cmd=" in hash too
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);
// also check query
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(window.location.href);
url.searchParams.set('cmd', anchor);
url.hash = location.hash || '#user';
return url.toString();
}
function replaceURLFor(anchor) {
try {
const url = new URL(window.location.href);
url.searchParams.set('cmd', anchor);
history.replaceState(null, '', url.toString());
} catch {}
}
async function copyText(s) {
try {
await navigator.clipboard.writeText(s);
return true;
} catch {
return false;
}
}
function openCopyModal(text) {
const modal = document.getElementById('copyModal');
const sheet = modal.querySelector('.sheet');
document.getElementById('copyText').textContent = text;
const action = document.getElementById('copyAction');
const closeables = modal.querySelectorAll('[data-close]');
const close = () => { modal.classList.remove('open'); action.onclick = null; closeables.forEach(el => el.onclick = null); };
closeables.forEach(el => el.onclick = close);
action.onclick = async () => { const ok = await copyText(text); if (ok) close(); };
modal.classList.add('open');
}
// --------- 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) {
const n = (r.display_name || r.name || '');
return n.replace(/^\\//, '');
}
function helpSansMod(r) {
return (r.help || '').replace(/^\\s*\\[MOD\\]\\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) openCopyModal(url);
}
function usageBlockHTML(text) {
if (!text) return '';
const esc = String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&');
const ok = await copyText(t);
if (!ok) openCopyModal(t);
});
});
}
function openDetails(r) {
const anchor = rowAnchor(r);
replaceURLFor(anchor); // keep URL synced
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>' : ''}
<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>` : '' }
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
</div>
${usageBlockHTML(r.usage_prefix)}
${usageBlockHTML(r.usage_slash)}
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
`;
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>
<details><summary>Extras</summary><pre style="white-space:pre-wrap">${r.extras ? JSON.stringify(r.extras, null, 2) : ''}</pre></details>
</div>
`;
}
detailsEl.innerHTML = html;
// Wire share + copy buttons
const shareBtn = document.getElementById('shareDetails');
if (shareBtn) shareBtn.onclick = (e) => { e.stopPropagation(); shareFor(r); };
wireUsageCopy(detailsEl);
// Mobile sheet open
if (window.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;
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 || ""].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>' : ''}
<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>` : '' }
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</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();
await shareFor(r);
});
c.addEventListener('click', () => openDetails(r));
// Wire usage copy buttons within card
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 || [];
const mods = (data.sections && data.sections.moderator) || [];
const users = (data.sections && data.sections.user) || [];
// Counts order: User · Moderator · Total
const ct = `User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
countsEl.textContent = ct;
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 || ""]
.join(' ').toLowerCase().includes(q)
));
render(listEl, rows);
// Auto-select first or anchor target
let sel = rows[0];
if (selectAnchorIfPresent && cmd) {
const hit = rows.find(r => rowAnchor(r) === cmd);
if (hit) sel = hit;
// Also scroll the specific card into view smoothly
const cardEl = document.getElementById('card-' + cmd);
if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
}
if (sel) openDetails(sel);
}
async function boot() {
computeStickyTop();
try {
if (!data) {
const res = await fetch('/api/commands', {cache: 'no-store'});
if (!res.ok) throw new Error('HTTP ' + res.status);
data = await res.json();
}
window.addEventListener('hashchange', () => applyFilter(false));
qEl.addEventListener('input', () => applyFilter(false));
applyFilter(true);
} catch (e) {
document.getElementById('alerts').textContent = 'Failed to load.';
}
// Footer
const fromYear = 2025;
const 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 {}
}
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(' ');
}
boot();
})();
</script>
</body>
</html>
"""
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)
@ -1110,6 +661,15 @@ class _DocsHandler(BaseHTTPRequestHandler):
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())
@ -1120,7 +680,25 @@ class _DocsHandler(BaseHTTPRequestHandler):
path = urlparse(self.path).path
# Serve static assets from /assets/docs/*
# 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()
@ -1133,55 +711,44 @@ class _DocsHandler(BaseHTTPRequestHandler):
if str(root) not in str(fs_path):
raise ValueError("outside root")
if fs_path.is_file():
mime = _guess_mime(fs_path)
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Cache-Control", "public, max-age=86400, immutable")
self.end_headers()
with fs_path.open("rb") as f:
self.wfile.write(f.read())
self._serve_file(fs_path)
return
self._set(404, "text/plain; charset=utf-8")
self.wfile.write(b"not found")
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 == "/":
self._set()
html = _HTML.replace("__TITLE__", self.title)
try:
schema = build_command_schema(self.bot)
inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
# Inject support link + visibility
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("__SUPPORT_URL__", support_url)
.replace("__SUPPORT_LABEL__", support_label)
.replace("__SUPPORT_VIS__", vis)
)
# Fallback to values stored on the cog (well wire them below in _start_server)
support_url = support_url or getattr(_DocsHandler, "support_url", "")
support_label = support_label or getattr(_DocsHandler, "support_label", "Buy me a ☕")
vis = "block" if support_url else "none"
html = (html
.replace("__SUPPORT_URL__", support_url)
.replace("__SUPPORT_LABEL__", support_label)
.replace("__SUPPORT_VIS__", vis)
)
except Exception:
traceback.print_exc()
self.wfile.write(html.encode("utf-8"))
return
if path == "/api/status":
@ -1191,9 +758,7 @@ class _DocsHandler(BaseHTTPRequestHandler):
return
if path == "/healthz":
self._set(200, "text/plain; charset=utf-8")
self.wfile.write(b"ok")
return
self._set(200, "text/plain; charset=utf-8"); self.wfile.write(b"ok"); return
if path == "/api/commands":
schema = build_command_schema(self.bot)
@ -1202,13 +767,11 @@ class _DocsHandler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
self._set(404, "text/plain; charset=utf-8")
self.wfile.write(b"not found")
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")
self._set(500, "text/plain; charset=utf-8"); self.wfile.write(b"internal error")
except Exception:
pass
@ -1245,7 +808,7 @@ class DocsSite(commands.Cog):
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 via bot (read in _start_server)
# Expose to handler
self.bot.docs_support_url = self.support_url
self.bot.docs_support_label = self.support_label